From 6ecb95c4de6673e7319843cc2c198df0dd7122e1 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 | 15 ++++-- src/subagent/fleet-dry-drive.ts | 72 ++++++++++++++++++---------- 5 files changed, 63 insertions(+), 33 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 86df5b6b4..7bfa939b9 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 (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. +- **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. 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 32b9b90a7..f8133c4cf 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 (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). +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). `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 c9817da76..a0d32090b 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -725,8 +725,9 @@ 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 (Findings when Summary is empty) with a blob pointer named for `read_file`. -A failed blob write inlines a truncated report. Fleet-dry continuation lists -already-collected IDs as id/status/description only. The mail and that fleet-dry continuation +A failed blob write inlines a truncated 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..a3bdd7402 100644 --- a/src/subagent/fleet-dry-drive.test.ts +++ b/src/subagent/fleet-dry-drive.test.ts @@ -529,6 +529,7 @@ describe("driveOpenTasksAfterFleetDry", () => { agent_id: string; status: string; description?: string; + summary?: string; report?: string; }[]; expect(parsed).toEqual([ @@ -536,6 +537,7 @@ describe("driveOpenTasksAfterFleetDry", () => { agent_id: "fresh", status: "done", description: "new lane", + summary: "fresh report", report: "fresh report", }, { @@ -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("use read_file with that URI"); + 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 3d39e938f..c24503ac2 100644 --- a/src/subagent/fleet-dry-drive.ts +++ b/src/subagent/fleet-dry-drive.ts @@ -15,9 +15,12 @@ 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 FLEET_DRY_CONTINUATION_PREFIX = "The fleet has gone dry. Remaining open tasks:"; @@ -206,9 +209,12 @@ 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()}…`; } function mailboxSpillNotice(text: string, uri: string): string { @@ -230,7 +236,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"); @@ -260,6 +268,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, @@ -274,18 +287,17 @@ export async function digestCollectedReport( ); const summary = parsed !== undefined && parsed.summary.length > 0 - ? clipDigestSection(parsed.summary) + ? clipDigestSection( + parsed.summary, + unstructured + ? UNSTRUCTURED_DIGEST_CHARS + : MAILBOX_DIGEST_SECTION_CHARS, + ) : undefined; const findings = parsed !== undefined && summary === undefined && parsed.findings.length > 0 ? clipDigestSection(parsed.findings) : undefined; - const blockers = - parsed !== undefined - ? clipDigestSection( - parsed.blockers.length > 0 ? parsed.blockers : "None.", - ) - : undefined; const reportInline = report.report === undefined ? undefined @@ -298,6 +310,14 @@ export async function digestCollectedReport( : errorUri !== undefined ? mailboxSpillNotice(report.error, errorUri) : clipDigestSection(report.error); + 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, @@ -494,13 +514,14 @@ 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 taskLines = open .map((task) => `- ${task.id}: ${task.title} (${task.status})`) .join("\n"); @@ -509,7 +530,7 @@ 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), "", "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", @@ -558,7 +579,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)); @@ -566,26 +588,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,