Skip to content

Commit 2fd15a8

Browse files
Merge pull request #746 from corbitsdev/cl-7269-make-one-store-own-worker-lifecycle-and-wait-results
Make one store own worker lifecycle and wait results
2 parents 9915453 + 9957775 commit 2fd15a8

19 files changed

Lines changed: 1281 additions & 419 deletions

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
2121
Dedicated Nordic å in `/model` is treated as that shortcut when Add
2222
Provider is offered.
2323

24+
### Changed
25+
26+
- Cancelling a `task` or `wait_agents` worker reports wait status `interrupted`,
27+
not `failed`.
28+
2429
### Fixed
2530

2631
- Codex ChatGPT subscription sessions no longer show a public-rate dollar

docs/ARCHITECTURE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ In TUI chat mode there is no completion gate — the session stays open across t
100100
- Entry: `corbits exec "prompt"` (alias `corbits run`); `loadConfig` sets `command: "exec"`
101101
- Streams assistant text deltas to stdout; lifecycle errors to stderr
102102
- Shares ChatDirector compaction continuation (`requestContinuation` → content-less deliver after compact) so long runs do not stall post-compact
103-
- 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`
103+
- 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
104104
- 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
105105
- Used by `scripts/demo.ts` (mode `exec`) and the capability eval suite (`scripts/eval-capability.ts` / `evals/capability/`)
106106

@@ -226,7 +226,7 @@ Every director package carries a required `tier: SubagentTier` field (`src/agent
226226
Enforcement is runtime code at the existing tool-mount point, not prompt wording — this is the fix for four prior mechanisms (`writePaths`, `report.requiredSections`, a `--config` comment, the thrash matcher) that were documented-as-enforced while enforcing nothing:
227227

228228
- **Mount-time gate — live today, and fails closed.** `task-tool.ts` resolves the caller's tier at dispatch time — a closed director's `DirectorPackage.tier` — and forwards it as `RunSubAgentParams.orchestratorTier`. `runSubAgent` (`src/subagent/run.ts`) then calls `assertTierMayMountFleetVerb(tier, toolName)` (`src/subagent/authority.ts`) before installing fleet verbs, treating a **missing** `orchestratorTier` as `"leaf"` — deny, not skip. This is the case that matters most: a project-local or plugin `AgentProfile` with `orchestrator: true` is outside the closed director set and is **not** trusted with fleet verbs just because `orchestrator: true` is set — there is no profile-level opt-in today, so the mount always throws `FleetAuthorityError` for a profile-sourced orchestrator. `FLEET_VERBS` in `authority.ts` names the live verbs (`task`, `spawn_agent`, `wait_agents`, `list_agents`, `send_input`, `interrupt_agent`, `close_agent`, `resume_agent`, `read_agent_trace`, `search_agents`) so every mount site inherits the same gate. `list_agents` is the non-blocking mailbox-scoped list of this install's own `spawn_agent` workers (same scope as `wait_agents`); nested orchestrators may mount it. Fleet discovery (`search_agents`) remains Tier 1 only.
229-
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's own `fleetRecords`, not every running session in the shared store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` / `send_input` with `interrupt:true` terminalize the wait mailbox immediately; the soft-interrupt wait path collects so a later followup cannot resurrect an already-observed interrupt. `close_agent` also terminalizes the wait mailbox before teardown.
229+
- **Subtree authority — wired for addressing verbs.** `assertCanTargetAgent(actor, targetId, nodes)` implements the "root owns its tree; a child manages only its own descendants" rule over the `{id, parentSessionId}` shape `SubAgentSessionStore` already tracks. Production call sites: `read_agent_trace`, `send_input`, `interrupt_agent`, `close_agent`, and `resume_agent`. Nested mounts pass `{actorId, tier, getNodes}` from `run.ts`; a missing `actorId` fails closed. Tier-1 primary omits authority and stays unrestricted. `spawn_agent` records `parentSessionId` on nested workers so `close_agent`'s descendant walk can see them. `wait_agents` with omitted targets waits only on that caller's per-install wait mailbox over the shared session store, not every running session. Wait JSON is a projection of stored lifecycle plus mailbox membership/pin/collected/interrupt override — not a second terminal store. `list_agents` reports that same mailbox without blocking. `interrupt_agent` stamps the session `interrupted`; wait JSON projects that stored lifecycle and does not write a mailbox overlay. `send_input` with `interrupt:true` sets the mailbox interrupt overlay so wait unblocks while a queued followup may already be running. The wait path collects a terminal status so a later followup cannot resurrect an already-observed interrupt. `close_agent` terminalizes the wait mailbox before teardown. Operator cancel (`cancel` / `cancelAll`) projects wait status `interrupted`.
230230
- `task()` remains the deprecated fused spawn+wait fallback. `spawn_agent` + `wait_agents` is the supported parallel path. The tier check still gates which packages may mount any fleet verb.
231231

