Skip to content

Commit 3320c99

Browse files
committed
Resume the parent when the fleet goes dry with open tasks
A session with todo/doing tasks was settling idle just because the fleet printed nothing running. Re-enter with collected reports on the wentDry edge. Idle-with-live-fleet stays.
1 parent 6ea5969 commit 3320c99

17 files changed

Lines changed: 702 additions & 6 deletions

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ 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. 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. 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.
116116
- **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.
117117
`spawn_agent` starts each worker and records it in the caller's fleet mailbox; `wait_agents` collects terminal reports from that mailbox. Deadline salvage prepends an advisory parent hint suggesting continuation plus a longer deadline if more wall-clock time is warranted. Cancelled salvage asks the parent to synthesize Findings and Paths and wait for the operator instead of auto-starting another specialist. Deadline hints are advisory only — an identical re-dispatch is still admitted.
118118

docs/TUI.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,10 @@ lane finishes
249249
(`N done · nothing running`; failed and cancelled counts appear only
250250
when non-zero, e.g. `N done, M failed, K cancelled · nothing running`).
251251
Per-lane `done — summary` walls and live `dispatched` re-announcements
252-
are never printed.
252+
are never printed. That dry-fleet line stays operator-facing. If tasks
253+
are still todo/doing, the runtime re-enters the parent with collected
254+
reports as a system continuation — it does not paint the report wall as
255+
a user message.
253256

254257
`src/subagent/fleet-report.ts` is pure: it reads the same fleet-agent session
255258
store and the same `agentProgress()` stall definition. Store changes drive it;
@@ -565,7 +568,9 @@ there is no parent tool left to steer — while Alt+Enter follow-ups keep
565568
waiting for true session-idle. A steer still pending when the hold engages
566569
sends at once (the parent it was steering has stopped), and the last lane
567570
terminalizing releases the hold, drains follow-ups, and returns the session
568-
to idle.
571+
to idle — unless todo/doing tasks remain, in which case a system
572+
continuation starts before the fleet-0 event so the run stays busy and
573+
follow-ups wait one more turn.
569574

570575
Interrupting (Ctrl+C) never discards a queued or steered message. It used to
571576
— the transcript literally said `interrupt — discarded N pending`, and an

src/agent/director.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,12 @@ export interface ChatDirectorOptions {
368368
getProviderId?: (() => string | undefined) | undefined;
369369
/** Explicit retry policy; when set, skips the default Corbits policy. */
370370
retryPolicy?: RetryPolicy | undefined;
371+
/**
372+
* Live `status === "running"` fleet-lane count. When greater than zero the
373+
* director allows a terminal wait/reply with open tasks (idle-with-fleet).
374+
* Omitted or 0 keeps the open-task nudge. Exec omits this.
375+
*/
376+
getLiveFleetCount?: (() => number) | undefined;
371377
}
372378

