Repository navigation
fix(chat): an abandoned approval must not leave a needs-approval dot - #328
Merged
Merged
Conversation
Claude Code applies its MCP idle timeout to our `approve` call and
aborts the tool after 1800s of silence, reporting it as an is_error
tool_result on the original tool_use:
Error calling tool (AskUserQuestion): MCP server "ness-permissions"
tool "approve" sent no response or progress for 1800s; aborting.
Nothing reaches the approval socket when that happens — the MCP
subprocess is still parked on it — so the pending entry survived the
turn and the status deriver kept publishing needs-approval. The sidebar
showed a red dot forever for a tool the chat already showed as skipped,
and the only cure was killing the tab.
Two halves:
* Reconcile on tool_result. A result means the call was decided, so an
approval still pending for that tool_use id can never be answered.
The stream reader hands every result to the bridge, which denies the
waiting socket (so the subprocess unwinds instead of leaking until
the session dies) and drops the card.
* Don't let it fire in the first place. The `approve` call is parked on
a human, and the needs-approval dot exists precisely so you can
answer the tab whenever you get to it — half an hour is not a
meaningful deadline. Give the ness-permissions MCP entry its own
one-week timeout; verified against the bundled binary that a
per-server `timeout` overrides the global idle timeout rather than
being min'd with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous commit gave the ness-permissions MCP entry a one-week
per-server timeout on the theory that an approval parked on a human has
no meaningful deadline. It does: the abort is what lets an agent nobody
answered work around the question — or go do something else — instead
of blocking on a tab until someone opens it. The transcript that
started this even shows the good outcome ("The question tool timed out,
so I'll proceed on my own defaults and flag both assumptions at the
end").
So the default stays, and its absence is now load-bearing — asserted in
a test and noted at the config site, because "the approval card
vanished after 30 minutes" reads like a bug worth fixing with a
timeout. It isn't. The stale-dot half of the fix stands on its own:
Ness reconciles the abort via cancelApprovalForToolUse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
When a tool-call approval timed out, the worktree kept a red
needs-approval dot in the sidebar indefinitely — even though the chat
already showed the tool as skipped. The only cure was killing the tab.
Root cause
Claude Code applies its MCP tool idle timeout to our
approvecall andaborts it after 1800s of silence. From a real session transcript:
It reports that abort as an
is_errortool_result on the originaltool_use. The approval socket itself hears nothing — the MCP subprocess
is still parked on it — so
pendingResponsesand thependingApprovalsslice entry both survive the turn.
JsonClaudeStatusDeriverkeepsderiving
needs-approvalfrom that entry, and that's the dot. Nothingin Ness ever cleared it.
Fix
ApprovalBridge.cancelPendingForToolUse(sessionId, toolUseId). Thejson-claude stream reader hands every tool_result to it: a result means
the call was decided, so an approval still pending for that tool_use id
can never be answered. The bridge denies the waiting socket — so the
subprocess unwinds instead of leaking until the session dies — and drops
the pending entry, which clears the card and the dot together.
Keyed on tool_result rather than on a timer, so it also covers any other
path where Claude abandons a tool call.
What this deliberately does not do
The first commit also gave the
ness-permissionsMCP entry a one-weekper-server
timeout, on the theory that an approval parked on a humanhas no meaningful deadline. The second commit reverts that: the abort is
what lets an agent nobody answered work around the question, or go do
something else, instead of blocking on a tab until someone opens it. The
transcript above shows the good outcome a few lines later — "The
question tool timed out, so I'll proceed on my own defaults and flag
both assumptions at the end."
So the 1800s default stays, and its absence is now load-bearing:
asserted in a test and noted at the config site, because "the approval
card vanished after 30 minutes" reads like a bug you'd fix with a longer
timeout. It isn't.
Verified along the way (against bundled claude 2.1.301, with a
deliberately-hanging MCP server) that a per-server
timeoutoverridesthe global idle timeout rather than being min'd with it —
CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT=5000withtimeout: 20000abortedat 20s, not 5s. Not used by this PR, but worth recording for whoever
needs that knob later.
Testing
5 new tests — 3 in
approval-bridge-rules.test.ts(drops the staleentry and denies the socket; leaves other tool_use ids and other
sessions alone; no-op for an approval the user already answered), 2 in
json-claude-manager.test.ts(a replayed real 1800s abort framereaches the bridge; the MCP config carries no
timeout).npm run typecheck,npx electron-vite build,npx vitest run— 1837passed. One unrelated flake:
worktree-watcher.integration.test.ts"fires again on commit in a LINKED worktree" timed out at 5s under
full-suite parallel load; it passes in isolation both with and without
these changes (real
fs.watch+ git).🤖 Generated with Claude Code