Skip to content

Commit 6ecb95c

Browse files
committed
fix(subagent): keep fleet-dry parent mail to a digest plus blob
1 parent ed4fe81 commit 6ecb95c

5 files changed

Lines changed: 63 additions & 33 deletions

File tree

‎docs/ARCHITECTURE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ A worker deny-on-ask registers a harness-owned denied-call envelope (`src/permis
148148

149149
Two directors, selected by role:
150150

151-
- **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, and the workflow idle rail does not declare the parent stuck while helpers run. The TUI idle-with-fleet seed alone is not occupancy: an empty fleet still auto-continues slash workflows. Occupancy, mailbox, and operator inbounds reset the workflow idle counter. When a worker finishes or fails while the parent is idle (including idle-with-fleet), occupancy delivers mailbox mail as system inbound so Skywalker starts a new turn without polling `wait_agents`. Mailbox mail is a Summary + Blockers digest (Findings when Summary is empty) with a blob pointer named for `read_file`, not the full report JSON. A failed blob write inlines a truncated report with an honest not-retrievable notice. 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. Already-collected IDs in that continuation are id/status/description only.
151+
- **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, and the workflow idle rail does not declare the parent stuck while helpers run. The TUI idle-with-fleet seed alone is not occupancy: an empty fleet still auto-continues slash workflows. Occupancy, mailbox, and operator inbounds reset the workflow idle counter. When a worker finishes or fails while the parent is idle (including idle-with-fleet), occupancy delivers mailbox mail as system inbound so Skywalker starts a new turn without polling `wait_agents`. Mailbox mail is a Summary + Blockers digest (Findings when Summary is empty) with a blob pointer named for `read_file`, not the full report JSON. A failed blob write inlines a truncated report with an honest not-retrievable notice. When the fleet goes dry with tasks still todo/doing, the TUI runtime re-enters the parent with the same digest for newly collected worker reports rather than settling idle. Already-collected IDs in that continuation are id/status/description only.
152152

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

‎docs/IMPLEMENTATION.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ Listings are list-free, dumps are dump-locked: a bounded `ls`/`tree` prints name
212212

213213
- **Alt+Enter** queues a follow-up (kind `"queue"`) delivered only on **session-idle** — parent-idle **and** no live fleet lanes (`run` goes idle). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run.
214214

215-
Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count (same liveness rule as the progress strip: interrupted leftovers are not live); the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. The workflow idle rail honors live helper occupancy, not the TUI idle-with-fleet seed: a live fleet is not a stuck parent, an empty fleet still auto-continues slash workflows, and occupancy / mailbox / operator inbounds reset the workflow idle counter. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. The mail payload is a Summary + Blockers digest (Findings when Summary is empty) with a blob pointer named for `read_file`, not the full report JSON; fleet-dry continuation lists already-collected IDs as id/status/description only and does not re-deliver an ID mailbox mail is already sending. Skip that shot when a fleet-dry open-task continuation is latched. Both wakes wait for send to settle as accepted before taking reports; a pending Promise is not a continuation, and a failed or uncertain send idles the parent. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; parent interrupt/abort cancels that parked waiter via the tool AbortSignal (`timed_out`, workers stay running) rather than a second waiter table. `interrupt_agent` on a child unblocks wait as interrupted through the mailbox overlay. `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it).
215+
Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent turn can settle while workers keep running. The runner emits a `fleet` event carrying the live-lane count (same liveness rule as the progress strip: interrupted leftovers are not live); the bridge holds the run busy on that count, so mid-hold Enter upgrades to a new primary turn (sent immediately) instead of queueing a steer, follow-ups keep waiting for true session-idle, and any steer left pending at the hold's engagement delivers immediately — the parent it was steering has already stopped. The workflow idle rail honors live helper occupancy, not the TUI idle-with-fleet seed: a live fleet is not a stuck parent, an empty fleet still auto-continues slash workflows, and occupancy / mailbox / operator inbounds reset the workflow idle counter. While the hold is up and the parent is not processing, occupancy flushes mailbox mail (`driveMailboxMail` + `buildMailboxMailMessage`) on store subscribe and idle-with-fleet settle — one child done while siblings run is enough. The mail payload is a Summary + Blockers digest (Findings when Summary is empty) with a blob pointer named for `read_file`, not the full report JSON; fleet-dry continuation uses the same digest for newly collected terminals, lists already-collected IDs as id/status/description only and does not re-deliver an ID mailbox mail is already sending. Skip that shot when a fleet-dry open-task continuation is latched. Both wakes wait for send to settle as accepted before taking reports; a pending Promise is not a continuation, and a failed or uncertain send idles the parent. Exec-primary `wait_agents` mounts with no yield predicate (`createWaitAgentsTool({ sessions, fleetRecords })`) and blocks to ready/timeout/abort; parent interrupt/abort cancels that parked waiter via the tool AbortSignal (`timed_out`, workers stay running) rather than a second waiter table. `interrupt_agent` on a child unblocks wait as interrupted through the mailbox overlay. `shouldYieldWait` remains a supported-but-unwired tool option (no production mount passes it).
216216