373379
// The constructor takes the resolved ModelFamilyPolicy rather than the raw
@@ -405,6 +411,7 @@ class ChatDirectorImpl extends DefaultDirector {
405411
private readonly compaction: CompactionGovernor;
406412
private readonly modelFamilyPolicy: ModelFamilyPolicy;
407413
private readonly retryPolicy: RetryPolicy;
414+
private readonly getLiveFleetCount: (() => number) | undefined;
408415
// Consecutive assistant turns that contain tool calls and no text. Reset on
409416
// any turn with text and on every fresh user message — a weak model that
410417
// spins in place on one thread of tool calls still converges to the
@@ -438,6 +445,7 @@ class ChatDirectorImpl extends DefaultDirector {
438445
this.modelFamilyPolicy =
439446
options.modelFamilyPolicy ?? resolveModelFamilyPolicy({ providerName: "" });
440447
this.retryPolicy = options.retryPolicy ?? createCorbitsRetryPolicy();
448+
this.getLiveFleetCount = options.getLiveFleetCount;
441449
}
442450

443451
setWorkflowCoordinator(coordinator: WorkflowCoordinator | undefined): void {
@@ -854,6 +862,9 @@ class ChatDirectorImpl extends DefaultDirector {
854862
if (!atWorkflowGate && hasActiveTasks(this.tasks)) {
855863
const hasTerminal = baseActions.some((a) => a.type === "wait" || a.type === "reply");
856864
if (hasTerminal) {
865+
if ((this.getLiveFleetCount?.() ?? 0) > 0) {
866+
return base;
867+
}
857868
if (this.idleTerminationNudges < MAX_OPEN_TASK_NUDGES) {
858869
this.idleTerminationNudges++;
859870
const passThrough = baseActions.filter(

src/agent/directors/skywalker/package.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ describe("skywalkerPackage", () => {
117117
expect(p).not.toContain("task()");
118118
expect(p).toContain('mode="all"');
119119
expect(p).toContain("uncollected spawns");
120+
expect(p).toContain("When the fleet goes dry the runtime re-enters with collected reports");
121+
expect(p).toContain("do not tight-loop wait_agents");
120122
expect(p).not.toContain("Present the plan when the change is large or ambiguous");
121123
});
122124

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.
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.
2020
2121
# Operator updates (mandatory while fleet is live)
2222

src/agent/tools.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ import {
5959
createSpawnAgentTool,
6060
createWaitAgentsTool,
6161
createListAgentsTool,
62+
type FleetMailboxHandle,
6263
} from "../subagent/agent-fleet.js";
6364
import { DEFAULT_CLOSE_DEADLINE_MS } from "../subagent/dispose.js";
6465
import {
@@ -252,6 +253,12 @@ export interface AgentToolset {
252253
setToolPromoter: (promote: (names: string[]) => void) => void;
253254
// Session-start skill snapshot shared with the prompt listing.
254255
skills: SkillSummary[];
256+
/**
257+
* The live wait mailbox this toolset already built for spawn_agent /
258+
* wait_agents. Optional because a session without sub-agents has none.
259+
* Callers must read this each time — do not capture a startup snapshot.
260+
*/
261+
fleetRecords?: FleetMailboxHandle;
255262
dispose: () => Promise<void>;
256263
}
257264

@@ -377,9 +384,10 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
377384
// spawn_agent/wait_agents.
378385
const orchestratorTools: AgentTool[] = [];
379386
let fleetSessionsForDispose: SubAgentSessionStore | undefined;
387+
let fleetRecords: FleetMailboxHandle | undefined;
380388
if (subAgentsEnabled && args.subAgent !== undefined) {
381389
const sa = args.subAgent;
382-
const fleetRecords = sa.sessions !== undefined ? createFleetMailbox(sa.sessions) : undefined;
390+
fleetRecords = sa.sessions !== undefined ? createFleetMailbox(sa.sessions) : undefined;
383391
if (sa.profiles !== undefined) {
384392
orchestratorTools.push(
385393
createSearchAgentsTool(() => {
@@ -977,6 +985,7 @@ export async function createAgentToolset(args: AgentToolsetArgs): Promise<AgentT
977985
promoter.promote = promote;
978986
},
979987
skills,
988+
...(fleetRecords !== undefined ? { fleetRecords } : {}),
980989
dispose,
981990
};
982991
}

src/director.test.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,48 @@ describe("open-task termination guard", () => {
239239
expect(hasInfer(exhausted)).toBe(false);
240240
});
241241

242+
test("live fleet with open tasks allows terminal wait/reply and does not spend the nudge budget", async () => {
243+
let live = 1;
244+
const director = createChatDirector("base", [], {
245+
onTasksChange: () => {},
246+
getLiveFleetCount: () => live,
247+
});
248+
await director.decide(manageTasksEvent("doing"), mockState, mockCapabilities);
249+
250+
for (let i = 0; i < 4; i++) {
251+
const actions = actionsArray(await director.decide(textTurn(), mockState, mockCapabilities));
252+
expect(hasInfer(actions)).toBe(false);
253+
expect(hasReply(actions)).toBe(true);
254+
}
255+
256+
live = 0;
257+
for (let i = 0; i < 3; i++) {
258+
const nudged = actionsArray(await director.decide(textTurn(), mockState, mockCapabilities));
259+
expect(hasInfer(nudged)).toBe(true);
260+
expect(hasReply(nudged)).toBe(false);
261+
}
262+
const exhausted = actionsArray(await director.decide(textTurn(), mockState, mockCapabilities));
263+
expect(hasReply(exhausted)).toBe(true);
264+
expect(hasInfer(exhausted)).toBe(false);
265+
});
266+
267+
test("omitted or zero live fleet count still nudges while a task is open", async () => {
268+
const omitted = createChatDirector("base", [], { onTasksChange: () => {} });
269+
await omitted.decide(manageTasksEvent("doing"), mockState, mockCapabilities);
270+
expect(
271+
hasInfer(actionsArray(await omitted.decide(textTurn(), mockState, mockCapabilities))),
272+
).toBe(true);
273+
274+
const zero = createChatDirector("base", [], {
275+
onTasksChange: () => {},
276+
getLiveFleetCount: () => 0,
277+
});
278+
await zero.decide(manageTasksEvent("doing"), mockState, mockCapabilities);
279+
expect(hasInfer(actionsArray(await zero.decide(textTurn(), mockState, mockCapabilities)))).toBe(
280+
true,
281+
);
282+
});
283+
242284
test("empty model turn settles with a valid empty reply", async () => {
243285
// DefaultDirector ends empty responses with bare wait; without a reply,
244286
// agent.send hangs and the TUI Working spinner sticks forever.

src/session/assemble-runtime.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,11 @@ export interface ChatAgentWiring {
335335
inactivityTimeoutMs: number;
336336
totalTimeoutMs?: number | undefined;
337337
onTasksChange: (tasks: Task[]) => void;
338+
/**
339+
* Live running-lane count for ChatDirector idle-with-fleet. Omitted in exec
340+
* (treated as 0).
341+
*/
342+
getLiveFleetCount?: () => number;
338343
/** Compaction governor re-entry (the reactor emits no event after compact). */
339344
requestContinuation: () => void;
340345
getProvider: () => { providerName: string; model: string };
@@ -392,6 +397,7 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
392397
requestContinuation: wiring.requestContinuation,
393398
provider: { ...wiring.getProvider() },
394399
getProviderId: wiring.getProviderId,
400+
getLiveFleetCount: wiring.getLiveFleetCount,
395401
},
396402
);
397403
directorHolder.instance = d;

src/session/runtime-assembly.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,23 @@ export function buildCompactionContinuationMessage(): InboundMessage {
379379
signatureStatus: "missing",
380380
};
381381
}
382+
383+
/**
384+
* 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.
387+
*/
388+
export function buildFleetDryContinuationMessage(text: string): InboundMessage {
389+
return {
390+
ref: { uid: 0, mailbox: "system" },
391+
headers: {
392+
from: "user@local",
393+
to: ["agent@local"],
394+
date: new Date().toISOString(),
395+
messageId: `fleet-dry-continue-${Date.now()}@local`,
396+
},
397+
flags: [],
398+
content: text,
399+
signatureStatus: "missing",
400+
};
401+
}

0 commit comments

Comments
 (0)