Skip to content

fix(cli): dispatch turn cancellation ahead of the queue barrier - #3713

Open
cat0825 wants to merge 1 commit into
apache:mainfrom
cat0825:fix/3698-tui-interrupt-first
Open

fix(cli): dispatch turn cancellation ahead of the queue barrier#3713
cat0825 wants to merge 1 commit into
apache:mainfrom
cat0825:fix/3698-tui-interrupt-first

Conversation

@cat0825

Copy link
Copy Markdown
Contributor

What

Double-Escape and Ctrl-C recognized the interrupt gesture but did not cancel anything until the client-side queue work had settled, and the TUI gave no sign the keypress had registered.

requestTurnInterrupt awaited settlePendingEnqueues() and retractQueued() before it ever reached driver.stop(). Those pending enqueues are turn.message.submit round trips, so anything that delays one — transport, Session admission, storage, a fallback retry — puts an unbounded wait in front of the abort. The strip meanwhile kept rendering Working… <elapsed>, so the only feedback for a recognized cancellation was the absence of change.

How

Cancellation goes out first, and the runtime owns the ordering.MakaSessionDriver gains an optional interruptTurn(): Promise<string>. RuntimeHostMakaSessionDriver implements it with the atomic turn.interrupt operation (mode control), which commits the queue stop fence, retracts, and aborts the owning turn in one call — the same authority apps/desktop already routes through (runtime-host-session-execution-ipc-main.tsruntime-host-client.ts).

Ordering stays exact because the fence, not client-side sequencing, decides each message's fate:

  • an enqueue that committed before the fence comes back in retracted and is refilled into the editor;
  • one that lost the race rejects and restores its own text through the existing enqueue catch.

Each message therefore survives exactly once, which is what the old retract-then-stop sequence was trying to buy with a client-side barrier — at the cost of the latency this issue reports. Drivers without interruptTurn() compose retractQueued() then stop() in that same order, so their semantics are unchanged.

Acceptance is visible in the tick it happens.interruptRequestedAt is stamped alongside interruptRequested and rendered as Cancelling… <elapsed>. It outranks Working… and a scheduled provider retry — the abort supersedes the retry, since the turn is no longer working towards anything the user asked for. The elapsed counter keeps a slow cleanup (a tool held through DEFAULT_PROCESS_TERMINATION_GRACE_MS) legible as progress rather than a hang. Acceptance is a local fact and deliberately does not wait on the authority: backend abort, tool cleanup, termination grace, and durable terminal publication all land after it.

Note on two changed test fakes

InterruptibleTurnDriver and SteeringTurnDriver modelled cancellation as edge-triggered — a bare resolve callback, and a turnEnded flag reset at async-generator body entry. An async generator does not run its body until the first next() call, so with the abort now dispatched earlier, stop() landed one microtask before the body ran and the release was dropped; the fake turn parked forever.

This is a fake-only artifact, not a product regression. The real driver creates its event buffer synchronously in preparePrompt (channel.eventsForTurn(turnId)), so an abort arriving before the drain pulls is still observed — level-triggered. Both fakes now arm their abort state in preparePrompt() to match. Verified by stashing the change and confirming main passes, and by reading preparePrompt/stop to confirm both versions no-op an Escape landing during turn creation.

Testing

npm --workspace maka-agent run test — 442 tests, 442 pass, 0 fail. Typecheck and biome check clean.

New coverage:

  • runtime-host-session-driver.test.tsinterruptTurn() emits exactly one turn.interrupt with originHostEpoch/sessionId/interruptId/turnId/runId and joins retracted[].content.text; no queue.retract or turn.stop accompanies it. A terminal root turn falls back to queue.retract alone.
  • pi-tui-runner.test.ts — the interrupt reaches the stop authority while an enqueue never settles; Cancelling… appears during convergence and clears after it; a driver exposing interruptTurn is used instead of composing retract and stop.
  • pi-transcript.test.ts — precedence over Working… and over a scheduled retry, including interruptElapsedMs: 0 (the common first-render case).

