Skip to content

Commit 7af83cd

Browse files
Merge pull request #1358 from corbitsdev/cl-10149-ctrlc-stops-the-agent-loop-then-auto-restarts-queued-follow
feat(tui): pause the queue on ctrl+c instead of resuming the run
2 parents 46091fa + e4aaf4d commit 7af83cd

29 files changed

Lines changed: 1278 additions & 67 deletions

‎docs/PRODUCT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ The evidence is in how the product fails today: the personas already produce exc
4141
5. **Resume capability** — Runs persist to a git-backed store and resume from the last point after interruption.
4242
6. **Legible loop** — A live event log, working-tree diff panel, plan tracker, and real-time cost meter show what happened, when, and why.
4343
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.
44-
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).
44+
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).
4545
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.
4646

4747
## User Experience

‎docs/TUI.md‎

Lines changed: 29 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,9 @@ nearest the prompt, first selected — stay visible, and a guidance row naming
693693
its keys (`pending-column.ts`, painted by `syncPendingRows` in `chrome.ts`).
694694
The transcript only ever sees the item that actually delivers, as an ordinary
695695
user row — pending/delivery labels (`[will steer next]`, `[steering]`,
696-
`[following up]`) are gone on purpose.
696+
`[following up]`) are gone on purpose. After a first Ctrl+C pause the column
697+
is the durable surface: held items stay pending and deliver only on the next
698+
explicit operator send.
697699

698700
While the column has items, `↑` at the prompt buffer's top edge selects the
699701
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
736738
are runtime-to-agent traffic — the fleet board owns worker status — so
737739
neither paints a transcript row, and neither rehydrates as one.
738740

739-
Interrupting (Ctrl+C) never discards a queued or steered message. It used to
740-
— the transcript literally said `interrupt — discarded N pending`, and an
741-
operator who queued an instruction and then lost patience destroyed the very
742-
thing they were trying to deliver. It now reports `interrupt — N pending
743-
kept`: the run stops, the queue survives, and those messages are handed over
744-
at the interrupt itself (`doInterrupt` drains after `port.interrupt()`), not
745-
left waiting on an idle event the stop may never produce (`interrupt` in
746-
`delivery-queue.ts` no longer clears `items`).
741+
Interrupting (Ctrl+C) never discards a queued or steered message. The first
742+
press is a pause: it stops the primary, holds the queue, and reports
743+
`N pending kept` (`interrupt` in `delivery-queue.ts` does not clear `items`).
744+
Held items stay in the pending column and deliver only on the next explicit
745+
operator send. Stall and expire aborts do not pause, so they still drain.
746+
Occupancy and ask-wake do not start a new primary while paused. A compaction
747+
continuation hop is deferred while paused; it stays consume-once-intact and
748+
delivers after resume (an explicit operator send), not dropped.
747749

748750
**Fleet agent lanes on redirect.** Soft steer (Enter mid-run) and follow-up
749751
(queued drain) leave running workers alone — they never call
750752
`runner.ts`'s `interrupt()`, so the parent's operation signal stays live and
751-
spawned workers keep running. Ctrl+C is the explicit fleet teardown:
752-
`doInterrupt` → `port.interrupt()` → `currentAgent.close()` aborts the shared
753-
operation signal and routes cancellation through the fleet/session-store
754-
teardown path, so in-flight fleet-agent dispatch reports back as cancelled by
755-
the operator rather than being left to finish silently detached. `/clear` and
753+
spawned workers keep running. The first Ctrl+C pauses the parent and does not
754+
tear down the fleet. A second press while workers are live stops those workers
755+
(`cancelLiveWorkers`); the app stays up and the held queue is kept. A third
756+
press quits. With no live workers, the second press still quits. `/clear` and
756757
session exit still call `subAgentSessions.cancelAll` for an explicit
757758
session-wide cancel; that path is separate from interrupt and must stay off
758-
the soft-steer / follow-up gestures.
759+
the soft-steer / follow-up gestures. Quit still full-teardowns, including
760+
wiping the delivery queue.
759761

760762
Up/Down are caret motion first inside a multi-line buffer — except while the
761763
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
813815
not on a debounce) — anything added to the prompt's paint path must stay
814816
cheap, because it runs at typing speed.
815817

