Skip to content

Commit 2c67dac

Browse files
Merge pull request #852 from corbitsdev/cl-7540-keep-driving-open-tasks-after-the-fleet-goes-dry
Resume the parent when the fleet goes dry with open tasks
2 parents 7801f23 + 459fff4 commit 2c67dac

20 files changed

Lines changed: 1495 additions & 25 deletions

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. 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

docs/TUI.md

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

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

587592
Interrupting (Ctrl+C) never discards a queued or steered message. It used to
588593
— 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
@@ -377,6 +377,12 @@ export interface ChatDirectorOptions {
377377
getProviderId?: (() => string | undefined) | undefined;
378378
/** Explicit retry policy; when set, skips the default Corbits policy. */
379379
retryPolicy?: RetryPolicy | undefined;
380+
/**
381+
* Live `status === "running"` fleet-lane count. When greater than zero the
382+
* director allows a terminal wait/reply with open tasks (idle-with-fleet).
383+
* Omitted or 0 keeps the open-task nudge. Exec omits this.
384+
*/
385+
getLiveFleetCount?: (() => number) | undefined;
380386
}
381387

382388
// The constructor takes the resolved ModelFamilyPolicy rather than the raw
@@ -415,6 +421,7 @@ class ChatDirectorImpl extends DefaultDirector {
415421
private readonly compaction: CompactionGovernor;
416422
private readonly modelFamilyPolicy: ModelFamilyPolicy;
417423
private readonly retryPolicy: RetryPolicy;
424+
private readonly getLiveFleetCount: (() => number) | undefined;
418425
// Consecutive assistant turns that contain tool calls and no text. Reset on
419426
// any turn with text and on every fresh user message — a weak model that
420427
// spins in place on one thread of tool calls still converges to the
@@ -448,6 +455,7 @@ class ChatDirectorImpl extends DefaultDirector {
448455
this.modelFamilyPolicy =
449456
options.modelFamilyPolicy ?? resolveModelFamilyPolicy({ providerName: "" });
450457
this.retryPolicy = options.retryPolicy ?? createCorbitsRetryPolicy();
458+
this.getLiveFleetCount = options.getLiveFleetCount;
451459
}
452460

453461
setWorkflowCoordinator(coordinator: WorkflowCoordinator | undefined): void {
@@ -883,6 +891,9 @@ class ChatDirectorImpl extends DefaultDirector {
883891
if (!atWorkflowGate && hasActiveTasks(this.tasks)) {
884892
const hasTerminal = baseActions.some((a) => a.type === "wait" || a.type === "reply");
885893
if (hasTerminal) {
894+
if ((this.getLiveFleetCount?.() ?? 0) > 0) {
895+
return base;
896+
}
886897
if (this.idleTerminationNudges < MAX_OPEN_TASK_NUDGES) {
887898
this.idleTerminationNudges++;
888899
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.
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
@@ -244,6 +244,48 @@ describe("open-task termination guard", () => {
244244
expect(hasInfer(exhausted)).toBe(false);
245245
});
246246

247+
test("live fleet with open tasks allows terminal wait/reply and does not spend the nudge budget", async () => {
248+
let live = 1;
249+
const director = createChatDirector("base", [], {
250+
onTasksChange: () => {},
251+
getLiveFleetCount: () => live,
252+
});
253+
await director.decide(manageTasksEvent("doing"), mockState, mockCapabilities);
254+
255+
for (let i = 0; i < 4; i++) {
256+
const actions = actionsArray(await director.decide(textTurn(), mockState, mockCapabilities));
257+
expect(hasInfer(actions)).toBe(false);
258+
expect(hasReply(actions)).toBe(true);
259+
}
260+
261+
live = 0;
262+
for (let i = 0; i < 3; i++) {
263+
const nudged = actionsArray(await director.decide(textTurn(), mockState, mockCapabilities));
264+
expect(hasInfer(nudged)).toBe(true);
265+
expect(hasReply(nudged)).toBe(false);
266+
}
267+
const exhausted = actionsArray(await director.decide(textTurn(), mockState, mockCapabilities));
268+
expect(hasReply(exhausted)).toBe(true);
269+
expect(hasInfer(exhausted)).toBe(false);
270+
});
271+
272+
test("omitted or zero live fleet count still nudges while a task is open", async () => {
273+
const omitted = createChatDirector("base", [], { onTasksChange: () => {} });
274+
await omitted.decide(manageTasksEvent("doing"), mockState, mockCapabilities);
275+
expect(
276+
hasInfer(actionsArray(await omitted.decide(textTurn(), mockState, mockCapabilities))),
277+
).toBe(true);
278+
279+
const zero = createChatDirector("base", [], {
280+
onTasksChange: () => {},
281+
getLiveFleetCount: () => 0,
282+
});
283+
await zero.decide(manageTasksEvent("doing"), mockState, mockCapabilities);
284+
expect(hasInfer(actionsArray(await zero.decide(textTurn(), mockState, mockCapabilities)))).toBe(
285+
true,
286+
);
287+
});
288+
247289
test("empty model turn settles with a valid empty reply", async () => {
248290
// DefaultDirector ends empty responses with bare wait; without a reply,
249291
// 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: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,3 +379,25 @@ 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. 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.
389+
*/
390+
export function buildFleetDryContinuationMessage(text: string): InboundMessage {
391+
return {
392+
ref: { uid: 0, mailbox: "system" },
393+
headers: {
394+
from: "user@local",
395+
to: ["agent@local"],
396+
date: new Date().toISOString(),
397+
messageId: `fleet-dry-continue-${Date.now()}@local`,
398+
},
399+
flags: [],
400+
content: text,
401+
signatureStatus: "missing",
402+
};
403+
}

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

0 commit comments

Comments
 (0)