From a648ffb0a6dd9bfdec102a1b64ebeb9f4dcd3efd Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 29 Sep 2026 16:32:50 -0700 Subject: [PATCH] fix(subagent): keep fleet-dry parent mail to a digest plus blob --- docs/ARCHITECTURE.md | 2 +- docs/IMPLEMENTATION.md | 2 +- docs/TUI.md | 5 +- src/subagent/fleet-dry-drive.test.ts | 17 +++-- src/subagent/fleet-dry-drive.ts | 87 ++++++++++++++++++------- src/subagent/mailbox-mail-drive.test.ts | 2 + src/subagent/mailbox-mail-drive.ts | 11 +++- 7 files changed, 91 insertions(+), 35 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ab0a44b67..0b97b3aa4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -148,7 +148,7 @@ A worker deny-on-ask registers a harness-owned denied-call envelope (`src/permis Two directors, selected by role: -- **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. +- **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. 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. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index 429ee35cf..07a6e23ba 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -212,7 +212,7 @@ Listings are list-free, dumps are dump-locked: a bounded `ls`/`tree` prints name - **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. -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). +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). `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. diff --git a/docs/TUI.md b/docs/TUI.md index 73c4f8060..d3a6e1013 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -724,8 +724,9 @@ continuation starts before the fleet-0 event so the run stays busy and follow-ups wait one more turn. Occupancy wakes consume mailbox reports only after send settles as accepted; a failed or uncertain send idles the parent so a later flush can retry. Mailbox mail is a Summary + Blockers digest with -a blob pointer to the full report. Fleet-dry continuation lists -already-collected IDs as id/status/description only. The mail and that fleet-dry continuation +a blob pointer to the full report. Fleet-dry continuation uses the same digest +for newly collected terminals and lists already-collected IDs as +id/status/description only. The mail and that fleet-dry continuation are runtime-to-agent traffic — the fleet board owns worker status — so neither paints a transcript row, and neither rehydrates as one. diff --git a/src/subagent/fleet-dry-drive.test.ts b/src/subagent/fleet-dry-drive.test.ts index 27103e9e0..d0f5b141c 100644 --- a/src/subagent/fleet-dry-drive.test.ts +++ b/src/subagent/fleet-dry-drive.test.ts @@ -9,6 +9,7 @@ import { FLEET_DRY_CONTINUATION_PREFIX, FLEET_DRY_REPORT_CHARS, fleetDrySpillKey, + OCCUPANCY_DIGEST_READ_HINT, shouldDriveOpenTasks, type FleetDryMailboxRecord, } from "./fleet-dry-drive.js"; @@ -529,6 +530,7 @@ describe("driveOpenTasksAfterFleetDry", () => { agent_id: string; status: string; description?: string; + summary?: string; report?: string; }[]; expect(parsed).toEqual([ @@ -536,7 +538,7 @@ describe("driveOpenTasksAfterFleetDry", () => { agent_id: "fresh", status: "done", description: "new lane", - report: "fresh report", + summary: "fresh report", }, { agent_id: "prior", @@ -620,13 +622,16 @@ describe("driveOpenTasksAfterFleetDry", () => { send: () => ACCEPTED_DELIVERY, }); expect(driven).toBe(true); - const parsed = reportsJSONFromPrompt(sent[0] ?? ""); - expect(Array.isArray(parsed)).toBe(true); - const report = (parsed as { report?: string }[])[0]?.report ?? ""; - expect(report).toContain( + const parsed = reportsJSONFromPrompt(sent[0] ?? "") as { + report_uri?: string; + summary?: string; + }[]; + expect(parsed[0]?.report_uri).toBe( `tool-output:///${fleetDrySpillKey("big", "report")}`, ); - expect(report).not.toContain("TAIL-MARKER"); + expect(sent[0]).toContain(OCCUPANCY_DIGEST_READ_HINT); + expect(sent[0]).not.toContain("TAIL-MARKER"); + expect(parsed[0]?.summary).not.toContain("TAIL-MARKER"); expect( new TextDecoder().decode( store.blobs.get(fleetDrySpillKey("big", "report"))?.bytes ?? diff --git a/src/subagent/fleet-dry-drive.ts b/src/subagent/fleet-dry-drive.ts index 9ea3c8983..ab6e6e37d 100644 --- a/src/subagent/fleet-dry-drive.ts +++ b/src/subagent/fleet-dry-drive.ts @@ -15,9 +15,15 @@ import { parseSubAgentReport } from "./report.js"; /** Enough of a lane report for a parent continuation; traces stay on disk. */ export const FLEET_DRY_REPORT_CHARS = 8_192; -/** Summary/Blockers inline in mailbox digest; the blob holds the rest. */ +/** Summary/Blockers inline in occupancy digest; the blob holds the rest. */ export const MAILBOX_DIGEST_SECTION_CHARS = 2_048; +/** Unstructured reports have no envelope; keep the inline teaser short. */ +export const UNSTRUCTURED_DIGEST_CHARS = 280; + +export const OCCUPANCY_DIGEST_READ_HINT = + "read_file each report_uri (offset/limit supported) for the full report."; + export const FLEET_DRY_CONTINUATION_PREFIX = "The fleet has gone dry. Remaining open tasks:"; @@ -204,9 +210,26 @@ export function fleetDrySpillKey( return `fleet-dry:${agentId}:${field}`; } -function clipDigestSection(text: string): string { - if (text.length <= MAILBOX_DIGEST_SECTION_CHARS) return text; - return `${text.slice(0, MAILBOX_DIGEST_SECTION_CHARS - 1).trimEnd()}…`; +function clipDigestSection( + text: string, + max = MAILBOX_DIGEST_SECTION_CHARS, +): string { + if (text.length <= max) return text; + return `${text.slice(0, max - 1).trimEnd()}…`; +} + +export function occupancyDigestReadHint( + reports: readonly object[], +): string | undefined { + for (const report of reports) { + if ( + "report_uri" in report && + typeof (report as { report_uri?: unknown }).report_uri === "string" + ) { + return OCCUPANCY_DIGEST_READ_HINT; + } + } + return undefined; } export async function spillWorkerField( @@ -215,7 +238,9 @@ export async function spillWorkerField( field: "report" | "error", writeBlob?: FleetDryBlobWriter, ): Promise { - if (text === undefined || writeBlob === undefined) return undefined; + if (text === undefined || text.length === 0 || writeBlob === undefined) { + return undefined; + } const key = fleetDrySpillKey(agentId, field); try { await writeBlob(key, new TextEncoder().encode(text), "text/plain"); @@ -245,6 +270,11 @@ export async function digestCollectedReport( report.report !== undefined ? parseSubAgentReport(report.report) : undefined; + const unstructured = + parsed !== undefined && + parsed.findings.length === 0 && + parsed.blockers.length === 0 && + parsed.paths.length === 0; const reportUri = await spillWorkerField( report.report, report.agent_id, @@ -259,14 +289,21 @@ export async function digestCollectedReport( ); const summary = parsed !== undefined && parsed.summary.length > 0 - ? clipDigestSection(parsed.summary) - : undefined; - const blockers = - parsed !== undefined ? clipDigestSection( - parsed.blockers.length > 0 ? parsed.blockers : "None.", + parsed.summary, + unstructured + ? UNSTRUCTURED_DIGEST_CHARS + : MAILBOX_DIGEST_SECTION_CHARS, ) : undefined; + const blockers = + parsed === undefined || unstructured + ? parsed !== undefined && parsed.blockers.length > 0 + ? clipDigestSection(parsed.blockers) + : undefined + : clipDigestSection( + parsed.blockers.length > 0 ? parsed.blockers : "None.", + ); return { agent_id: report.agent_id, status: report.status, @@ -463,13 +500,15 @@ export async function collectUncollectedTerminals( return reports; } -export function buildFleetDryContinuationPrompt( +export function buildFleetDryContinuationPrompt( tasks: readonly Task[], - reports: readonly CollectedWorkerReport[], + reports: readonly T[], ): string { const open = tasks.filter( (task) => task.status === "todo" || task.status === "doing", ); + const unique = dedupeByAgentId(reports); + const hint = occupancyDigestReadHint(unique); const taskLines = open .map((task) => `- ${task.id}: ${task.title} (${task.status})`) .join("\n"); @@ -478,7 +517,8 @@ export function buildFleetDryContinuationPrompt( taskLines, "", "Collected worker reports (already collected — do not call wait_agents for these agent_ids):", - JSON.stringify(dedupeByAgentId(reports)), + JSON.stringify(unique), + ...(hint !== undefined ? [hint] : []), "", "Continue the remaining work. Mark each task done or cancelled with manage_tasks", "when finished, or spawn_agent the next specialist. Do not end this turn while", @@ -527,7 +567,8 @@ async function driveOpenTasksAfterFleetDrySpill( args.mailbox, args.lanes, false, - args.writeBlob, + undefined, + false, ) ).filter((report) => !delivering.has(report.agent_id)); const uncollectedIds = new Set(uncollected.map((report) => report.agent_id)); @@ -535,26 +576,28 @@ async function driveOpenTasksAfterFleetDrySpill( (stub) => !uncollectedIds.has(stub.agent_id) && !delivering.has(stub.agent_id), ); - const reports = dedupeByAgentId([...uncollected, ...stubs]); const takeIds = uncollected.map((report) => report.agent_id); for (const id of takeIds) delivering.add(id); - const prompt = buildFleetDryContinuationPrompt(tasks, reports); - const takeReports = (): void => { - for (const id of takeIds) { - args.mailbox?.take(id); - } - releaseOccupancyDelivering(args.mailbox, takeIds); - }; const fail = (): boolean => { releaseOccupancyDelivering(args.mailbox, takeIds); args.onSendFailure?.(); return false; }; + let prompt: string; try { + const digested = await digestCollectedReports(uncollected, args.writeBlob); + const reports = dedupeByAgentId([...digested, ...stubs]); + prompt = buildFleetDryContinuationPrompt(tasks, reports); args.beginSystemContinuation(prompt); } catch { return fail(); } + const takeReports = (): void => { + for (const id of takeIds) { + args.mailbox?.take(id); + } + releaseOccupancyDelivering(args.mailbox, takeIds); + }; return settleOccupancySend({ send: () => args.send(prompt), onSuccess: takeReports, diff --git a/src/subagent/mailbox-mail-drive.test.ts b/src/subagent/mailbox-mail-drive.test.ts index e4517af7a..b61c421ab 100644 --- a/src/subagent/mailbox-mail-drive.test.ts +++ b/src/subagent/mailbox-mail-drive.test.ts @@ -12,6 +12,7 @@ import { createSubAgentSessionStore } from "./session-store.js"; import { digestCollectedReports, fleetDrySpillKey, + OCCUPANCY_DIGEST_READ_HINT, type FleetDryMailboxRecord, } from "./fleet-dry-drive.js"; import { @@ -217,6 +218,7 @@ describe("driveMailboxMail", () => { ]); expect(sent[0]).not.toContain("SECRET_FINDINGS_BODY"); expect(sent[0]).not.toContain('"report":'); + expect(sent[0]).toContain(OCCUPANCY_DIGEST_READ_HINT); expect( new TextDecoder().decode( store.blobs.get(fleetDrySpillKey("done", "report"))?.bytes ?? diff --git a/src/subagent/mailbox-mail-drive.ts b/src/subagent/mailbox-mail-drive.ts index a73205b1b..00ec359af 100644 --- a/src/subagent/mailbox-mail-drive.ts +++ b/src/subagent/mailbox-mail-drive.ts @@ -11,6 +11,7 @@ import { dedupeByAgentId, digestCollectedReports, occupancyDeliveringSet, + occupancyDigestReadHint, releaseOccupancyDelivering, settleOccupancySend, type FleetDryBlobWriter, @@ -82,9 +83,13 @@ export function occupancyShouldYieldWait( export function buildMailboxMailPrompt( reports: readonly T[], ): string { - return [mailboxMailWakeLine(), JSON.stringify(dedupeByAgentId(reports))].join( - "\n", - ); + const unique = dedupeByAgentId(reports); + const hint = occupancyDigestReadHint(unique); + return [ + mailboxMailWakeLine(), + JSON.stringify(unique), + ...(hint !== undefined ? [hint] : []), + ].join("\n"); } function hasDeliverableMailboxMail(