Skip to content

Commit d3292b8

Browse files
committed
Fire one occupancy shot after a missed fleet-dry edge
A last-lane terminal while the parent is still processing dropped the wentDry edge, then settle idled with open tasks. Occupancy now latches that edge and drives once from settle. Send failure no longer consumes mailbox reports.
1 parent fa51962 commit d3292b8

10 files changed

Lines changed: 299 additions & 60 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ In TUI chat mode there is no completion gate — the session stays open across t
112112

113113
Two directors, selected by role:
114114

115-
- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Yielding while a live fleet is running is allowed (idle-with-fleet); the open-task nudge does not rewrite that wait/reply. When the fleet goes dry with tasks still todo/doing, the TUI runtime re-enters the parent with collected worker reports rather than settling idle. 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.
115+
- **ChatDirector** (interactive, `src/agent/director.ts`) — Extends `DefaultDirector` with task list tracking, workflow nudges, LSP auto-activation, and multi-turn chat semantics. It never terminates the session: operator declines are surfaced as replies and the reactor stays alive for the next message. Yielding while a live fleet is running is allowed (idle-with-fleet); the open-task nudge does not rewrite that wait/reply. When the fleet goes dry with tasks still todo/doing, the TUI runtime re-enters the parent with collected worker reports rather than settling idle.
116+
117+
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.
118+
116119
- **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); a missing envelope nudges once (**incomplete-report**) and a second tool-less turn still without the envelope salvages as **incomplete-report-stop**. 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. 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.
117120
`spawn_agent` starts each worker and records it in the caller's fleet mailbox; `wait_agents` collects terminal reports from that mailbox. 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; 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.
118121

src/agent/directors/skywalker/package.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ You do not do the specialists' jobs by default. For tiny bounded product edits,
1616
1717
Do not run long-blocking jobs on the parent (evals, full test suites, long installs, long-running implementation). Dispatch intern (mechanical shell), tester (suite / repro), or builder (substantial code). Path tools (write_file/edit_file/delete_file) are the DIY surface; shell file-writes stay denied.
1818
19-
Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms) so Enter can land; do not immediately fuse into a long wait_agents right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). list_agents shows that same fleet without blocking. Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents holds those steers. A bare spawn_agent does not. When the fleet goes dry the runtime re-enters with collected reports; do not tight-loop wait_agents.
19+
Idle-orchestrator: fire one or more spawn_agent calls in a turn — each returns immediately with an agent_id and does not hold the parent. Then **reply to the operator** with who is running and what happens next before you block. Prefer ending that turn (or calling wait_agents with a short timeout_ms) so Enter can land; do not immediately fuse into a long wait_agents right after spawn. wait_agents later on the targets you need (or omit targets to wait on this session's own uncollected spawns — never a sibling's). list_agents shows that same fleet without blocking. Use mode="all" when you need every target to finish; interrupt_agent unblocks wait_agents immediately. A timeout means still running — do not tight-loop wait_agents hoping for a different answer. Enter mid-run delivers at the next parent tool.boundary — a long parent run_shell or awaiting wait_agents holds those steers. A bare spawn_agent does not. When the fleet goes dry the runtime re-enters with collected reports.
2020
2121
# Operator updates (mandatory while fleet is live)
2222

src/session/runtime-assembly.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -382,8 +382,10 @@ export function buildCompactionContinuationMessage(): InboundMessage {
382382

383383
/**
384384
* System-originated inbound that re-enters the parent after the fleet goes dry
385-
* with todo/doing tasks still open. No OPERATOR_ORIGINATED_FLAG — this is not
386-
* an operator prompt and must not reset the tool-only loop-protection backstop.
385+
* with todo/doing tasks still open. Not operator input, so no
386+
* OPERATOR_ORIGINATED_FLAG. ChatDirector still resets idle and tool-only
387+
* nudge counters on any message.received — occupancy therefore fires one
388+
* deferred shot per dry edge rather than re-driving on every settle.
387389
*/
388390
export function buildFleetDryContinuationMessage(text: string): InboundMessage {
389391
return {

src/subagent/agent-fleet.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import { formatSubAgentSpawnAuthFailureMessage } from "./inference-auth-failure.
9494
import { isResolvedProviderFailureError } from "../inference-error-message.js";
9595
import { isSubAgentCancelError } from "./dispose.js";
9696
import { createInterventionLog, type InterventionSink } from "./intervention-log.js";
97+
import { takeAndProjectMailboxRecord } from "./fleet-dry-drive.js";
9798

9899
const log = getLogger([LOG_NAMESPACE_ROOT, "subagent", "agent-fleet"]);
99100

@@ -1398,20 +1399,14 @@ export function createWaitAgentsTool(deps: WaitAgentsDeps): AgentTool {
13981399
if (isLiveWaitStatus(record.status)) {
13991400
return { agent_id: id, status: record.status };
14001401
}
1401-
const taken = deps.fleetRecords.take(id) ?? record;
1402+
const projected = takeAndProjectMailboxRecord(deps.fleetRecords, id);
1403+
if (projected === undefined) {
1404+
return { agent_id: id, status: "unknown" as const };
1405+
}
14021406
return {
1403-
agent_id: id,
1404-
status: taken.status,
1405-
...(taken.question !== undefined ? { question: taken.question } : {}),
1406-
...(taken.questionId !== undefined ? { question_id: taken.questionId } : {}),
1407-
...(taken.description !== undefined ? { description: taken.description } : {}),
1408-
...(taken.status !== "failed" && taken.report !== undefined
1409-
? { report: taken.report }
1410-
: {}),
1411-
...(taken.error !== undefined ? { error: taken.error } : {}),
1412-
...(taken.stopReason !== undefined ? { stop_reason: taken.stopReason } : {}),
1413-
...(taken.providerFailure === true ? { provider_failure: true } : {}),
1414-
...(taken.hint !== undefined ? { hint: taken.hint } : {}),
1407+
...projected,
1408+
...(record.question !== undefined ? { question: record.question } : {}),
1409+
...(record.questionId !== undefined ? { question_id: record.questionId } : {}),
14151410
};
14161411
});
14171412

src/subagent/fleet-dry-drive.test.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,45 @@ describe("shouldDriveOpenTasks", () => {
6161
}),
6262
).toBe(false);
6363
});
64+
65+
test("deferred dry edge fires once when still dry+open and parent is idle", () => {
66+
expect(
67+
shouldDriveOpenTasks({
68+
previousRunning: 0,
69+
running: 0,
70+
hasOpenTasks: true,
71+
parentProcessing: false,
72+
deferredDryEdge: true,
73+
}),
74+
).toBe(true);
75+
expect(
76+
shouldDriveOpenTasks({
77+
previousRunning: 0,
78+
running: 0,
79+
hasOpenTasks: true,
80+
parentProcessing: true,
81+
deferredDryEdge: true,
82+
}),
83+
).toBe(false);
84+
expect(
85+
shouldDriveOpenTasks({
86+
previousRunning: 0,
87+
running: 0,
88+
hasOpenTasks: false,
89+
parentProcessing: false,
90+
deferredDryEdge: true,
91+
}),
92+
).toBe(false);
93+
expect(
94+
shouldDriveOpenTasks({
95+
previousRunning: 0,
96+
running: 1,
97+
hasOpenTasks: true,
98+
parentProcessing: false,
99+
deferredDryEdge: true,
100+
}),
101+
).toBe(false);
102+
});
64103
});
65104

