Skip to content

feat(core): acknowledge session interruption immediately - #43552

Merged
kitlangton merged 5 commits into
v2from
interrupt-ack
Aug 20, 2026
Merged

kitlangton merged 5 commits into
v2from
interrupt-ack

Conversation

@kitlangton

@kitlangton kitlangton commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

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:

  1. A turn is mid-tool. The user presses Esc (twice).
  2. Coordinator.interrupt ends in Fiber.interrupt(execution.owner), which awaits every finalizer, so the HTTP interrupt request stays pending for the full cleanup duration.
  3. The mini TUI sets a local aborting flag for the duration of that request and returns false for any further Esc press, while the footer consumes the gesture anyway, so repeated presses are silently swallowed.
  4. The UI only flips to idle when the terminal session.execution.interrupted event arrives after cleanup.

After:

  1. Esc marks the execution stopping, forks the fiber interruption, and the interrupt endpoint returns immediately.
  2. The mini TUI paints idle at the ack: the composer is usable as soon as the server accepts the interruption, late deltas from the dying turn only append text, and the terminal execution event re-confirms idle. A successor turn's execution.started legitimately 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.
  3. Repeating the interrupt is an immediate idempotent no-op, so the local aborting swallow guard is deleted.
  4. Invariant preserved: the write-ahead claim still releases exactly at the terminal event, and callers that need settlement (Session.remove, session.wait) already compose with awaitIdle.

How

  • packages/core/src/session/run-coordinator.ts: interrupt records stopping, clears the doorbell, then fork(Fiber.interrupt(owner)) and returns. Every existing caller either ignores the result or follows with awaitIdle, and the nested-agent cancel path (subagent tool interrupting children inside the parent's own interruption finalizer) stops compounding cleanup latency.
  • packages/core/src/session/run-coordinator.ts: done now resolves with the execution's exit as a success value (Deferred<Exit<void, E>>). Joiners Effect.flatten to inherit the exit; awaitIdle and run'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 recorded pendingWake, so a successor for the interrupted intent cannot spin up after the user cancelled.
  • packages/core/src/session/execution.ts: on continue: 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 shared paintIdle(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.aborting and the repeated-press swallow are removed.

Scope

  • No protocol or server API changes: the interrupt endpoint shape is unchanged, it just resolves immediately.
  • No authoritative stopping lifecycle 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.
  • No cleanup deadline or force-stop escalation: a tool finalizer that hangs forever still holds the session busy (invisible to the canceling user now, but session.wait callers would observe it). Deliberately deferred until observed in practice.
  • Full TUI keeps its event-driven state; only the mini TUI gets the ack paint here.
  • The upstream Deferred.doneUnsafe waiter-starvation bug (Effect-TS/effect#7365) has a draft fix at Effect-TS/effect#7364; this PR is structurally immune either way.
  • No committed simulation scripts: the end-to-end run below uses an external driver, so the script lives in this description rather than the repo.

Testing

  • packages/core: bun typecheck, full suite bun run test (1891 pass / 0 fail). New coordinator tests: acknowledges interruption before cleanup settles (holds cleanup open behind a gate and asserts interrupt resolves first, repeated interrupt is a no-op, and the settled hook still records the user reason at settlement) and an interrupt during terminal settlement claims the recorded wake (no dead-intent successor).
  • packages/tui, packages/server, packages/simulation: bun typecheck; packages/server suite green.
  • End-to-end: an opencode-drive simulation 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.ts from the repo root)
// Black-box simulation: interrupting a turn mid-tool acknowledges immediately and
// leaves the session usable.
//
//   opencode-drive run packages/simulation/drive/interrupt-ack.drive.ts
//
// Run from the repository root: the simulation launches this checkout's server and TUI.
// The turn is held open by a Drive-controlled shell tool that never settles on its
// own, so the interrupt gesture races real server-side cleanup instead of an
// already-finished drain.
import { Effect } from "effect"
import { Llm, OpenCodeDriver } from "opencode-drive"

export default OpenCodeDriver.use(
  {
    opencode: { dev: process.cwd() },
    project: {
      git: true,
      files: { "README.md": "# Interrupt fixture\n" },
    },
    tools: ["shell"],
  },
  ({ ui, llm, tools }) =>
    Effect.gen(function* () {
      const shells = yield* tools.control("shell")
      yield* llm.queue(
        Llm.toolCall({ index: 0, id: "call_slow", name: "shell", input: { command: "make build" } }),
        Llm.finish("tool-calls"),
      )
      yield* ui.submit("Build the project")

      const shell = yield* shells.take("call_slow")
      yield* shell.progress("building...\n")
      yield* ui.screenshot("mid-tool")

      // Double Esc while the tool call is still running: the interrupt must acknowledge
      // without waiting out cleanup, and the tool observes native interruption.
      yield* ui.press("escape")
      yield* ui.press("escape")
      yield* shell.awaitInterrupted()
      yield* ui.waitFor("interrupted")
      yield* ui.screenshot("interrupted")

      // A repeated gesture during or after teardown is a safe no-op, never an error.
      yield* ui.press("escape")
      yield* ui.press("escape")

      // The session stays usable: the next prompt runs a fresh turn.
      yield* llm.queue(Llm.text("Still here."))
      yield* ui.submit("Are you still there?")
      yield* ui.waitFor("Still here.")
      yield* ui.screenshot("recovered")
    }),
)

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:

mid-tool.png

After double Esc, tool interrupted, turn terminal, composer usable:

interrupted.png

Next prompt runs a fresh turn:

recovered.png

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
Loading

@kitlangton
kitlangton enabled auto-merge (squash) August 20, 2026 03:06
@kitlangton
kitlangton merged commit f434740 into v2 Aug 20, 2026
10 checks passed
@kitlangton
kitlangton deleted the interrupt-ack branch August 20, 2026 03:16
jinhuang712 pushed a commit to jinhuang712/opencode that referenced this pull request Sep 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant