Skip to content

Commit 1604b74

Browse files
LiuwqGitcursoragent
andcommitted
fix(agents): reconcile child output before lost-context sweep failure
When a subagent run loses live execution context, treat readable child assistant output as terminal evidence and complete as ok instead of always emitting lost-context error. Closes #90299. Co-authored-by: Cursor <[email protected]>
1 parent 1bdf210 commit 1604b74

4 files changed

Lines changed: 130 additions & 6 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import * as announceOutput from "./subagent-announce-output.js";
3+
import {
4+
LOST_ACTIVE_EXECUTION_CONTEXT_ERROR,
5+
resolveStaleActiveSubagentOutcome,
6+
} from "./subagent-lost-context-completion.js";
7+
8+
describe("resolveStaleActiveSubagentOutcome", () => {
9+
beforeEach(() => {
10+
vi.restoreAllMocks();
11+
});
12+
13+
it("returns ok when child session has readable assistant output", async () => {
14+
vi.spyOn(announceOutput, "readSubagentOutput").mockResolvedValue("# ARCHITECTURE.md");
15+
await expect(
16+
resolveStaleActiveSubagentOutcome({
17+
childSessionKey: "agent:main:subagent:child",
18+
}),
19+
).resolves.toEqual({ status: "ok" });
20+
});
21+
22+
it("returns lost-context error when no readable output is available", async () => {
23+
vi.spyOn(announceOutput, "readSubagentOutput").mockResolvedValue(undefined);
24+
await expect(
25+
resolveStaleActiveSubagentOutcome({
26+
childSessionKey: "agent:main:subagent:child",
27+
}),
28+
).resolves.toEqual({
29+
status: "error",
30+
error: LOST_ACTIVE_EXECUTION_CONTEXT_ERROR,
31+
});
32+
});
33+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/**
2+
* Reconcile stale active subagent runs that lost live execution context.
3+
*
4+
* When the sweeper cannot resolve terminal state from the session store, readable child
5+
* assistant output is treated as ground truth and completes as ok instead of a plain failure.
6+
*/
7+
import type { SubagentRunOutcome } from "./subagent-announce-output.js";
8+
import { readSubagentOutput } from "./subagent-announce-output.js";
9+
10+
export const LOST_ACTIVE_EXECUTION_CONTEXT_ERROR = "subagent run lost active execution context";
11+
12+
/** Resolve terminal outcome for a stale active run with no live agent.run context. */
13+
export async function resolveStaleActiveSubagentOutcome(params: {
14+
childSessionKey: string;
15+
}): Promise<SubagentRunOutcome> {
16+
const output = await readSubagentOutput(params.childSessionKey);
17+
if (output?.trim()) {
18+
return { status: "ok" };
19+
}
20+
return {
21+
status: "error",
22+
error: LOST_ACTIVE_EXECUTION_CONTEXT_ERROR,
23+
};
24+
}

src/agents/subagent-registry.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2218,6 +2218,67 @@ describe("subagent registry seam flow", () => {
22182218
expect(run?.cleanupCompletedAt).toBeTypeOf("number");
22192219
});
22202220

2221+
it("completes stale active runs as ok when child session has readable output", async () => {
2222+
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
2223+
if (request.method === "agent.wait") {
2224+
return { status: "pending" };
2225+
}
2226+
if (request.method === "chat.history") {
2227+
return {
2228+
messages: [
2229+
{
2230+
role: "assistant",
2231+
content: [{ type: "text", text: "# ARCHITECTURE.md\nrelease readiness design" }],
2232+
},
2233+
],
2234+
};
2235+
}
2236+
return {};
2237+
});
2238+
mocks.loadSessionStore.mockReturnValue({
2239+
"agent:main:subagent:child": {
2240+
sessionId: "sess-child",
2241+
updatedAt: 1,
2242+
status: "running",
2243+
},
2244+
});
2245+
2246+
const createdAt = Date.parse("2026-03-24T11:00:00Z");
2247+
vi.setSystemTime(createdAt);
2248+
mod.registerSubagentRun({
2249+
runId: "run-lost-context-with-output",
2250+
childSessionKey: "agent:main:subagent:child",
2251+
requesterSessionKey: "agent:main:main",
2252+
requesterDisplayKey: "main",
2253+
task: "deliver architecture doc",
2254+
cleanup: "keep",
2255+
expectsCompletionMessage: true,
2256+
});
2257+
2258+
vi.setSystemTime(createdAt + 65_000);
2259+
await mod.testing.sweepOnceForTests();
2260+
2261+
await waitForFast(() => {
2262+
const announceParams = findRecordCallArg(
2263+
mocks.runSubagentAnnounceFlow,
2264+
0,
2265+
"lost context recovered announce",
2266+
(record) => record.childRunId === "run-lost-context-with-output",
2267+
);
2268+
expectRecordFields(
2269+
announceParams.outcome,
2270+
{ status: "ok" },
2271+
"lost context recovered announce outcome",
2272+
);
2273+
});
2274+
2275+
const run = mod
2276+
.listSubagentRunsForRequester("agent:main:main")
2277+
.find((entry) => entry.runId === "run-lost-context-with-output");
2278+
expectRecordFields(run?.outcome, { status: "ok" }, "lost context recovered run outcome");
2279+
expect(run?.outcome?.error).toBeUndefined();
2280+
});
2281+
22212282
it("uses session-store start time when sweeping stale explicit-timeout runs", async () => {
22222283
mocks.callGateway.mockImplementation(async (request: { method?: string }) => {
22232284
if (request.method === "agent.wait") {

src/agents/subagent-registry.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
SUBAGENT_ENDED_REASON_KILLED,
4141
type SubagentLifecycleEndedReason,
4242
} from "./subagent-lifecycle-events.js";
43+
import { resolveStaleActiveSubagentOutcome } from "./subagent-lost-context-completion.js";
4344
import {
4445
emitSubagentEndedHookOnce,
4546
resolveLifecycleOutcomeFromRunOutcome,
@@ -958,20 +959,25 @@ async function sweepSubagentRuns() {
958959
continue;
959960
}
960961

962+
const lostContextOutcome = await resolveStaleActiveSubagentOutcome({
963+
childSessionKey: entry.childSessionKey,
964+
});
961965
await completeSubagentRunWithRecovery(
962966
{
963967
runId,
964968
endedAt: now,
965-
outcome: {
966-
status: "error",
967-
error: "subagent run lost active execution context",
968-
},
969-
reason: SUBAGENT_ENDED_REASON_ERROR,
969+
outcome: lostContextOutcome,
970+
reason:
971+
lostContextOutcome.status === "ok"
972+
? SUBAGENT_ENDED_REASON_COMPLETE
973+
: SUBAGENT_ENDED_REASON_ERROR,
970974
sendFarewell: true,
971975
accountId: entry.requesterOrigin?.accountId,
972976
triggerCleanup: true,
973977
},
974-
"sweeper-lost-context",
978+
lostContextOutcome.status === "ok"
979+
? "sweeper-lost-context-recovered"
980+
: "sweeper-lost-context",
975981
);
976982
continue;
977983
}

0 commit comments

Comments
 (0)