Skip to content

Commit 306c252

Browse files
committed
Cancel live workers when a headless exec run ends
Headless exec finally cancels live sub-agents the same way TUI runtime shutdown does, then closes the primary agent and disposes the toolset. cancelAll is fire-and-forget and does not serialize closeOne.
1 parent d305255 commit 306c252

3 files changed

Lines changed: 70 additions & 19 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ In TUI chat mode there is no completion gate — the session stays open across t
9393
- Entry: `corbits exec "prompt"` (alias `corbits run`); `loadConfig` sets `command: "exec"`
9494
- Streams assistant text deltas to stdout; lifecycle errors to stderr
9595
- Shares ChatDirector compaction continuation (`requestContinuation` → content-less deliver after compact) so long runs do not stall post-compact
96-
- Single primary `agent.send(task)` turn; samples run-sink status/error **before** close (close emits `reactor.done` which would clear sticky errors); then closes the agent before draining the stream so the process exits; toolset is always disposed in `finally`
96+
- Single primary `agent.send(task)` turn; samples run-sink status/error **before** close (close emits `reactor.done` which would clear sticky errors); then closes the agent before draining the stream so the process exits; `finally` cancels live sub-agents (`subAgentSessions.cancelAll("Session closed")`, matching TUI runtime-shutdown), closes the agent, and always disposes the toolset
9797
- Status: chat sessions rarely emit `reactor.done` before close, so a completed `send()` maps to `done` unless the pre-close run sink holds a real error
9898
- Used by `scripts/demo.ts` (mode `exec`) and the capability eval suite (`scripts/eval-capability.ts` / `evals/capability/`)
9999

src/exec/runner.ts

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ import {
4545
import { detectLanguageServerAvailable } from "../agent/lsp-availability.js";
4646
import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js";
4747
import { resolveSessionMode, type SessionMode } from "../config/session-mode.js";
48-
import { createSubAgentSessionStore, type SubAgentProvider } from "../subagent/index.js";
48+
import {
49+
createSubAgentSessionStore,
50+
type SubAgentProvider,
51+
type SubAgentSessionStore,
52+
} from "../subagent/index.js";
4953
import type {
5054
ContextStore,
5155
InferenceSource,
@@ -116,6 +120,33 @@ export function formatCaughtError(err: unknown): string {
116120
return err instanceof Error ? err.message : String(err);
117121
}
118122

123+
/**
124+
* Headless analogue of TUI `runtime-shutdown`: abort live workers, then close
125+
* the primary agent and dispose the toolset. `cancelAll` is fire-and-forget —
126+
* it does not serialize `closeOne`.
127+
*/
128+
export async function disposeExecRuntime(args: {
129+
agent: { close: () => Promise<unknown> } | null;
130+
toolset: { dispose: () => Promise<unknown> } | null;
131+
subAgentSessions: Pick<SubAgentSessionStore, "cancelAll"> | null;
132+
}): Promise<void> {
133+
args.subAgentSessions?.cancelAll("Session closed");
134+
if (args.agent !== null) {
135+
await args.agent.close().catch((err: unknown) => {
136+
logger.debug("agent.close during exec finally failed: {error}", {
137+
error: formatCaughtError(err),
138+
});
139+
});
140+
}
141+
if (args.toolset !== null) {
142+
await args.toolset.dispose().catch((err: unknown) => {
143+
logger.debug("toolset.dispose during exec finally failed: {error}", {
144+
error: formatCaughtError(err),
145+
});
146+
});
147+
}
148+
}
149+
119150
/**
120151
* Exec-primary director overlay. Omit / skywalker keep the product default
121152
* (`loadSessionChatPrompt` + advertised session tools). Any other closed-fleet
@@ -248,6 +279,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
248279
let connectedMcp: ConnectedMcpServer[] = [];
249280
let agent: Agent | null = null;
250281
let toolset: AgentToolset | null = null;
282+
let subAgentSessions: SubAgentSessionStore | null = null;
251283
let textOut = "";
252284
let finalized = false;
253285
let turnsUsed = 0;
@@ -392,7 +424,8 @@ export async function runExec(config: Config): Promise<ExecResult> {
392424
const liveSubAgentProvider: { current: SubAgentProvider } = {
393425
current: buildSubAgentProvider(config),
394426
};
395-
const subAgentSessions = createSubAgentSessionStore();
427+
const fleetSessions = createSubAgentSessionStore();
428+
subAgentSessions = fleetSessions;
396429
const shellTimeout = shellTimeoutFromSettings(config.settings);
397430
const toolWatchdog = toolWatchdogFromSettings(config.settings);
398431
const toolAvailability: ToolAvailability = {
@@ -447,7 +480,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
447480
? {
448481
subAgent: {
449482
provider: () => liveSubAgentProvider.current,
450-
sessions: subAgentSessions,
483+
sessions: fleetSessions,
451484
getWorkdirBase: () => sessionDir(config.cwd, sessionId),
452485
onProgress: () => undefined,
453486
...(config.settings !== undefined ? { settings: () => config.settings! } : {}),
@@ -888,21 +921,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
888921
model: config.model,
889922
};
890923
} finally {
891-
if (agent !== null) {
892-
await agent.close().catch((err: unknown) => {
893-
logger.debug("agent.close during exec finally failed: {error}", {
894-
error: formatCaughtError(err),
895-
});
896-
});
897-
}
898-
// Match TUI: always dispose toolset (MCP clients + posix/plugin resources).
899-
if (toolset !== null) {
900-
await toolset.dispose().catch((err: unknown) => {
901-
logger.debug("toolset.dispose during exec finally failed: {error}", {
902-
error: formatCaughtError(err),
903-
});
904-
});
905-
}
924+
await disposeExecRuntime({ agent, toolset, subAgentSessions });
906925
}
907926
}
908927

tests/unit/exec/runner.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { describe, expect, test } from "bun:test";
22
import type { Config } from "../../../src/config/index.js";
33
import {
4+
disposeExecRuntime,
45
formatCaughtError,
56
resolveExecDirectorOverlay,
67
runExec,
78
} from "../../../src/exec/runner.js";
89
import { BUILD_TOOLS } from "../../../src/agent/directors/tool-sets.js";
10+
import { createSubAgentSessionStore } from "../../../src/subagent/session-store.js";
911

1012
function bareConfig(task: string): Config {
1113
// Minimal unconfigured-shaped object is not enough — runExec only needs
@@ -54,6 +56,36 @@ describe("runExec", () => {
5456
});
5557
});
5658

59+
describe("disposeExecRuntime", () => {
60+
test("cancels fire-and-forget workers when exec finishes", async () => {
61+
const store = createSubAgentSessionStore();
62+
const worker = store.start({ description: "bg", agentId: "w", brief: "b" });
63+
let aborted = 0;
64+
store.registerCancel(worker.id, () => {
65+
aborted += 1;
66+
});
67+
68+
const calls: string[] = [];
69+
await disposeExecRuntime({
70+
agent: {
71+
close: async () => {
72+
calls.push("agent");
73+
},
74+
},
75+
toolset: {
76+
dispose: async () => {
77+
calls.push("toolset");
78+
},
79+
},
80+
subAgentSessions: store,
81+
});
82+
83+
expect(aborted).toBe(1);
84+
expect(store.get(worker.id)?.status).toBe("cancelled");
85+
expect(calls).toEqual(["agent", "toolset"]);
86+
});
87+
});
88+
5789
describe("resolveExecDirectorOverlay", () => {
5890
test("builder exec primary does not mount task", () => {
5991
const overlay = resolveExecDirectorOverlay("builder");

0 commit comments

Comments
 (0)