Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 41 additions & 2 deletions packages/agent/src/dkg-agent-publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ import {
withSpan,
getMetrics,
assertQuadLiteralsMutf8Safe,
awaitTailWithGrace,
resolvePublishTailGraceMs,
} from '@origintrail-official/dkg-core';
import { SpanStatusCode } from '@opentelemetry/api';
import { GraphManager, PrivateContentStore, createTripleStore, loadSelectedSharedMemoryQuads, type TripleStore, type TripleStoreConfig, type Quad, type LargeLiteralStorageConfig } from '@origintrail-official/dkg-storage';
Expand Down Expand Up @@ -4453,6 +4455,14 @@ export class PublishMethods extends DKGAgentBase {
}
}

// GH #1572: everything from here to the return is eventual `_meta`
// bookkeeping (VM-pointer stamps, memoryLayer/state flips, publishedUal,
// assertion-graph re-stamps, swmShareComplete clearing) — the caller's
// result is already complete. Run it with a bounded grace so a congested
// store queue cannot hold the publish response hostage; on a healthy node
// it finishes inside the grace and behavior is unchanged, including error
// propagation.
const metaTail = (async () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: The detachable metadata tail should be a named operation, not a 140-line inline async closure

What's wrong
The PR identifies a real conceptual boundary: everything after this point is post-response metadata tail work. But instead of making that boundary part of the structure, it wraps a large, already dense block in an immediately invoked async function inside a 5k-line file. That makes the publish flow harder to scan and makes future edits riskier because the detached work's scope is defined by distant braces rather than a named operation.

Example
A reader has to scan from const metaTail = (async () => { through all VM pointer stamps, lifecycle flips, publishedUal writes, assertion graph updates, and swmShareComplete cleanup before reaching the line that actually applies the grace. The new async boundary is not visible from a named function or indentation structure.

Suggested direction
Use the new tail boundary as the decomposition point. Move the metadata bookkeeping into a focused writePublishMetadataTail/runPostPublishMetadataTail helper that receives the few values it needs, and keep the main publish flow as: compute result, run metadata tail with grace, return result. That deletes the hidden closure and makes the detachable work explicit without changing behavior.

For Agents
Extract the block currently wrapped by metaTail in packages/agent/src/dkg-agent-publish.ts into a named private method or local pure orchestration function such as writePublishMetadataTail(...). Preserve all existing internal best-effort logging and the final return shape. Then call the publish-tail grace runner around that named work. Existing publish/finalize tests should still cover the metadata rows; add a narrow test if extracting changes the observable sequencing.

// OT-RFC-43 A2 (decision 2) — stamp the VM pointer on the lifecycle URN
// whenever the publish/update is confirmed. (For the mint path this is the
// first VM pointer; for the update path the DELETE/INSERT above already set
Expand Down Expand Up @@ -4595,6 +4605,21 @@ export class PublishMethods extends DKGAgentBase {
);
}
}
})();
{
const tailCtx = opts?.operationCtx ?? createOperationContext('publishFromSWM');
const graceMs = resolvePublishTailGraceMs();
const outcome = await awaitTailWithGrace(graceMs, metaTail, (err) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: Detached publish metadata can overwrite newer lifecycle state

What's wrong
The detached tail is not just logging or ancillary bookkeeping. It mutates the authoritative lifecycle rows that later API calls use for status and publish routing. Returning before those mutations are ordered means a later user operation can be applied first and then be clobbered by the stale publish tail.

Example
A publish confirms, the store queue is congested, and awaitTailWithGrace returns detached. The caller immediately starts an edit flow for the same KA through pull-from/create, which writes the fresh WM lifecycle rows. When the old detached publish tail resumes, it deletes those state/layer rows and writes published/VM, so the new draft is reported and routed as already VM-confirmed.

Suggested direction
Keep the slow work bounded, but make the lifecycle state transition atomic or version-checked before returning, or have the detached tail skip its writes when the lifecycle has advanced since the publish it belongs to.

For Agents
In PublishMethods.publishFromFinalizedAssertion, do not let stale lifecycle mutations run after the confirmed publish response unless they are ordered against later lifecycle operations. Preserve the ability to respond quickly, but add an awaited materialization/version guard or lifecycle lock so a detached publish tail cannot overwrite a newer create/pull-from/discard. Add a test with a delayed metadata tail followed by pull-from/create proving the newer WM state survives.

if (err !== undefined) {
this.log.warn(tailCtx, `Detached publish metadata tail failed for <${lifecycleUri}> (GH #1572): ${err instanceof Error ? err.message : String(err)}`);
} else {
this.log.info(tailCtx, `Detached publish metadata tail completed for <${lifecycleUri}> (GH #1572)`);
}
});
if (outcome === 'detached') {
this.log.info(tailCtx, `Publish metadata tail still running after ${graceMs}ms grace for <${lifecycleUri}> — responding now, tail continues detached (GH #1572)`);
}
}

return { ...result, assertionUri, seal };
}
Expand Down Expand Up @@ -5095,7 +5120,10 @@ export class PublishMethods extends DKGAgentBase {
// reconcile path can mirror the gossip dual-write decision. Read back by
// `FinalizationHandler.getKeepRootCopySignal`. Updates reuse a root
// entity, so replace any prior value rather than accumulate.
try {
// GH #1572: this durable stamp is a reconcile backstop for subscribers
// that missed the (already-sent) gossip broadcast — eventual by design,
// so it must not gate the publish response on a congested store queue.
const keepRootStamp = (async () => {
const gm = new GraphManager(this.store);
const wsMetaGraph = options?.subGraphName
? gm.sharedMemoryMetaUri(contextGraphId, options.subGraphName)
Expand All @@ -5114,8 +5142,19 @@ export class PublishMethods extends DKGAgentBase {
graph: wsMetaGraph,
}]);
}
})();
const keepRootOnSettled = (err?: unknown) => {
if (err !== undefined) {
this.log.warn(ctx, `Failed to persist keepRootCopyOnLabel signal for ${result.ual}: ${err instanceof Error ? err.message : String(err)}`);
}
};
try {
const graceMs = resolvePublishTailGraceMs();
if (await awaitTailWithGrace(graceMs, keepRootStamp, keepRootOnSettled) === 'detached') {
this.log.info(ctx, `keepRootCopyOnLabel stamp still running after ${graceMs}ms grace for ${result.ual} — continuing detached (GH #1572)`);
}
} catch (err) {
this.log.warn(ctx, `Failed to persist keepRootCopyOnLabel signal for ${result.ual}: ${err instanceof Error ? err.message : String(err)}`);
keepRootOnSettled(err);
}
}

Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './types.js';
export * from './constants.js';
export * from './assertion-scoped-graphs.js';
export * from './protocol-limits.js';
export * from './publish-tail-grace.js';
export * from './catalog.js';
export { parseDotenvValue } from './dotenv.js';
export * from './memory-model.js';
Expand Down
81 changes: 81 additions & 0 deletions packages/core/src/publish-tail-grace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Bounded grace for post-confirm publish "tails" (GH #1572).
*
* After a V10 publish confirms on-chain, the caller-facing result
* (ual / kaId / txHash / status) is complete — but the code still runs a
* serial tail of store mutations (SWM cleanup sweeps, `_meta` lifecycle
* stamps). Every one of those mutations is submitted to the store queue
* with no deadline, so on a node whose store queue is congested (large
* accumulated graphs + sustained inbound ACK/SWM traffic) the tail — and
* therefore the HTTP response that awaits it — can take many minutes.
* Measured on mainnet cores: confirm at +4s, response at +9..18 min and
* growing, with the TCP connection dying at ~12 min. The same publish on
* an idle node completes the tail in well under a second.
*
* `awaitTailWithGrace` keeps the tail's semantics intact while unhooking
* it from the response path:
* - the tail always runs to completion, in order, exactly once;
* - if it settles within `graceMs`, behavior is byte-identical to a plain
* `await` (including error propagation to the caller);
* - if it is still running after `graceMs`, the caller proceeds (the
* response goes out) and the tail continues detached; its eventual
* settlement is reported through `onDetachedSettled` so a failure is
* logged rather than becoming an unhandled rejection.
*
* On a healthy node the grace never elapses, so this changes nothing.
*/

export const DEFAULT_PUBLISH_TAIL_GRACE_MS = 5_000;

/**
* Resolve the grace budget from `DKG_PUBLISH_TAIL_GRACE_MS`. Invalid or
* missing values fall back to {@link DEFAULT_PUBLISH_TAIL_GRACE_MS}; `0`
* is honored (respond immediately, tail fully detached).
*/
export function resolvePublishTailGraceMs(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.DKG_PUBLISH_TAIL_GRACE_MS;
if (raw === undefined || raw === '') return DEFAULT_PUBLISH_TAIL_GRACE_MS;
const n = Number(raw);
if (!Number.isFinite(n) || n < 0) return DEFAULT_PUBLISH_TAIL_GRACE_MS;
return Math.floor(n);
}

const TAIL_GRACE_TIMEOUT: unique symbol = Symbol('publish-tail-grace-timeout');

/**
* Await `tail` for up to `graceMs`.
*
* Returns `'completed'` when the tail settled in time — a rejection inside
* the grace window is rethrown to the caller, exactly like a plain `await`.
* Returns `'detached'` when the grace elapsed first; the still-running tail
* keeps executing and `onDetachedSettled` fires exactly once when it
* eventually settles (with the error when it failed, `undefined` on
* success), guaranteeing the rejection is consumed.
*/
export async function awaitTailWithGrace(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: The tail-grace abstraction is too low-level, so the policy is still scattered across call sites

What's wrong
The new core helper only races a pre-started Promise<void>, so every caller still has to know the protocol for starting an IIFE, resolving the env grace, wiring detached settlement, deciding whether in-grace errors propagate, and formatting logs. That leaves the new behavior as repeated incidental control flow rather than a single canonical publish-tail policy. It is especially visible in the keep-root stamp, which has an extra catch solely because the helper's default error semantics do not match that tail's best-effort semantics.

Example
The keep-root path has to create keepRootStamp, define keepRootOnSettled, call awaitTailWithGrace, and add a catch that feeds back into the same callback. The SWM cleanup and metadata-tail paths duplicate the same shape with slightly different log text.

Suggested direction
Raise this to a publish-tail policy helper, e.g. runPublishTailWithGrace({ ctx, logger, label, graceMs, errorPolicy }, work), or provide explicit propagateWithinGrace vs bestEffort modes. Let that helper own grace resolution, attaching the late rejection handler, and the standard detached/completed logging so the call sites describe only the work being delayed.

For Agents
Look in packages/core/src/publish-tail-grace.ts and the three new call sites. Preserve the current grace behavior: in-grace failures should behave according to each tail's existing policy, post-grace failures must be consumed and logged, and pending tails must continue. Prove the helper with the existing publish-tail-grace tests plus at least one call-site-level test or focused unit that shows the call site no longer needs bespoke try/catch/log glue.

graceMs: number,
tail: Promise<void>,
onDetachedSettled: (error?: unknown) => void,
): Promise<'completed' | 'detached'> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const winner = await Promise.race([
tail,
new Promise<typeof TAIL_GRACE_TIMEOUT>((resolve) => {
timer = setTimeout(() => resolve(TAIL_GRACE_TIMEOUT), Math.max(0, graceMs));
// Never keep the process alive just to time out a grace window.
timer.unref?.();
}),
]);
if (winner === TAIL_GRACE_TIMEOUT) {
tail.then(
() => onDetachedSettled(),
(err) => onDetachedSettled(err),
);
return 'detached';
}
return 'completed';
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
74 changes: 74 additions & 0 deletions packages/core/test/publish-tail-grace.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import {
awaitTailWithGrace,
resolvePublishTailGraceMs,
DEFAULT_PUBLISH_TAIL_GRACE_MS,
} from '../src/publish-tail-grace.js';

const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));

