Skip to content

fix(loop): derive tool-card running state from finally-guarded inflight set#566

Merged
esengine merged 1 commit into
mainfrom
fix/inflight-set-issue-557
May 10, 2026
Merged

fix(loop): derive tool-card running state from finally-guarded inflight set#566
esengine merged 1 commit into
mainfrom
fix/inflight-set-issue-557

Conversation

@esengine

Copy link
Copy Markdown
Owner

Tool-card spinners occasionally kept spinning after the underlying work
had finished. Self-reported on dogfooding; preventive fix per #557.

Why this happens

`status` was set imperatively from paired events: `tool_start` flips
the card to running, `tool` flips it to done. Every dispatch exit path
has to remember to emit the closing event — and there are a lot of
them: normal return, thrown error caught upstream, parent abort
propagating into a running child, storm-breaker truncation, network
drop. Miss one and the spinner stays running forever.

Fix — derive, don't accumulate

Make `running` a derived view of an authoritative inflight set on the
loop, not an event-driven flag.

  • `InflightSet` (`src/core/inflight.ts`) — add / delete / has /
    subscribe with notify-on-mutation. Only one contract: insertions
    paired with `finally`-guarded deletions. The language guarantees
    `finally` on every exit path, so there's no closing event to forget.
  • `CacheFirstLoop` holds one (`loop.inflight`). `runOneToolCall`
    adds the call's id at function entry and deletes it in `finally`;
    the chunk loop pre-adds before yielding `tool_start` so the very
    first card render already reflects the running state. `clearLog`
    drains the set on `/new`.
  • `LoopEvent.tool_start` / `tool` carry a new `callId` — same
    id used as the inflight key. `TurnTranslator` threads it into
    `Scrollback.startTool`, which uses it as the card id, so the card's
    id and the inflight key match.
  • `InflightProvider` + `useIsInflight` hook expose the set as a
    React context backed by `useSyncExternalStore`. `ToolCard`'s
    `toolStatus` consults the hook for "running"; `card.done` is no
    longer the spinner discriminator — only the terminal flavor (ok /
    rejected / aborted / error).

The whole class of "we forgot to emit the end event on path X"
disappears: there's no end event to forget.

Tests

  • `tests/inflight.test.ts` (11 cases) — InflightSet contract:
    idempotent add, notify-only-on-change, listener errors don't break
    the gate, clear, finally-on-throw, finally-on-abort, parallel
    allSettled.
  • `tests/loop-inflight.test.ts` (4 cases) — `runOneToolCall`
    `finally` clears the set on success and on tool throw;
    `tool_start` carries `callId` matching the inflight key;
    `clearLog` drains.

Type-check + dashboard build clean. Full suite: 2428 passed, 2 skipped
(was 2413, +15 new tests).

Out of scope (follow-ups)

Per the issue, both mentioned but punted to keep this PR tight:

  • Subagent runId in the same inflight set. Subagent end-event
    delivery has its own try-block in `spawnSubagent`; tightening that
    is a separate change.
  • Streaming / reasoning running flags. Tied to the model-API call
    path, not tool dispatch — different leak surface.

Both are tracked in #557's checklist and can land incrementally now
that the InflightSet primitive exists.

Closes #557

…ht set

Closes #557

Tool spinners occasionally kept spinning after the underlying work had
finished. The cause: status was set imperatively from paired tool_start
/ tool events, and every code path that exits a dispatch had to
remember to emit the closing event — normal return, throw, abort,
storm-breaker, network drop. Miss one and the card was stuck.

Switch the running flag to a derived view:

- New InflightSet (src/core/inflight.ts) with add / delete / has /
  subscribe + notify-on-mutation. Parallel finally-driven cleanup is
  the contract: the language guarantees finally on every exit path,
  so there's no closing event to forget.
- CacheFirstLoop holds one. runOneToolCall adds the call's id at
  function entry and deletes it in finally; the chunk-loop pre-adds
  before yielding tool_start so the very first card render already
  reflects the running state. clearLog drains the set on /new.
- LoopEvent.tool_start / tool now carry callId — same id used as the
  inflight key. TurnTranslator threads it into Scrollback.startTool,
  which uses it as the card id so the card and the inflight key match.
- InflightProvider + useIsInflight (src/cli/ui/state/inflight-context.tsx)
  expose the set as a React context backed by useSyncExternalStore.
  ToolCard's toolStatus consults the hook for "running"; card.done is
  no longer the spinner discriminator, only terminal flavor (ok /
  rejected / aborted / error).