232232
#### Closed director fleet (`src/agent/directors/`)
@@ -295,6 +295,8 @@ Every shipped specialist is a **director package** — a prompt-first `DirectorP
295295

296296
**Session records** (`src/subagent/session-store.ts`): each spawn is retained as an inspectable child session (id, profile, description, brief, status, tool activity, transcript entries). Child events land only in this store — not in the parent chat transcript. Live progress still uses the light `onProgress` channel for the status bar. Completed sessions are capped (`maxCompleted`) so a long chat does not grow without bound.
297297

298+
**Wait mailbox** (`src/subagent/agent-fleet.ts` `FleetMailbox`): per-install overlay over that session store. Wait JSON is a projection of stored lifecycle plus mailbox membership, pin, collected, and optional interrupt override — not a second terminal store. Mailbox `register` pins an uncollected result (honored by prune); past `MAX_FLEET_RECORDS` the oldest never-collected pin is compacted to a tombstone. Operator cancel projects wait status `interrupted`.
299+
298300
**Observe (OpenTUI)**: `shell.ts:enterSubagentObserve` swaps the transcript for a child's stream (live while running, historical when done) without stealing the parent reactor; child events are mapped to stream rows by `src/tui/observe-map.ts`. Esc leaves observe and restores the parent transcript. Parent Esc/stop and `/clear` still call `cancelAll` so live children close (`agent.close`) instead of continuing after the parent stops. The host-injection point that resolves a live session (`onObserveRequest``observeSessionFromSubAgents`, `src/tui/runner-host.ts`, picking the newest running child else the most recent session of any status) is triggered by Alt+O (`shell.ts:observeActiveSubagent`) — the command palette action that used to call it is gone along with `src/tui/palette.ts` itself, but the chord replaces it rather than dropping the feature.
299301

300302
Data-only agent plugins (`src/plugins/data-only-agent.ts`) synthesize `agentPlugin.agents[]` from `agents/*.md` or flat `*.md` in the plugin directory, with optional co-located `skills/`. `loadPluginEntry` tries JS entrypoints first, then falls back to this layout (`/plugins` add-by-path supports filesystem completion via `listPathSuggestions`).

src/agent/tools.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import {
5050
type SubAgentSessionStore,
5151
} from "../subagent/index.js";
5252
import {
53-
createFleetRecords,
53+
createFleetMailbox,
5454
createSpawnAgentTool,
5555
createWaitAgentsTool,
5656
createListAgentsTool,
@@ -347,7 +347,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
347347
const orchestratorTools: AgentTool[] = [];
348348
if (subAgentsEnabled && args.subAgent !== undefined) {
349349
const sa = args.subAgent;
350-
const fleetRecords = sa.sessions !== undefined ? createFleetRecords() : undefined;
350+
const fleetRecords = sa.sessions !== undefined ? createFleetMailbox(sa.sessions) : undefined;
351351
orchestratorTools.push(
352352
createTaskTool({
353353
cwd,

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

0 commit comments

Comments
 (0)