Skip to content

Commit 98e4e5f

Browse files
committed
feat(subagent): record typed terminal failures for the parent
A failed worker reached the parent as two booleans and error prose, so the parent could not tell a transient provider fault from a stuck or cancelled lane, and an answered ask_director left no trace once the run died. The store now writes one frozen failure record in the same mutation that makes a non-clean terminal visible, and provider_failure and continuable derive from its class so every projection agrees.
1 parent a86b77c commit 98e4e5f

14 files changed

Lines changed: 1344 additions & 136 deletions

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ Two directors, selected by role:
153153
Auto mode is toggled by CLI flags (`--auto` / `--no-auto`); there is currently no in-session key to toggle it (default on; constrained envelope — workspace writes and unconstrained shell auto-allow; installs, recursive rm, force/uncontained worktree changes, sensitive-path and opaque-wrapper shell still ask; contained non-force `git worktree add`/`remove`/`prune` and `list` auto-allow; shell file-mutation denied). It is not a separate edit/plan mode.
154154

155155
- **SubAgentDirector** (delegated work, `src/subagent/index.ts`) — Drives a dispatched worker until a turn arrives with no tool calls, then replies with the final assistant text and ends the run. A tool-less turn **after tools** completes only with the four-heading envelope (Summary, Findings, Blockers, Paths). Assistant text that prints explicit `<tool_call>` markup is treated as attempted tool use, not narration: one **verbatim-tool-call** nudge asks the worker to re-issue a real `tool_call` and does not count toward the tool-less spiral. A missing envelope otherwise nudges once (**incomplete-report**) and a second tool-less turn still without the envelope salvages as **incomplete-report-stop** (once; later tool-less turns wait). Explore/read-only workers that used tools then replied with findings remain normal completes; `requireEvidence` (off by default, set per director) additionally requires at least one read before a tool-less spawn-only reply can complete. `requirePlanSubstance` (planner or `intent=plan`, not `modelRole === "plan"`) additionally requires Findings to contain files/paths, acceptance criteria, non-goals, risks, and ordered steps with a non-placeholder line each — four headings with stub Findings are incomplete-report, not an attachable plan. After real tool work, wrap-up Findings that are not placeholder or outline-only complete instead of being salvaged as a stub plan. Reads done through `run_shell` count as evidence too — `src/subagent/shell-evidence.ts` classifies shell reads (`cat`, `grep`, `sed` without `-i`, …) over the same subject expansion the auto-shell policy uses — but there is no corresponding shell-write evidence or file-write requirement: a run that never touches a file still completes normally once it replies with the envelope. There is no turn budget. Operator/parent cancel after any progress returns a **cancelled** salvage report (partial findings + tool activity) instead of a bare cancel string; cancel before progress still surfaces as cancelled-by-operator. There is no repetition/no-progress/never-acted/never-edited hard stop and no fingerprint-based re-dispatch block — a genuinely stuck worker runs until it completes, stalls, hits an opt-in wall-clock deadline, or is cancelled.
156-
`spawn_agent` starts each worker and records it in the caller's fleet mailbox. On the TUI primary, mailbox mail is the collect path: occupancy takes uncollected terminals and re-enters the parent as system inbound with a Summary + Blockers digest (Findings when Summary is empty) and a blob pointer named for `read_file`. Nested orchestrators collect through mailbox mail the same way. A mounted `wait_agents` (exec primary) collects worker reports directly instead. Already-collected waits return status without a second report or error body. Wait JSON includes `stop_reason` from the session when present so a salvage that is wait-`done` is not mistaken for a clean complete, and so parent-initiated interrupt (`interrupted`) is not mistaken for operator-cancel (`cancelled`). Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Failed and incomplete-report salvage tell the parent to diagnose from the report or error and MAY spawn one successor with a changed brief. A parent-initiated interrupt is a resumable pause: wait unblocks with `stop_reason: interrupted` (often while the session is still running and has no report); the parent should `resume_agent` or re-wait, and must not spawn a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Identical re-dispatch of the same brief stays refused at the prompt / spawn-handoff layer, except a recoverable/continuable child failure (`continuable: true`) MAY spawn one successor with the same brief; there is no fingerprint-based re-dispatch hard-block. Deadline hints are advisory only — an identical re-dispatch is still admitted at runtime. Parent hints are prepended on salvage reports returned to the parent. The runtime does not auto-spawn successors.
156+
`spawn_agent` starts each worker and records it in the caller's fleet mailbox. On the TUI primary, mailbox mail is the collect path: occupancy takes uncollected terminals and re-enters the parent as system inbound with a Summary + Blockers digest (Findings when Summary is empty) and a blob pointer named for `read_file`. Nested orchestrators collect through mailbox mail the same way. A mounted `wait_agents` (exec primary) collects worker reports directly instead. Already-collected waits return status without a second report or error body. Wait JSON includes `stop_reason` from the session when present so a salvage that is wait-`done` is not mistaken for a clean complete, and so parent-initiated interrupt (`interrupted`) is not mistaken for operator-cancel (`cancelled`). Every terminal non-clean outcome (failed, cancelled, interrupted, or a forced-stop salvage) also carries a `failure` record, written in the same store mutation that makes the outcome visible and never rewritten: a typed `failure_class` (`provider_retryable`, `provider_fatal`, `loop_guard` for the reactor's doom-loop guard, the forced-stop reasons, or `error`), `recovery` availability with a typed reason, the attempt number, the ask_director `handoff` state at termination (`delivered` only when the worker's own ask_director call returned the answer), and whether `cleanup` released everything. `provider_failure` and `continuable` derive from that record, so wait_agents, list_agents, and mailbox mail cannot disagree. Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Failed and incomplete-report salvage tell the parent to diagnose from the report or error and MAY spawn one successor with a changed brief. A parent-initiated interrupt is a resumable pause: wait unblocks with `stop_reason: interrupted` (often while the session is still running and has no report); the parent should `resume_agent` or re-wait, and must not spawn a successor against a still-live worker. Successor only if that session is no longer resumable. Operator-cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Identical re-dispatch of the same brief stays refused at the prompt / spawn-handoff layer, except a recoverable/continuable child failure (`continuable: true`) MAY spawn one successor with the same brief; there is no fingerprint-based re-dispatch hard-block. Deadline hints are advisory only — an identical re-dispatch is still admitted at runtime. Parent hints are prepended on salvage reports returned to the parent. The runtime does not auto-spawn successors.
157157

158158
#### Model-family policy (`src/agent/model-family-policy.ts`)
159159

‎src/subagent/agent-fleet.test.ts‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1200,9 +1200,15 @@ describe("interrupt_agent unblocks wait_agents", () => {
12001200
agent_id: string;
12011201
status: string;
12021202
stop_reason?: string;
1203+
failure?: unknown;
12031204
}[];
12041205
expect(results).toEqual([
1205-
{ agent_id: id, status: "interrupted", stop_reason: "interrupted" },
1206+
{
1207+
agent_id: id,
1208+
status: "interrupted",
1209+
stop_reason: "interrupted",
1210+
failure: expect.objectContaining({ failure_class: "interrupted" }),
1211+
},
12061212
]);
12071213
expect(deps.sessions.get(id)?.lifecycleStatus).toBe("interrupted");
12081214
expect(deps.sessions.get(id)?.status).toBe("running");
@@ -1562,9 +1568,15 @@ describe("interrupt_agent unblocks wait_agents", () => {
15621568
agent_id: string;
15631569
status: string;
15641570
stop_reason?: string;
1571+
failure?: unknown;
15651572
}[];
15661573
expect(results).toEqual([
1567-
{ agent_id: id, status: "interrupted", stop_reason: "interrupted" },
1574+
{
1575+
agent_id: id,
1576+
status: "interrupted",
1577+
stop_reason: "interrupted",
1578+
failure: expect.objectContaining({ failure_class: "interrupted" }),
1579+
},
15681580
]);
15691581
expect(deps.fleetRecords.peek(id)?.status).toBe("interrupted");
15701582
expect(deps.fleetRecords.peek(id)?.collected).toBe(true);
@@ -2431,9 +2443,15 @@ describe("admission queue", () => {
24312443
agent_id: string;
24322444
status: string;
24332445
stop_reason?: string;
2446+
failure?: unknown;
24342447
}[];
24352448
expect(results).toEqual([
2436-
{ agent_id: queuedId, status: "interrupted", stop_reason: "cancelled" },
2449+
{
2450+
agent_id: queuedId,
2451+
status: "interrupted",
2452+
stop_reason: "cancelled",
2453+
failure: expect.objectContaining({ failure_class: "cancelled" }),
2454+
},
24372455
]);
24382456
expect(started()).toBe(1);
24392457

0 commit comments

Comments
 (0)