All three runner tests and the transcript test were negative-controlled: reverting only the ordering and the acceptance stamp makes each of them fail, so none is vacuous.

Existing tests already assert exactly-once queue preservation across an interrupt ('double-Escape interrupt refills the editor with the cleared queue', 'interrupt refills only messages still queued, not steering already consumed', 'interrupt refills CLI-held fallback text into the editor', 'input during the interrupt convergence window stays in the editor and opens no turn') and still pass, so that criterion is not duplicated here.

Relationship to #3633

#3633 refactors this same interrupt path but keeps the current ordering, adds no turn.interrupt routing, and adds no cancellation state to the activity strip — so it does not fix this issue. This PR is based on main and does not depend on it.

If #3633 lands first, the rebase is mechanical: it removes state.pendingFallback/takePendingFallbackSettled(), so the fallback term drops out of the refill and the body becomes refillEditorFromQueues(retracted). It does not touch renderMakaPiActivityStrip, session-driver.ts, or runtime-host-session-driver.ts. Happy to rebase in whichever order maintainers prefer.

Out of scope

  • Splitting acceptance from terminal convergence in the host protocol (issue item 3, conditional) — the atomic turn.interrupt already makes acceptance authoritative from the client's side, so no protocol change was needed for this fix.
  • Keybinding discoverability (tracked in proposal(cli): make Steer, Queue and Interrupt easier to reach from the composer #3538).
  • Shortening the SIGTERM grace — the counter now makes it visible; whether 2s is the right value is a separate call.

Fixes#3698

@cat0825

Copy link
Copy Markdown
ContributorAuthor

@Astro-Han a review here would be appreciated whenever you have bandwidth.

Status: CI is green and GitHub reports it as mergeable against current main. The fix dispatches turn cancellation ahead of the queue barrier in the CLI session driver, so a cancel no longer waits behind queued work. Most of the +497 is test coverage across pi-transcript, pi-tui-runner, and runtime-host-session-driver.

Since you have been the main reviewer on packages/cli lately, you are probably the right person for the cancellation-ordering semantics. Glad to adjust the approach if you would rather see the barrier handled differently.

@yunaremaiayunaremaia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed against #3698's acceptance criteria, with an independent local run of all three touched suites (applied the head diff onto main, built maka-agent, node --test): pi-tui-runner 135/135, pi-transcript 78/78, runtime-host-session-driver 52/52 - all green, plus CI success on the head SHA.

What I verified maps to the criteria

  1. Never-settling enqueue cannot block dispatch - StuckEnqueueDriver implements the issue's own recipe (steer RPC that never settles) and asserts the stop authority is reached and the turn converges.
  2. Same-tick acknowledgement without faking completion - SlowStopDriver asserts Cancelling… renders while progressStates is still true, then flips only after real convergence; the strip also keeps elapsed time legible during a slow grace.
  3. Single authority instead of composed calls - both levels covered: InterruptAuthorityDriver proves the runner stops composing retractQueued+stop, and the driver-level test asserts exactly one turn.interrupt request (with originHostEpoch/ids) and zero queue.retract/turn.stop. The terminal-turn fallback (retract alone, queue may still hold entries) is tested too.
  4. The activity-strip precedence tests pin the ordering I'd otherwise worry about: cancellation outranks a scheduled provider retry, and zero elapsed renders Cancelling… 0s rather than falling back to Working….
  5. Nice catch beyond the issue: making the test drivers' abort level-triggered (a stop landing before the first event pull is still observed) removes a latent race from the harness itself.

Two questions, neither blocking

  1. Fallback text after the fence. The old sequence took takePendingFallbackSettled() before stop(); now the turn is already aborted when it runs. If a fallback retry was pending at gesture time, does it settle promptly post-abort so the refill still happens? StuckEnqueueDriver covers a stuck steer, but not a pending fallback racing the interrupt. Either a test with a pending-fallback driver or a sentence on why existing DeferredRetryDriver coverage implies this would close the gap for me.
  2. Authority RPC failure mid-flight. In the catch, UI state resets and submit re-enables, but if turn.interrupt errors after the Host fence committed (e.g. response lost), the client believes nothing happened while the queue is fenced. A repeated gesture gets a fresh interruptId - is the operation idempotent enough that this self-heals? Worth a line in the driver docs if so.

Both are documentation/test-completeness items; the ordering fix itself looks correct and well-tested. Thanks especially for keeping the graceful-process-cleanup concern out of scope per the issue's item 5.

@Astro-HanAstro-Han left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this head and found no blocking issues. No P0-P2.

Checks on 056f55f are test: success.

简体中文该头未发现阻断问题。

@M4n5ter
M4n5terforce-pushed the fix/3698-tui-interrupt-first branch 4 times, most recently from 2fe8c05 to 16bd288CompareAugust 26, 2026 09:54
@github-actionsgithub-actionsBot added the effort/L Under 1000 readable lines label Aug 27, 2026
Double-Escape and Ctrl-C recognized the interrupt gesture but did not
cancel anything until the client-side queue work had settled. The
interrupt path awaited `settlePendingEnqueues()` and `retractQueued()`
before it ever reached `driver.stop()`, so a pending
`turn.message.submit` round trip that hung on transport, Session
admission, storage, or a fallback retry put an unbounded wait in front of
the abort. The TUI meanwhile kept rendering `Working…`, leaving the user
with no evidence the keypress had registered.
Reverse the order and give the runtime the authority. `MakaSessionDriver`
gains an optional `interruptTurn()`; the Runtime Host driver implements
it with the atomic `turn.interrupt` operation, which commits the queue
stop fence, retracts, and aborts the owning turn as one control-mode
call. Cancellation now goes out first, and ordering is still exact
because the fence — not client-side sequencing — decides each message's
fate: an enqueue that committed before the fence returns in `retracted`,
and one that lost the race rejects and restores its own text through the
existing enqueue catch. Drivers without `interruptTurn()` compose
`retractQueued()` then `stop()`, preserving today's semantics.
Acceptance is also now visible immediately. `interruptRequestedAt` is
stamped in the same tick as gesture recognition and rendered by the
activity strip as `Cancelling… <elapsed>`, which outranks both `Working…`
and a scheduled provider retry; the counter keeps a slow cleanup, such as
a tool held through its process termination grace, legible as progress
rather than a hang.
Two existing runner fakes modelled cancellation as edge-triggered — a
bare `resolve` callback, and a flag reset at async-generator body entry —
so an abort landing before the drain pulled its first event was dropped.
An async generator does not run its body until the first `next()` call,
which the reordering exposed. The real Host channel buffers durable
events from `eventsForTurn()` at turn creation, so it is level-triggered;
the fakes now arm their abort state in `preparePrompt()` to match.
Fixesapache#3698
@cat0825
cat0825force-pushed the fix/3698-tui-interrupt-first branch from 16bd288 to cebca11CompareAugust 29, 2026 16:43
@cat0825

Copy link
Copy Markdown
ContributorAuthor

Rebased onto current main (d7efbb478) and force-pushed as cebca11c4 — the branch is conflict-free and GitHub now reports it MERGEABLE.

Six files conflicted. How each was resolved:

  • session-driver.ts — upstream changed retractQueued to return MakaRetractedMessages ({text, messageIds}) and dropped steer/queueMessage/takePendingFollowup. Kept upstream's interface and re-typed interruptTurn to Promise<MakaRetractedMessages> so the authority reports a retraction in exactly the same form as an ordinary retract.
  • runtime-host-session-driver.ts — auto-merged, then ported by hand: interruptTurn now returns the {text, messageIds} pair. The turn.interrupt operation and the isTerminalTurn import both still exist upstream, so the terminal-turn fallback to retractQueued() is unchanged.
  • pi-tui-runner.ts — the ordering hunk applied cleanly; the tail conflicted because upstream replaced refillEditorFromQueues(...) with acceptRetraction(retracted) (which also retires transient rows by messageId). Took upstream's call and fed it the authority's result, so cancellation is still dispatched before settlePendingEnqueues() — the whole point of the fix. Also dropped the now-dead takePendingFallbackSettled() step (see below) and re-typed the compose-fallback helper.
  • pi-transcript.ts — additive: kept the new interruptElapsedMs field beside upstream's providerRetry, whose type was renamed to ProviderRetryCountdown by fix(ui,cli): count down provider retry wait from the event timestamp #3400. The Cancelling… precedence branch in the activity strip needed no change and still outranks retry and Working….
  • pi-transcript.test.ts — combined both import lists, and rebuilt the scheduledRetry() helper to return the {event, receivedAtMs} countdown shape the strip now reads.
  • pi-tui-runner.test.ts — upstream deleted the entire FallbackSteeringDriver family (refactor: make Runtime Host the sole Message admission authority #3803 made Runtime Host the sole Message admission authority). Those fakes were unreferenced after the merge, so I dropped them rather than reviving a removed API, and re-based StuckEnqueueDriver / InterruptAuthorityDriver on upstream's FakeSessionDriver, expressing the never-settling enqueue as a submitMessage that never resolves instead of the removed steer(). Kept upstream's SlowStopDriver and merged the PR's level-triggered-abort change into InterruptibleTurnDriver.
  • runtime-host-session-driver.test.ts — both sides added tests at the same spot; kept upstream's and re-added both cancellation tests, with assertions updated to the {text, messageIds} shape against the existing turn.interrupt fake.

Verified locally (npm workspaces, not pnpm/turbo, so npm --workspace maka-agent):

  • npm --workspace maka-agent run typecheck → clean, no output.
  • npm --workspace maka-agent run test638/638 pass, 0 fail (20.1s). Touched suites individually: pi-tui-runner 170/170, pi-transcript 108/108, runtime-host-session-driver 71/71.
  • npx biome lint + npx biome format on the 7 changed files → no findings.
  • CI test on cebca11c4 is green (6m41s).

I also checked the ordering assertion is not vacuous: swapping the two awaits in the built runner so the barrier precedes dispatch makes dispatches the interrupt ahead of a never-settling enqueue fail on a 5s timeout, and reverting restores 170/170.

@yunaremaia both of your non-blocking points resolved themselves against current main, so no new tests were needed:

  1. Fallback text after the fence — moot now. Upstream refactor: make Runtime Host the sole Message admission authority #3803 removed the client-side pendingFallback machinery entirely (along with takePendingFallbackSettled), so there is no fallback retry left to race the interrupt; the refill comes solely from the authority's retraction. I removed that step from the interrupt path as part of the rebase.
  2. Authority RPC failure mid-flight — it self-heals, and I added a line to that effect in the driver. turn.interrupt is keyed by interruptId in message-coordinator.ts: a replay with the same id returns the recorded outcome (#readCompletedInterrupt, and pendingInterrupts while still in flight) rather than aborting twice. A repeated gesture carrying a fresh id is also safe, because by then the root turn is terminal and interruptTurn takes the isTerminalTurn branch — retract only, no second abort.

@Astro-Han this is the rebase you asked contributors to do themselves; it is on current main with CI green whenever you have time to look.

@github-actionsgithub-actionsBot added effort/M Under 500 readable lines and removed effort/L Under 1000 readable lines labels Aug 30, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/MUnder 500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(cli): TUI interrupt waits behind queue RPCs and terminal cleanup while still rendering Working

3 participants

@cat0825@yunaremaia@Astro-Han