fix(loop): derive tool-card running state from finally-guarded inflight set#566
Merged
Conversation
…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.
This was referenced May 10, 2026
Closed
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.
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.
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.
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.
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.
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`.
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.
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
idempotent add, notify-only-on-change, listener errors don't break
the gate, clear, finally-on-throw, finally-on-abort, parallel
allSettled.
`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:
delivery has its own try-block in `spawnSubagent`; tightening that
is a separate change.
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