Repository navigation
bug(cli): TUI interrupt waits behind queue RPCs and terminal cleanup while still rendering Working #3698
Description
Problem
The TUI interrupt path is not cancellation-first. Once the double-Escape gesture is recognized, requestTurnInterrupt() marks local state but serializes the actual stop behind queue convergence:
await settlePendingEnqueues();
const retracted = (await input.driver.retractQueued?.()) ?? '';
const fallback = await takePendingFallbackSettled();
// ...refill editor...
await input.driver.stop();This is in packages/cli/src/pi-tui-runner.ts around the requestTurnInterrupt implementation.
settlePendingEnqueues() does not wait for the model provider response. It waits for outstanding Runtime Host turn.message.submit RPCs used by Steer/Queue. Those operations normally only mutate queue/admission/receipt state, but an RPC delayed by transport, Session admission, storage, or a fallback retry becomes an unbounded barrier in front of stop. queue.retract then adds a second Host round trip before cancellation is even requested.
After stop is finally sent, the TUI driver uses turn.stop. Runtime Host requestStop() awaits stopRoot(), and stopRoot() awaits active.done, so the control response includes backend abort, active tool cleanup, process termination grace, I/O drain, and durable terminal publication. During that entire interval the TUI continues to render Working…; there is no Cancelling… state.
This combines two distinct delays into one apparently ignored keypress:
- pre-stop delay: pending enqueue settlement + queue retract;
- post-stop delay: tool/backend cleanup + durable terminal convergence.
The Host already exposes the atomic turn.interrupt operation, which commits the queue stop fence, retracts queued entries, and owns the matching root Turn. Desktop already uses it in createRuntimeHostSessionStop; the TUI still composes queue.retract and turn.stop client-side.
User-visible trigger
The interrupt binding is itself difficult to discover: a running Turn requires two Escapes within 600 ms. The first Escape has no visible effect or hint. The binding appears only in /help, not in the startup guidance or running footer. This overlaps the discoverability discussion in #3538; this issue focuses on cancellation ordering, acknowledgement, and feedback rather than keybinding selection.
Ctrl+C is not a safe discoverability fallback because it also owns process-exit behavior: a second press while interruption is converging exits, and two presses while idle exit the TUI.
Reproduction and measurements
Tested with the installed Maka 0.2.0 TUI in a fresh tmux session on macOS, using qwen3.8-max through alibaba-token-plan-cn.
Provider stream
- Start a prompt and interrupt while the first model response is pending.
- Send Esc twice within 600 ms.
The durable aborted event was committed within the measurement resolution of the second Escape, and the TUI returned to idle in under 170 ms. This confirms the basic provider abort path can be fast.
Cooperative Bash process
- Ask the agent to execute
sleep 120. - Wait for the Bash tool to show
running. - Press one Escape: after one second the process is still alive and no stop was issued.
- Press Escape twice within 600 ms.
Observed after the second Escape:
- child process exit: approximately 63 ms;
- TUI idle: approximately 132 ms;
abort_requestedtotool_completed: 20 ms;abort_requestedto durableaborted: 27 ms.
Process ignoring SIGTERM
Repeat with:
trap '' TERM; sleep 119Observed after the second Escape:
- child process group exit: approximately 2.13 s;
- TUI idle: approximately 2.20 s.
This matches DEFAULT_PROCESS_TERMINATION_GRACE_MS = 2000. The grace may be intentional, but rendering Working… throughout makes it look as though interrupt was not accepted.
Missing deterministic regression case
A focused TUI test can expose the pre-stop barrier without timing or a real provider:
- use a driver whose
steer()/queueMessage()returns a Promise that does not settle; - submit a mid-Turn message so it enters
pendingEnqueueTasks; - trigger interrupt;
- assert stop/interrupt authority is invoked promptly.
Today step 4 never occurs because settlePendingEnqueues() waits first.
Expected behavior
- User interrupt has control-plane priority over pending message submission.
- Acceptance is visible immediately, independently of backend/tool cleanup.
- Queue contents are not lost or duplicated when enqueue and interrupt race.
- The running footer changes from
Working…toCancelling…as soon as the gesture is accepted. - Terminal durability remains authoritative; fast acknowledgement must not fake completion.
Proposed direction
- Give the TUI driver a first-class interrupt method backed by Host
turn.interrupt, rather than composingqueue.retractfollowed byturn.stop. - Commit the stop/queue fence before waiting for client-side enqueue Promises. Host serialization can determine whether an enqueue committed before the fence and is returned as retracted, or lost the race and must be restored from its failed client request.
- Split interrupt acceptance from terminal convergence if needed: return/emit an accepted or stopping snapshot once the stop fence is durable and abort delivery has begun; observe the final terminal through the existing subscription.
- Render local
Cancelling…immediately frominterruptRequested, with elapsed cleanup time if convergence is slow. - Keep graceful process cleanup, but consider a shorter user-stop grace or an explicit force-stop escalation separately; it should not be required to fix acknowledgement and feedback.
Acceptance criteria
- A never-settling enqueue request cannot prevent interrupt dispatch.
- Interrupt + enqueue races preserve each queued message exactly once, either in Host retraction output or restored client-side.
- TUI shows
Cancelling…in the same render tick as interrupt recognition. - Cooperative provider and Bash cancellation remain sub-second in integration coverage.
- A SIGTERM-resistant process may take the configured grace, but the UI truthfully reports cancellation in progress.
- TUI and Desktop use the same Runtime Host interrupt authority instead of divergent client-composed sequences.
Related: #3538 (composer reachability/discoverability), #3556 (queue-code simplification).
Investigated with AI assistance. Runtime behavior was verified against local source and a real tmux TUI run.
Activity
cat0825 commentedon Aug 24, 2026
I'd like to work on this issue.
Proposed Approach
requestTurnInterruptinpackages/cli/src/pi-tui-runner.tsto triggerinput.driver.stop()immediately upon recognizing the interrupt gesture, rather than serializing it behindsettlePendingEnqueues().MoonOld commentedon Aug 24, 2026
Thanks for volunteering. Heads-up: @Astro-Han is already actively changing the same TUI/Runtime Host message-lifecycle area in #3633, including
pi-tui-runner.ts,runtime-host-session-driver.ts, and the Host queue/Stop authority. That PR removes the CLI fallback owner and changes durable queue/retraction semantics, so this fix should coordinate with or build on its current shape to avoid conflicting implementations.\n\nI checked the current #3633 diff: it does not yet close this issue — the interrupt path still keepssettlePendingEnqueues() -> retractQueued() -> driver.stop(), and it does not add TUIturn.interruptrouting or aCancelling…state. So #3698 remains valid, but #3633 is overlapping work and likely a prerequisite/rebase dependency.fix(cli): dispatch turn cancellation ahead of the queue barrier (3698)
fix(cli): dispatch turn cancellation ahead of the queue barrier (3698)
fix(cli): dispatch turn cancellation ahead of the queue barrier (3698)
fix(cli): dispatch turn cancellation ahead of the queue barrier (3698)
fix(cli): dispatch turn cancellation ahead of the queue barrier (3698)
github-actions commentedon Sep 24, 2026
This issue has had no human activity for 30 days and has been marked stale. It will be closed in 30 days unless someone comments.
If the issue is still current, please confirm it against the latest
mainand add any information that would help move it forward. Assigned issues and issues labelledpinnedare exempt from this policy.