Repository navigation
feat(core): acknowledge session interruption immediately - #43552
Merged
Merged
Conversation
kitlangton
enabled auto-merge (squash)
August 20, 2026 03:06
jinhuang712
pushed a commit
to jinhuang712/opencode
that referenced
this pull request
Sep 26, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Pressing Esc to interrupt a running session can hang for as long as the turn's cleanup takes: tool teardown, stream finalizers, and the settled hook all complete before the interrupt request even returns. This PR makes interruption acknowledge immediately and lets cleanup settle asynchronously, and makes the mini TUI paint the interrupted state at the ack so the composer is usable the instant the server accepts the cancel.
Before / After
Before:
Coordinator.interruptends inFiber.interrupt(execution.owner), which awaits every finalizer, so the HTTP interrupt request stays pending for the full cleanup duration.abortingflag for the duration of that request and returnsfalsefor any further Esc press, while the footer consumes the gesture anyway, so repeated presses are silently swallowed.session.execution.interruptedevent arrives after cleanup.After:
execution.startedlegitimately returns to running, and an execution-lifecycle epoch guard keeps a stale ack from stomping either transition. A failed request paints nothing, so the two-press gesture stays available for retry.abortingswallow guard is deleted.Session.remove,session.wait) already compose withawaitIdle.How
packages/core/src/session/run-coordinator.ts:interruptrecordsstopping, clears the doorbell, thenfork(Fiber.interrupt(owner))and returns. Every existing caller either ignores the result or follows withawaitIdle, and the nested-agent cancel path (subagenttool interrupting children inside the parent's own interruption finalizer) stops compounding cleanup latency.packages/core/src/session/run-coordinator.ts:donenow resolves with the execution's exit as a success value (Deferred<Exit<void, E>>). JoinersEffect.flattento inherit the exit;awaitIdleandrun's stopping-branch rejoin only await settlement. Rationale: completing a deferred with an interrupted exit interrupts suspended waiters as it resumes them, and an upstream bug lets a dying waiter's synchronous unregistration starve later waiters of their resume entirely (reproduced and filed as Effect-TS/effect#7365, fix at Effect-TS/effect#7364; previously masked here because awaiting-interrupt serialized settlement). Success-valued completion makes every waiter immune rather than only some.packages/core/src/session/run-coordinator.ts: an interrupt landing in the settlement window (owner exited, settled hook still publishing) now claims the recordedpendingWake, so a successor for the interrupted intent cannot spin up after the user cancelled.packages/core/src/session/execution.ts: oncontinue: true, the follow-up steer wake now usually lands on the stopping execution's doorbell and starts the successor at settle. The concurrent inbox read is safe because delivery consumes rows inside uninterruptible publications (comments added; behavior equivalent).packages/tui/src/mini/stream-v2.transport.ts: a sharedpaintIdle(status)owns the go-idle transition for settlement, terminal events, and the interrupt ack; the ack paint preserves blocker status hints instead of wiping them.packages/tui/src/mini/runtime.ts:state.abortingand the repeated-press swallow are removed.Scope
stoppinglifecycle event; clients keep deriving idle from existing terminal events, with the mini TUI's ack paint bridging the gap. Can be added later if the full TUI wants a server-confirmed stopping state.session.waitcallers would observe it). Deliberately deferred until observed in practice.Deferred.doneUnsafewaiter-starvation bug (Effect-TS/effect#7365) has a draft fix at Effect-TS/effect#7364; this PR is structurally immune either way.Testing
packages/core:bun typecheck, full suitebun run test(1891 pass / 0 fail). New coordinator tests:acknowledges interruption before cleanup settles(holds cleanup open behind a gate and assertsinterruptresolves first, repeated interrupt is a no-op, and the settled hook still records theuserreason at settlement) andan interrupt during terminal settlement claims the recorded wake(no dead-intent successor).packages/tui,packages/server,packages/simulation:bun typecheck;packages/serversuite green.opencode-drivesimulation runs this checkout's real server and TUI, holds a turn open with a controlled shell tool that never settles on its own, double-Esc interrupts it, asserts the tool observes native interruption, the UI shows the interrupted turn, repeated Esc is a safe no-op, and the next prompt completes normally. Re-run green after the review refactor.End-to-end simulation script (run with
opencode-drive run interrupt-ack.drive.tsfrom the repo root)Demo
Drive simulation screenshots (real server + TUI from this branch; the shell tool is simulated and never settles on its own):
Mid-tool, turn running:
After double Esc, tool interrupted, turn terminal, composer usable:
Next prompt runs a fresh turn:
Flow
sequenceDiagram participant U as User (Esc x2) participant T as Mini TUI participant S as Server participant C as Coordinator participant X as Execution fiber U->>T: Esc, Esc T->>S: POST session/interrupt S->>C: interrupt(sessionID, "user") C->>C: stopping = true, clear doorbell C--)X: fork Fiber.interrupt C-->>S: accepted (immediate) S-->>T: 200 T->>T: paint idle (epoch-guarded) X->>X: finalizers + settled hook X->>C: settle: done resolves with Exit.succeed(exit) C-->>T: session.execution.interrupted (re-confirms idle) Note over C: doorbell rung during cleanup<br/>starts successor at settle