816-
Ctrl+C interrupts a busy run, or clears idle prompt text and pending
818+
Ctrl+C pauses a busy run (stop the primary, hold the queue, stay idle until
819+
an explicit operator send), or clears idle prompt text and pending
817820
attachments. Clearing prompt text arms a 2-second quit window
818-
(`CTRL_C_EXIT_WINDOW_MS`); clearing attachments alone does not. A second
819-
Ctrl+C while the window is open quits — this
820-
replaced an Ink-era yes/no exit-confirm modal with the same intent (an
821-
explicit second confirmation) without adding a modal (`handleCtrlC`,
822-
`src/tui/shell/prompt.ts`). See "Soft steer vs. follow-up" above for the two
823-
mid-run gestures and what interrupting does to fleet-agent lanes. The interrupt
824-
keeps whatever is sitting in the queue rather than discarding it — the
825-
operator typed those messages meaning them delivered, not meaning "cancel
826-
this run and also throw away what I typed"; the transcript row says so
827-
(`"N pending kept"`). Kept items are handed over at the
828-
interrupt itself (`doInterrupt` in `runtime-bridge.ts` drains after
829-
`port.interrupt()`), serialized behind the agent rebuild the stop starts —
830-
a stop does not reliably produce an idle event to drain against later.
821+
(`CTRL_C_EXIT_WINDOW_MS`); clearing attachments alone does not. With live
822+
sub-agents, a second Ctrl+C in the window stops the workers and keeps the
823+
app up (queue kept); a third quits. With no live workers, the second press
824+
still quits. The extra press is the confirmation, with no modal
825+
(`handleCtrlC`, `src/tui/shell/keys.ts`). See "Soft steer vs. follow-up"
826+
above for the two mid-run gestures and what interrupting does to
827+
fleet-agent lanes. The pause keeps whatever is sitting in the queue rather
828+
than discarding it — the transcript row says so (`"N pending kept"`). Held
829+
items deliver only on the next explicit send; occupancy and ask-wake do
830+
not start a new primary while paused.
831831

832832
## Overflows, scrolling, and key macros
833833

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,31 @@ describe("driveOpenTasksAfterFleetDry", () => {
680680
).toBe(false);
681681
});
682682