describe('awaitTailWithGrace (GH #1572)', () => {
it('returns "completed" when the tail settles inside the grace, without firing the detach callback', async () => {
let detached = 0;
const outcome = await awaitTailWithGrace(1_000, sleep(20), () => { detached += 1; });
expect(outcome).toBe('completed');
// give any stray callback a chance to fire before asserting
await sleep(50);
expect(detached).toBe(0);
});

it('rethrows a tail failure that happens inside the grace (plain-await parity)', async () => {
let detached = 0;
const failing = (async () => { await sleep(10); throw new Error('store exploded'); })();
await expect(awaitTailWithGrace(1_000, failing, () => { detached += 1; })).rejects.toThrow('store exploded');
await sleep(50);
expect(detached).toBe(0);
});

it('returns "detached" promptly when the tail outlives the grace, and the tail still completes', async () => {
let tailDone = false;
let detachedError: unknown = 'not-called';
const tail = (async () => { await sleep(300); tailDone = true; })();
const started = Date.now();
const outcome = await awaitTailWithGrace(50, tail, (err) => { detachedError = err; });
const waited = Date.now() - started;
expect(outcome).toBe('detached');
expect(waited).toBeLessThan(250); // responded at ~graceMs, not at tail completion
expect(tailDone).toBe(false); // tail genuinely still running at detach time
await sleep(400);
expect(tailDone).toBe(true); // ...and it ran to completion afterwards
expect(detachedError).toBeUndefined(); // settled-ok callback fired with no error
});

it('routes a post-grace tail failure into the callback instead of an unhandled rejection', async () => {
let detachedError: unknown;
const tail = (async () => { await sleep(150); throw new Error('late failure'); })();
const outcome = await awaitTailWithGrace(20, tail, (err) => { detachedError = err; });
expect(outcome).toBe('detached');
await sleep(300);
expect(detachedError).toBeInstanceOf(Error);
expect((detachedError as Error).message).toBe('late failure');
});

it('graceMs=0 detaches immediately for a pending tail', async () => {
let tailDone = false;
const tail = (async () => { await sleep(100); tailDone = true; })();
const outcome = await awaitTailWithGrace(0, tail, () => {});
expect(outcome).toBe('detached');
expect(tailDone).toBe(false);
await sleep(200);
expect(tailDone).toBe(true);
});
});