Tests:
- tests/inflight.test.ts — InflightSet contract: idempotent add,
  notify-only-on-change, listener errors don't break the gate, clear,
  finally-on-throw, finally-on-abort, parallel allSettled.
- tests/loop-inflight.test.ts — runOneToolCall finally clears the
  set on success and on tool throw; tool_start carries callId
  matching the inflight key; clearLog drains.

Out of scope (follow-up): subagent runId in the same set; deriving
streaming/reasoning running flags. Both are mentioned in the issue
but live behind different leak surfaces; the tool-card path is the
one users see most.
@esengine esengine merged commit cf122c3 into main May 10, 2026
3 checks passed
@esengine esengine deleted the fix/inflight-set-issue-557 branch May 10, 2026 01:12
esengine added a commit that referenced this pull request May 10, 2026
Surfaces a SingleSelect modal for the terminal theme preference. Bare
\`/theme\` opens the picker; \`/theme <name>\` keeps its existing
"persist and report" behaviour. Moves the slash entry from the
advanced group to setup and adds high-contrast to the arg completer.

Adapted from J3y0r's PR — repointed onto current main (which had
since reformatted App.tsx around the InflightProvider wrap from
#566) and the count test in tests/ui-slash-suggestions.test.tsx
updated from 12 → 11 advanced commands now that /theme has moved
out of the advanced bucket.

Thanks @J3y0r for the contribution.
@esengine esengine mentioned this pull request May 10, 2026
5 tasks
ChasLui pushed a commit to ChasLui/DeepSeek-Reasonix that referenced this pull request May 23, 2026
…ht set (esengine#566)

Closes esengine#557

Tool spinners occasionally kept spinning after the underlying work had
finished. The cause: status was set imperatively from paired tool_start
/ tool events, and every code path that exits a dispatch had to
remember to emit the closing event — normal return, throw, abort,
storm-breaker, network drop. Miss one and the card was stuck.

Switch the running flag to a derived view:

- New InflightSet (src/core/inflight.ts) with add / delete / has /
  subscribe + notify-on-mutation. Parallel finally-driven cleanup is
  the contract: the language guarantees finally on every exit path,
  so there's no closing event to forget.
- CacheFirstLoop holds one. runOneToolCall adds the call's id at
  function entry and deletes it in finally; the chunk-loop pre-adds
  before yielding tool_start so the very first card render already
  reflects the running state. clearLog drains the set on /new.
- LoopEvent.tool_start / tool now carry callId — same id used as the
  inflight key. TurnTranslator threads it into Scrollback.startTool,
  which uses it as the card id so the card and the inflight key match.
- InflightProvider + useIsInflight (src/cli/ui/state/inflight-context.tsx)
  expose the set as a React context backed by useSyncExternalStore.
  ToolCard's toolStatus consults the hook for "running"; card.done is
  no longer the spinner discriminator, only terminal flavor (ok /
  rejected / aborted / error).

Tests:
- tests/inflight.test.ts — InflightSet contract: idempotent add,
  notify-only-on-change, listener errors don't break the gate, clear,
  finally-on-throw, finally-on-abort, parallel allSettled.
- tests/loop-inflight.test.ts — runOneToolCall finally clears the
  set on success and on tool throw; tool_start carries callId
  matching the inflight key; clearLog drains.

Out of scope (follow-up): subagent runId in the same set; deriving
streaming/reasoning running flags. Both are mentioned in the issue
but live behind different leak surfaces; the tool-card path is the
one users see most.
ChasLui pushed a commit to ChasLui/DeepSeek-Reasonix that referenced this pull request May 23, 2026
Surfaces a SingleSelect modal for the terminal theme preference. Bare
\`/theme\` opens the picker; \`/theme <name>\` keeps its existing
"persist and report" behaviour. Moves the slash entry from the
advanced group to setup and adds high-contrast to the arg completer.

Adapted from J3y0r's PR — repointed onto current main (which had
since reformatted App.tsx around the InflightProvider wrap from
esengine#566) and the count test in tests/ui-slash-suggestions.test.tsx
updated from 12 → 11 advanced commands now that /theme has moved
out of the advanced bucket.

Thanks @J3y0r for the contribution.
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.

Inflight cards occasionally fail to auto-close — derive running from an authoritative inflight set

1 participant