66105
describe("buildFleetDryContinuationPrompt", () => {
@@ -264,4 +303,69 @@ describe("driveOpenTasksAfterFleetDry", () => {
264303
}),
265304
).toBe(false);
266305
});
306+
307+
test("deferred dry edge after parentProcessing still collects and sends", () => {
308+
const records = new Map<string, FleetDryMailboxRecord>([
309+
["w1", { status: "done", report: "ok" }],
310+
]);
311+
const mailbox: FleetDryMailbox = {
312+
ids: () => [...records.keys()],
313+
peek: (id) => records.get(id),
314+
take: (id) => {
315+
const existing = records.get(id);
316+
if (existing === undefined) return undefined;
317+
const taken = { ...existing, collected: true };
318+
records.set(id, taken);
319+
return taken;
320+
},
321+
};
322+
const sent: string[] = [];
323+
const driven = driveOpenTasksAfterFleetDry({
324+
previousRunning: 0,
325+
running: 0,
326+
deferredDryEdge: true,
327+
openTasks: [openTask],
328+
parentProcessing: false,
329+
mailbox,
330+
lanes: [],
331+
beginSystemContinuation: () => undefined,
332+
send: (prompt) => {
333+
sent.push(prompt);
334+
},
335+
});
336+
expect(driven).toBe(true);
337+
expect(sent[0]).toContain("w1");
338+
expect(records.get("w1")?.collected).toBe(true);
339+
});
340+
341+
test("send failure after take leaves reports waitable", () => {
342+
const records = new Map<string, FleetDryMailboxRecord>([
343+
["w1", { status: "done", report: "ok" }],
344+
]);
345+
const mailbox: FleetDryMailbox = {
346+
ids: () => [...records.keys()],
347+
peek: (id) => records.get(id),
348+
take: (id) => {
349+
const existing = records.get(id);
350+
if (existing === undefined) return undefined;
351+
const taken = { ...existing, collected: true };
352+
records.set(id, taken);
353+
return taken;
354+
},
355+
};
356+
const driven = driveOpenTasksAfterFleetDry({
357+
previousRunning: 1,
358+
running: 0,
359+
openTasks: [openTask],
360+
parentProcessing: false,
361+
mailbox,
362+
lanes: [],
363+
beginSystemContinuation: () => undefined,
364+
send: () => {
365+
throw new Error("send failed");
366+
},
367+
});
368+
expect(driven).toBe(false);
369+
expect(records.get("w1")?.collected).not.toBe(true);
370+
});
267371
});

src/subagent/fleet-dry-drive.ts

Lines changed: 69 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Drive the parent back into a turn when the live fleet goes dry while
3-
* todo/doing tasks remain. Pure: the TUI subscriber decides when to call,
4-
* this module decides whether to drive and what to send.
3+
* todo/doing tasks remain. Pure: occupancy (settleRunToIdle) decides when
4+
* to call; this module decides whether to drive and what to send.
55
*/
66

77
import { hasActiveTasks, type Task } from "../agent/tasks.js";
@@ -50,9 +50,11 @@ export function shouldDriveOpenTasks(input: {
5050
running: number;
5151
hasOpenTasks: boolean;
5252
parentProcessing: boolean;
53+
deferredDryEdge?: boolean;
5354
}): boolean {
5455
const wentDry = input.running === 0 && input.previousRunning > 0;
55-
return wentDry && input.hasOpenTasks && !input.parentProcessing;
56+
const dryEdge = wentDry || input.deferredDryEdge === true;
57+
return dryEdge && input.running === 0 && input.hasOpenTasks && !input.parentProcessing;
5658
}
5759