describe('resolvePublishTailGraceMs', () => {
it('defaults when unset, empty, or invalid; honors valid values including 0', () => {
expect(resolvePublishTailGraceMs({} as NodeJS.ProcessEnv)).toBe(DEFAULT_PUBLISH_TAIL_GRACE_MS);
expect(resolvePublishTailGraceMs({ DKG_PUBLISH_TAIL_GRACE_MS: '' } as NodeJS.ProcessEnv)).toBe(DEFAULT_PUBLISH_TAIL_GRACE_MS);
expect(resolvePublishTailGraceMs({ DKG_PUBLISH_TAIL_GRACE_MS: 'soon' } as NodeJS.ProcessEnv)).toBe(DEFAULT_PUBLISH_TAIL_GRACE_MS);
expect(resolvePublishTailGraceMs({ DKG_PUBLISH_TAIL_GRACE_MS: '-5' } as NodeJS.ProcessEnv)).toBe(DEFAULT_PUBLISH_TAIL_GRACE_MS);
expect(resolvePublishTailGraceMs({ DKG_PUBLISH_TAIL_GRACE_MS: '0' } as NodeJS.ProcessEnv)).toBe(0);
expect(resolvePublishTailGraceMs({ DKG_PUBLISH_TAIL_GRACE_MS: '12000' } as NodeJS.ProcessEnv)).toBe(12_000);
expect(resolvePublishTailGraceMs({ DKG_PUBLISH_TAIL_GRACE_MS: '2500.9' } as NodeJS.ProcessEnv)).toBe(2_500);
});
});
31 changes: 25 additions & 6 deletions packages/publisher/src/dkg-publisher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { Quad, TripleStore } from '@origintrail-official/dkg-storage';
import type { ChainAdapter, OnChainPublishResult, AddBatchToContextGraphParams } from '@origintrail-official/dkg-chain';
import { enrichEvmError } from '@origintrail-official/dkg-chain';
import type { EventBus, OperationContext } from '@origintrail-official/dkg-core';
import { DKGEvent, Logger, createOperationContext, sha256, encodeWorkspacePublishRequest, encodeEncryptedWorkspacePayload, encryptWorkspacePayload, contextGraphDataUri, contextGraphDataGraphUri, contextGraphMetaUri, contextGraphAssertionUri, contextGraphLayerUri, MemoryLayer, assertionLifecycleUri, contextGraphSubGraphUri, contextGraphSubGraphMetaUri, SYSTEM_CONTEXT_GRAPHS, validateSubGraphName, isSafeIri, assertSafeIri, assertSafeRdfTerm, assertQuadLiteralsMutf8Safe, DKG_GOSSIP_MAX_MESSAGE_BYTES, SwmGossipPayloadTooLargeError, STORAGE_ACK_MAX_STAGING_BYTES, type Ed25519Keypair, buildAuthorAttestationTypedData, buildUpdateAuthorAttestationTypedData, AUTHOR_SCHEME_VERSION_V1, TrustLevel, TRUST_LEVEL_PREDICATE, assertNoUserAuthoredTrustLevelQuads, buildTrustLevelQuads, isTrustLevelQuad, isSwmMerkleExcludedQuad, WORKSPACE_OWNER_PREDICATE, DKG_ENTITY, DKG_ROOT_ENTITY_LEGACY, ENTITY_PRED_ALT, parseAssertionSealQuads, ASSERTION_SEAL_PREDICATES, sharedMemoryReadBothFilter, DKG_ONTOLOGY } from '@origintrail-official/dkg-core';
import { DKGEvent, Logger, createOperationContext, sha256, encodeWorkspacePublishRequest, encodeEncryptedWorkspacePayload, encryptWorkspacePayload, contextGraphDataUri, contextGraphDataGraphUri, contextGraphMetaUri, contextGraphAssertionUri, contextGraphLayerUri, MemoryLayer, assertionLifecycleUri, contextGraphSubGraphUri, contextGraphSubGraphMetaUri, SYSTEM_CONTEXT_GRAPHS, validateSubGraphName, isSafeIri, assertSafeIri, assertSafeRdfTerm, assertQuadLiteralsMutf8Safe, DKG_GOSSIP_MAX_MESSAGE_BYTES, SwmGossipPayloadTooLargeError, STORAGE_ACK_MAX_STAGING_BYTES, type Ed25519Keypair, buildAuthorAttestationTypedData, buildUpdateAuthorAttestationTypedData, AUTHOR_SCHEME_VERSION_V1, TrustLevel, TRUST_LEVEL_PREDICATE, assertNoUserAuthoredTrustLevelQuads, buildTrustLevelQuads, isTrustLevelQuad, isSwmMerkleExcludedQuad, WORKSPACE_OWNER_PREDICATE, DKG_ENTITY, DKG_ROOT_ENTITY_LEGACY, ENTITY_PRED_ALT, parseAssertionSealQuads, ASSERTION_SEAL_PREDICATES, sharedMemoryReadBothFilter, DKG_ONTOLOGY, awaitTailWithGrace, resolvePublishTailGraceMs } from '@origintrail-official/dkg-core';
import { GraphManager, PrivateContentStore, loadSelectedSharedMemoryQuads } from '@origintrail-official/dkg-storage';
import { DEFAULT_PUBLISH_EPOCHS, MAX_PUBLISH_EPOCHS, type Publisher, type PublishOptions, type PublishResult, type KAManifestEntry, type PhaseCallback, type V10CoreNodeACK, type V10ACKProviderParams, type V10ACKProviderObject, type LegacyV10ACKProvider } from './publisher.js';
import { skolemizeByEntity } from './auto-partition.js';
Expand Down Expand Up @@ -1891,11 +1891,30 @@ export class DKGPublisher implements Publisher {
// clearSharedMemoryAfter controls only whether the REMAINING unpublished triples are also cleared.
if (publishResult.status === 'confirmed') {
const kaMap = skolemizeByEntity(quads);
await this.clearPublishedSwmRoots(contextGraphId, [...kaMap.keys()], options?.subGraphName, ctx);
// If clearSharedMemoryAfter is explicitly true, also clear any remaining unpublished content.
// Default is false: unpublished entities stay in SWM for future publishes.
if (options?.clearSharedMemoryAfter === true) {
await this.clearRemainingSharedMemory(contextGraphId, options?.subGraphName, ctx);
// GH #1572: the cleanup sweep scales with the accumulated SWM graph and
// runs on the store queue with no deadline — on a congested node it can
// take minutes and must not gate the caller's publish response. Give it
// a bounded grace (a healthy node finishes well inside it, keeping
// today's behavior — including error propagation — byte-identical),
// then let it finish detached with its settlement logged.
const swmCleanup = (async () => {
await this.clearPublishedSwmRoots(contextGraphId, [...kaMap.keys()], options?.subGraphName, ctx);
// If clearSharedMemoryAfter is explicitly true, also clear any remaining unpublished content.
// Default is false: unpublished entities stay in SWM for future publishes.
if (options?.clearSharedMemoryAfter === true) {
await this.clearRemainingSharedMemory(contextGraphId, options?.subGraphName, ctx);
}
})();
const graceMs = resolvePublishTailGraceMs();
const outcome = await awaitTailWithGrace(graceMs, swmCleanup, (err) => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: Confirmed publish can return while the same SWM roots remain publishable

What's wrong
Before this change, a confirmed publish did not resolve until the selected SWM roots had been consumed. With the new grace path, callers can observe success while those roots are still present and accepted by the next publish call, which can create duplicate transactions/reverts and inconsistent local lifecycle behavior.

Example
On a congested store queue, publishFromSharedMemory(CG, { rootEntities: [alice] }) returns confirmed after the grace window while alice is still in SWM. A sequential retry or second publish with the same selection can load the same quads and submit another publish instead of failing with no SWM data/already-consumed share.

Suggested direction
Separate the expensive cleanup sweep from the small correctness gate: await an idempotent consumed marker or marker clear that all publish entry points honor, then optionally detach the bulk deletion.

For Agents
In DKGPublisher.publishFromSharedMemory and the named publish path, preserve the post-confirm consumption contract before resolving a successful publish. If the full graph delete is too expensive to await, write and check an awaited per-root/per-share consumed marker before returning, then let only the bulk deletion run detached. Add a delayed-cleanup test where a second sequential publish of the same roots is rejected.

No caller-level regression test proves publish returns before a blocked tail

What's wrong
The added unit tests verify the generic helper, but they do not verify the user-facing publish methods that were changed to use it. This PR is specifically meant to stop confirmed publishes from timing out behind SWM cleanup and metadata writes, and that behavior can regress at the integration point while all helper tests remain green.

Example
A regression could accidentally restore await this.clearPublishedSwmRoots(...) in publishFromSharedMemory, or omit the metadata tail wrapper in publishQueuedKnowledgeAssetVmPublish. The new core helper tests would still pass, but a confirmed publish with a blocked store cleanup would again wait for cleanup before returning instead of responding after DKG_PUBLISH_TAIL_GRACE_MS=0 or a tiny grace.

Suggested direction
Add at least one focused publish-path test with a delayed or manually controlled store cleanup/meta mutation and a tiny grace budget. The assertion should be on the public publish method's timing/result, not only on awaitTailWithGrace in isolation.

For Agents
Add caller-level regression coverage around DKGPublisher.publishFromSharedMemory and the agent publish path that forces the cleanup/meta write promise to stay pending past a tiny grace, then asserts the publish method returns a confirmed result before the tail resolves and that the tail still runs/logs settlement afterward. Keep existing healthy-path cleanup assertions for the within-grace behavior.

if (err !== undefined) {
this.log.warn(ctx, `Detached SWM cleanup failed after confirmed publish (GH #1572): ${err instanceof Error ? err.message : String(err)}`);
} else {
this.log.info(ctx, 'Detached SWM cleanup completed after confirmed publish (GH #1572)');
}
});
if (outcome === 'detached') {
this.log.info(ctx, `SWM cleanup still running after ${graceMs}ms grace — continuing detached so the publish response is not delayed (GH #1572)`);
}
}

Expand Down
Loading