diff --git a/docs/TUI.md b/docs/TUI.md index 46ca2ae25..b956aaf83 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -263,6 +263,39 @@ the way down. Only once every other collapsible zone ahead of it in `COLLAPSE_ORDER` and the panel itself are exhausted does it reach 0, the same last-resort floor every other optional zone shares. +### Unprompted fleet reports + +The agents panel is a standing picture of what is running right now; it says +nothing when a lane finishes, stalls, or fails unless the operator interrupts +to ask. `src/subagent/fleet-report.ts` closes that gap with its own channel: +a system-notice line, pushed into the transcript through the same +`surfaceSystemNotice` path as any other system row, the moment a lane +transition is worth saying. It does not touch the panel's rows or its +`laneState()` computation — it reads the same sub-agent session store the +panel reads, and calls the same `agentProgress()` stall definition +(`isStalled`) so the two surfaces never disagree about whether a lane is +stalled, only about *when* they say so: the panel shows it continuously, +the notice announces the transition once. + +Store changes drive it directly, so a lane finishing or failing lands the +moment it happens. A `FLEET_REPORT_SETTLE_MS` (400ms) timer lets a parallel +dispatch that lands as N store changes settle into one observation instead +of N lines. Quiet detection is separate: `FLEET_STALL_POLL_MS` (5s) re-runs +observation so a lane that went quiet with no further store event is still +announced once. Past `COALESCE_ABOVE` (3) changes in one observation the +individual lines collapse into a single tally (`"9 done, 3 failed"`); below +that threshold each change gets its own line. The one case both the fleet +going idle and a coalesced tally would otherwise say the same thing — +all changes are terminal and the tally alone already says "N done, N +failed" — the idle line replaces the tally instead of repeating it with +"— nothing running" tacked on. + +Outcomes and errors are clipped to `OUTCOME_CHARS`/`MAX_UPDATE_CHARS` on the +same "one update is one row, never wrapped" rule the panel's rows follow. +`fleetDigest()` is the on-demand counterpart: the same picture in one line, +answering "where is the fleet" without an interrupt, for `/status` or an +operator question mid-run. + ## How pop-ups should feel A blocking surface (permissions, an operator question, the model/provider @@ -446,7 +479,13 @@ Ctrl+C interrupts a busy run (or clears a non-empty idle prompt); a second Ctrl+C within a 2-second window (`CTRL_C_EXIT_WINDOW_MS`) quits — this replaced an Ink-era yes/no exit-confirm modal with the same intent (an explicit second confirmation) without adding a modal (`handleCtrlC`, -`shell.ts`). +`shell.ts`). The interrupt keeps whatever is sitting in the queue rather than +discarding it — the operator typed those messages meaning them delivered, not +meaning "cancel this run and also throw away what I typed"; the transcript +row says so (`"interrupt — N pending kept"`). Kept items are handed over at +the interrupt itself (`doInterrupt` in `runtime-bridge.ts` drains after +`port.interrupt()`), serialized behind the agent rebuild the stop starts — +a stop does not reliably produce an idle event to drain against later. ## Overflows, scrolling, and key macros diff --git a/src/subagent/fleet-report.test.ts b/src/subagent/fleet-report.test.ts new file mode 100644 index 000000000..3e3605ff3 --- /dev/null +++ b/src/subagent/fleet-report.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; +import { + createFleetWatch, + fleetDigest, + observeFleet, + type FleetLane, +} from "./fleet-report.js"; + +const T0 = 1_000_000; + +function lane(overrides: Partial & { id: string }): FleetLane { + return { + description: overrides.id, + status: "running", + startedAt: T0, + lastActivityAt: T0, + currentToolName: null, + currentToolStartedAt: null, + ...overrides, + }; +} + +describe("observeFleet", () => { + test("the first observation seeds without announcing an in-flight fleet", () => { + const { watch, updates } = observeFleet( + createFleetWatch(), + [lane({ id: "api" }), lane({ id: "docs" })], + T0, + ); + expect(updates).toEqual([]); + expect(watch.running).toBe(2); + }); + + test("a finished lane is reported with what it produced", () => { + const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch; + const { updates } = observeFleet( + seeded, + [ + lane({ + id: "api", + status: "done", + report: "## Summary\nRewired the reporter and added six tests.", + }), + ], + T0 + 1000, + ); + expect(updates[0]).toBe( + "fleet · api done — Rewired the reporter and added six tests.", + ); + }); + + test("the last lane finishing says so, which is the silence the operator hit", () => { + const seeded = observeFleet( + createFleetWatch(), + [lane({ id: "api" }), lane({ id: "docs", status: "done" })], + T0, + ).watch; + const { updates } = observeFleet( + seeded, + [lane({ id: "api", status: "done", report: "done" }), lane({ id: "docs", status: "done" })], + T0 + 1000, + ); + expect(updates).toEqual([ + "fleet · api done — done", + "fleet · 2 done — nothing running", + ]); + }); + + test("a failure names what went wrong", () => { + const seeded = observeFleet(createFleetWatch(), [lane({ id: "build" })], T0).watch; + const { updates } = observeFleet( + seeded, + [lane({ id: "build", status: "failed", error: "typecheck exited 1" })], + T0 + 1000, + ); + expect(updates[0]).toContain("build failed — typecheck exited 1"); + }); + + test("a dispatch carries the load it was decided against", () => { + const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch; + const { updates } = observeFleet( + seeded, + [lane({ id: "api" }), lane({ id: "docs" })], + T0 + 1000, + ); + expect(updates).toEqual(["fleet · dispatched docs (2 running)"]); + }); + + test("a quiet lane is announced once, not on every tick it stays quiet", () => { + const quiet = lane({ id: "api", lastActivityAt: T0 }); + const seeded = observeFleet(createFleetWatch(), [quiet], T0).watch; + const first = observeFleet(seeded, [quiet], T0 + 60_000); + expect(first.updates[0]).toContain("api stalled"); + const second = observeFleet(first.watch, [quiet], T0 + 90_000); + expect(second.updates).toEqual([]); + }); + + test("routine activity that changes nothing produces no update", () => { + const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch; + const busy = observeFleet( + seeded, + [lane({ id: "api", lastActivityAt: T0 + 4000, currentToolName: "grep" })], + T0 + 5000, + ); + expect(busy.updates).toEqual([]); + }); + + test("an update is one row — a long outcome is clipped, never wrapped", () => { + const seeded = observeFleet(createFleetWatch(), [lane({ id: "api" })], T0).watch; + const { updates } = observeFleet( + seeded, + [ + lane({ + id: "api", + status: "done", + report: "Rewired the reporter, added the digest, wired the poll, and updated every affected test in the suite.", + }), + ], + T0 + 1000, + ); + expect(updates[0]!.length).toBeLessThanOrEqual(76); + expect(updates[0]).toContain("…"); + }); + + test("a dozen lanes landing at once collapse into one tally", () => { + const before = Array.from({ length: 12 }, (_, i) => lane({ id: `l${i}` })); + const seeded = observeFleet(createFleetWatch(), before, T0).watch; + const after = before.map((l, i) => + i < 9 + ? { ...l, status: "done" as const, report: "ok" } + : { ...l, status: "failed" as const, error: "boom" }, + ); + const { updates } = observeFleet(seeded, after, T0 + 1000); + expect(updates).toEqual(["fleet · 9 done, 3 failed — nothing running"]); + }); +}); + +describe("fleetDigest", () => { + test("one row carries running lanes, their clocks, and the finished tally", () => { + const digest = fleetDigest( + [ + lane({ id: "api", startedAt: T0 - 80_000, lastActivityAt: T0 - 1000 }), + lane({ id: "docs", startedAt: T0 - 20_000, lastActivityAt: T0 - 120_000 }), + lane({ id: "web", status: "done" }), + lane({ id: "cli", status: "failed" }), + ], + T0, + ); + expect(digest).toBe("fleet · 2 running (api 1:20, docs 0:20 stalled) · 1 done · 1 failed"); + }); + + test("a fleet with nothing left running says so rather than going blank", () => { + expect(fleetDigest([lane({ id: "api", status: "done" })], T0)).toBe( + "fleet · nothing running · 1 done", + ); + expect(fleetDigest([], T0)).toBe("fleet · no lanes dispatched"); + }); +}); diff --git a/src/subagent/fleet-report.ts b/src/subagent/fleet-report.ts new file mode 100644 index 000000000..51cdc2d95 --- /dev/null +++ b/src/subagent/fleet-report.ts @@ -0,0 +1,266 @@ +/** + * What the orchestrator says to the operator about the fleet, unprompted. + * + * Every lane completion, stall and failure already passes through the parent + * session, which then said nothing about any of it unless interrupted and + * asked. This module turns that stream into the small number of lines that + * change the operator's picture, and nothing else: a lane finished and what it + * produced, a lane stalled or failed, work dispatched, and the moment the + * fleet runs dry. + * + * Pure and stateless per call — the caller keeps the returned watch and hands + * it back on the next observation. No painting, no store access. + */ + +import { agentProgress, clockLabel, DEFAULT_STALL_MS } from "../tui-opentui/agent-progress.js"; +import type { SubAgentSessionStatus } from "./session-store.js"; + +/** The lane fields a report is written from. `SubAgentSession` satisfies it. */ +export type FleetLane = { + readonly id: string; + readonly description: string; + readonly status: SubAgentSessionStatus; + readonly startedAt: number; + readonly lastActivityAt: number; + readonly currentToolName: string | null; + readonly currentToolStartedAt: number | null; + readonly report?: string; + readonly error?: string; +}; + +type LaneMark = { + readonly status: SubAgentSessionStatus; + /** + * Sticky once set. A lane that flaps either side of the stall threshold + * would otherwise re-announce itself every time it went quiet, which is the + * wall of noise this module exists to avoid. + */ + readonly stallReported: boolean; +}; + +export type FleetWatch = { + readonly lanes: ReadonlyMap; + readonly running: number; + /** False until the first observation, so a resumed fleet is not re-announced. */ + readonly seeded: boolean; +}; + +export function createFleetWatch(): FleetWatch { + return { lanes: new Map(), running: 0, seeded: false }; +} + +/** + * Above this many changes in one observation the individual lines stop being + * readable and start being a scroll, so they collapse into one tally. Set by + * what a glance can take in, not by fleet size. + */ +const COALESCE_ABOVE = 3; + +/** Enough of an outcome to judge it; past this the operator opens the lane. */ +const OUTCOME_CHARS = 56; + +/** + * One update is one row. A line that wraps doubles the cost of every update on + * screen, which is how a report meant to be glanced at turns into a scroll. + */ +const MAX_UPDATE_CHARS = 76; + +const PREFIX = "fleet"; + +/** + * A lane going quiet is the one change that produces no event, so it has to be + * looked for. Coarse on purpose: the stall threshold is tens of seconds, and + * the observation is a cheap diff either way. + */ +export const FLEET_STALL_POLL_MS = 5_000; + +/** + * A parallel dispatch lands as one store change per lane, so observing each + * one on its own turns a single decision into a line per lane. Settling first + * is what lets the tally do its job. + */ +export const FLEET_REPORT_SETTLE_MS = 400; + +/** Lanes named in a digest before it starts counting instead of listing. */ +const DIGEST_NAMED_LANES = 4; + +function firstLine(text: string | undefined): string { + if (text === undefined) return ""; + for (const raw of text.split("\n")) { + // A report that opens with "## Summary" says nothing an operator can act + // on; the first line of prose under it is the outcome they wanted. + if (/^\s*#/.test(raw)) continue; + const line = raw.replace(/^[>*\-\s]+/, "").trim(); + if (line.length > 0) return line; + } + return ""; +} + +function clip(text: string, max: number): string { + if (text.length <= max) return text; + return `${text.slice(0, max - 1).trimEnd()}…`; +} + +function outcome(lane: FleetLane): string { + const summary = firstLine(lane.report); + return summary.length > 0 ? clip(summary, OUTCOME_CHARS) : "no summary reported"; +} + +function isStalled(lane: FleetLane, nowMs: number, stallMs: number): boolean { + // One definition of a stalled lane lives in `agentProgress`; asking it is + // what keeps this report and the agents panel from disagreeing on screen. + return agentProgress(lane, nowMs, stallMs)?.stalled === true; +} + +type Change = + | { readonly kind: "dispatched"; readonly line: string } + | { readonly kind: "done"; readonly line: string } + | { readonly kind: "failed"; readonly line: string } + | { readonly kind: "stalled"; readonly line: string }; + +export type FleetObservation = { + readonly watch: FleetWatch; + /** Ready-to-print lines, already coalesced. Usually empty. */ + readonly updates: readonly string[]; +}; + +export function observeFleet( + previous: FleetWatch, + lanes: readonly FleetLane[], + nowMs: number, + stallMs: number = DEFAULT_STALL_MS, +): FleetObservation { + const marks = new Map(); + const changes: Change[] = []; + let running = 0; + + for (const lane of lanes) { + if (lane.status === "running") running += 1; + const before = previous.lanes.get(lane.id); + const stalled = + lane.status === "running" && + (before?.stallReported === true || isStalled(lane, nowMs, stallMs)); + marks.set(lane.id, { status: lane.status, stallReported: stalled }); + + if (!previous.seeded) continue; + + if (before === undefined) { + if (lane.status === "running") { + changes.push({ kind: "dispatched", line: `dispatched ${lane.description}` }); + } + continue; + } + + if (before.status !== lane.status) { + if (lane.status === "done") { + changes.push({ kind: "done", line: `${lane.description} done — ${outcome(lane)}` }); + } else if (lane.status === "failed") { + changes.push({ + kind: "failed", + line: `${lane.description} failed — ${clip(firstLine(lane.error) || "no error reported", OUTCOME_CHARS)}`, + }); + } else if (lane.status === "cancelled") { + changes.push({ kind: "failed", line: `${lane.description} cancelled` }); + } + continue; + } + + if (lane.status === "running" && stalled && before.stallReported !== true) { + changes.push({ + kind: "stalled", + line: `${lane.description} stalled — quiet for ${clockLabel(nowMs - lane.lastActivityAt)}`, + }); + } + } + + const watch: FleetWatch = { lanes: marks, running, seeded: true }; + if (changes.length === 0) return { watch, updates: [] }; + + // A dispatch carries the load it was decided against, so an operator who + // would have scheduled differently can say so while it still matters. + const lines = + changes.length > COALESCE_ABOVE + ? [tally(changes)] + : changes.map((c) => + c.kind === "dispatched" ? `${c.line} (${running} running)` : c.line, + ); + + // The defect this report exists for: work finished, nothing left running, + // and no one said so. That transition is always worth its own line — unless + // the tally above already said the same thing, in which case a second line + // restating it verbatim (with "— nothing running" tacked on) is noise, not + // information. + if (running === 0 && previous.running > 0) { + const idle = `${idleSummary(lanes)} — nothing running`; + if (lines.length === 1 && lines[0] === idleSummary(lanes)) { + lines[0] = idle; + } else { + lines.push(idle); + } + } + + return { + watch, + updates: lines.map((line) => clip(`${PREFIX} · ${line}`, MAX_UPDATE_CHARS)), + }; +} + +function tally(changes: readonly Change[]): string { + const count = (kind: Change["kind"]): number => + changes.filter((c) => c.kind === kind).length; + const parts: string[] = []; + const dispatched = count("dispatched"); + const done = count("done"); + const failed = count("failed"); + const stalled = count("stalled"); + if (done > 0) parts.push(`${done} done`); + if (failed > 0) parts.push(`${failed} failed`); + if (stalled > 0) parts.push(`${stalled} stalled`); + if (dispatched > 0) parts.push(`${dispatched} dispatched`); + return parts.join(", "); +} + +function idleSummary(lanes: readonly FleetLane[]): string { + const done = lanes.filter((l) => l.status === "done").length; + const failed = lanes.filter((l) => l.status === "failed" || l.status === "cancelled").length; + const parts = [`${done} done`]; + if (failed > 0) parts.push(`${failed} failed`); + return parts.join(", "); +} + +/** + * The answer to "where are we" on demand — the same picture the unprompted + * lines build up to, in one row, so asking never costs an interrupt. + */ +export function fleetDigest( + lanes: readonly FleetLane[], + nowMs: number, + stallMs: number = DEFAULT_STALL_MS, +): string { + if (lanes.length === 0) return `${PREFIX} · no lanes dispatched`; + const running = lanes.filter((l) => l.status === "running"); + const done = lanes.filter((l) => l.status === "done").length; + const failed = lanes.filter((l) => l.status === "failed").length; + const cancelled = lanes.filter((l) => l.status === "cancelled").length; + + const parts: string[] = []; + if (running.length === 0) { + parts.push("nothing running"); + } else { + const named = running + .slice(0, DIGEST_NAMED_LANES) + .map((lane) => { + const quiet = isStalled(lane, nowMs, stallMs) ? " stalled" : ""; + return `${lane.description} ${clockLabel(nowMs - lane.startedAt)}${quiet}`; + }) + .join(", "); + const extra = running.length - Math.min(running.length, DIGEST_NAMED_LANES); + parts.push( + `${running.length} running (${named}${extra > 0 ? `, +${extra} more` : ""})`, + ); + } + if (done > 0) parts.push(`${done} done`); + if (failed > 0) parts.push(`${failed} failed`); + if (cancelled > 0) parts.push(`${cancelled} cancelled`); + return `${PREFIX} · ${parts.join(" · ")}`; +} diff --git a/src/subagent/index.ts b/src/subagent/index.ts index 7228d3507..ca62b6817 100644 --- a/src/subagent/index.ts +++ b/src/subagent/index.ts @@ -7,6 +7,16 @@ export type { SubAgentSession, SubAgentSessionStore, SubAgentTranscriptEntry } from "./session-store.js"; export { createSubAgentSessionStore } from "./session-store.js"; +export { + createFleetWatch, + fleetDigest, + FLEET_REPORT_SETTLE_MS, + FLEET_STALL_POLL_MS, + observeFleet, + type FleetLane, + type FleetObservation, + type FleetWatch, +} from "./fleet-report.js"; export { DEFAULT_THRASH_CONFIG, EMPTY_THRASH_STATE, diff --git a/src/tui-opentui/runtime-bridge.test.ts b/src/tui-opentui/runtime-bridge.test.ts index e9809da1d..e62d766c7 100644 --- a/src/tui-opentui/runtime-bridge.test.ts +++ b/src/tui-opentui/runtime-bridge.test.ts @@ -139,7 +139,7 @@ describe("attachSessionBridge", () => { ) }) - test("Ctrl+C hits port.interrupt and clears pending", async () => { + test("Ctrl+C hits port.interrupt and keeps pending for the next turn", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -157,9 +157,14 @@ describe("attachSessionBridge", () => { h.pressKey("c", { ctrl: true }) await h.renderOnce() expect(port.calls.some((c) => c.op === "interrupt")).toBe(true) - expect(badgeCount(shell.session)).toBe(0) expect(shell.session.interruptFlash).toBe(true) expect(shell.session.run).toBe("idle") + // Handed over, not thrown away — and handed over here rather than + // left waiting on an idle event the stop may never produce. + expect( + port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), + ).toEqual(["b", "a"]) + expect(badgeCount(shell.session)).toBe(0) } finally { bridge.dispose() shell.dispose() diff --git a/src/tui-opentui/runtime-bridge.ts b/src/tui-opentui/runtime-bridge.ts index e9762c46a..dac8b47bc 100644 --- a/src/tui-opentui/runtime-bridge.ts +++ b/src/tui-opentui/runtime-bridge.ts @@ -955,6 +955,11 @@ export function attachSessionBridge( bag.pendingEchoes.length = 0 applyShellInterrupt(shell) bag.port.interrupt() + // The stop settles the turn without necessarily producing an idle event to + // drain against, so anything the operator had queued would sit there + // forever. Hand it over here instead: the host serialises it behind the + // agent rebuild the interrupt just started. + drainAtBoundary(shell, bag) // Clearing the last prompt is what stops the quota loop from replaying a // turn the operator (or the watchdog) deliberately stopped. bag.lastSentMessage = "" diff --git a/src/tui-opentui/session-queue.test.ts b/src/tui-opentui/session-queue.test.ts index cf78e2654..4cff4262b 100644 --- a/src/tui-opentui/session-queue.test.ts +++ b/src/tui-opentui/session-queue.test.ts @@ -52,12 +52,12 @@ describe("session-queue", () => { expect(d3.item?.text).toBe("q1") }) - test("Ctrl+C interrupt clears pending + sets flash + idle", () => { + test("Ctrl+C interrupt keeps pending + sets flash + idle", () => { let s = createSessionQueue("busy") s = enqueue(s, "a") s = enqueueSteer(s, "b") s = interrupt(s) - expect(badgeCount(s)).toBe(0) + expect(drainOrder(s).map((i) => i.text)).toEqual(["b", "a"]) expect(s.interruptFlash).toBe(true) expect(s.run).toBe("idle") s = clearInterruptFlash(s) diff --git a/src/tui-opentui/session-queue.ts b/src/tui-opentui/session-queue.ts index b90f6a1c4..851e90392 100644 --- a/src/tui-opentui/session-queue.ts +++ b/src/tui-opentui/session-queue.ts @@ -93,15 +93,17 @@ export function enqueueSteer( } /** - * Hard interrupt: discard all pending queue + steer, clear flash flag set, - * force run to idle (caller re-sets busy when a new run starts). + * Hard interrupt: stop the run, keep everything the operator queued. Typing a + * correction and then interrupting so it lands sooner is the common shape of + * this gesture, so discarding the queue destroyed exactly the input the + * operator most wanted delivered. Pending items survive to the next drain + * boundary; only the run state and the flash change here. */ export function interrupt(state: SessionQueueState): SessionQueueState { return { + ...state, run: "idle", - items: [], interruptFlash: true, - nextId: state.nextId, } } diff --git a/src/tui-opentui/shell.test.ts b/src/tui-opentui/shell.test.ts index b94c74a0e..caf120139 100644 --- a/src/tui-opentui/shell.test.ts +++ b/src/tui-opentui/shell.test.ts @@ -448,7 +448,7 @@ describe("product skin: stream + queue + overlay", () => { ) }) - test("Ctrl+C interrupt clears pending + flash", async () => { + test("Ctrl+C interrupt keeps pending + sets flash", async () => { await withTestRenderer( async (h) => { const shell = createAppShell(h.renderer, { @@ -463,14 +463,12 @@ describe("product skin: stream + queue + overlay", () => { submitPrompt(shell, "steer") expect(shell.pendingQueue).toBe(2) interruptShell(shell) - expect(shell.pendingQueue).toBe(0) + expect(shell.pendingQueue).toBe(2) expect(shell.session.interruptFlash).toBe(true) expect(shell.session.run).toBe("idle") await h.renderOnce() const row = noticeRow(h.captureCharFrame()) expect(row).toContain("interrupt") - // An empty queue is the default state, so it stays off the row. - expect(row).not.toContain("queue") } finally { shell.dispose() } diff --git a/src/tui-opentui/shell.ts b/src/tui-opentui/shell.ts index 458695141..daf267b60 100644 --- a/src/tui-opentui/shell.ts +++ b/src/tui-opentui/shell.ts @@ -3171,13 +3171,16 @@ export function applyShellInterrupt(shell: AppShell): void { shell.prompt.value = "" appendStreamRow(shell, { role: "system", - text: `interrupt — discarded ${had} pending`, + text: + had > 0 + ? `interrupt — ${had} pending kept` + : "interrupt", meta: "stop", }) paintChrome(shell) } -/** Ctrl+C interrupt path: clear pending, flash, idle. */ +/** Ctrl+C interrupt path: keep pending, flash, idle. */ export function interruptShell(shell: AppShell): void { const hooks = getShellBridgeHooks(shell) if (hooks?.exclusive) { diff --git a/src/tui/commands/built-in.test.ts b/src/tui/commands/built-in.test.ts index 7ac1228de..f95fc9c35 100644 --- a/src/tui/commands/built-in.test.ts +++ b/src/tui/commands/built-in.test.ts @@ -41,6 +41,26 @@ describe("removed commands", () => { }); }); +describe("/status command", () => { + it("answers from the live fleet without sending anything to the model", () => { + const ctx: CommandContext = { + signalClear: () => {}, + getFleetStatus: () => "fleet · 2 running (api 1:20, docs 0:04) · 1 done", + }; + expect(getCommand("status")!.handler("", ctx)).toEqual({ + type: "message", + text: "fleet · 2 running (api 1:20, docs 0:04) · 1 done", + }); + }); + + it("says so rather than throwing when no fleet source is wired", () => { + expect(getCommand("status")!.handler("", makeCtx())).toEqual({ + type: "message", + text: "Fleet status is not available in this session.", + }); + }); +}); + describe("removed approval command", () => { it("is not registered", () => { expect(getCommand("auto")).toBeUndefined(); diff --git a/src/tui/commands/built-in.ts b/src/tui/commands/built-in.ts index 723f61933..ebeb4f3fc 100644 --- a/src/tui/commands/built-in.ts +++ b/src/tui/commands/built-in.ts @@ -119,6 +119,18 @@ export function registerBuiltInCommands(): void { }, }); + registerCommand({ + name: "status", + description: "Show what the dispatched fleet is doing right now", + handler: (_args, ctx) => { + const status = ctx.getFleetStatus?.(); + if (status === undefined) { + return { type: "message", text: "Fleet status is not available in this session." }; + } + return { type: "message", text: status }; + }, + }); + registerCommand({ name: "changelog", description: "Show recent release notes (or full history)", diff --git a/src/tui/commands/registry.ts b/src/tui/commands/registry.ts index bf44e9c34..e73380c79 100644 --- a/src/tui/commands/registry.ts +++ b/src/tui/commands/registry.ts @@ -3,6 +3,12 @@ import type { CostSummary } from "../../cost/cost-summary.js"; export type CommandContext = { signalClear: () => void; getCostSummary?: () => CostSummary; + /** + * One-row answer to "where are we" on the dispatched fleet. Read live and + * answered locally, so asking never costs the operator an interrupt (and + * with it whatever they had queued). + */ + getFleetStatus?: () => string; // Start a workflow by name; returns a status message to surface to the user. startWorkflow?: (name: string) => string; /** Rename the active session (persisted as run.json task). */ diff --git a/src/tui/deliver-agent-message.test.ts b/src/tui/deliver-agent-message.test.ts new file mode 100644 index 000000000..c34143bb7 --- /dev/null +++ b/src/tui/deliver-agent-message.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test } from "bun:test"; +import { deliverAgentMessage } from "./deliver-agent-message.js"; + +describe("deliverAgentMessage", () => { + test("surfaces a not-delivered notice instead of throwing when the agent is mid-rebuild", () => { + const notices: string[] = []; + const fatal = new Error("agent rebuild failed: provider unreachable"); + let delivered = false; + + deliverAgentMessage({ + getFatalBuildError: () => fatal, + deliverToLiveAgent: () => { + delivered = true; + }, + onDeliverFailure: (message) => notices.push(message), + }); + + // The rebuild failed, so currentAgent still points at the closed agent. + // Delivery must never be attempted against it, and the operator must see + // why their message did not go through. + expect(delivered).toBe(false); + expect(notices).toHaveLength(1); + expect(notices[0]).toContain("not delivered"); + expect(notices[0]).toContain("provider unreachable"); + }); + + test("surfaces a not-delivered notice when the live agent throws on delivery", () => { + const notices: string[] = []; + + deliverAgentMessage({ + getFatalBuildError: () => null, + deliverToLiveAgent: () => { + throw new Error("agent is closed"); + }, + onDeliverFailure: (message) => notices.push(message), + }); + + expect(notices).toHaveLength(1); + expect(notices[0]).toContain("not delivered"); + expect(notices[0]).toContain("agent is closed"); + }); + + test("delivers normally and stays silent when the agent is healthy", () => { + const notices: string[] = []; + let delivered = false; + + deliverAgentMessage({ + getFatalBuildError: () => null, + deliverToLiveAgent: () => { + delivered = true; + }, + onDeliverFailure: (message) => notices.push(message), + }); + + expect(delivered).toBe(true); + expect(notices).toHaveLength(0); + }); +}); diff --git a/src/tui/deliver-agent-message.ts b/src/tui/deliver-agent-message.ts new file mode 100644 index 000000000..c41e52675 --- /dev/null +++ b/src/tui/deliver-agent-message.ts @@ -0,0 +1,26 @@ +/** + * Guards a queued/steer deliver against a mid-rebuild agent. The shell paints + * the delivered row and pops the queue item before this runs, so a failure + * here must be surfaced — a swallowed error here means the transcript claims + * delivery for a message that never reached the agent. + */ +export type DeliverAgentMessageDeps = { + getFatalBuildError: () => Error | null; + deliverToLiveAgent: () => void; + onDeliverFailure: (message: string) => void; +}; + +export function deliverAgentMessage(deps: DeliverAgentMessageDeps): void { + const fatal = deps.getFatalBuildError(); + if (fatal !== null) { + deps.onDeliverFailure(`Message not delivered: ${fatal.message}`); + return; + } + try { + deps.deliverToLiveAgent(); + } catch (err) { + deps.onDeliverFailure( + `Message not delivered: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} diff --git a/src/tui/runner.ts b/src/tui/runner.ts index d5ff008ec..bdc23c0be 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -117,7 +117,16 @@ import { detectLanguageServerAvailable } from "../agent/lsp-availability.js"; import { normalizeToolDefinitionsForProvider } from "../agent/tool-schema-normalize.js"; import { resolveSessionMode, type SessionMode } from "../config/session-mode.js"; import { promptSessionModeIfUnset } from "./session-mode-prompt.js"; -import { createSubAgentSessionStore, taskToolDefinition, type SubAgentProvider } from "../subagent/index.js"; +import { + createFleetWatch, + createSubAgentSessionStore, + fleetDigest, + FLEET_REPORT_SETTLE_MS, + FLEET_STALL_POLL_MS, + observeFleet, + taskToolDefinition, + type SubAgentProvider, +} from "../subagent/index.js"; import type { InferenceSource, ToolDefinition, InboundMessage } from "@intx/types/runtime"; import { createSessionOperationQueue } from "./session-operation-queue.js"; import { setAgentSourceUnlessClosed } from "./agent-source-sync.js"; @@ -189,6 +198,7 @@ import { import { createAttachmentRehydrateTransform } from "../session/attachment-store.js"; import { createModelSummarizer } from "../session/summarizer.js"; import { COMMAND_NAME, ID_PREFIX, LOG_NAMESPACE_ROOT } from "../branding.js"; +import { deliverAgentMessage } from "./deliver-agent-message.js"; const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -1173,11 +1183,14 @@ export async function runTUI(initialConfig: Config): Promise { const sessionOps = createSessionOperationQueue(); const enqueueAgentDeliver = (deliverToLiveAgent: () => void): void => { void sessionOps.enqueue(async () => { - try { - deliverToLiveAgent(); - } catch { - // Agent may be mid-reload or closing; a dropped message is harmless. - } + // The shell already popped the queue item and painted it as delivered + // by the time this runs, so a failed rebuild must be surfaced here — + // otherwise the message silently never reaches the agent. + deliverAgentMessage({ + getFatalBuildError: () => fatalBuildError, + deliverToLiveAgent, + onDeliverFailure: systemNotice, + }); }); }; @@ -1741,6 +1754,7 @@ export async function runTUI(initialConfig: Config): Promise { }); }, startWorkflow: (name) => workflowController.start(name), + getFleetStatus: () => fleetDigest(subAgentSessions.list(), Date.now()), renameSession: (name) => { const trimmed = name.trim(); if (trimmed.length === 0) return "Session name cannot be empty"; @@ -2194,6 +2208,29 @@ export async function runTUI(initialConfig: Config): Promise { setMentionSuggestionSource(host.shell, (prefix) => listPathSuggestions(prefix, config.cwd)); + // The fleet reports itself. Store changes drive it, so a lane finishing or + // failing is on screen the moment it happens rather than at the next turn + // boundary. The settle timer coalesces a parallel burst into one observation; + // the stall poll re-runs so a lane that goes quiet with no further store + // event is still announced once. `observeFleet` decides what is worth saying. + let fleetWatch = createFleetWatch(); + const reportFleet = (): void => { + const observation = observeFleet(fleetWatch, subAgentSessions.list(), Date.now()); + fleetWatch = observation.watch; + for (const update of observation.updates) surfaceSystemNotice(host.shell, update); + }; + let fleetSettle: ReturnType | null = null; + const unsubscribeFleetReport = subAgentSessions.subscribe(() => { + if (fleetSettle !== null) return; + fleetSettle = setTimeout(() => { + fleetSettle = null; + reportFleet(); + }, FLEET_REPORT_SETTLE_MS); + if (typeof fleetSettle.unref === "function") fleetSettle.unref(); + }); + const fleetStallPoll = setInterval(reportFleet, FLEET_STALL_POLL_MS); + if (typeof fleetStallPoll.unref === "function") fleetStallPoll.unref(); + // Same names the operator can already reach by typing them: skills the // session discovered at startup, agents from the live profile registry // (which trust changes can update mid-session, so read through the @@ -2294,6 +2331,9 @@ export async function runTUI(initialConfig: Config): Promise { surfaceSystemNotice(host.shell, notice); await host.waitUntilExit(); + clearInterval(fleetStallPoll); + if (fleetSettle !== null) clearTimeout(fleetSettle); + unsubscribeFleetReport(); // Quitting mid-stream is an abnormal end for the in-flight cycle: nothing // downstream delivers its terminal event once the app is gone. await cycleRecorder.dispose("exit");