Skip to content

fix(chat): an abandoned approval must not leave a needs-approval dot - #328

Merged
frenchie4111 merged 2 commits into
mainfrom
fix-approval-timeout-stale-dot
Oct 18, 2026
Merged

frenchie4111 merged 2 commits into
mainfrom
fix-approval-timeout-stale-dot

Conversation

@frenchie4111

Copy link
Copy Markdown
Collaborator

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 approve call and
aborts it after 1800s of silence. From a real session transcript:

<tool_use_error>Error calling tool (AskUserQuestion): MCP server "ness-permissions"
tool "approve" sent no response or progress for 1800s; aborting.</tool_use_error>

It reports that abort as an is_error tool_result on the original
tool_use. The approval socket itself hears nothing — the MCP subprocess
is still parked on it — so pendingResponses and the pendingApprovals
slice entry both survive the turn. JsonClaudeStatusDeriver keeps
deriving needs-approval from that entry, and that's the dot. Nothing
in Ness ever cleared it.

Fix

ApprovalBridge.cancelPendingForToolUse(sessionId, toolUseId). The
json-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-permissions MCP entry a one-week
per-server timeout, on the theory that an approval parked on a human
has 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 timeout overrides
the global idle timeout rather than being min'd with it —
CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT=5000 with timeout: 20000 aborted
at 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 stale
entry 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 frame
reaches the bridge; the MCP config carries no timeout).

npm run typecheck, npx electron-vite build, npx vitest run — 1837
passed. 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

frenchie4111 and others added 2 commits October 18, 2026 11:07
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>
@frenchie4111
frenchie4111 merged commit 3f60225 into main Oct 18, 2026
1 check passed
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