Skip to content

Commit a648ffb

Browse files
committed
fix(subagent): keep fleet-dry parent mail to a digest plus blob
1 parent 3b71856 commit a648ffb

7 files changed

Lines changed: 91 additions & 35 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 with a blob pointer to the full report, not the full report JSON. 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 with a blob pointer to the full report, not the full report JSON. When the fleet goes dry with tasks still todo/doing, the TUI runtime re-enters the parent with a digest of 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 with a blob pointer, 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 with a blob pointer, 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
@@ -724,8 +724,9 @@ continuation starts before the fleet-0 event so the run stays busy and
724724
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 with
727-
a blob pointer to the full report. Fleet-dry continuation lists
728-
already-collected IDs as id/status/description only. The mail and that fleet-dry continuation
727+
a blob pointer to the full report. Fleet-dry continuation uses the same digest
728+
for newly collected terminals and lists already-collected IDs as
729+
id/status/description only. The mail and that fleet-dry continuation
729730
are runtime-to-agent traffic — the fleet board owns worker status — so
730731
neither paints a transcript row, and neither rehydrates as one.
731732

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

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
FLEET_DRY_CONTINUATION_PREFIX,
1010
FLEET_DRY_REPORT_CHARS,
1111
fleetDrySpillKey,
12+
OCCUPANCY_DIGEST_READ_HINT,
1213
shouldDriveOpenTasks,
1314
type FleetDryMailboxRecord,
1415
} from "./fleet-dry-drive.js";
@@ -529,14 +530,15 @@ describe("driveOpenTasksAfterFleetDry", () => {
529530
agent_id: string;
530531
status: string;
531532
description?: string;
533+
summary?: string;
532534
report?: string;
533535
}[];
534536
expect(parsed).toEqual([
535537
{
536538
agent_id: "fresh",
537539
status: "done",
538540
description: "new lane",
539-
report: "fresh report",
541+
summary: "fresh report",
540542
},
541543
{
542544
agent_id: "prior",
@@ -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(OCCUPANCY_DIGEST_READ_HINT);
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: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,15 @@ 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+
24+
export const OCCUPANCY_DIGEST_READ_HINT =
25+
"read_file each report_uri (offset/limit supported) for the full report.";
26+
2127
export const FLEET_DRY_CONTINUATION_PREFIX =
2228
"The fleet has gone dry. Remaining open tasks:";
2329

@@ -204,9 +210,26 @@ export function fleetDrySpillKey(
204210
return `fleet-dry:${agentId}:${field}`;
205211
}
206212

207-
function clipDigestSection(text: string): string {
208-
if (text.length <= MAILBOX_DIGEST_SECTION_CHARS) return text;
209-
return `${text.slice(0, MAILBOX_DIGEST_SECTION_CHARS - 1).trimEnd()}…`;
213+
function clipDigestSection(
214+
text: string,
215+
max = MAILBOX_DIGEST_SECTION_CHARS,
216+
): string {
217+
if (text.length <= max) return text;
218+
return `${text.slice(0, max - 1).trimEnd()}…`;
219+
}
220+
221+
export function occupancyDigestReadHint(
222+
reports: readonly object[],
223+
): string | undefined {
224+
for (const report of reports) {
225+
if (
226+
"report_uri" in report &&
227+
typeof (report as { report_uri?: unknown }).report_uri === "string"
228+
) {
229+
return OCCUPANCY_DIGEST_READ_HINT;
230+
}
231+
}
232+
return undefined;
210233
}
211234

212235
export async function spillWorkerField(
@@ -215,7 +238,9 @@ export async function spillWorkerField(
215238
field: "report" | "error",
216239
writeBlob?: FleetDryBlobWriter,
217240
): Promise<string | undefined> {
218-
if (text === undefined || writeBlob === undefined) return undefined;
241+
if (text === undefined || text.length === 0 || writeBlob === undefined) {
242+
return undefined;
243+
}
219244
const key = fleetDrySpillKey(agentId, field);
220245
try {
221246
await writeBlob(key, new TextEncoder().encode(text), "text/plain");
@@ -245,6 +270,11 @@ export async function digestCollectedReport(
245270
report.report !== undefined
246271
? parseSubAgentReport(report.report)
247272
: undefined;
273+
const unstructured =
274+
parsed !== undefined &&
275+
parsed.findings.length === 0 &&
276+
parsed.blockers.length === 0 &&
277+
parsed.paths.length === 0;
248278
const reportUri = await spillWorkerField(
249279
report.report,
250280
report.agent_id,
@@ -259,14 +289,21 @@ export async function digestCollectedReport(
259289
);
260290
const summary =
261291
parsed !== undefined && parsed.summary.length > 0
262-
? clipDigestSection(parsed.summary)
263-
: undefined;
264-
const blockers =
265-
parsed !== undefined
266292
? clipDigestSection(
267-
parsed.blockers.length > 0 ? parsed.blockers : "None.",
293+
parsed.summary,
294+
unstructured
295+
? UNSTRUCTURED_DIGEST_CHARS
296+
: MAILBOX_DIGEST_SECTION_CHARS,
268297
)
269298
: undefined;
299+
const blockers =
300+
parsed === undefined || unstructured
301+
? parsed !== undefined && parsed.blockers.length > 0
302+
? clipDigestSection(parsed.blockers)
303+
: undefined
304+
: clipDigestSection(
305+
parsed.blockers.length > 0 ? parsed.blockers : "None.",
306+
);
270307
return {
271308
agent_id: report.agent_id,
272309
status: report.status,
@@ -463,13 +500,15 @@ export async function collectUncollectedTerminals(
463500
return reports;
464501
}
465502

466-
export function buildFleetDryContinuationPrompt(
503+
export function buildFleetDryContinuationPrompt<T extends { agent_id: string }>(
467504
tasks: readonly Task[],
468-
reports: readonly CollectedWorkerReport[],
505+
reports: readonly T[],
469506
): string {
470507
const open = tasks.filter(
471508
(task) => task.status === "todo" || task.status === "doing",
472509
);
510+
const unique = dedupeByAgentId(reports);
511+
const hint = occupancyDigestReadHint(unique);
473512
const taskLines = open
474513
.map((task) => `- ${task.id}: ${task.title} (${task.status})`)
475514
.join("\n");
@@ -478,7 +517,8 @@ export function buildFleetDryContinuationPrompt(
478517
taskLines,
479518
"",
480519
"Collected worker reports (already collected — do not call wait_agents for these agent_ids):",
481-
JSON.stringify(dedupeByAgentId(reports)),
520+
JSON.stringify(unique),
521+
...(hint !== undefined ? [hint] : []),
482522
"",
483523
"Continue the remaining work. Mark each task done or cancelled with manage_tasks",
484524
"when finished, or spawn_agent the next specialist. Do not end this turn while",
@@ -527,34 +567,37 @@ async function driveOpenTasksAfterFleetDrySpill(
527567
args.mailbox,
528568
args.lanes,
529569
false,
530-
args.writeBlob,
570+
undefined,
571+
false,
531572
)
532573
).filter((report) => !delivering.has(report.agent_id));
533574
const uncollectedIds = new Set(uncollected.map((report) => report.agent_id));
534575
const stubs = collectAlreadyCollectedStubs(args.mailbox, args.lanes).filter(
535576
(stub) =>
536577
!uncollectedIds.has(stub.agent_id) && !delivering.has(stub.agent_id),
537578
);
538-
const reports = dedupeByAgentId([...uncollected, ...stubs]);
539579
const takeIds = uncollected.map((report) => report.agent_id);
540580
for (const id of takeIds) delivering.add(id);
541-
const prompt = buildFleetDryContinuationPrompt(tasks, reports);
542-
const takeReports = (): void => {
543-
for (const id of takeIds) {
544-
args.mailbox?.take(id);
545-
}
546-
releaseOccupancyDelivering(args.mailbox, takeIds);
547-
};
548581
const fail = (): boolean => {
549582
releaseOccupancyDelivering(args.mailbox, takeIds);
550583
args.onSendFailure?.();
551584
return false;
552585
};
586+
let prompt: string;
553587
try {
588+
const digested = await digestCollectedReports(uncollected, args.writeBlob);
589+
const reports = dedupeByAgentId([...digested, ...stubs]);
590+
prompt = buildFleetDryContinuationPrompt(tasks, reports);
554591
args.beginSystemContinuation(prompt);
555592
} catch {
556593
return fail();
557594
}
595+
const takeReports = (): void => {
596+
for (const id of takeIds) {
597+
args.mailbox?.take(id);
598+
}
599+
releaseOccupancyDelivering(args.mailbox, takeIds);
600+
};
558601
return settleOccupancySend({
559602
send: () => args.send(prompt),
560603
onSuccess: takeReports,

‎src/subagent/mailbox-mail-drive.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createSubAgentSessionStore } from "./session-store.js";
1212
import {
1313
digestCollectedReports,
1414
fleetDrySpillKey,
15+
OCCUPANCY_DIGEST_READ_HINT,
1516
type FleetDryMailboxRecord,
1617
} from "./fleet-dry-drive.js";
1718
import {
@@ -217,6 +218,7 @@ describe("driveMailboxMail", () => {
217218
]);
218219
expect(sent[0]).not.toContain("SECRET_FINDINGS_BODY");
219220
expect(sent[0]).not.toContain('"report":');
221+
expect(sent[0]).toContain(OCCUPANCY_DIGEST_READ_HINT);
220222
expect(
221223
new TextDecoder().decode(
222224
store.blobs.get(fleetDrySpillKey("done", "report"))?.bytes ??

‎src/subagent/mailbox-mail-drive.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
dedupeByAgentId,
1212
digestCollectedReports,
1313
occupancyDeliveringSet,
14+
occupancyDigestReadHint,
1415
releaseOccupancyDelivering,
1516
settleOccupancySend,
1617
type FleetDryBlobWriter,
@@ -82,9 +83,13 @@ export function occupancyShouldYieldWait(
8283
export function buildMailboxMailPrompt<T extends { agent_id: string }>(
8384
reports: readonly T[],
8485
): string {
85-
return [mailboxMailWakeLine(), JSON.stringify(dedupeByAgentId(reports))].join(
86-
"\n",
87-
);
86+
const unique = dedupeByAgentId(reports);
87+
const hint = occupancyDigestReadHint(unique);
88+
return [
89+
mailboxMailWakeLine(),
90+
JSON.stringify(unique),
91+
...(hint !== undefined ? [hint] : []),
92+
].join("\n");
8893
}
8994

9095
function hasDeliverableMailboxMail(

0 commit comments

Comments
 (0)