5860
function clipField(text: string | undefined): string | undefined {
@@ -61,9 +63,56 @@ function clipField(text: string | undefined): string | undefined {
6163
return `${text.slice(0, FLEET_DRY_REPORT_CHARS - 1).trimEnd()}…`;
6264
}
6365

66+
export function projectMailboxRecord(
67+
id: string,
68+
taken: FleetDryMailboxRecord,
69+
lane?: FleetDryLane,
70+
): CollectedWorkerReport {
71+
const report = taken.report ?? lane?.report;
72+
const error = taken.error ?? lane?.error;
73+
const description = taken.description ?? lane?.description;
74+
return {
75+
agent_id: id,
76+
status: taken.status,
77+
...(description !== undefined && description.length > 0 ? { description } : {}),
78+
...(taken.status !== "failed" && report !== undefined ? { report } : {}),
79+
...(error !== undefined ? { error } : {}),
80+
...(taken.hint !== undefined ? { hint: taken.hint } : {}),
81+
...(taken.providerFailure === true ? { provider_failure: true } : {}),
82+
};
83+
}
84+
85+
/**
86+
* Mailbox take plus the wait_agents/fleet-dry projection. Live statuses are
87+
* not collected. Callers that need question fields (wait_agents) spread them
88+
* from the pre-take peek.
89+
*/
90+
export function takeAndProjectMailboxRecord(
91+
mailbox: FleetDryMailbox,
92+
id: string,
93+
lane?: FleetDryLane,
94+
): CollectedWorkerReport | undefined {
95+
const peeked = mailbox.peek(id);
96+
if (peeked === undefined) return undefined;
97+
if (isLiveWaitStatus(peeked.status)) return undefined;
98+
const taken = mailbox.take(id) ?? peeked;
99+
return projectMailboxRecord(id, taken, lane);
100+
}
101+
102+
function clipCollectedReport(report: CollectedWorkerReport): CollectedWorkerReport {
103+
const clippedReport = clipField(report.report);
104+
const clippedError = clipField(report.error);
105+
return {
106+
...report,
107+
...(clippedReport !== undefined ? { report: clippedReport } : {}),
108+
...(clippedError !== undefined ? { error: clippedError } : {}),
109+
};
110+
}
111+
64112
export function collectUncollectedTerminals(
65113
mailbox: FleetDryMailbox | undefined,
66114
lanes: readonly FleetDryLane[],
115+
consume = true,
67116
): CollectedWorkerReport[] {
68117
if (mailbox === undefined) return [];
69118
const byId = new Map(lanes.map((lane) => [lane.id, lane]));
@@ -73,20 +122,11 @@ export function collectUncollectedTerminals(
73122
if (peeked === undefined) continue;
74123
if (peeked.collected === true) continue;
75124
if (isLiveWaitStatus(peeked.status)) continue;
76-
const taken = mailbox.take(id) ?? peeked;
77-
const lane = byId.get(id);
78-
const report = clipField(taken.report ?? lane?.report);
79-
const error = clipField(taken.error ?? lane?.error);
80-
const description = taken.description ?? lane?.description;
81-
reports.push({
82-
agent_id: id,
83-
status: taken.status,
84-
...(description !== undefined && description.length > 0 ? { description } : {}),
85-
...(taken.status !== "failed" && report !== undefined ? { report } : {}),
86-
...(error !== undefined ? { error } : {}),
87-
...(taken.hint !== undefined ? { hint: taken.hint } : {}),
88-
...(taken.providerFailure === true ? { provider_failure: true } : {}),
89-
});
125+
const projected = consume
126+
? takeAndProjectMailboxRecord(mailbox, id, byId.get(id))
127+
: projectMailboxRecord(id, peeked, byId.get(id));
128+
if (projected === undefined) continue;
129+
reports.push(clipCollectedReport(projected));
90130
}
91131
return reports;
92132
}
@@ -115,6 +155,7 @@ export function driveOpenTasksAfterFleetDry(args: {
115155
running: number;
116156
openTasks: readonly Task[];
117157
parentProcessing: boolean;
158+
deferredDryEdge?: boolean;
118159
mailbox: FleetDryMailbox | undefined;
119160
lanes: readonly FleetDryLane[];
120161
beginSystemContinuation: (prompt: string) => void;
@@ -127,13 +168,21 @@ export function driveOpenTasksAfterFleetDry(args: {
127168
running: args.running,
128169
hasOpenTasks: hasActiveTasks(tasks),
129170
parentProcessing: args.parentProcessing,
171+
...(args.deferredDryEdge === true ? { deferredDryEdge: true } : {}),
130172
})
131173
) {
132174
return false;
133175
}
134-
const reports = collectUncollectedTerminals(args.mailbox, args.lanes);
176+
const reports = collectUncollectedTerminals(args.mailbox, args.lanes, false);
135177
const prompt = buildFleetDryContinuationPrompt(tasks, reports);
136-
args.beginSystemContinuation(prompt);
137-
args.send(prompt);
178+
try {
179+
args.beginSystemContinuation(prompt);
180+
args.send(prompt);
181+
} catch {
182+
return false;
183+
}
184+
for (const report of reports) {
185+
args.mailbox?.take(report.agent_id);
186+
}
138187
return true;
139188
}

src/subagent/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ export {
3030
collectUncollectedTerminals,
3131
driveOpenTasksAfterFleetDry,
3232
FLEET_DRY_CONTINUATION_PREFIX,
33+
projectMailboxRecord,
3334
shouldDriveOpenTasks,
35+
takeAndProjectMailboxRecord,
3436
type CollectedWorkerReport,
3537
type FleetDryLane,
3638
type FleetDryMailbox,

0 commit comments

Comments
 (0)