Skip to content

Commit 3d2976b

Browse files
committed
Fail persist close_agent when child dispose throws
A leftover child after posix reap must fail persist close and parent toolset dispose instead of looking like a successful shutdown. A wedged close still times out as shutdown.
1 parent ed15287 commit 3d2976b

6 files changed

Lines changed: 164 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1818
- TUI quit, crash, and process signals await once-only runtime shutdown so live
1919
shell-guard children are reaped. Teardown failure after a completed session
2020
exits 1; SIGINT, SIGTERM, and SIGHUP still exit 128+n.
21+
- Persist close_agent surfaces leftover-child dispose failure so a worker
22+
that survives reap is not reported as a successful shutdown.
2123

2224
## [0.3.18] - 2026-09-08
2325

src/agent/fleet-verbs-mount.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,38 @@ describe("primary fleet verb mount", () => {
5353
await toolset.dispose();
5454
});
5555

56+
test("createAgentToolset dispose rejects when a fleet closeOne throws leftover children", async () => {
57+
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
58+
const { createAgentToolset } = await import("./tools.js");
59+
const permissionGate = {
60+
check: async () => ({ allowed: true }),
61+
getSkipPermissions: () => false,
62+
} as never;
63+
const sessions = createSubAgentSessionStore();
64+
const worker = sessions.start({ description: "d", agentId: "a", brief: "b" });
65+
sessions.markRunning(worker.id);
66+
sessions.registerClose(worker.id, async () => {
67+
throw new Error("1 shell child process still live after 2000ms reap");
68+
});
69+
70+
const toolset = await createAgentToolset({
71+
cwd,
72+
permissionGate,
73+
onOperatorGate: async () => ({ kind: "option", index: 0 }),
74+
subAgent: {
75+
provider: {
76+
providerName: "test",
77+
baseURL: "http://127.0.0.1:0",
78+
model: "test-model",
79+
},
80+
getWorkdirBase: () => cwd,
81+
sessions,
82+
},
83+
});
84+
85+
await expect(toolset.dispose()).rejects.toThrow(/still live after 2000ms reap/);
86+
});
87+
5688
test("createAgentToolset omits fleet verbs when subAgent is not set", async () => {
5789
const cwd = mkdtempSync(join(tmpdir(), "corbits-fleet-mount-"));
5890
const { createAgentToolset } = await import("./tools.js");
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
/**
2+
* Persist close_agent must surface a leftover-child posix dispose, not treat
3+
* it as a successful bounded close.
4+
*/
5+
import { describe, expect, test } from "bun:test";
6+
import { mkdtemp } from "node:fs/promises";
7+
import { tmpdir } from "node:os";
8+
import { join } from "node:path";
9+
10+
import type { ReactorEmittedEvent } from "@intx/inference";
11+
12+
import { withMockedModuleDuring } from "../../tests/helpers/mock-module.js";
13+
import { createPermissionGate } from "../permission/gate.js";
14+
import type { RunSubAgentParams } from "./types.js";
15+
16+
const permissionGate = createPermissionGate({
17+
approvals: [],
18+
interactive: false,
19+
skipPermissions: true,
20+
reactorGated: false,
21+
});
22+
23+
function stubAgent() {
24+
return {
25+
send: async () => {
26+
await new Promise((resolve) => setTimeout(resolve, 20));
27+
return {
28+
type: "reply" as const,
29+
reply: "done",
30+
turn: { role: "assistant", content: [] },
31+
};
32+
},
33+
stream: () => (async function* (): AsyncGenerator<ReactorEmittedEvent> {})(),
34+
deliver: () => {},
35+
close: async () => {},
36+
setSource: () => {},
37+
setSources: () => {},
38+
history: async () => [],
39+
checkpoints: async () => [],
40+
readAt: async () => [],
41+
blobReader: {},
42+
};
43+
}
44+
45+
describe("persist close_agent leftover dispose", () => {
46+
test("onAgentReady close rejects when posix dispose reports leftover children", async () => {
47+
const cwd = await mkdtemp(join(tmpdir(), "corbits-persist-close-"));
48+
49+
await withMockedModuleDuring(
50+
import.meta.resolve("@intx/tools-posix"),
51+
(real: typeof import("@intx/tools-posix")) => ({
52+
...real,
53+
createPosixTools: (opts: Parameters<typeof real.createPosixTools>[0]) =>
54+
Object.assign(real.createPosixTools(opts), {
55+
dispose: async () => {
56+
throw new Error("1 shell child process still live after 2000ms reap");
57+
},
58+
}),
59+
}),
60+
async () =>
61+
withMockedModuleDuring(
62+
import.meta.resolve("../agent/live-tool-dispatch.js"),
63+
(real: typeof import("../agent/live-tool-dispatch.js")) => ({
64+
...real,
65+
createAgentWithLiveToolDispatch: async () =>
66+
stubAgent() as unknown as Awaited<
67+
ReturnType<typeof real.createAgentWithLiveToolDispatch>
68+
>,
69+
}),
70+
async () => {
71+
const { runSubAgent } = await import("./run.js");
72+
let handles:
73+
| {
74+
close: (deadlineMs?: number) => Promise<void>;
75+
}
76+
| undefined;
77+
const params: RunSubAgentParams = {
78+
cwd,
79+
workdirBase: join(cwd, ".ctx"),
80+
permissionGate,
81+
provider: { providerName: "test", baseURL: "http://localhost", model: "test-model" },
82+
description: "persist close leftover probe",
83+
prompt: "finish the first turn",
84+
persist: true,
85+
onAgentReady: (h) => {
86+
handles = h;
87+
},
88+
};
89+
const result = await runSubAgent(params);
90+
expect(result.agentRetained).toBe(true);
91+
if (handles === undefined) throw new Error("onAgentReady never fired");
92+
await expect(handles.close(1000)).rejects.toThrow(/still live after 2000ms reap/);
93+
},
94+
),
95+
);
96+
});
97+
});

src/subagent/run.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,15 +1095,19 @@ async function runSubAgentInner(
10951095
if (params.onAgentReady !== undefined) {
10961096
const boundedClose = async (deadlineMs = DEFAULT_CLOSE_DEADLINE_MS): Promise<void> => {
10971097
if (!runController.signal.aborted) runController.abort(new Error("closed by close_agent"));
1098+
let disposeError: unknown;
10981099
const teardown = disposeSubAgentSession({
10991100
signal: runController.signal,
11001101
...(closeOnAbort !== undefined ? { closeOnAbort } : {}),
11011102
agent,
11021103
...(streamPromise !== undefined ? { streamPromise } : {}),
11031104
posixTools,
1104-
}).catch(() => {
1105-
// Best-effort: a wedged descendant must not reject the caller.
1106-
});
1105+
}).then(
1106+
() => undefined,
1107+
(err: unknown) => {
1108+
disposeError = err;
1109+
},
1110+
);
11071111
await Promise.race([
11081112
teardown,
11091113
new Promise<void>((resolve) => setTimeout(resolve, deadlineMs)),
@@ -1112,6 +1116,7 @@ async function runSubAgentInner(
11121116
// for a persisted session (see runController.dispose's doc); now that
11131117
// this session is actually closing, tear it down for real.
11141118
runController.dispose();
1119+
if (disposeError !== undefined) throw disposeError;
11151120
};
11161121
// Interrupt only fires interruptController — never runController/
11171122
// close, so it cannot hit the close()-ordering wedge documented in

src/subagent/session-store.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,19 @@ describe("CL-6943 reusable worker sessions", () => {
547547
expect(store.get(session.id)?.retained).toBe(false);
548548
});
549549

550+
test("closeOne rejects when the registered close throws a leftover child after reap", async () => {
551+
const store = createSubAgentSessionStore();
552+
const session = store.start({ description: "d", agentId: "a", brief: "b", retained: true });
553+
store.registerClose(session.id, async () => {
554+
throw new Error("1 shell child process still live after 2000ms reap");
555+
});
556+
557+
await expect(store.closeOne(session.id, 1000)).rejects.toThrow(/still live after 2000ms reap/);
558+
expect(store.get(session.id)?.lifecycleStatus).toBe("shutdown");
559+
expect(store.get(session.id)?.retained).toBe(false);
560+
expect(await store.closeOne(session.id, 1000)).toBe("shutdown");
561+
});
562+
550563
test("closeOne is idempotent and returns not_found for an unknown id", async () => {
551564
const store = createSubAgentSessionStore();
552565
const session = store.start({ description: "d", agentId: "a", brief: "b", retained: true });

src/subagent/session-store.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1218,12 +1218,17 @@ export function createSubAgentSessionStore(
12181218
// Bounded here too, defense-in-depth against a caller-registered
12191219
// close that does not honor its own deadline argument — a wedged
12201220
// descendant must not hang the whole close_agent call.
1221+
let closeError: unknown;
12211222
await Promise.race([
1222-
close(deadlineMs).catch((err: unknown) => {
1223-
log.warn("session close raced deadline: {error}", {
1224-
error: err instanceof Error ? err.message : String(err),
1225-
});
1226-
}),
1223+
close(deadlineMs).then(
1224+
() => undefined,
1225+
(err: unknown) => {
1226+
closeError = err;
1227+
log.warn("session close raced deadline: {error}", {
1228+
error: err instanceof Error ? err.message : String(err),
1229+
});
1230+
},
1231+
),
12271232
new Promise<void>((resolve) => setTimeout(resolve, deadlineMs)),
12281233
]);
12291234
if (keepFailed) {
@@ -1235,6 +1240,7 @@ export function createSubAgentSessionStore(
12351240
deliverHandles.delete(id);
12361241
runInFlight.delete(id);
12371242
pruneCompleted();
1243+
if (closeError !== undefined) throw closeError;
12381244
const after = sessions.get(id);
12391245
return after === undefined ? "not_found" : projectLifecycleStatus(after.lifecycle);
12401246
}
@@ -1258,6 +1264,7 @@ export function createSubAgentSessionStore(
12581264
deliverHandles.delete(id);
12591265
runInFlight.delete(id);
12601266
pruneCompleted();
1267+
if (closeError !== undefined) throw closeError;
12611268
return "shutdown";
12621269
},
12631270

0 commit comments

Comments
 (0)