683+
test("does not send if begin does not claim the parent turn", async () => {
684+
const records = recordsOf({
685+
w1: { status: "done", report: "ok" },
686+
});
687+
const sends: string[] = [];
688+
expect(
689+
await driveOpenTasksAfterFleetDry({
690+
previousRunning: 1,
691+
running: 0,
692+
openTasks: [openTask],
693+
parentProcessing: false,
694+
isParentProcessing: () => false,
695+
mailbox: peekMailbox(records),
696+
lanes: [],
697+
beginSystemContinuation: () => undefined,
698+
send: (prompt) => {
699+
sends.push(prompt);
700+
return ACCEPTED_DELIVERY;
701+
},
702+
}),
703+
).toBe(false);
704+
expect(sends).toEqual([]);
705+
expect(records.get("w1")?.collected).not.toBe(true);
706+
});
707+
683708
test("deferred dry edge after parentProcessing still collects and sends", async () => {
684709
const records = recordsOf({
685710
w1: { status: "done", report: "ok" },

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,8 @@ export function driveOpenTasksAfterFleetDry(args: {
543543
running?: number | undefined;
544544
openTasks: readonly Task[];
545545
parentProcessing: boolean;
546+
/** Live check after awaited collect; occupancy must not start a turn already claimed. */
547+
isParentProcessing?: () => boolean;
546548
deferredDryEdge?: boolean;
547549
mailbox: FleetDryMailbox | undefined;
548550
lanes: readonly FleetDryLane[];
@@ -601,6 +603,13 @@ async function driveOpenTasksAfterFleetDrySpill(
601603
const reports = dedupeByAgentId([...digested, ...stubs]);
602604
prompt = buildFleetDryContinuationPrompt(tasks, reports);
603605
args.beginSystemContinuation(prompt);
606+
// begin is a no-op while paused. Do not send; release the delivering
607+
// claim so a later explicit send can still deliver. The dry latch
608+
// unclaims when this drive returns false.
609+
if (args.isParentProcessing?.() === false) {
610+
releaseOccupancyDelivering(args.mailbox, takeIds);
611+
return false;
612+
}
604613
} catch {
605614
return fail();
606615
}

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,6 +578,28 @@ describe("driveMailboxMail", () => {
578578
expect(records.get("w1")?.collected).not.toBe(true);
579579
});
580580

581+
test("does not send if begin does not claim the parent turn", async () => {
582+
const records = recordsOf({
583+
w1: { status: "done", report: "ok" },
584+
});
585+
const sends: string[] = [];
586+
expect(
587+
await driveMailboxMail({
588+
parentProcessing: false,
589+
isParentProcessing: () => false,
590+
mailbox: collectingMailbox(records),
591+
lanes: [],
592+
beginSystemContinuation: () => undefined,
593+
send: (prompt) => {
594+
sends.push(prompt);
595+
return ACCEPTED_DELIVERY;
596+
},
597+
}),
598+
).toBe(false);
599+
expect(sends).toEqual([]);
600+
expect(records.get("w1")?.collected).not.toBe(true);
601+
});
602+
581603
test("not-delivered send leaves the wake retryable", async () => {
582604
const records = await driveMailNotDelivered();
583605
expect(

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,13 @@ async function driveMailboxMailAfterCollect(
179179
await digestCollectedReports(reports, args.writeBlob),
180180
);
181181
args.beginSystemContinuation(prompt);
182+
// begin is a no-op while paused (and if disposed/empty). Do not send a
183+
// primary, and release the delivering claim so a later explicit send can
184+
// still deliver. The mailbox latch unclaims when this drive settles.
185+
if (args.isParentProcessing?.() === false) {
186+
releaseOccupancyDelivering(args.mailbox, ids);
187+
return false;
188+
}
182189
} catch {
183190
return fail();
184191
}

‎src/tui/agent-ask-wake.test.ts‎

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import {
1111
createDeliveryGeneration,
1212
createLeftoverSend,
1313
createSessionOperationQueue,
14+
isPaused,
1415
routeQueuedDelivery,
1516
} from "./delivery-queue.js";
17+
import { handleCtrlC } from "./shell/keys.js";
1618
import { ingestOperatorPrompt } from "./prompt-attachments.js";
1719
import {
1820
armFeedbackCapture,
@@ -623,6 +625,106 @@ describe("agent ask wake delivery", () => {
623625
});
624626
}
625627

628+
test("operator pause does not flush a stashed ask or mailbox occupancy", async () => {
629+
await withAppShell(
630+
async (shell) => {
631+
const sends: string[] = [];
632+
const bridge = attachSessionBridge(
633+
shell,
634+
createLiveSessionPort({
635+
send: (text) => {
636+
sends.push(text);
637+
},
638+
deliver: (text) => {
639+
sends.push(text);
640+
},
641+
interrupt: () => undefined,
642+
}),
643+
);
644+
try {
645+
const order = trackMailOrder(bridge, true);
646+
bridge.handle({ type: "inference.start", data: {} });
647+
bridge.handle({
648+
type: "inference.text.delta",
649+
data: { token: "ok" },
650+
});
651+
bridge.handle({ type: "agent-ask", asks: [wake("a1", "q1")] });
652+
expect(sends).toEqual([]);
653+
654+
handleCtrlC(shell);
655+
expect(isPaused(shell.session)).toBe(true);
656+
expect(sends).toEqual([]);
657+
expect(order).toEqual([]);
658+
expect(bridge.turn.isProcessing).toBe(false);
659+
660+
bridge.handle({ type: "agent-ask", asks: [wake("a2", "q2")] });
661+
expect(sends).toEqual([]);
662+
expect(order).toEqual([]);
663+
expect(bridge.turn.isProcessing).toBe(false);
664+
} finally {
665+
bridge.dispose();
666+
}
667+
},
668+
{ shell: { run: "busy" } },
669+
);
670+
});
671+
672+
test("paused beginSystemContinuation does not mark the turn processing", async () => {
673+
await withWakeBridge((bridge, sends, shell) => {
674+
handleCtrlC(shell);
675+
expect(isPaused(shell.session)).toBe(true);
676+
bridge.beginSystemContinuation("mailbox occupancy");
677+
expect(bridge.turn.isProcessing).toBe(false);
678+
expect(shell.session.run).not.toBe("busy");
679+
expect(sends).toEqual([]);
680+
});
681+
});
682+
683+
test("in-flight mailbox collect after pause does not start a primary", async () => {
684+
await withWakeBridge(async (bridge, sends, shell) => {
685+
const order: string[] = [];
686+
let release: () => void = () => undefined;
687+
const hold = new Promise<void>((resolve) => {
688+
release = resolve;
689+
});
690+
const driver = latchAsyncMailboxMail(bridge, order, hold);
691+
bridge.setMailboxMailDriver(driver);
692+
bridge.flushMailboxMail();
693+
expect(order).toEqual(["mail"]);
694+
expect(driver.claimed()).toBe(true);
695+
expect(bridge.turn.isProcessing).toBe(false);
696+
697+
handleCtrlC(shell);
698+
expect(isPaused(shell.session)).toBe(true);
699+
700+
release();
701+
await hold;
702+
await Promise.resolve();
703+
await Promise.resolve();
704+
705+
expect(order).toEqual(["mail", "begin"]);
706+
expect(bridge.turn.isProcessing).toBe(false);
707+
expect(sends).toEqual([]);
708+
expect(shell.session.run).not.toBe("busy");
709+
expect(isPaused(shell.session)).toBe(true);
710+
expect(driver.claimed()).toBe(false);
711+
});
712+
});
713+
714+
test("unpaused interrupt still begins a system continuation", async () => {
715+
await withWakeBridge((bridge, sends, shell) => {
716+
bridge.handle({ type: "inference.start", data: {} });
717+
expect(bridge.turn.isProcessing).toBe(true);
718+
bridge.interrupt();
719+
expect(isPaused(shell.session)).toBe(false);
720+
expect(bridge.turn.isProcessing).toBe(false);
721+
bridge.beginSystemContinuation("mailbox occupancy");
722+
expect(bridge.turn.isProcessing).toBe(true);
723+
expect(shell.session.run).toBe("busy");
724+
expect(sends).toEqual([]);
725+
});
726+
});
727+
626728
test("a wake question with bracket lines does not spoof attachment-echo matching", async () => {
627729
await withWakeBridge((bridge, sends, shell) => {
628730
const ask = {
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, test } from "bun:test";
2+
import {
3+
createSessionQueue,
4+
enqueue,
5+
enqueueSteer,
6+
interrupt,
7+
isPaused,
8+
pause,
9+
resumeForSend,
10+
badgeCount,
11+
} from "./delivery-queue";
12+
13+
/**
14+
* Phase 1 (CL-10149) pins the `paused` state and its pure helpers. The drain
15+
* gate that consumes this flag ships in Phase 2; here we assert only the state
16+
* transitions — pause never drops items and resumeForSend clears the flag
17+
* without touching the queue.
18+
*/
19+
describe("delivery-queue pause", () => {
20+
test("createSessionQueue defaults to not paused", () => {
21+
const s = createSessionQueue("busy");
22+
expect(isPaused(s)).toBe(false);
23+
});
24+
25+
test("pause after interrupt holds queue items and sets paused", () => {
26+
let s = createSessionQueue("busy");
27+
s = enqueue(s, "follow-up");
28+
s = enqueueSteer(s, "steer");
29+
s = interrupt(s);
30+
s = pause(s);
31+
expect(isPaused(s)).toBe(true);
32+
// NG2: pause keeps every queued item.
33+
expect(badgeCount(s)).toBe(2);
34+
expect(s.items.map((i) => i.text)).toEqual(["follow-up", "steer"]);
35+
});
36+
37+
test("resumeForSend clears paused and keeps items intact", () => {
38+
let s = createSessionQueue("busy");
39+
s = enqueue(s, "follow-up");
40+
s = pause(s);
41+
expect(isPaused(s)).toBe(true);
42+
const resumed = resumeForSend(s);
43+
expect(isPaused(resumed)).toBe(false);
44+
// No item is dropped by the resume.
45+
expect(resumed.items.map((i) => i.text)).toEqual(["follow-up"]);
46+
expect(badgeCount(resumed)).toBe(1);
47+
});
48+
49+
test("enqueue while paused accepts items and does not auto-resume", () => {
50+
let s = createSessionQueue("busy");
51+
s = pause(s);
52+
s = enqueue(s, "queued-while-paused");
53+
expect(isPaused(s)).toBe(true);
54+
expect(badgeCount(s)).toBe(1);
55+
});
56+
});

0 commit comments

Comments
 (0)