217217
`src/tui/stream-event-map.ts` maps reactor events onto the bridge's inbound events, and `src/tui/turn-state.ts` tracks the turn's status. `src/tui/turns-to-blocks.ts` hydrates a resumed session's stored turns into the same content blocks.
218218

‎docs/TUI.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -725,8 +725,9 @@ follow-ups wait one more turn. Occupancy wakes consume mailbox reports only
725725
after send settles as accepted; a failed or uncertain send idles the parent
726726
so a later flush can retry. Mailbox mail is a Summary + Blockers digest
727727
(Findings when Summary is empty) with a blob pointer named for `read_file`.
728-
A failed blob write inlines a truncated report. Fleet-dry continuation lists
729-
already-collected IDs as id/status/description only. The mail and that fleet-dry continuation
728+
A failed blob write inlines a truncated report. Fleet-dry continuation uses
729+
the same digest for newly collected terminals and lists already-collected IDs
730+
as id/status/description only. The mail and that fleet-dry continuation
730731
are runtime-to-agent traffic — the fleet board owns worker status — so
731732
neither paints a transcript row, and neither rehydrates as one.
732733

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

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -529,13 +529,15 @@ describe("driveOpenTasksAfterFleetDry", () => {
529529
agent_id: string;
530530
status: string;
531531
description?: string;
532+
summary?: string;
532533
report?: string;
533534
}[];
534535
expect(parsed).toEqual([
535536
{
536537
agent_id: "fresh",
537538
status: "done",
538539
description: "new lane",
540+
summary: "fresh report",
539541
report: "fresh report",
540542
},
541543
{
@@ -620,13 +622,16 @@ describe("driveOpenTasksAfterFleetDry", () => {
620622
send: () => ACCEPTED_DELIVERY,
621623
});
622624
expect(driven).toBe(true);
623-
const parsed = reportsJSONFromPrompt(sent[0] ?? "");
624-
expect(Array.isArray(parsed)).toBe(true);
625-
const report = (parsed as { report?: string }[])[0]?.report ?? "";
626-
expect(report).toContain(
625+
const parsed = reportsJSONFromPrompt(sent[0] ?? "") as {
626+
report_uri?: string;
627+
summary?: string;
628+
}[];
629+
expect(parsed[0]?.report_uri).toBe(
627630
`tool-output:///${fleetDrySpillKey("big", "report")}`,
628631
);
629-
expect(report).not.toContain("TAIL-MARKER");
632+
expect(sent[0]).toContain("use read_file with that URI");
633+
expect(sent[0]).not.toContain("TAIL-MARKER");
634+
expect(parsed[0]?.summary).not.toContain("TAIL-MARKER");
630635
expect(
631636
new TextDecoder().decode(
632637
store.blobs.get(fleetDrySpillKey("big", "report"))?.bytes ??

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

Lines changed: 48 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,12 @@ import { parseSubAgentReport } from "./report.js";
1515
/** Enough of a lane report for a parent continuation; traces stay on disk. */
1616
export const FLEET_DRY_REPORT_CHARS = 8_192;
1717

18-
/** Summary/Blockers inline in mailbox digest; the blob holds the rest. */
18+
/** Summary/Blockers inline in occupancy digest; the blob holds the rest. */
1919
export const MAILBOX_DIGEST_SECTION_CHARS = 2_048;
2020

21+
/** Unstructured reports have no envelope; keep the inline teaser short. */
22+
export const UNSTRUCTURED_DIGEST_CHARS = 280;
23+
2124
export const FLEET_DRY_CONTINUATION_PREFIX =
2225
"The fleet has gone dry. Remaining open tasks:";
2326

@@ -206,9 +209,12 @@ export function fleetDrySpillKey(
206209
return `fleet-dry:${agentId}:${field}`;
207210
}
208211

209-
function clipDigestSection(text: string): string {
210-
if (text.length <= MAILBOX_DIGEST_SECTION_CHARS) return text;
211-
return `${text.slice(0, MAILBOX_DIGEST_SECTION_CHARS - 1).trimEnd()}…`;
212+
function clipDigestSection(
213+
text: string,
214+
max = MAILBOX_DIGEST_SECTION_CHARS,
215+
): string {
216+
if (text.length <= max) return text;
217+
return `${text.slice(0, max - 1).trimEnd()}…`;
212218
}
213219

214220
function mailboxSpillNotice(text: string, uri: string): string {
@@ -230,7 +236,9 @@ export async function spillWorkerField(
230236
field: "report" | "error",
231237
writeBlob?: FleetDryBlobWriter,
232238
): Promise<string | undefined> {
233-
if (text === undefined || writeBlob === undefined) return undefined;
239+
if (text === undefined || text.length === 0 || writeBlob === undefined) {
240+
return undefined;
241+
}
234242
const key = fleetDrySpillKey(agentId, field);
235243
try {
236244
await writeBlob(key, new TextEncoder().encode(text), "text/plain");
@@ -260,6 +268,11 @@ export async function digestCollectedReport(
260268
report.report !== undefined
261269
? parseSubAgentReport(report.report)
262270
: undefined;
271+
const unstructured =
272+
parsed !== undefined &&
273+
parsed.findings.length === 0 &&
274+
parsed.blockers.length === 0 &&
275+
parsed.paths.length === 0;
263276
const reportUri = await spillWorkerField(
264277
report.report,
265278
report.agent_id,
@@ -274,18 +287,17 @@ export async function digestCollectedReport(
274287
);
275288
const summary =
276289
parsed !== undefined && parsed.summary.length > 0
277-
? clipDigestSection(parsed.summary)
290+
? clipDigestSection(
291+
parsed.summary,
292+
unstructured
293+
? UNSTRUCTURED_DIGEST_CHARS
294+
: MAILBOX_DIGEST_SECTION_CHARS,
295+
)
278296
: undefined;
279297
const findings =
280298
parsed !== undefined && summary === undefined && parsed.findings.length > 0
281299
? clipDigestSection(parsed.findings)
282300
: undefined;
283-
const blockers =
284-
parsed !== undefined
285-
? clipDigestSection(
286-
parsed.blockers.length > 0 ? parsed.blockers : "None.",
287-
)
288-
: undefined;
289301
const reportInline =
290302
report.report === undefined
291303
? undefined
@@ -298,6 +310,14 @@ export async function digestCollectedReport(
298310
: errorUri !== undefined
299311
? mailboxSpillNotice(report.error, errorUri)
300312
: clipDigestSection(report.error);
313+
const blockers =
314+
parsed === undefined || unstructured
315+
? parsed !== undefined && parsed.blockers.length > 0
316+
? clipDigestSection(parsed.blockers)
317+
: undefined
318+
: clipDigestSection(
319+
parsed.blockers.length > 0 ? parsed.blockers : "None.",
320+
);
301321
return {
302322
agent_id: report.agent_id,
303323
status: report.status,
@@ -494,13 +514,14 @@ export async function collectUncollectedTerminals(
494514
return reports;
495515
}
496516

497-
export function buildFleetDryContinuationPrompt(
517+
export function buildFleetDryContinuationPrompt<T extends { agent_id: string }>(
498518
tasks: readonly Task[],
499-
reports: readonly CollectedWorkerReport[],
519+
reports: readonly T[],
500520
): string {
501521
const open = tasks.filter(
502522
(task) => task.status === "todo" || task.status === "doing",
503523
);
524+
const unique = dedupeByAgentId(reports);
504525
const taskLines = open
505526
.map((task) => `- ${task.id}: ${task.title} (${task.status})`)
506527
.join("\n");
@@ -509,7 +530,7 @@ export function buildFleetDryContinuationPrompt(
509530
taskLines,
510531
"",
511532
"Collected worker reports (already collected — do not call wait_agents for these agent_ids):",
512-
JSON.stringify(dedupeByAgentId(reports)),
533+
JSON.stringify(unique),
513534
"",
514535
"Continue the remaining work. Mark each task done or cancelled with manage_tasks",
515536
"when finished, or spawn_agent the next specialist. Do not end this turn while",
@@ -558,34 +579,37 @@ async function driveOpenTasksAfterFleetDrySpill(
558579
args.mailbox,
559580
args.lanes,
560581
false,
561-
args.writeBlob,
582+
undefined,
583+
false,
562584
)
563585
).filter((report) => !delivering.has(report.agent_id));
564586
const uncollectedIds = new Set(uncollected.map((report) => report.agent_id));
565587
const stubs = collectAlreadyCollectedStubs(args.mailbox, args.lanes).filter(
566588
(stub) =>
567589
!uncollectedIds.has(stub.agent_id) && !delivering.has(stub.agent_id),
568590
);
569-
const reports = dedupeByAgentId([...uncollected, ...stubs]);
570591
const takeIds = uncollected.map((report) => report.agent_id);
571592
for (const id of takeIds) delivering.add(id);
572-
const prompt = buildFleetDryContinuationPrompt(tasks, reports);
573-
const takeReports = (): void => {
574-
for (const id of takeIds) {
575-
args.mailbox?.take(id);
576-
}
577-
releaseOccupancyDelivering(args.mailbox, takeIds);
578-
};
579593
const fail = (): boolean => {
580594
releaseOccupancyDelivering(args.mailbox, takeIds);
581595
args.onSendFailure?.();
582596
return false;
583597
};
598+
let prompt: string;
584599
try {
600+
const digested = await digestCollectedReports(uncollected, args.writeBlob);
601+
const reports = dedupeByAgentId([...digested, ...stubs]);
602+
prompt = buildFleetDryContinuationPrompt(tasks, reports);
585603
args.beginSystemContinuation(prompt);
586604
} catch {
587605
return fail();
588606
}
607+
const takeReports = (): void => {
608+
for (const id of takeIds) {
609+
args.mailbox?.take(id);
610+
}
611+
releaseOccupancyDelivering(args.mailbox, takeIds);
612+
};
589613
return settleOccupancySend({
590614
send: () => args.send(prompt),
591615
onSuccess: takeReports,

0 commit comments

Comments
 (0)