Skip to content

fix(advisor): stop discarding consults that outlive the MCP default timeout - #388

Open
astyfx wants to merge 1 commit into
mainfrom
fix/advisor-consult-timeout
Open

fix(advisor): stop discarding consults that outlive the MCP default timeout#388
astyfx wants to merge 1 commit into
mainfrom
fix/advisor-consult-timeout

Conversation

@astyfx

@astyfx astyfx commented Aug 24, 2026

Copy link
Copy Markdown
Member

Summary

Every Advisor consult longer than 60 seconds was silently thrown away.

The Claude Agent SDK caps an MCP tool call at a hard 60s wall clock that progress notifications do not extend. Stave set neither the per-server timeout nor MCP_TOOL_TIMEOUT, so that default governed every Stave Local MCP tool — including stave_consult_advisor, whose own deadline is 2–10 minutes by effort tier. Consults past the minute mark were structurally impossible to complete.

The failure was invisible in the worst way. The consult ran to completion, billed its tokens, and emitted its completed trace to the turn event stream — so the turn detail read Advisor consult 1/5 completed with Codex · gpt-5.6-sol in 139.5s. Meanwhile the client had already aborted and the MCP SDK discarded the reply with if (signal.aborted) return;: no error, no log, no metric. The UI was the only thing that ever saw the advice; the primary never received a word of it.

The boundary is confirmed empirically — in one session a 56.9s consult succeeded and a 139.5s one did not.

Key changes

1. State the outermost timeout rung explicitly. Derived from the host-service backstop so the ordering is enforced in code rather than by comment:

Layer Deadline Defined in
One advisor call 2–10 min by effort tier resolveAdvisorTimeoutMs
Host-service backstop 15 min HOST_SERVICE_ADVISOR_CONSULT_TIMEOUT_MS
MCP tool call (client) 16 min STAVE_LOCAL_MCP_TOOL_TIMEOUT_MS ← was an implicit 60s

The host-service comment already argued that its backstop must sit above the advisor ceiling. That reasoning was simply missing one layer up.

stave-mcp-stdio-proxy now shares the same cap instead of setting its own 120s.

2. An advisor_activity progress heartbeat (throttled to one tick per 5s) naming what the provider was last seen doing — Codex item: reasoning, Claude event: assistant, Loading the Claude runtime. Both providers resolve only once generation has finished, so a consult was previously indistinguishable from a wedged thread for its entire duration. Reuses the existing item/completed and onProgress observation points; no new provider plumbing.

The heartbeat is deliberately not a lifecycle stage. The reducer folds it into lastProgressAt/progressDetail rather than appending to the bounded stages list (cap 12), so a chatty provider cannot evict the steps that matter or make settledConsults depend on tick timing.

Interaction with #384

#384 (archive consults for review) landed on two of the same files after this work was written. The heartbeat needed one adaptation: applyAdvisorActivityEvents treats a new snapshot reference as "something changed" and upserts the archive row. A straggler heartbeat from a runner racing its own timeout would therefore have bumped updatedAt on a consult the log had already closed. The reducer now returns the snapshot by reference when the consult is already terminal, which the archive's existing if (next === snapshot) continue; reads as a non-event — zero changes to any #384 file.

Two invariants worth noting, both holding by construction rather than by check: isNewExchange only fires on started, so a heartbeat can never open a card; and startSnapshot normalizes its seeded stage to armed/started, so "progress" cannot enter stages even through the event-replay synthesis path.

What this deliberately does not do

  • No abort propagation. An earlier attempt threaded the caller's AbortSignal into the run. It cannot work: the tool handler runs in the Electron main process while the grant registry lives in the host-service child, and an AbortSignal does not survive the JSON IPC hop. Fixing the timeout removes the need — 16 min > 10 min means a healthy consult is never abandoned, so no orphan is created to cancel.
  • No budget refund. Same reason, and refunding would be wrong anyway: a timed-out consult burned real tokens, and the budget is a spend ceiling, not a delivery guarantee.
  • No change to the Claude Code settings-file MCP entry shape. That form nests connection info under transport:, and whether the CLI reads a per-server timeout inside or beside that wrapper could not be confirmed from the binary. Writing it to the wrong place risks the CLI rejecting a user's settings file — worse than the original bug. The fix is limited to the verified SDK path.
  • No lowering of defaultCodexReasoningEffort. Every catalog model defaults to xhigh, which plausibly explains the 139.5s; but the catalog comment states it mirrors what codex-cli's model/list reports, so the provider's own default is respected. The prompt is already minimal (4 lines plus caller context, no repo state or conversation history) and the Codex process is warm, so there was little else to trim — hence surfacing the wait rather than hiding it.

Known limitation, documented

The MCP timeout is per-server, not per-tool, so it now applies to all 74 Stave Local MCP tools. An audit found one that can legitimately exceed 16 min: stave_create_workspace blocks on the project's configured init command (typically a dependency install) with no deadline of its own, and stave_delegate_task awaits it when creating a new worktree. This is not a regression — the previous effective cap was 60s, so every tool improves — but the ladder invariant holds by construction only for the consult path. Recorded in docs/providers/provider-runtimes.md.

