Skip to content

fix(tui): flush pending modals + cancel pauseGate on Esc and /new#552

Merged
esengine merged 1 commit into
mainfrom
fix/escape-and-new-flush-pause-gate
May 10, 2026
Merged

fix(tui): flush pending modals + cancel pauseGate on Esc and /new#552
esengine merged 1 commit into
mainfrom
fix/escape-and-new-flush-pause-gate

Conversation

@esengine

Copy link
Copy Markdown
Owner

A user reported a stuck-task pattern: the plan card freezes at `0/N
done`, can't be dismissed, and `/new` doesn't clear it either.

Reproducer

The model proposes a plan, the user approves, the model calls a tool
that goes through pauseGate (e.g. `mark_step_complete` →
`plan_checkpoint`). Either the modal mounts and the user closes the
terminal modal in some way that misses the resolve, or the modal mounts
but the user wants to bail out via Esc / `/new`.

Root cause

`PauseGate.ask()` returns a Promise that ignores AbortSignal — it only
resolves when someone calls `gate.resolve(id, verdict)`. Three knock-on
problems:

  1. Esc during busy runs `loop.abort()`, which aborts the turn's
    AbortController. The model fetch path bails on `signal.aborted`,
    but the `await pauseGate.ask(...)` inside a tool fn doesn't see
    the signal — the tool fn stays awaiting forever.
  2. With the tool fn stuck, the loop's `for-await` stays awaiting,
    `busy` stays `true`, and `` stays
    disabled. The user can't type.
  3. Even when the user does manage to type `/new`, `handleSubmit`'s
    `if (busy) return` (App.tsx:2066) silently drops it.

The plan card the user sees frozen at `0/N done` is the visible
manifestation of the awaiting tool fn behind it.

Fix

  • Add `pauseGate.cancelAll()` that resolves every outstanding request
    with its kind's safe-cancel verdict (`run_command` → `deny`,
    `plan_proposed` → `cancel`, `plan_checkpoint` → `stop`,
    `plan_revision` → `cancelled`, `choice` → `cancel`). Awaiting tool
    fns return or throw cleanly.
  • Add a `resetPendingModals` helper in App.tsx that clears every
    `setPending*` modal state and calls `pauseGate.cancelAll()`.
  • Wire it into:
    • Esc-during-busy — replaces the prior edit-review-only cleanup.
    • `/new` — `apply-slash-result`'s `result.clear` branch now
      calls it before `log.reset()`, so the wipe is atomic.

After the fix:

  • Esc during a stuck plan_checkpoint flushes the gate → the tool's
    await returns with `{type: "stop"}` → `mark_step_complete` throws
    → the loop's `signal.aborted` check catches the next iter → busy
    clears → prompt re-enables.
  • `/new` (issued after Esc, since it still respects the busy gate)
    now also flushes any modals/gate state that survived a partial
    cleanup.

Test plan

  • `npx tsc --noEmit` clean
  • `npx vitest run` — 2406 passed, 2 skipped (added 2 cancelAll
    tests in `tests/pause-gate.test.ts`)
  • Smoke: start a plan, when the checkpoint modal mounts hit Esc —
    confirm modal closes, prompt re-enables, busy spinner stops
  • Smoke: same scenario but type `/new` after Esc — confirm cards
    wipe and no plan card returns

When a tool awaits pauseGate.ask (plan_checkpoint, plan_proposed, etc.),
the gate's promise ignores AbortSignal. Esc-during-busy fires
loop.abort(), but the awaiting tool fn never returns, so:

  - busy stays true forever — PromptInput stays disabled.
  - The pending modal (or its plan card) can't be dismissed.
  - /new is silently dropped by handleSubmit's `if (busy) return`,
    so the user can't even wipe the session to start over.

Add pauseGate.cancelAll() that resolves every outstanding request with
its kind's safe-cancel verdict (deny / cancel / stop / cancelled). Wire
it into a resetPendingModals helper that also clears every pending*
modal state, and call it from both Esc-during-busy and apply-slash-
result's clear branch. The awaiting tool fn now returns or throws
cleanly, busy clears, and the user can recover.
@esengine esengine merged commit 94dc192 into main May 10, 2026
3 checks passed
@esengine esengine deleted the fix/escape-and-new-flush-pause-gate branch May 10, 2026 00:08
ChasLui pushed a commit to ChasLui/DeepSeek-Reasonix that referenced this pull request May 23, 2026
…engine#552)

When a tool awaits pauseGate.ask (plan_checkpoint, plan_proposed, etc.),
the gate's promise ignores AbortSignal. Esc-during-busy fires
loop.abort(), but the awaiting tool fn never returns, so:

  - busy stays true forever — PromptInput stays disabled.
  - The pending modal (or its plan card) can't be dismissed.
  - /new is silently dropped by handleSubmit's `if (busy) return`,
    so the user can't even wipe the session to start over.

Add pauseGate.cancelAll() that resolves every outstanding request with
its kind's safe-cancel verdict (deny / cancel / stop / cancelled). Wire
it into a resetPendingModals helper that also clears every pending*
modal state, and call it from both Esc-during-busy and apply-slash-
result's clear branch. The awaiting tool fn now returns or throws
cleanly, busy clears, and the user can recover.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant