diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 6e484a1be..b7b9b14b7 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -41,7 +41,7 @@ The evidence is in how the product fails today: the personas already produce exc 5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption. 6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why. 7. **Operator-in-the-loop** — The agent can call `ask_operator` to pause and ask a clarifying question; the operator answers from a modal (TUI). Headless `corbits exec` unmounts `ask_operator` when stdin/stdout are not TTYs. TTY exec still reads a single line from stdin. -8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary dispatch turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` is parent-busy, so Enter is a queued steer, not a new turn. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** stops the run outright. The notice row shows distinct `steer N` / `follow-up M` badges, and held messages list in a pending column stacked on the prompt box — `↑`/`↓` select, `Enter` force-pushes one now, `Ctrl+X` drops it — instead of echoing labelled transcript rows; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` stop). +8. **Mid-run steering** — Two modes while the agent is running, keyed to **whose** idle. **Parent-idle** is when the primary dispatch turn is not inside an in-flight parent tool; **session-idle** is parent-idle **and** no live fleet lanes. **Enter** soft-steers while the parent is busy — delivers at the next **parent** `tool.boundary` without stopping the current run; a long parent `run_shell` is parent-busy, so Enter is a queued steer, not a new turn. A queued steer delivers at the next parent `tool.boundary` so occupancy can pick it up. Idle-with-fleet is shipped: after a non-blocking `spawn_agent` dispatch the parent goes idle while workers keep running, mailbox mail arrives as inbound when a worker finishes or fails, and mid-hold Enter starts a new primary turn instead of queueing a steer. **Alt+Enter** queues a follow-up delivered only on session-idle (`run` goes idle; does not interrupt). Session-idle Alt+Enter is a no-op. **Ctrl+C** pauses: stop the primary, hold the queue, stay idle until an explicit operator send. The notice row shows distinct `steer N` / `follow-up M` badges, and held messages list in a pending column stacked on the prompt box — `↑`/`↓` select, `Enter` force-pushes one now, `Ctrl+X` drops it — instead of echoing labelled transcript rows; when steers are pending and a parent tool has been in flight a few seconds, the notice names that command. Shortcuts are listed in `/help` (`Enter` soft-steer · `Alt+Enter` follow-up · `Ctrl+C` pause). 9. **Orchestrator-only (TUI + exec)** — The primary session is always the orchestrator: it can act directly and delegates via `spawn_agent` (then idle; mailbox mail inbound) / `search_agents`. Nested orchestrators collect through mailbox mail the same way. Long jobs belong on workers — a parent that runs them itself stays parent-busy and holds Enter steers. Single-agent session mode, the first-run mode picker, and Settings → Session are gone (CL-5814). Legacy `sessionMode` values on disk are ignored. ## User Experience diff --git a/docs/TUI.md b/docs/TUI.md index 2433509ea..a2a7a9497 100644 --- a/docs/TUI.md +++ b/docs/TUI.md @@ -693,7 +693,9 @@ nearest the prompt, first selected — stay visible, and a guidance row naming its keys (`pending-column.ts`, painted by `syncPendingRows` in `chrome.ts`). The transcript only ever sees the item that actually delivers, as an ordinary user row — pending/delivery labels (`[will steer next]`, `[steering]`, -`[following up]`) are gone on purpose. +`[following up]`) are gone on purpose. After a first Ctrl+C pause the column +is the durable surface: held items stay pending and deliver only on the next +explicit operator send. While the column has items, `↑` at the prompt buffer's top edge selects the newest held item and `↑`/`↓` walk the rows; `↓` past the last row hands the @@ -736,26 +738,26 @@ 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. -Interrupting (Ctrl+C) never discards a queued or steered message. It used to -— the transcript literally said `interrupt — discarded N pending`, and an -operator who queued an instruction and then lost patience destroyed the very -thing they were trying to deliver. It now reports `interrupt — N pending -kept`: the run stops, the queue survives, and those messages are handed over -at the interrupt itself (`doInterrupt` drains after `port.interrupt()`), not -left waiting on an idle event the stop may never produce (`interrupt` in -`delivery-queue.ts` no longer clears `items`). +Interrupting (Ctrl+C) never discards a queued or steered message. The first +press is a pause: it stops the primary, holds the queue, and reports +`N pending kept` (`interrupt` in `delivery-queue.ts` does not clear `items`). +Held items stay in the pending column and deliver only on the next explicit +operator send. Stall and expire aborts do not pause, so they still drain. +Occupancy and ask-wake do not start a new primary while paused. A compaction +continuation hop is deferred while paused; it stays consume-once-intact and +delivers after resume (an explicit operator send), not dropped. **Fleet agent lanes on redirect.** Soft steer (Enter mid-run) and follow-up (queued drain) leave running workers alone — they never call `runner.ts`'s `interrupt()`, so the parent's operation signal stays live and -spawned workers keep running. Ctrl+C is the explicit fleet teardown: -`doInterrupt` → `port.interrupt()` → `currentAgent.close()` aborts the shared -operation signal and routes cancellation through the fleet/session-store -teardown path, so in-flight fleet-agent dispatch reports back as cancelled by -the operator rather than being left to finish silently detached. `/clear` and +spawned workers keep running. The first Ctrl+C pauses the parent and does not +tear down the fleet. A second press while workers are live stops those workers +(`cancelLiveWorkers`); the app stays up and the held queue is kept. A third +press quits. With no live workers, the second press still quits. `/clear` and session exit still call `subAgentSessions.cancelAll` for an explicit session-wide cancel; that path is separate from interrupt and must stay off -the soft-steer / follow-up gestures. +the soft-steer / follow-up gestures. Quit still full-teardowns, including +wiping the delivery queue. Up/Down are caret motion first inside a multi-line buffer — except while the pending column is engaged, when ↑ at the buffer's top edge selects a held item @@ -813,21 +815,19 @@ The prompt repaints on every keystroke (`onFrame` in `src/tui/shell/index.ts` ca not on a debounce) — anything added to the prompt's paint path must stay cheap, because it runs at typing speed. -Ctrl+C interrupts a busy run, or clears idle prompt text and pending +Ctrl+C pauses a busy run (stop the primary, hold the queue, stay idle until +an explicit operator send), or clears idle prompt text and pending attachments. Clearing prompt text arms a 2-second quit window -(`CTRL_C_EXIT_WINDOW_MS`); clearing attachments alone does not. A second -Ctrl+C while the window is open quits — this -replaced an Ink-era yes/no exit-confirm modal with the same intent (an -explicit second confirmation) without adding a modal (`handleCtrlC`, -`src/tui/shell/prompt.ts`). See "Soft steer vs. follow-up" above for the two -mid-run gestures and what interrupting does to fleet-agent lanes. 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 -(`"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. +(`CTRL_C_EXIT_WINDOW_MS`); clearing attachments alone does not. With live +sub-agents, a second Ctrl+C in the window stops the workers and keeps the +app up (queue kept); a third quits. With no live workers, the second press +still quits. The extra press is the confirmation, with no modal +(`handleCtrlC`, `src/tui/shell/keys.ts`). See "Soft steer vs. follow-up" +above for the two mid-run gestures and what interrupting does to +fleet-agent lanes. The pause keeps whatever is sitting in the queue rather +than discarding it — the transcript row says so (`"N pending kept"`). Held +items deliver only on the next explicit send; occupancy and ask-wake do +not start a new primary while paused. ## Overflows, scrolling, and key macros diff --git a/src/subagent/fleet-dry-drive.test.ts b/src/subagent/fleet-dry-drive.test.ts index a3bdd7402..76a4371a3 100644 --- a/src/subagent/fleet-dry-drive.test.ts +++ b/src/subagent/fleet-dry-drive.test.ts @@ -680,6 +680,31 @@ describe("driveOpenTasksAfterFleetDry", () => { ).toBe(false); }); + test("does not send if begin does not claim the parent turn", async () => { + const records = recordsOf({ + w1: { status: "done", report: "ok" }, + }); + const sends: string[] = []; + expect( + await driveOpenTasksAfterFleetDry({ + previousRunning: 1, + running: 0, + openTasks: [openTask], + parentProcessing: false, + isParentProcessing: () => false, + mailbox: peekMailbox(records), + lanes: [], + beginSystemContinuation: () => undefined, + send: (prompt) => { + sends.push(prompt); + return ACCEPTED_DELIVERY; + }, + }), + ).toBe(false); + expect(sends).toEqual([]); + expect(records.get("w1")?.collected).not.toBe(true); + }); + test("deferred dry edge after parentProcessing still collects and sends", async () => { const records = recordsOf({ w1: { status: "done", report: "ok" }, diff --git a/src/subagent/fleet-dry-drive.ts b/src/subagent/fleet-dry-drive.ts index c24503ac2..3004910e7 100644 --- a/src/subagent/fleet-dry-drive.ts +++ b/src/subagent/fleet-dry-drive.ts @@ -543,6 +543,8 @@ export function driveOpenTasksAfterFleetDry(args: { running?: number | undefined; openTasks: readonly Task[]; parentProcessing: boolean; + /** Live check after awaited collect; occupancy must not start a turn already claimed. */ + isParentProcessing?: () => boolean; deferredDryEdge?: boolean; mailbox: FleetDryMailbox | undefined; lanes: readonly FleetDryLane[]; @@ -601,6 +603,13 @@ async function driveOpenTasksAfterFleetDrySpill( const reports = dedupeByAgentId([...digested, ...stubs]); prompt = buildFleetDryContinuationPrompt(tasks, reports); args.beginSystemContinuation(prompt); + // begin is a no-op while paused. Do not send; release the delivering + // claim so a later explicit send can still deliver. The dry latch + // unclaims when this drive returns false. + if (args.isParentProcessing?.() === false) { + releaseOccupancyDelivering(args.mailbox, takeIds); + return false; + } } catch { return fail(); } diff --git a/src/subagent/mailbox-mail-drive.test.ts b/src/subagent/mailbox-mail-drive.test.ts index d357c91b1..74e19bebd 100644 --- a/src/subagent/mailbox-mail-drive.test.ts +++ b/src/subagent/mailbox-mail-drive.test.ts @@ -578,6 +578,28 @@ describe("driveMailboxMail", () => { expect(records.get("w1")?.collected).not.toBe(true); }); + test("does not send if begin does not claim the parent turn", async () => { + const records = recordsOf({ + w1: { status: "done", report: "ok" }, + }); + const sends: string[] = []; + expect( + await driveMailboxMail({ + parentProcessing: false, + isParentProcessing: () => false, + mailbox: collectingMailbox(records), + lanes: [], + beginSystemContinuation: () => undefined, + send: (prompt) => { + sends.push(prompt); + return ACCEPTED_DELIVERY; + }, + }), + ).toBe(false); + expect(sends).toEqual([]); + expect(records.get("w1")?.collected).not.toBe(true); + }); + test("not-delivered send leaves the wake retryable", async () => { const records = await driveMailNotDelivered(); expect( diff --git a/src/subagent/mailbox-mail-drive.ts b/src/subagent/mailbox-mail-drive.ts index 7efa4cc49..e744dcfba 100644 --- a/src/subagent/mailbox-mail-drive.ts +++ b/src/subagent/mailbox-mail-drive.ts @@ -179,6 +179,13 @@ async function driveMailboxMailAfterCollect( await digestCollectedReports(reports, args.writeBlob), ); args.beginSystemContinuation(prompt); + // begin is a no-op while paused (and if disposed/empty). Do not send a + // primary, and release the delivering claim so a later explicit send can + // still deliver. The mailbox latch unclaims when this drive settles. + if (args.isParentProcessing?.() === false) { + releaseOccupancyDelivering(args.mailbox, ids); + return false; + } } catch { return fail(); } diff --git a/src/tui/agent-ask-wake.test.ts b/src/tui/agent-ask-wake.test.ts index ebcb654ca..2cec3eccc 100644 --- a/src/tui/agent-ask-wake.test.ts +++ b/src/tui/agent-ask-wake.test.ts @@ -11,8 +11,10 @@ import { createDeliveryGeneration, createLeftoverSend, createSessionOperationQueue, + isPaused, routeQueuedDelivery, } from "./delivery-queue.js"; +import { handleCtrlC } from "./shell/keys.js"; import { ingestOperatorPrompt } from "./prompt-attachments.js"; import { armFeedbackCapture, @@ -623,6 +625,106 @@ describe("agent ask wake delivery", () => { }); } + test("operator pause does not flush a stashed ask or mailbox occupancy", async () => { + await withAppShell( + async (shell) => { + const sends: string[] = []; + const bridge = attachSessionBridge( + shell, + createLiveSessionPort({ + send: (text) => { + sends.push(text); + }, + deliver: (text) => { + sends.push(text); + }, + interrupt: () => undefined, + }), + ); + try { + const order = trackMailOrder(bridge, true); + bridge.handle({ type: "inference.start", data: {} }); + bridge.handle({ + type: "inference.text.delta", + data: { token: "ok" }, + }); + bridge.handle({ type: "agent-ask", asks: [wake("a1", "q1")] }); + expect(sends).toEqual([]); + + handleCtrlC(shell); + expect(isPaused(shell.session)).toBe(true); + expect(sends).toEqual([]); + expect(order).toEqual([]); + expect(bridge.turn.isProcessing).toBe(false); + + bridge.handle({ type: "agent-ask", asks: [wake("a2", "q2")] }); + expect(sends).toEqual([]); + expect(order).toEqual([]); + expect(bridge.turn.isProcessing).toBe(false); + } finally { + bridge.dispose(); + } + }, + { shell: { run: "busy" } }, + ); + }); + + test("paused beginSystemContinuation does not mark the turn processing", async () => { + await withWakeBridge((bridge, sends, shell) => { + handleCtrlC(shell); + expect(isPaused(shell.session)).toBe(true); + bridge.beginSystemContinuation("mailbox occupancy"); + expect(bridge.turn.isProcessing).toBe(false); + expect(shell.session.run).not.toBe("busy"); + expect(sends).toEqual([]); + }); + }); + + test("in-flight mailbox collect after pause does not start a primary", async () => { + await withWakeBridge(async (bridge, sends, shell) => { + const order: string[] = []; + let release: () => void = () => undefined; + const hold = new Promise((resolve) => { + release = resolve; + }); + const driver = latchAsyncMailboxMail(bridge, order, hold); + bridge.setMailboxMailDriver(driver); + bridge.flushMailboxMail(); + expect(order).toEqual(["mail"]); + expect(driver.claimed()).toBe(true); + expect(bridge.turn.isProcessing).toBe(false); + + handleCtrlC(shell); + expect(isPaused(shell.session)).toBe(true); + + release(); + await hold; + await Promise.resolve(); + await Promise.resolve(); + + expect(order).toEqual(["mail", "begin"]); + expect(bridge.turn.isProcessing).toBe(false); + expect(sends).toEqual([]); + expect(shell.session.run).not.toBe("busy"); + expect(isPaused(shell.session)).toBe(true); + expect(driver.claimed()).toBe(false); + }); + }); + + test("unpaused interrupt still begins a system continuation", async () => { + await withWakeBridge((bridge, sends, shell) => { + bridge.handle({ type: "inference.start", data: {} }); + expect(bridge.turn.isProcessing).toBe(true); + bridge.interrupt(); + expect(isPaused(shell.session)).toBe(false); + expect(bridge.turn.isProcessing).toBe(false); + bridge.beginSystemContinuation("mailbox occupancy"); + expect(bridge.turn.isProcessing).toBe(true); + expect(shell.session.run).toBe("busy"); + expect(sends).toEqual([]); + }); + }); + test("a wake question with bracket lines does not spoof attachment-echo matching", async () => { await withWakeBridge((bridge, sends, shell) => { const ask = { diff --git a/src/tui/delivery-queue-pause.test.ts b/src/tui/delivery-queue-pause.test.ts new file mode 100644 index 000000000..567884352 --- /dev/null +++ b/src/tui/delivery-queue-pause.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "bun:test"; +import { + createSessionQueue, + enqueue, + enqueueSteer, + interrupt, + isPaused, + pause, + resumeForSend, + badgeCount, +} from "./delivery-queue"; + +/** + * Phase 1 (CL-10149) pins the `paused` state and its pure helpers. The drain + * gate that consumes this flag ships in Phase 2; here we assert only the state + * transitions — pause never drops items and resumeForSend clears the flag + * without touching the queue. + */ +describe("delivery-queue pause", () => { + test("createSessionQueue defaults to not paused", () => { + const s = createSessionQueue("busy"); + expect(isPaused(s)).toBe(false); + }); + + test("pause after interrupt holds queue items and sets paused", () => { + let s = createSessionQueue("busy"); + s = enqueue(s, "follow-up"); + s = enqueueSteer(s, "steer"); + s = interrupt(s); + s = pause(s); + expect(isPaused(s)).toBe(true); + // NG2: pause keeps every queued item. + expect(badgeCount(s)).toBe(2); + expect(s.items.map((i) => i.text)).toEqual(["follow-up", "steer"]); + }); + + test("resumeForSend clears paused and keeps items intact", () => { + let s = createSessionQueue("busy"); + s = enqueue(s, "follow-up"); + s = pause(s); + expect(isPaused(s)).toBe(true); + const resumed = resumeForSend(s); + expect(isPaused(resumed)).toBe(false); + // No item is dropped by the resume. + expect(resumed.items.map((i) => i.text)).toEqual(["follow-up"]); + expect(badgeCount(resumed)).toBe(1); + }); + + test("enqueue while paused accepts items and does not auto-resume", () => { + let s = createSessionQueue("busy"); + s = pause(s); + s = enqueue(s, "queued-while-paused"); + expect(isPaused(s)).toBe(true); + expect(badgeCount(s)).toBe(1); + }); +}); diff --git a/src/tui/delivery-queue.ts b/src/tui/delivery-queue.ts index af90d345f..b1026421f 100644 --- a/src/tui/delivery-queue.ts +++ b/src/tui/delivery-queue.ts @@ -221,6 +221,13 @@ export interface SessionQueueState { readonly interruptFlash: boolean; /** Monotonic id seed for queue items. */ readonly nextId: number; + /** + * True after the operator pause gesture (first Ctrl+C). While paused the + * drain boundary holds the queue: follow-ups and compaction continuations + * stay PENDING and deliver only on an explicit new send (set by + * resumeForSend). The gate lives in the bridge (Phase 2), not here. + */ + readonly paused: boolean; } export function createSessionQueue(run: RunState = "idle"): SessionQueueState { @@ -229,6 +236,7 @@ export function createSessionQueue(run: RunState = "idle"): SessionQueueState { items: [], interruptFlash: false, nextId: 1, + paused: false, }; } @@ -323,6 +331,31 @@ export function clearInterruptFlash( return { ...state, interruptFlash: false }; } +/** + * Operator pause (first Ctrl+C): stop the run and hold the queue. Follow-ups + * and compaction continuations stay PENDING; nothing drains while paused. The + * items array is untouched — pause never drops queued work (NG2). + */ +export function pause(state: SessionQueueState): SessionQueueState { + if (state.paused) return state; + return { ...state, paused: true }; +} + +/** + * Clear the pause on an explicit new send. The held follow-ups and compaction + * continuations remain intact and become deliverable again; only the flag + * changes here, the bridge gates the next drain. + */ +export function resumeForSend(state: SessionQueueState): SessionQueueState { + if (!state.paused) return state; + return { ...state, paused: false }; +} + +/** True while the queue is held after an operator pause gesture. */ +export function isPaused(state: SessionQueueState): boolean { + return state.paused; +} + /** * Retract the most recently enqueued item, queue or steer alike. Last-only: * an operator who wants an earlier item gone has no path here (see diff --git a/src/tui/keybindings.test.ts b/src/tui/keybindings.test.ts index 5efed9600..959dddc0b 100644 --- a/src/tui/keybindings.test.ts +++ b/src/tui/keybindings.test.ts @@ -17,6 +17,7 @@ import { EventEmitter } from "node:events"; import { describe, expect, test } from "bun:test"; import { defined } from "../../testkit/defined.js"; +import { isPaused } from "./delivery-queue.js"; import { PROMPT_KEY_BINDINGS } from "./prompt-input.js"; import { SHELL_SHORTCUTS } from "./keybindings.js"; import { createHarness, type Harness } from "./harness.js"; @@ -574,6 +575,9 @@ const PROBES: Readonly< press(h, chords[0]); expect(interrupted).toBe(1); expect(exited).toBe(0); + // First Ctrl+C is the operator pause gesture: the session is paused so + // queued work stays held (CL-10149). + expect(isPaused(shell.session)).toBe(true); press(h, chords[0]); expect(exited).toBe(1); setShellRunState(shell, "idle"); @@ -594,7 +598,7 @@ const PROBES: Readonly< expect(notice?.text).not.toContain("discarded"); // Other probes in this group share one shell — leave both the queue // and the transcript as this probe found them. - shell.session = { ...shell.session, items: [] }; + shell.session = { ...shell.session, items: [], paused: false }; truncateStreamRows(shell, rowsBefore); setShellRunState(shell, "idle"); diff --git a/src/tui/keybindings.ts b/src/tui/keybindings.ts index 9a8f722d3..0a347888c 100644 --- a/src/tui/keybindings.ts +++ b/src/tui/keybindings.ts @@ -38,7 +38,7 @@ export const SHELL_SHORTCUTS: readonly ShellShortcut[] = [ { keys: "Ctrl+C", description: - "interrupt the run, or clear the prompt and attachments when idle; press twice to exit", + "pause a busy run and hold the queue, or clear the prompt and attachments when idle; Ctrl+C again to exit — with live sub-agents, a 2nd press stops them, a 3rd quits", }, { keys: "Ctrl+G", diff --git a/src/tui/prompt-slash-exit.test.ts b/src/tui/prompt-slash-exit.test.ts index 8729a162a..3979b7cb1 100644 --- a/src/tui/prompt-slash-exit.test.ts +++ b/src/tui/prompt-slash-exit.test.ts @@ -15,9 +15,14 @@ import { createAppShell } from "./shell/index"; import { isSlashPopupOpen, setShellExitHandler, + setShellStopAffordance, type AppShell, } from "./shell/internals"; -import { CTRL_C_EXIT_WINDOW_MS, handleCtrlC } from "./shell/keys"; +import { + CTRL_C_EXIT_WINDOW_MS, + handleCtrlC, + N_SUBAGENT_RUNNING_NOTE_PREFIX, +} from "./shell/keys"; import { addPendingAttachment, clearPendingAttachments } from "./shell/prompt"; import { RUNTIME_FLASH_MS } from "./runtime-notices"; @@ -510,6 +515,127 @@ describe("Ctrl+C exit", () => { } }); }); + + describe("second-press stop state machine (three-press contract)", () => { + let stops = 0; + let exits = 0; + let count = 0; + let onStop: () => void = () => { + stops += 1; + }; + let onExit: () => void = () => { + exits += 1; + }; + + function wireWorkers(shell: AppShell, n: number): void { + count = n; + stops = 0; + exits = 0; + setShellStopAffordance(shell, { + liveWorkerCount: () => count, + onStopWorkers: () => onStop(), + }); + setShellExitHandler(shell, () => onExit()); + } + + test("no live workers: two presses quit, onStopWorkers never fires", async () => { + await withShell(async ({ shell }) => { + wireWorkers(shell, 0); + handleCtrlC(shell, 0); + expect(stops).toBe(0); + expect(exits).toBe(0); + handleCtrlC(shell, 1); + expect(exits).toBe(1); + expect(stops).toBe(0); + }); + }); + + test("live workers: 2nd press stops (no quit), 3rd press quits", async () => { + await withShell(async ({ shell }) => { + wireWorkers(shell, 2); + handleCtrlC(shell, 0); + expect(exits).toBe(0); + handleCtrlC(shell, 1); + expect(stops).toBe(1); + expect(exits).toBe(0); + handleCtrlC(shell, 2); + expect(stops).toBe(1); + expect(exits).toBe(1); + }); + }); + + test("window re-arm reads a fresh worker count", async () => { + await withShell(async ({ shell }) => { + // Two live workers on the 1st press arm. + wireWorkers(shell, 2); + handleCtrlC(shell, 0); + // Fleet drains to zero between presses; outside the window the press + // re-arms rather than exiting, but the next in-window press must quit. + count = 0; + handleCtrlC(shell, CTRL_C_EXIT_WINDOW_MS + 1); + expect(stops).toBe(0); + expect(exits).toBe(0); + handleCtrlC(shell, CTRL_C_EXIT_WINDOW_MS + 2); + expect(stops).toBe(0); + expect(exits).toBe(1); + }); + }); + + test("rejecting onStopWorkers stays STOPPED without quitting", async () => { + await withShell(async ({ shell }) => { + count = 1; + stops = 0; + exits = 0; + setShellStopAffordance(shell, { + liveWorkerCount: () => count, + onStopWorkers: () => Promise.reject(new Error("cancelAll failed")), + }); + setShellExitHandler(shell, () => onExit()); + handleCtrlC(shell, 0); + handleCtrlC(shell, 1); + await Promise.resolve(); + await Promise.resolve(); + expect(exits).toBe(0); + handleCtrlC(shell, 2); + expect(exits).toBe(1); + }); + }); + + test("single press pauses only: neither stops nor exits", async () => { + await withShell(async ({ shell }) => { + wireWorkers(shell, 2); + handleCtrlC(shell, 0); + expect(stops).toBe(0); + expect(exits).toBe(0); + }); + }); + + test("count-aware note: 1st press names live workers, 2nd press resets to plain exit", async () => { + await withShell(async ({ shell }) => { + wireWorkers(shell, 3); + handleCtrlC(shell, 0); + // 1st press: the flash reuses the count-aware prefix and shows the live + // count, and is not just the plain exit string. + expect(shell.statusFlash).toContain(N_SUBAGENT_RUNNING_NOTE_PREFIX); + expect(shell.statusFlash).toContain("3"); + expect(shell.statusFlash).not.toBe("press ctrl+c again to exit"); + // 2nd press stops (no quit); the flash becomes the decided stop string. + handleCtrlC(shell, 1); + expect(stops).toBe(1); + expect(exits).toBe(0); + expect(shell.statusFlash).toBe("press ctrl+c again to exit"); + }); + }); + + test("no-worker note: 1st press keeps the plain exit string", async () => { + await withShell(async ({ shell }) => { + wireWorkers(shell, 0); + handleCtrlC(shell, 0); + expect(shell.statusFlash).toBe("press ctrl+c again to exit"); + expect(shell.statusFlash).not.toContain(N_SUBAGENT_RUNNING_NOTE_PREFIX); + }); + }); + }); }); function pendingImage( diff --git a/src/tui/queued-delivery-hop.test.ts b/src/tui/queued-delivery-hop.test.ts index a75f15d3e..f94f352cc 100644 --- a/src/tui/queued-delivery-hop.test.ts +++ b/src/tui/queued-delivery-hop.test.ts @@ -11,6 +11,7 @@ import { badgeCount, createLiveSteerDeliver, createSessionOperationQueue, + pause, routeQueuedDelivery, type AgentDeliveryResult, type DeliverySettle, @@ -181,12 +182,47 @@ describe("queued delivery last hop", () => { }); }); - test("interrupt leftover steer last-hops to send", async () => { - await withBridge("busy", ({ bridge, sends, steers }) => { + test("interrupt leftover steer is held under pause until an explicit new send", async () => { + await withBridge("busy", ({ shell, bridge, sends, steers }) => { + // A steer the operator queued while the run was busy. bridge.submit("after stop", "steer"); + // Operator Ctrl+C pauses (Phase 4 sets the flag; the gate here honors an + // already-paused state) then stops and interrupts the run. + shell.session = pause(shell.session); + bridge.interrupt(); + // Paused: the leftover steer must NOT deliver to the rebuilt agent. + expect(sends).toEqual([]); + expect(steers).toEqual([]); + expect(badgeCount(shell.session)).toBe(1); + // An explicit new send clears the pause (the submit path resumes the + // session) and the boundary drains the held steer onto the fresh turn. + bridge.submit("fresh prompt", "immediate"); + bridge.handle({ type: "run", state: "idle" }); + expect(sends).toEqual(["fresh prompt", "after stop"]); + expect(steers).toEqual([]); + expect(badgeCount(shell.session)).toBe(0); + }); + }); + + test("held follow-up delivers at the boundary only after an explicit new send", async () => { + await withBridge("busy", ({ shell, bridge, sends, steers }) => { + bridge.submit("held follow-up", "queue"); + expect(badgeCount(shell.session)).toBe(1); + // Operator Ctrl+C pauses and stops the run. + shell.session = pause(shell.session); bridge.interrupt(); - expect(sends).toEqual(["after stop"]); + // Paused: nothing drains at the interrupt/following boundaries. + bridge.handle({ type: "run", state: "idle" }); + expect(sends).toEqual([]); expect(steers).toEqual([]); + expect(badgeCount(shell.session)).toBe(1); + // Explicit new send clears the pause (submit path resumes the session) + // and starts a fresh turn. + bridge.submit("fresh prompt", "immediate"); + // Now the boundary delivers both the new send and the held follow-up. + bridge.handle({ type: "run", state: "idle" }); + expect(sends).toEqual(["fresh prompt", "held follow-up"]); + expect(badgeCount(shell.session)).toBe(0); }); }); diff --git a/src/tui/runner/exit.test.ts b/src/tui/runner/exit.test.ts index 4c5127699..ea4c4e4fc 100644 --- a/src/tui/runner/exit.test.ts +++ b/src/tui/runner/exit.test.ts @@ -45,6 +45,7 @@ import { COMPACTION_ABORTED_REASON, createCompactionLifecycle, } from "../../session/compaction-lifecycle.js"; +import { COMPACTION_CONTINUATION_EVENT } from "../../agent/compaction.js"; import type { RunnerServices, RunnerState } from "./state.js"; import type { InboundMessage } from "@intx/types/runtime"; import { createCredentialRecoveryState } from "./credential-recovery.js"; @@ -255,6 +256,21 @@ function recordingAgent(sends: string[]): Agent { }; } +/** + * An agent whose stream replays a single COMPACTION_CONTINUATION_EVENT. Used + * to drive a continuation emission through the stream sink so the test can + * assert whether exit.ts defers it while paused and delivers it after resume. + * Deliveries are counted so a spy on the enqueue hop is unambiguous. + */ +function continuationAgent(): Agent { + return { + ...recordingAgent([]), + stream: async function* stream() { + yield { type: COMPACTION_CONTINUATION_EVENT, seq: 1, data: {} } as never; + }, + }; +} + function stubSendLifecycle(agent: Agent): { state: RunnerState; services: RunnerServices; @@ -1004,6 +1020,50 @@ describe("rebuild close helpers", () => { expect(order[0]).toBe("bump"); expect(order.indexOf("enqueue")).toBeGreaterThan(0); }); + + test("compaction continuation is deferred while paused and delivers after resume", async () => { + const directorHolder: RunnerServices["directorHolder"] = {}; + const agent = continuationAgent(); + const { state, services } = stubSendLifecycle(agent); + // The continuation event walks current() on its way through the sink; + // give the stub tracker a no-op so observeRecoveryAttempts is a no-op. + ( + services.providerFailureAttempts as { + current?: () => ProviderFailureAttempt; + } + ).current = () => undefined as never; + // Wire the hop exit.ts calls: the real assembly routes it through + // enqueueCompactionContinuation → liveAgent.deliver. Here we spy on the + // call itself so we can assert whether exit.ts invokes it while paused. + let hops = 0; + state.enqueueCompactionContinuation = () => { + hops += 1; + }; + // Simulated operator pause (first Ctrl+C) holds the queue. exit.ts + // consults this observer (wired to the shell session in index.ts). + let paused = true; + state.isPaused = () => paused; + wireRebuildServices(services, directorHolder, agent); + await createRunLifecycle(state, services); + + // Drain the initial stream emission while paused: the continuation must + // NOT hop onto the rebuilt agent, and stay consume-once-intact so a later + // emission can still deliver. + await state.streamPromise; + expect(hops).toBe(0); + + // Explicit new send clears the pause; rebuilding re-mounts the stream and + // the next boundary re-drives the continuation, which now hops. + paused = false; + const agent2 = continuationAgent(); + services.buildAgent = (async () => + agent2) as unknown as RunnerServices["buildAgent"]; + state.currentAgent = agent2; + defined(state.interrupt, "interrupt")(); + await services.sessionOps.awaitTail(); + await state.streamPromise; + expect(hops).toBeGreaterThan(0); + }); }); describe("observeRecoveryAttempts fan-out", () => { diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index 48d45b37f..6d69c3080 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -379,7 +379,16 @@ export async function createRunLifecycle( // interrupt rebuild (generation bump + rebuild already queued) is // re-queued onto the replacement agent so consume-once cannot land on // the outgoing liveAgent. - if (continuationGate.shouldDeliver(event.seq)) { + // CL-10149 pause gate: while the operator holds the queue (first + // Ctrl+C), the continuation is NOT re-queued onto the rebuilt agent — + // that would be the visible auto-restart. It stays consume-once-intact + // and only fires after resume (an explicit new send clears the flag; + // the next boundary re-drives the emit). The observer is optional so + // tests build partial sessions without the shell mount. + if ( + state.isPaused?.() !== true && + continuationGate.shouldDeliver(event.seq) + ) { state.enqueueCompactionContinuation?.(() => liveAgent(state).deliver(buildCompactionContinuationMessage()), ); @@ -560,7 +569,11 @@ export async function createRunLifecycle( // aborts the reactor mid-inference (the send signal only rejects the send // promise); that close cascades: operationController.abort → child // parent-abort forwarding → child abort. Do not add cancelAll here — fleet cancelAll is - // reserved for /clear (newSession) and shutdown. + // reserved for /clear (newSession), shutdown, and the explicit 2nd-press + // stop-workers gesture (see wiring.cancelWorkersForStop). This interrupt must + // stay cancelAll-free; the 2nd-press stop is a distinct caller that snapshots + // live sessions and leaves tombstones, so a later quit-path stop finds + // nothing live and is a no-op (no double cancel). // Close it, drain the old stream, and rebuild a fresh agent so the next send // works. const interrupt = (): void => { diff --git a/src/tui/runner/index.ts b/src/tui/runner/index.ts index e437ca568..332b8a158 100644 --- a/src/tui/runner/index.ts +++ b/src/tui/runner/index.ts @@ -41,6 +41,7 @@ import { createReconnectRecoveryPresenter } from "./reconnect-recovery.js"; import { applyStartupTheme } from "../theme-startup.js"; import { getLogger } from "@intx/log"; import { LOG_NAMESPACE_ROOT } from "../../branding.js"; +import { isPaused } from "../delivery-queue.js"; export async function runTUI(initialConfig: Config): Promise { const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); @@ -233,6 +234,11 @@ export async function runTUI(initialConfig: Config): Promise { }); state.host = host; services.hostHolder.instance = host; + // CL-10149: the pause flag lives on the shell session queue owned by the + // bridge. Mount a tiny observer the runner consults at the compaction + // continuation emit so it holds the continuation while paused without the + // runner reaching into the shell directly. + state.isPaused = () => isPaused(host.shell.session); state.presentCredentialRecovery = (pending) => { const opened = host.openCredentialRecovery({ alternatives: pending.alternatives, diff --git a/src/tui/runner/state.ts b/src/tui/runner/state.ts index 58e2d635b..f66fec5df 100644 --- a/src/tui/runner/state.ts +++ b/src/tui/runner/state.ts @@ -293,6 +293,12 @@ export interface RunnerState { onSettle?: (result: AgentDeliveryResult) => void, ) => void; enqueueCompactionContinuation?: (deliverToLiveAgent: () => void) => void; + // CL-10149: late-wired observer the runner's compaction-continuation emit + // consults. The pause flag physically lives on the shell session queue; the + // runner must not reach into the shell, so index.ts mounts a tiny callback + // reading it (isPaused(host.shell.session)) the way systemNotice is wired. + // Optional because tests build partial states without that mount. + isPaused?: () => boolean; // CL-8220: abort-aware compaction lifecycle, created by the TUI session // assembly (session.ts) and read by the interrupt/rotation paths (exit.ts). // Optional because tests build partial states without session assembly. diff --git a/src/tui/runner/wiring.stall-bound.test.ts b/src/tui/runner/wiring.stall-bound.test.ts index 39e45a5e8..f5191ee26 100644 --- a/src/tui/runner/wiring.stall-bound.test.ts +++ b/src/tui/runner/wiring.stall-bound.test.ts @@ -5,6 +5,7 @@ import { type SessionBridge, type TurnMonitorOptions, } from "../runtime-bridge.js"; +import { isPaused } from "../delivery-queue.js"; import { withAppShell } from "../test-helpers.js"; import { ASK_DIRECTOR_WAKE_PREFIX, @@ -184,6 +185,9 @@ describe("stall-bound primary turn (CL-8016)", () => { expect(bridge.turnMarkers().map((marker) => marker.path)).toContain( "stall-abort:awaiting-first-token", ); + // Stall-abort is a non-operator path (CL-10149): it must NOT set the + // operator pause flag, so it keeps the old drain semantics. + expect(isPaused(bridge.shell.session)).toBe(false); // ... the hung inference was interrupted before any new deliver ... expect(wakeDeliveriesAfterInterrupt(port)).toBe(0); // ... the queued operator message reached the port ... diff --git a/src/tui/runner/wiring.stop-affordance.test.ts b/src/tui/runner/wiring.stop-affordance.test.ts new file mode 100644 index 000000000..16062c84e --- /dev/null +++ b/src/tui/runner/wiring.stop-affordance.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test"; + +import { + createSubAgentSessionStore, + type SubAgentSessionStore, +} from "../../subagent/session-store.js"; +import { isLiveStrip } from "../../subagent/lifecycle.js"; +import { createAppShell } from "../shell/index.js"; +import { + getShellStopAffordance, + setShellExitHandler, + setShellStopAffordance, +} from "../shell/internals.js"; +import { handleCtrlC } from "../shell/keys.js"; +import { withTestRenderer } from "../harness.js"; +import { + badgeCount, + createSessionQueue, + enqueue, + isPaused, +} from "../delivery-queue.js"; +import { buildShellStopAffordance, cancelWorkersForStop } from "./wiring.js"; + +// CL-10149 Phase 4 -- the integration half of Phase 3 deferred here. +// +// The runner wires the REAL stop affordance into the shell inside +// wirePostStartup via buildShellStopAffordance: the affordance's +// liveWorkerCount reports the underlying session store's live fleet and its +// onStopWorkers drives the production `cancelLiveWorkers` closure. This test +// proves that end-to-end against a harnessed store: a 2nd Ctrl+C press stops +// the live workers and the app STAYS RUNNING (no exit), and liveWorkerCount +// reports the harnessed store's live fleet before and after the stop. + +function liveStripCount(store: SubAgentSessionStore): number { + return store.list().filter((session) => isLiveStrip(session.lifecycle)) + .length; +} + +function harnessWithLiveWorkers(count: number): SubAgentSessionStore { + const store = createSubAgentSessionStore(); + for (let i = 0; i < count; i++) { + const session = store.start({ + description: `worker-${i}`, + agentId: "w", + brief: "b", + }); + store.markRunning(session.id); + store.registerCancel(session.id, () => undefined); + } + return store; +} + +describe("shell stop affordance wiring (CL-10149 Phase 4)", () => { + test("stop press drives real cancelWorkersForStop; app stays running; count is the store's live fleet", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + let exits = 0; + setShellExitHandler(shell, () => { + exits += 1; + }); + + const store = harnessWithLiveWorkers(2); + const fleetRecords = { clear: () => undefined }; + const affordance = buildShellStopAffordance({ + subAgentSessions: store, + toolset: { fleetRecords }, + }); + // The runner's wirePostStartup registers the affordance on the shell; + // mirror that so the key machine's readStopAffordance sees it. + setShellStopAffordance(shell, affordance); + try { + // The affordance is registered on the shell as production would. + expect(getShellStopAffordance(shell)).toBe(affordance); + // Before any press, liveWorkerCount reflects the harnessed store's fleet. + expect(affordance.liveWorkerCount()).toBe(2); + + // 1st press arms (pause); app neither stops workers nor exits. + handleCtrlC(shell, 0); + expect(affordance.liveWorkerCount()).toBe(2); + expect(exits).toBe(0); + expect(liveStripCount(store)).toBe(2); + + // 2nd press inside the window stops the live workers via the real + // cancelLiveWorkers closure -- app STAYS RUNNING (no exit). + handleCtrlC(shell, 1); + expect(liveStripCount(store)).toBe(0); + expect(exits).toBe(0); + + // liveWorkerCount now reports the drained store. + expect(affordance.liveWorkerCount()).toBe(0); + + // 3rd press quits (two-press preserved for an empty fleet). + handleCtrlC(shell, 2); + expect(exits).toBe(1); + expect(liveStripCount(store)).toBe(0); + } finally { + shell.dispose(); + } + }); + }); + + test("no live workers: a 2nd press quits, onStopWorkers never cancels", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + let exits = 0; + setShellExitHandler(shell, () => { + exits += 1; + }); + + const store = createSubAgentSessionStore(); + const affordance = buildShellStopAffordance({ + subAgentSessions: store, + toolset: { fleetRecords: undefined }, + }); + setShellStopAffordance(shell, affordance); + try { + expect(affordance.liveWorkerCount()).toBe(0); + handleCtrlC(shell, 0); + handleCtrlC(shell, 1); + expect(exits).toBe(1); + // The stop affordance is still the real one; live count stays zero. + expect(getShellStopAffordance(shell)).toBe(affordance); + } finally { + shell.dispose(); + } + }); + }); + + test("stay-alive stop keeps a held follow-up paused; quit still wipes", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + let exits = 0; + setShellExitHandler(shell, () => { + exits += 1; + }); + + const store = harnessWithLiveWorkers(2); + let cleared = 0; + const bridge = { + clearQueuedDelivery: () => { + cleared += 1; + shell.session = createSessionQueue("idle"); + }, + }; + const fleetRecords = { clear: () => undefined }; + const inner = buildShellStopAffordance({ + subAgentSessions: store, + toolset: { fleetRecords }, + }); + let stopDone: Promise = Promise.resolve(); + const affordance = { + liveWorkerCount: inner.liveWorkerCount, + onStopWorkers: () => { + stopDone = Promise.resolve(inner.onStopWorkers()); + return stopDone; + }, + }; + setShellStopAffordance(shell, affordance); + try { + shell.session = enqueue(shell.session, "follow up later"); + expect(badgeCount(shell.session)).toBe(1); + + handleCtrlC(shell, 0); + expect(isPaused(shell.session)).toBe(true); + expect(badgeCount(shell.session)).toBe(1); + expect(liveStripCount(store)).toBe(2); + + handleCtrlC(shell, 1); + await stopDone; + expect(exits).toBe(0); + expect(liveStripCount(store)).toBe(0); + expect(cleared).toBe(0); + expect(badgeCount(shell.session)).toBe(1); + expect(isPaused(shell.session)).toBe(true); + + await cancelWorkersForStop({ + subAgentSessions: store, + fleetRecords, + bridge, + }); + expect(cleared).toBe(1); + expect(badgeCount(shell.session)).toBe(0); + } finally { + shell.dispose(); + } + }); + }); +}); diff --git a/src/tui/runner/wiring.stop-idempotent.test.ts b/src/tui/runner/wiring.stop-idempotent.test.ts new file mode 100644 index 000000000..83398744a --- /dev/null +++ b/src/tui/runner/wiring.stop-idempotent.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test"; + +import { + createSubAgentSessionStore, + type SubAgentSessionStore, +} from "../../subagent/session-store.js"; +import { isLiveStrip } from "../../subagent/lifecycle.js"; +import { createAppShell } from "../shell/index.js"; +import { + setShellExitHandler, + setShellStopAffordance, +} from "../shell/internals.js"; +import { handleCtrlC } from "../shell/keys.js"; +import { withTestRenderer } from "../harness.js"; +import { buildShellStopAffordance, cancelWorkersForStop } from "./wiring.js"; + +// CL-10149 Phase 3 — idempotent stop-then-quit (three-press Ctrl+C). +// +// The 2nd-press stay-alive stop drives `cancelLiveWorkers`; the 3rd-press +// quit runs `cancelWorkersForStop` through shutdownRuntime. Both cancel +// via `cancelAll`, which snapshots live sessions and leaves tombstones, so +// a stop followed by a quit must cancel twice total, with the second call +// finding nothing live — no double cancel. The single-press pause (the +// interrupt path) must never call cancelAll at all. These assertions pin +// that idempotency at the cancel unit boundary. + +interface StopHarness { + store: SubAgentSessionStore; + /** Live count seen at the start of every cancelAll call, in order. */ + cancelCalls: number[]; +} + +function liveStripCount(store: SubAgentSessionStore): number { + return store.list().filter((session) => isLiveStrip(session.lifecycle)) + .length; +} + +function harnessWithLiveWorkers(count: number): StopHarness { + const store = createSubAgentSessionStore(); + const cancelCalls: number[] = []; + const realCancelAll = store.cancelAll.bind(store); + store.cancelAll = async (reason?: string): Promise => { + cancelCalls.push(liveStripCount(store)); + return realCancelAll(reason); + }; + for (let i = 0; i < count; i++) { + const session = store.start({ + description: `worker-${i}`, + agentId: "w", + brief: "b", + }); + store.markRunning(session.id); + store.registerCancel(session.id, () => undefined); + } + return { store, cancelCalls }; +} + +async function stopOnce(h: StopHarness): Promise { + // The production stop/quit path shares these exact deps (wiring.ts:296-300); + // a fresh clear/bridge triple is fine per stop since neither affects the + // cancelAll snapshot the idempotency claim is about. + await cancelWorkersForStop({ + subAgentSessions: h.store, + fleetRecords: { clear: () => undefined }, + bridge: { clearQueuedDelivery: () => undefined }, + }); +} + +describe("cancelWorkersForStop idempotency (CL-10149 Phase 3)", () => { + test("stop then quit invokes cancelAll twice total; the second sees zero live sessions", async () => { + const h = harnessWithLiveWorkers(2); + expect(liveStripCount(h.store)).toBe(2); + + // 2nd press: stop the live workers (onStopWorkers → cancelWorkersForStop). + await stopOnce(h); + // 3rd press: quit (shutdownRuntime → cancelWorkersForStop again). All + // sessions are already cancelled, so this must be a no-op for cancel. + await stopOnce(h); + + // cancelAll ran exactly twice, not more. + expect(h.cancelCalls).toHaveLength(2); + expect(h.cancelCalls[0]).toBe(2); + // The second snapshot sees nothing live — already-shutdown sessions skipped. + expect(h.cancelCalls[1]).toBe(0); + expect(liveStripCount(h.store)).toBe(0); + }); + + test("single-press pause never calls cancelAll", async () => { + await withTestRenderer(async (h) => { + const shell = createAppShell(h.renderer, { + terminal: { columns: 80, rows: 24 }, + wireKeys: false, + run: "busy", + }); + setShellExitHandler(shell, () => undefined); + const harn = harnessWithLiveWorkers(1); + setShellStopAffordance( + shell, + buildShellStopAffordance({ + subAgentSessions: harn.store, + toolset: { fleetRecords: { clear: () => undefined } }, + }), + ); + try { + handleCtrlC(shell, 0); + expect(harn.cancelCalls).toHaveLength(0); + expect(liveStripCount(harn.store)).toBe(1); + } finally { + shell.dispose(); + } + }); + }); + + test("a lone stop on many live workers still cancels exactly once", async () => { + const h = harnessWithLiveWorkers(3); + await stopOnce(h); + expect(h.cancelCalls).toHaveLength(1); + expect(h.cancelCalls[0]).toBe(3); + expect(liveStripCount(h.store)).toBe(0); + }); +}); diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index f0b2705bd..597b515a7 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -53,7 +53,9 @@ import { setEffortCycleHandler, setMentionSuggestionSource, setPromptRecognitionSource, + setShellStopAffordance, type AppShell, + type ShellStopAffordance, } from "../shell/internals.js"; import { setPromptModelLabel, @@ -155,23 +157,62 @@ export interface StopTeardownDeps { bridge: { clearQueuedDelivery: () => void }; } +/** + * Cancel live workers and drop fleet records, leaving tombstones so a later + * quit-path cancelAll is a no-op. Does not wipe the held delivery queue — + * stay-alive 2nd-press stop uses this so follow-ups stay pending. + */ +export async function cancelLiveWorkers( + deps: Omit, +): Promise { + await deps.subAgentSessions.cancelAll("Session closed"); + deps.subAgentSessions.teardown("Session closed"); + deps.fleetRecords?.clear(); +} + /** * Stop teardown (CL-8016): cancel the workers, wipe the sessions (leaving * tombstones so a late send_input names the teardown), drop the mailbox * lanes that pin them, and clear the bridge queue so no wake-turn bound * outlives the sessions it was owed to. Exported so the stall-bound * regression test drives this exact production path instead of re-wiring - * the three clears by hand. + * the three clears by hand. Quit / shutdown keep this wipe; stay-alive + * stop uses `cancelLiveWorkers` instead. */ export async function cancelWorkersForStop( deps: StopTeardownDeps, ): Promise { - await deps.subAgentSessions.cancelAll("Session closed"); - deps.subAgentSessions.teardown("Session closed"); - deps.fleetRecords?.clear(); + await cancelLiveWorkers(deps); deps.bridge.clearQueuedDelivery(); } +/** + * Stop-teardown sources for the shell stop affordance (CL-10149 Phase 4). + * Live-worker count is the authoritative `liveFleetCount` over the session + * store (not the bridge-local mirror); the stop callback cancels workers + * and fleet records without wiping the held queue, so a 2nd-press stay-alive + * stop keeps pending follow-ups. Quit still goes through + * `cancelWorkersForStop` (wiring.ts shutdown path) and wipes. Extracted so + * the Phase 4 wiring test can drive the real closure against a harnessed + * store without a full runner harness. + */ +export function buildShellStopAffordance(deps: { + subAgentSessions: Pick< + RunnerServices["subAgentSessions"], + "list" | "cancelAll" | "teardown" + >; + toolset: { fleetRecords?: { clear: () => void } | undefined }; +}): ShellStopAffordance { + return { + liveWorkerCount: () => liveFleetCount(deps.subAgentSessions.list()), + onStopWorkers: () => + cancelLiveWorkers({ + subAgentSessions: deps.subAgentSessions, + fleetRecords: deps.toolset.fleetRecords, + }), + }; +} + export function createFleetWakePublisher( sessions: RunnerServices["subAgentSessions"], emitter: RunnerServices["emitter"], @@ -306,6 +347,17 @@ export function wirePostStartup( services.crashGuard.setDisposeHost(() => shutdownRuntime()); setActiveDisposeHost(() => services.crashGuard.invokeDisposeHost()); + // CL-10149 three-press: register the shell stop affordance so the 2nd Ctrl+C + // (with live sub-agents) stops workers while the app stays running. The + // runner owns the source of truth; the shell stays service-free. Stay-alive + // stop cancels workers without wiping the held queue; quit still wipes + // through cancelWorkersForStop. Tombstones keep a later quit-path cancelAll + // a no-op. + setShellStopAffordance( + hostOf(state).shell, + buildShellStopAffordance(services), + ); + // Harness inference.error events omit providerId; stamp the live catalog id // onto the stream map so transcript copy can identify known-xAI short 429s. state.stampProvider.fn = (id) => @@ -355,6 +407,7 @@ export function wirePostStartup( deferredDryEdge: true, openTasks: services.directorHolder.instance?.getTasks() ?? [], parentProcessing: false, + isParentProcessing: () => sessionBridge.turn.isProcessing, mailbox: services.toolset.fleetRecords, lanes: services.subAgentSessions.list(), ...(storage !== null diff --git a/src/tui/runtime-bridge.test.ts b/src/tui/runtime-bridge.test.ts index 0991249a9..2fb0f892b 100644 --- a/src/tui/runtime-bridge.test.ts +++ b/src/tui/runtime-bridge.test.ts @@ -27,7 +27,11 @@ import { withAppShell } from "./test-helpers"; import { operatorCancelResult, wireGates } from "./gate-wire.js"; import { acceptOverlaySelection } from "./shell/overlay-host.js"; import { moveOverlaySelection } from "./shell/overlay-list.js"; -import { badgeCount, SESSION_IDENTITY_ABORT_REASON } from "./delivery-queue"; +import { + badgeCount, + isPaused, + SESSION_IDENTITY_ABORT_REASON, +} from "./delivery-queue"; import { LIVE_ACTIVITY_WORDS } from "./chrome-state"; type RecordingPort = ReturnType; @@ -248,7 +252,7 @@ describe("attachSessionBridge", () => { }); }); - test("Ctrl+C hits port.interrupt and keeps pending for the next turn", async () => { + test("Ctrl+C hits port.interrupt, pauses (CL-10149) and holds the queue", async () => { await withBridge( { run: "busy", wireKeys: true }, async ({ h, shell, port, bridge }) => { @@ -256,13 +260,23 @@ describe("attachSessionBridge", () => { bridge.submit("b", "steer"); expect(badgeCount(shell.session)).toBe(2); port.clear(); + // Operator first-press Ctrl+C routes through the pause gesture. h.pressKey("c", { ctrl: true }); await h.renderOnce(); expect(port.calls.some((c) => c.op === "interrupt")).toBe(true); 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. + // First Ctrl+C is a PAUSE (CL-10149): the queue stays held, nothing + // auto-drains onto a rebuilt agent. Delivered only on an explicit send. + expect(isPaused(shell.session)).toBe(true); + expect(badgeCount(shell.session)).toBe(2); + expect( + port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), + ).toEqual([]); + // An explicit new send clears the pause and the boundary drains the + // held items onto the fresh turn. + bridge.submit("fresh", "immediate"); + bridge.handle({ type: "run", state: "idle" }); expect( port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), ).toEqual(["b", "a"]); @@ -922,6 +936,13 @@ describe("same-turn retry after inference.error", () => { const text = shell.streamLog.map((r) => r.text).join("\n"); expect(text).toContain("restart from here"); + // Reinject interrupts are pause-oriented (CL-10149): the stop note reuses + // the pause wording ("pending kept"/"stopped" + the arming flash) and + // never says "restart from your message" anymore. + expect(text).toMatch( + /(pending kept|stopped).*press ctrl\+c again to exit/, + ); + expect(text).not.toContain("restarting from your message"); expect(errorRows(shell)).toEqual([]); }); }); diff --git a/src/tui/runtime-bridge.ts b/src/tui/runtime-bridge.ts index 179b18bfa..2c4a45f37 100644 --- a/src/tui/runtime-bridge.ts +++ b/src/tui/runtime-bridge.ts @@ -14,6 +14,9 @@ import { enqueue, enqueueSteer, interrupt, + isPaused, + badgeCount, + resumeForSend, setRunState, type QueueItem, type QueueKind, @@ -1364,6 +1367,10 @@ function rollbackAttempt(shell: AppShell, bag: BridgeBag): void { } function drainAtBoundary(shell: AppShell, bag: BridgeBag): void { + // Pause gate (CL-10149): while the operator holds the queue, no boundary may + // auto-drain into a rebuilt agent. Return early keeping every item pending; + // an explicit new send clears the flag and the next boundary delivers. + if (isPaused(shell.session)) return; for (;;) { const { state, item } = drainOne(shell.session); if (!item) break; @@ -1379,6 +1386,10 @@ function drainAtBoundary(shell: AppShell, bag: BridgeBag): void { * kind filter. */ function drainSteersAtBoundary(shell: AppShell, bag: BridgeBag): void { + // Pause gate (CL-10149): soft steers are also held while paused; the parent + // that accepted them has stopped, so they must wait for an explicit new send + // rather than auto-start on the rebuilt agent. + if (isPaused(shell.session)) return; for (;;) { const { state, item } = drainOne(shell.session, "steer"); if (!item) break; @@ -2002,9 +2013,16 @@ export function attachSessionBridge( bag.mapCtx.errorRollbackArmed = false; bag.attemptRow = null; shell.session = interrupt(shell.session); + // Reuse the first-Ctrl+C pause wording ("pending kept"/"stopped") plus the + // arming-window flash so this reinject stop reads as a pause, not a + // restart: queued work delivers only on the operator's next explicit send, + // and a second Ctrl+C quits. appendStreamRow(shell, { role: "system", - text: "stop — restarting from your message", + text: + badgeCount(shell.session) > 0 + ? `${badgeCount(shell.session)} pending kept — press ctrl+c again to exit` + : "stopped — press ctrl+c again to exit", meta: "stop", }); bag.port.interrupt(); @@ -2024,6 +2042,11 @@ export function attachSessionBridge( shell.session.run === "idle" || parentIdleWithFleet ) { + // An explicit operator send releases the operator pause (CL-10149): clear + // the flag so the next drain boundary delivers the held follow-ups and + // compaction continuations onto this fresh turn. Queued work never + // auto-drains after a single Ctrl+C — only an explicit new send clears it. + shell.session = resumeForSend(shell.session); appendStreamRow(shell, { role: "user", text: userRowText(t, attached), @@ -2070,6 +2093,7 @@ export function attachSessionBridge( }); }; const flushMailboxMail = (): boolean => { + if (isPaused(shell.session)) return false; if (bag.disposed || bag.turn.isProcessing) return false; try { if (bag.mailboxMailDriver?.() === true) return true; @@ -2081,6 +2105,7 @@ export function attachSessionBridge( bag.flushMailboxMail = flushMailboxMail; const flushPendingAskWake = (): void => { + if (isPaused(shell.session)) return; if (bag.disposed || bag.turn.isProcessing || bag.turn.blockedGateCount > 0) return; if (mailboxMailDriveClaimed(bag.mailboxMailDriver)) return; @@ -2110,6 +2135,7 @@ export function attachSessionBridge( bag.flushPendingAskWake = flushPendingAskWake; const flushOccupancyThenWake = (): void => { + if (isPaused(shell.session)) return; if (flushMailboxMail() || bag.turn.isProcessing) return; flushPendingAskWake(); }; @@ -2148,9 +2174,11 @@ export function attachSessionBridge( 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. + // drain against. On an operator PAUSE the drain gate (drainAtBoundary's + // isPaused check) keeps the held queue PENDING instead of handing it to a + // rebuilt agent — a later explicit new send clears the pause and the next + // boundary delivers it. Only non-operator drains (stall/expire aborts) + // fall through to the current handover behavior below. drainAtBoundary(shell, bag); // Clearing the last prompt is what stops the quota loop from replaying a // turn the operator (or the watchdog) deliberately stopped. @@ -2163,7 +2191,10 @@ export function attachSessionBridge( bag.askWakeTurnArmed = false; bag.turn = turnStateOnInterrupt(bag.turn, now()); paintPhase(); - flushOccupancyThenWake(); + // Operator pause holds occupancy/ask-wake: a stashed mailbox or ask must + // not start a new primary until an explicit send. Stall/expire do not + // set paused, so they still flush here. + if (!isPaused(shell.session)) flushOccupancyThenWake(); }; /** @@ -2451,6 +2482,10 @@ export function attachSessionBridge( }, beginSystemContinuation: (text) => { if (bag.disposed) return; + // Occupancy that already claimed a latch can still call begin after an + // awaited collect. Operator pause must not start a primary; stall/expire + // do not set paused, so they still begin. + if (isPaused(shell.session)) return; const t = text.trim(); if (t.length === 0) return; bag.lastSentMessage = t; diff --git a/src/tui/shell.test.ts b/src/tui/shell.test.ts index f68def77f..025b1c2d8 100644 --- a/src/tui/shell.test.ts +++ b/src/tui/shell.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, test } from "bun:test"; import { defined } from "../../testkit/defined.js"; +import { isPaused } from "./delivery-queue"; import { IDLE_TRANSCRIPT_FLOOR } from "./geometry/index"; import { focusOwner, scrollLease } from "./focus/index"; import { withTestRenderer } from "./harness"; @@ -22,11 +23,8 @@ import { shellInternals, stickyMode, } from "./shell/internals"; -import { - applyShellCancelLast, - interruptShell, - submitPrompt, -} from "./shell/prompt"; +import { applyShellCancelLast, submitPrompt } from "./shell/prompt"; +import { handleCtrlC } from "./shell/keys"; import { transcriptRowLayout } from "./shell/transcript"; /** The transient notice row sits directly above the prompt box's top rule. */ @@ -537,8 +535,11 @@ describe("product skin: stream + queue + overlay", () => { shell.prompt.value = "b"; submitPrompt(shell, "steer"); expect(shell.pendingQueue).toBe(2); - interruptShell(shell); + // Operator first-press Ctrl+C (handleCtrlC) = pause gesture (CL-10149): + // the session pauses, holding the queue, and the run interrupts. + handleCtrlC(shell); expect(shell.pendingQueue).toBe(2); + expect(isPaused(shell.session)).toBe(true); expect(shell.session.interruptFlash).toBe(true); expect(shell.session.run).toBe("idle"); await h.renderOnce(); diff --git a/src/tui/shell/internals.ts b/src/tui/shell/internals.ts index fcc906c96..4e286b472 100644 --- a/src/tui/shell/internals.ts +++ b/src/tui/shell/internals.ts @@ -97,6 +97,36 @@ export function getShellBridgeHooks( return shellBridgeHooks.get(shell); } +/** + * Second-press stop affordance for CL-10149 follow-up. The shell key layer + * stays service-free; the runner registers a live-worker-count getter and a + * stop-workers callback so `handleCtrlC` can distinguish "stop the active + * sub-agents, app keeps running" from a real quit without touching the service + * layer (see SOLUTION_SCOPE D1/D2). + */ +export interface ShellStopAffordance { + /** Live, faithful fleet count (liveFleetCount over session-store list). */ + liveWorkerCount: () => number; + /** Stop all active sub-agents; app stays running. Idempotent. */ + onStopWorkers: () => void | Promise; +} + +const shellStopAffordances = new WeakMap(); + +export function setShellStopAffordance( + shell: AppShell, + affordance: ShellStopAffordance | undefined, +): void { + if (affordance) shellStopAffordances.set(shell, affordance); + else shellStopAffordances.delete(shell); +} + +export function getShellStopAffordance( + shell: AppShell, +): ShellStopAffordance | undefined { + return shellStopAffordances.get(shell); +} + /** * What the focused overlay row is, and what choosing it costs. Painted in the * fixed description zone under every overlay list that opts in via `describe`. diff --git a/src/tui/shell/keys.ts b/src/tui/shell/keys.ts index 64bd0a0b6..33cfafe3a 100644 --- a/src/tui/shell/keys.ts +++ b/src/tui/shell/keys.ts @@ -1,13 +1,15 @@ /** * Key routing: paste guard, kill-ring chords, the onKey dispatcher body, Ctrl+C arming. */ +import { getLogger } from "@intx/log"; import { ScrollBoxRenderable, type BaseRenderable, type KeyEvent, type MouseEvent, } from "@opentui/core"; -import { badgeCount } from "../delivery-queue.js"; +import { LOG_NAMESPACE_ROOT } from "../../branding.js"; +import { badgeCount, pause } from "../delivery-queue.js"; import { type AppShell, @@ -15,6 +17,7 @@ import { effortCycleHandlers, isSlashPopupOpen, type PrimaryOverlayKind, + getShellStopAffordance, shellExitHandlers, shellInternals, } from "./internals.js"; @@ -90,6 +93,14 @@ import { } from "../sent-message-history.js"; import { EXPAND_KEY } from "../stream.js"; +const tuiLogger = getLogger([LOG_NAMESPACE_ROOT, "tui"]); + +function logStopWorkersFailure(error: unknown): void { + tuiLogger.warn("stop workers failed: {error}", { + error: error instanceof Error ? error.message : String(error), + }); +} + // Human keystrokes land tens of milliseconds apart at the fastest; a paste // replayed onto stdin without bracketed-paste framing lands effectively all // at once. 15ms is an empirical guess at a gap comfortably under normal @@ -162,7 +173,22 @@ function isDecisionGate(shell: AppShell): boolean { /** Window in which a second Ctrl+C is read as "yes, quit". */ export const CTRL_C_EXIT_WINDOW_MS = 2000; -const ctrlCArmedAt = new WeakMap(); +/** + * Prefix of the count-aware 1st-press note (Phase 5) shown when sub-agents are + * live, inserted after the resolved live-worker count. Imported from keys.ts + * by callers that build the flash so the wording has exactly one home. + */ +export const N_SUBAGENT_RUNNING_NOTE_PREFIX = "sub-agent(s) running — "; + +/** + * Per-shell Ctrl+C arming slot. `at` is the window anchor; `state` extends the + * base two-state (unarmed/armed) machine with a third reachable state, + * STOPPED, recorded on the press that stops live sub-agents so the next press + * quits instead of double-stopping (see handleCtrlC). + */ +type CtrlCArmed = { at: number; state: "armed" | "stopped" }; + +const ctrlCArmedAt = new WeakMap(); /** * Ctrl+C: interrupt / clear, and quit on a second press inside the window. @@ -176,10 +202,35 @@ export function handleCtrlC( options?: FlashOptions, ): void { const armedAt = ctrlCArmedAt.get(shell); - if (armedAt !== undefined && now - armedAt <= CTRL_C_EXIT_WINDOW_MS) { + if (armedAt !== undefined && now - armedAt.at <= CTRL_C_EXIT_WINDOW_MS) { ctrlCArmedAt.delete(shell); const onExit = shellExitHandlers.get(shell); if (onExit !== undefined) { + // Three-press machine: a press that would previously exit (in-window) + // first asks whether live sub-agents exist to stop. An already-stopped + // shell, or one with no live workers, quits (two-press preserved); a + // shell with live workers is the STOP press -- stop the fleet, stay + // running, and quits only on the next press. + const { count, onStop } = readStopAffordance(shell); + const wasStopped = armedAt.state === "stopped"; + if (!wasStopped && count > 0) { + // Stop press: do NOT quit. Record STOPPED at press time so a second + // simultaneous press cannot double-fire an async stop before it resolves. + ctrlCArmedAt.set(shell, { at: now, state: "stopped" }); + setStatusFlash(shell, "press ctrl+c again to exit", { + ttlMs: CTRL_C_EXIT_WINDOW_MS, + ...(options?.schedule !== undefined + ? { schedule: options.schedule } + : {}), + }); + try { + void Promise.resolve(onStop()).catch(logStopWorkersFailure); + } catch (error: unknown) { + logStopWorkersFailure(error); + } + return; + } + // Real quit (2nd press with no live workers, or 3rd press after stop). // Host teardown usually disposes; unlink here too so a stub/delayed // onExit cannot leave Corbits-created clipboard files behind. clearPendingAttachments(shell); @@ -197,19 +248,60 @@ export function handleCtrlC( if (!hasPromptText) return; } - ctrlCArmedAt.set(shell, now); + ctrlCArmedAt.set(shell, { at: now, state: "armed" }); + + // First Ctrl+C is the operator PAUSE gesture (CL-10149): hold the queue so + // queued follow-ups / compaction continuations do not auto-drain onto a + // rebuilt agent. Set before interruptShell so the exclusive bridge path + // already observes the paused flag; an explicit new send later clears it. + shell.session = pause(shell.session); if (shell.session.run === "busy" || badgeCount(shell.session) > 0) { interruptShell(shell); } // The notice is exactly as true as the arming window is open, so it expires - // with it rather than waiting for some later flash to overwrite it. - setStatusFlash(shell, "press ctrl+c again to exit", { + // with it rather than waiting for some later flash to overwrite it. When + // live sub-agents exist the note is count-aware (Phase 5) so the operator + // knows the next press stops them rather than quitting; otherwise the plain + // two-press exit string is kept. Read fresh at press time so a fleet that + // drained before arming falls back to the plain string. + const { count } = readStopAffordance(shell); + const note = + count > 0 + ? `${count} ${N_SUBAGENT_RUNNING_NOTE_PREFIX}press ctrl+c to stop, again to exit` + : "press ctrl+c again to exit"; + setStatusFlash(shell, note, { ttlMs: CTRL_C_EXIT_WINDOW_MS, ...(options?.schedule !== undefined ? { schedule: options.schedule } : {}), }); } +/** + * Pure read of the registered stop affordance scalars backing the Ctrl+C + * three-press machine. This is the live source for both the stop-press branch + * and the count-aware note in `handleCtrlC`: an in-window press that finds + * live workers becomes a "stop the fleet, stay up" press (`state: "stopped"`), + * routing the quit off to a third press; the note's count also comes straight + * from here, so the operator sees that the next press stops the sub-agents + * rather than exiting. The shell stays service-free: it never constructs a + * worker-count itself, only mirrors what the runner registered. Defaults keep + * an unregistered shell on the plain two-press contract (count 0, no-op stop) + * so a runner that has not wired the affordance still quits on the second + * press. + */ +export function readStopAffordance(shell: AppShell): { + count: number; + onStop: () => void | Promise; +} { + const affordance = getShellStopAffordance(shell); + return { + count: affordance?.liveWorkerCount() ?? 0, + // Expression-bodied no-op (not an empty block) so oxlint's + // no-empty-function rule stays satisfied; still a pure identity no-op. + onStop: affordance?.onStopWorkers ?? (() => undefined), + }; +} + /** * Wheel/trackpad scroll landing on the prompt scrolls the chat instead. * diff --git a/src/tui/shell/prompt.ts b/src/tui/shell/prompt.ts index f7f80d9bb..e6e8be951 100644 --- a/src/tui/shell/prompt.ts +++ b/src/tui/shell/prompt.ts @@ -364,9 +364,15 @@ export function submitPrompt( shell.session = interrupt(shell.session); shell.prompt.value = ""; clearPendingAttachments(shell); + // Reuse the pause wording ("pending kept"/"stopped") plus the arming-window + // flash: queued work only delivers on the operator's next explicit send, + // and a second Ctrl+C quits. appendStreamRow(shell, { role: "system", - text: "stop — restarting from your message", + text: + badgeCount(shell.session) > 0 + ? `${badgeCount(shell.session)} pending kept — press ctrl+c again to exit` + : "stopped — press ctrl+c again to exit", meta: "stop", }); appendStreamRow(shell, { diff --git a/src/tui/shell/stop-affordance.test.ts b/src/tui/shell/stop-affordance.test.ts new file mode 100644 index 000000000..3261ef3da --- /dev/null +++ b/src/tui/shell/stop-affordance.test.ts @@ -0,0 +1,134 @@ +/** + * Pure registry tests for the Phase 1 CL-10149 follow-up stop affordance + * (src/tui/shell/internals.ts). These pin the WeakMap-backed + * set/get round-trip, the unset behavior, and per-shell isolation so the + * second-press stop seam has an observable contract before Phase 2 wires it + * into handleCtrlC. + */ +import { describe, expect, test } from "bun:test"; + +import { withTestRenderer, type Harness } from "../harness.js"; +import { createAppShell } from "./index.js"; +import { N_SUBAGENT_RUNNING_NOTE_PREFIX, readStopAffordance } from "./keys.js"; +import { + getShellStopAffordance, + setShellStopAffordance, + type ShellStopAffordance, +} from "./internals.js"; + +function makeAffordance(workerCount = 2): ShellStopAffordance { + let stopped = 0; + return { + liveWorkerCount: () => workerCount, + onStopWorkers: () => { + stopped += 1; + }, + }; +} + +describe("shell stop affordance registry", () => { + test("exports the shared count-aware note prefix", () => { + // Phase 5 builds the 1st-press note from this constant so the wording has + // one home; pinning it here keeps it consumed and quarantines a rename. + expect(N_SUBAGENT_RUNNING_NOTE_PREFIX).toBe("sub-agent(s) running — "); + }); + + test("set then get round-trips the affordance", async () => { + await withTestRenderer(async (h: Harness) => { + const shell = createAppShell(h.renderer, { wireKeys: false }); + try { + const affordance = makeAffordance(3); + setShellStopAffordance(shell, affordance); + + const got = getShellStopAffordance(shell); + expect(got).toBe(affordance); + expect(got?.liveWorkerCount()).toBe(3); + expect(got?.onStopWorkers).toBe(affordance.onStopWorkers); + } finally { + shell.dispose(); + } + }); + }); + + test("getting an unregistered shell returns undefined", async () => { + await withTestRenderer(async (h: Harness) => { + const shell = createAppShell(h.renderer, { wireKeys: false }); + try { + expect(getShellStopAffordance(shell)).toBeUndefined(); + } finally { + shell.dispose(); + } + }); + }); + + test("unsetting a registered affordance returns undefined afterwards", async () => { + await withTestRenderer(async (h: Harness) => { + const shell = createAppShell(h.renderer, { wireKeys: false }); + try { + setShellStopAffordance(shell, makeAffordance()); + expect(getShellStopAffordance(shell)).toBeDefined(); + setShellStopAffordance(shell, undefined); + expect(getShellStopAffordance(shell)).toBeUndefined(); + } finally { + shell.dispose(); + } + }); + }); + + test("readStopAffordance defaults an unregistered shell (count 0, no-op onStop)", async () => { + await withTestRenderer(async (h: Harness) => { + const shell = createAppShell(h.renderer, { wireKeys: false }); + try { + const { count, onStop } = readStopAffordance(shell); + // Unregistered shell stays on today's two-press contract. + expect(count).toBe(0); + // No-op must be safe to call and return undefined (not a Promise). + expect(onStop()).toBeUndefined(); + } finally { + shell.dispose(); + } + }); + }); + + test("readStopAffordance mirrors a registered affordance's count and callback", async () => { + await withTestRenderer(async (h: Harness) => { + const shell = createAppShell(h.renderer, { wireKeys: false }); + try { + const affordance = makeAffordance(3); + setShellStopAffordance(shell, affordance); + const { count, onStop } = readStopAffordance(shell); + expect(count).toBe(3); + // The callback is the registered one, not the default no-op. + expect(onStop).toBe(affordance.onStopWorkers); + } finally { + shell.dispose(); + } + }); + }); + + test("two shells do not share affordances (WeakMap isolation)", async () => { + await withTestRenderer(async (ha: Harness) => { + await withTestRenderer(async (hb: Harness) => { + const a = createAppShell(ha.renderer, { wireKeys: false }); + const b = createAppShell(hb.renderer, { wireKeys: false }); + try { + const affordanceA = makeAffordance(4); + setShellStopAffordance(a, affordanceA); + + // b stays unregistered even though a was set. + expect(getShellStopAffordance(a)).toBe(affordanceA); + expect(getShellStopAffordance(b)).toBeUndefined(); + + // Setting a different affordance on b does not disturb a. + const affordanceB = makeAffordance(9); + setShellStopAffordance(b, affordanceB); + expect(getShellStopAffordance(a)).toBe(affordanceA); + expect(getShellStopAffordance(b)).toBe(affordanceB); + } finally { + a.dispose(); + b.dispose(); + } + }); + }); + }); +}); diff --git a/src/tui/steer-worker-invariant.test.ts b/src/tui/steer-worker-invariant.test.ts index 2ff566872..ec106f381 100644 --- a/src/tui/steer-worker-invariant.test.ts +++ b/src/tui/steer-worker-invariant.test.ts @@ -9,7 +9,7 @@ import { describe, expect, test } from "bun:test"; import { attachSessionBridge, createRecordingPort } from "./runtime-bridge"; import { createAppShell } from "./shell/index"; import { type Harness, withTestRenderer } from "./harness"; -import { badgeCount } from "./delivery-queue"; +import { badgeCount, isPaused } from "./delivery-queue"; interface BridgeFixture { readonly shell: ReturnType; @@ -157,15 +157,24 @@ describe("CL-6291 worker-alive invariants", () => { ); }); - test("Ctrl+C / doInterrupt still calls port.interrupt", async () => { + test("Ctrl+C / doInterrupt still calls port.interrupt and holds the queue paused", async () => { await withBridge({ wireKeys: true }, async ({ shell, port, bridge }, h) => { bridge.submit("kept", "steer"); expect(badgeCount(shell.session)).toBe(1); port.clear(); + // Operator first-press Ctrl+C pauses (CL-10149) instead of handing + // pending work to a rebuilt agent. h.pressKey("c", { ctrl: true }); await h.renderOnce(); expect(port.calls.some((c) => c.op === "interrupt")).toBe(true); - // Hard stop still hands pending over rather than discarding them. + expect(isPaused(shell.session)).toBe(true); + expect(badgeCount(shell.session)).toBe(1); + expect( + port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), + ).toEqual([]); + // Hard stop keeps pending held; only an explicit new send releases it. + bridge.submit("fresh", "immediate"); + bridge.handle({ type: "run", state: "idle" }); expect( port.calls.flatMap((c) => (c.op === "deliver" ? [c.item.text] : [])), ).toEqual(["kept"]);