(stave_respond_approval was checked for human-wait behaviour and does not wait: it forwards a decision to a pending request and throws immediately if none exists. The only human-waiting tool is Lens CDP approval, which is explicitly capped at 60s.)

Verification

  • bun run typecheck clean; bun run build:desktop succeeds (checked specifically because stave-mcp-stdio-proxy is a separate bundle entry — out/main/stave-mcp-stdio-proxy.mjs, 7.26 kB, no Electron-only imports pulled in)
  • Gates: reliability-gates, doc-paths, max-lines-ratchet, switch-exhaustiveness all pass
  • Focused: 97 pass / 0 fail across advisor-exchange, stave-local-mcp-manifest, advisor-consult-log-render, turn-activity-render, advisor-consult
  • Full suite: failure-name set difference against a git stash baseline at the same HEAD is empty in both directions — the same 11 pre-existing environment-dependent failures (provider binary discovery, PR-drafting untracked-diff timeout) plus one full-suite-only module-loading flake in tests/host-service-client.test.ts that passes in isolation and reproduces identically on the clean base. Test count rises by exactly the 6 tests added here.
  • New tests: the client config always advertises a deadline above 60s; the ladder ordering invariant itself; the heartbeat keeps a consult pending without touching stages; a heartbeat cannot settle or resurrect a finished consult; a late heartbeat does not re-touch the archived entry (asserts reference identity on both maps); the progress phase survives the provider event schema

Worth flagging: the ladder-ordering test exists because tsconfig's include is "src" only, so tsc never sees tests/. Type-level agreement between these constants is not something the typechecker can enforce here.

Notes

Split out of the same working tree as #387 (button group chrome), which is UI-only and independent — either can merge first.

One thing not chased down: electron/main/codex-mcp.ts does not set tool_timeout_sec and leaves Codex to its own default, which I could not determine. If Codex is the primary, the same symptom may still be reachable by that path.

🤖 Generated with Claude Code

…imeout

The Claude Agent SDK caps an MCP tool call at a hard 60-second wall clock
that progress notifications do not extend. Stave set neither the
per-server `timeout` nor `MCP_TOOL_TIMEOUT`, so that default applied to
every Stave Local MCP tool — including `stave_consult_advisor`, whose own
deadline is 2-10 minutes by effort tier.

Any consult past the minute mark therefore failed structurally: it ran to
completion, billed its tokens, and emitted its `completed` trace to the
turn, while the client had already aborted and the MCP SDK dropped the
reply with `if (signal.aborted) return;` — no error, no log, no metric.
The turn detail said the consult succeeded and the primary never received
a word of it. A 56.9s consult in the same session succeeded and a 139.5s
one did not, which puts the boundary exactly at the documented default.

State the outermost rung explicitly and derive it from the host-service
backstop, so the ordering is enforced in code rather than by comment:

  one advisor call    2-10 min by effort tier
  host-service backstop      15 min
  MCP tool call (client)     16 min   <- was an implicit 60s

`stave-mcp-stdio-proxy` shares the same cap instead of its own 120s.

Also add an `advisor_activity` `progress` heartbeat, throttled to one
tick per 5s, naming what the provider was last seen doing. Both providers
resolve only after generation finishes, so a consult was previously
indistinguishable from a wedged thread for minutes at a time. The
heartbeat is deliberately not a lifecycle stage: the reducer folds it
into `lastProgressAt`/`progressDetail` rather than appending to the
bounded `stages` list, so a chatty provider cannot evict the steps that
matter or make `settledConsults` depend on tick timing. A heartbeat
arriving after a consult settles returns the snapshot by reference, which
the archive's existing identity check reads as a non-event.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@astyfx
astyfx requested a review from a team as a code owner August 24, 2026 01:41
@astyfx
astyfx requested review from heath-s and paul-seo73 and removed request for a team August 24, 2026 01:41
@upwind-code-us

upwind-code-us Bot commented Aug 24, 2026

Copy link
Copy Markdown

Upwind Upwind Code Scan - ✅ Proceed with Deployment

0 newly introduced vulnerabilities · 0 resolved · 81 total in this PR vs main

🔴 1 Critical | 🔶 30 High | 🟡 47 Medium | 🟢 3 Low

View full analysis in Upwind Console

Scan completed in 18s

Scan history (1 scan)
Commit Scanned at New Resolved Net
ffa9254 < 2026-08-24 01:42 UTC 0 0 0

Last scanned: ffa9254 · 2026-08-24 01:42 UTC

@upwind-code-us

upwind-code-us Bot commented Aug 24, 2026

Copy link
Copy Markdown

Upwind Upwind IaC Scan - ✅ Proceed with Deployment

0 newly introduced misconfigurations · 0 resolved · 0 total in this PR vs main

View full analysis in Upwind Console →

Scan completed in 1s

Scan history (1 scan)
Commit Scanned at New Resolved Net
ffa9254 < 2026-08-24 01:42 UTC 0 0 0

Last scanned: ffa9254 · 2026-08-24 01:42 UTC

@heath-s heath-s left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved

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.

2 participants