test(gateway): avoid writer deadlock in abort fixture

Fix the red-main cancellation fixture exposed by fef7992b36. The
fixture held a child writer while waiting for terminal publication,
which now correctly joins that same writer queue.

Hold the first real abort-marker call after terminal publication and
then delegate to the original helper. Preserve all reset/delete,
transcript, watermark, pending-completion and timeout assertions.

Hosted main 36780090668/job110108043105 and dependency PR #161881
36780094080/job110108350025 reproduce the same three failures with
fs-safe 0.21.2 and 0.21.3 respectively.

Validation: AWS Linux Node24.19 focused file10/10 (90.77s), original
31-file/two-config group424/424 (244.23s), changed-file checks including
Gateway-methods types, lint and all Knip scans, formatting and diff
checks. Independent review through P2 found no actionable issues.
This commit is contained in:
Peter Steinberger 2026-09-30 15:12:57 -07:00
parent 12220221ff
commit 79f0e9bf12
No known key found for this signature in database

View file

@ -14,6 +14,7 @@ import {
setActiveEmbeddedRun,
} from "../../agents/embedded-agent-runner/runs.js";
import { createEmbeddedRunHandle } from "../../agents/embedded-agent-runner/runs.test-support.js";
import * as subagentControlSession from "../../agents/subagents/registry/subagent-control-session.js";
import { killSubagentRunAdmin } from "../../agents/subagents/registry/subagent-control.js";
import { SUBAGENT_KILL_TASK_ERROR } from "../../agents/subagents/registry/subagent-control.types.js";
import { onSubagentRegistryPersisted } from "../../agents/subagents/registry/subagent-registry-state.js";
@ -30,7 +31,6 @@ import {
appendTranscriptMessageSync,
loadExactSessionEntryReadOnly,
loadTranscriptEvents,
patchSessionEntryCore,
readSessionTranscriptWatermark,
replaceSessionEntrySync,
} from "../../config/sessions/session-accessor.js";
@ -404,8 +404,7 @@ it.each(["cascade native new", "RPC reset", "RPC delete"])(
const childKey = "agent:child:subagent:incarnation";
const storePath = path.join(fixture.stateDir, "agents/main/sessions/sessions.json");
const childStore = path.join(fixture.stateDir, "agents/child/sessions/sessions.json");
// Automatic seed maintenance would wait on the fixture's held child writer
// while retaining the archive queue needed by parent deletion.
// Seed directly; this fixture controls the cancellation/reset interleaving.
replaceSessionEntrySync(
{ storePath, sessionKey },
{
@ -458,19 +457,7 @@ it.each(["cascade native new", "RPC reset", "RPC delete"])(
}
});
const native = boundary.endsWith("native new");
let writer: Promise<unknown> | undefined;
const childHandle = createEmbeddedRunHandle({
abort: () => {
writer = patchSessionEntryCore(
{ storePath: childStore, sessionKey: childKey },
async () => {
entered.resolve();
await release.promise;
return null;
},
);
},
});
const childHandle = createEmbeddedRunHandle();
setActiveEmbeddedRun("incarnation-child", childHandle, childKey);
const parentAdmission = await beginSessionWorkAdmission({
scope: storePath,
@ -494,6 +481,19 @@ it.each(["cascade native new", "RPC reset", "RPC delete"])(
});
context.chatAbortControllers.set("parent", parent);
context.chatRunState.getOrCreate("parent").buffer = "old delayed partial";
// Hold only the original marker after termination, without occupying the child writer FIFO.
const persistMarker = subagentControlSession.persistSubagentAbortedLastRun;
let markerHeld = false;
const markerWriter = vi
.spyOn(subagentControlSession, "persistSubagentAbortedLastRun")
.mockImplementation(async (params) => {
if (!markerHeld && params.childSessionKey === childKey && params.abortedLastRun) {
markerHeld = true;
entered.resolve();
await release.promise;
}
return await persistMarker(params);
});
let completed = false;
const abort = invokeChatAbortHandler({
handler: (options) =>
@ -518,7 +518,7 @@ it.each(["cascade native new", "RPC reset", "RPC delete"])(
expect(context.chatAbortControllers.has("parent")).toBe(false);
expect(getSubagentRunByChildSessionKey(childKey)?.endedReason).toBe("subagent-killed");
// Explicit reset drains children, including native /new. Keep the original
// abort pending on its marker writer, not on work the reset must stop.
// abort pending on its marker publication, not on work the reset must stop.
clearActiveEmbeddedRun("incarnation-child", childHandle, childKey);
if (native) {
const reset = await initSessionState({
@ -575,7 +575,6 @@ it.each(["cascade native new", "RPC reset", "RPC delete"])(
).not.toHaveProperty("firstKeptEntryId");
}
release.resolve();
await writer;
const response = await abort;
expect(response).toHaveBeenCalledWith(true, expect.objectContaining({ aborted: true }));
expect(getSubagentRunByChildSessionKey(childKey)?.endedReason).toBe("subagent-killed");
@ -592,9 +591,12 @@ it.each(["cascade native new", "RPC reset", "RPC delete"])(
parentAdmission.release();
operation.complete();
release.resolve();
await writer;
await abort;
clearActiveEmbeddedRun("incarnation-child", childHandle, childKey);
markerWriter.mockRestore();
try {
await abort;
} finally {
clearActiveEmbeddedRun("incarnation-child", childHandle, childKey);
}
}
},
);