Skip to content

fix: recover wedged turns when provider event stream stalls (#46) - #47

Merged
saucam merged 7 commits into
mainfrom
fix/turn-stall-watchdog
Jun 29, 2026
Merged

fix: recover wedged turns when provider event stream stalls (#46)#47
saucam merged 7 commits into
mainfrom
fix/turn-stall-watchdog

Conversation

@saucam

@saucam saucam commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Fixes #46.

Problem

A session could get stuck in "replying" forever with no messages displayed (both Telegram and web UI) when a provider's event stream went silent without a terminal turn_done — e.g. a Bash command or MCP gateway call that never returns.

  • Session.#consumeEvents blocked in for await (const event of run.events) with no watchdog (only a 5s ZeroID fence existed). When the stream stalled, the loop never exited → finally never ran → #activeRun and status stayed pinned at tool_running.
  • With the session stuck "working", every subsequent send hit the wasWorking && #activeRun?.pushMidTurn fast-path in #sendInner, pushed into the dead run, and returned without starting a new turn. The user saw their message + "⎆ Queued mid-turn", then nothing.

Observed live: a session whose last transcript record was a Bash tool_call with no result; SDK subprocess alive 8h+ but sleeping. interrupt recovered it.

Not a regression — the wedge mechanism exists on main too (the multi-provider refactor inherited it unchanged). High severity regardless: any single hung tool bricks the session.

Fix

  • Stall watchdog in #consumeEvents — drives the async iterator manually and races each pull against session.turnStallTimeoutMs (default 300000, 0 disables). On silence, force-recovers.
  • #recoverStalledRun — flushes streaming UI, releases pending approvals, drops the run, emits a clear ⚠️ Turn timed out… notice, resets status to idle, and hard-tears-down the provider to reap the presumed-hung subprocess. Deliberately does not route through #teardownProvider (which awaits the session event-consumer task — i.e. potentially itself), avoiding a self-await deadlock; it only awaits provider.teardown() (the provider's own pump).
  • Liveness guard in #sendInner — a send arriving on an apparently-dead run recovers it and starts a fresh turn instead of silently queueing into the wedge.
  • Configsession.turnStallTimeoutMs + CODEOID_TURN_STALL_TIMEOUT_MS env override. Generous default because long-running tools still emit tool_progress/partial events, so total silence for the window is a reliable hang signal.
  • MockSessionProvider — opt-in stall mode (emit then leave the queue open) + close-on-teardown, mirroring ClaudeProvider, for deterministic offline tests.

Layered defense (added in review)

The watchdog is deliberately a coarse, provider-agnostic backstop — the SDK exposes no whole-turn / idle liveness signal, only per-tool timeouts. So the precise fix for the trigger we actually hit (a hung MCP gateway call) is the SDK's own per-server tool-call timeout:

  • session.mcpToolTimeoutMs (default 120000) is applied to external/user MCP servers via each server's SDK timeout. A hung call now returns an SDK error event the turn loop acts on, instead of going silent.
  • Default (120s) sits below turnStallTimeoutMs (300s) so the SDK signals first; the stall watchdog only fires for what per-tool timeouts can't cover (a non-MCP hang, a genuinely silent stream) and for non-Claude providers with their own stall modes.
  • Explicit per-server timeouts are preserved; the in-process memory server is untouched; 0 = use SDK default.

We continue to rely on the SDK's real terminal signals (stream end, errors/throws incl. subprocess crash, resultturn_done) — those are unchanged and not duplicated.

Tests

New T9 group in session-integration.test.ts:

  1. A stalled turn recovers to idle, emits the timeout notice, and reaps the provider.
  2. After recovery, the next send starts a fresh turn and gets a reply (no permanent wedge).
  3. With the watchdog disabled (0), a silent turn stays blocked (no false recovery).

Verified: tsc --noEmit clean, biome clean, 617/617 tests pass.

Base & bundled commits

Targets main. The stall watchdog wraps the #pendingMidTurnCount mid-turn-absorb logic, which isn't on main yet, so this PR bundles the two prerequisite consumer fixes it builds on:

  1. fix: remove premature tool cancellation from text_done handler
  2. fix: consume mid-turn continuation turn after SDK interrupts current turn
  3. fix: recover wedged turns when provider event stream stalls (#46) ← this fix

All three are consumer-loop fixes; bundling keeps main's turn-handling internally consistent. Merge once → main has the fix; restart the daemon from main to pick it up.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added a per-session stall watchdog that detects hung turns and recovers automatically.
    • Added session.turnStallTimeoutMs (default 300,000ms) via CODEOID_TURN_STALL_TIMEOUT_MS (supports 0 to disable).
    • Added session.mcpToolTimeoutMs (default 120,000ms) via CODEOID_MCP_TOOL_TIMEOUT_MS to apply timeouts to MCP tool calls.
  • Bug Fixes
    • Prevented tool messages from being incorrectly cancelled when assistant text finishes first.
    • Improved mid-turn continuation and recovery behavior.
  • Tests
    • Expanded coverage for stall recovery, watchdog disablement, approval waiting, and MCP timeout injection.

@coderabbitai

coderabbitai Bot commented Jun 29, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 5e859bdc-d8f6-4988-9980-a852ec583a69

📥 Commits

Reviewing files that changed from the base of the PR and between 2c59b2e and 4f7a33b.

📒 Files selected for processing (2)
  • src/config.ts
  • src/tests/config.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/config.ts

📝 Walkthrough

Walkthrough

Adds configurable session stall and MCP tool timeouts, stall detection and recovery in Session, mock provider stall simulation, and integration coverage for timeout, mid-turn, and text finalization paths.

Changes

Session timeouts and recovery

Layer / File(s) Summary
session timeout config
src/config.ts, src/tests/config.test.ts
Adds session stall and MCP tool timeout fields, exposes them on the public session config type, maps environment overrides, revalidates merged config after overrides, and covers the new parsing and validation cases.
mock provider stall mode
src/daemon/providers/mock/session-provider.ts
Adds stall simulation state to MockSessionProvider, keeps the active queue available for teardown, and suppresses terminal queue closure while stall mode is enabled.
session stall watchdog and recovery
src/daemon/session.ts
Adds event-liveness tracking, mid-turn continuation tracking, stall detection in send and event consumption, and forced recovery that clears session state, persists a stall message, and tears down the provider.
text_done finalization and streamed batch output
src/daemon/session.ts
Changes text_done handling to commit and broadcast the assistant message without tool cleanup, and adds artificial streaming for batch responses before the final committed message.
Claude MCP tool timeout
src/daemon/providers/claude/index.ts, src/tests/provider-claude.test.ts
Wraps external MCP server configs with a per-server timeout derived from session config, and covers preserved explicit timeouts and non-positive no-op behavior.
integration coverage
src/tests/session-integration.test.ts
Updates session test helpers for configurable config input and status waiting, and extends integration tests for tool finalization, mid-turn continuation, stall recovery, disabled watchdog behavior, and approval-waiting behavior.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • saucam/codeoid#35: Modifies src/daemon/session.ts around tool finalization and interruption behavior in the same execution path affected here.
  • saucam/codeoid#38: Updates the same session/provider runtime area and is directly related to the stall watchdog and MCP-provider flow changes.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the main change: recovering wedged turns when the provider stream stalls.
Linked Issues check ✅ Passed The PR implements stall watchdog recovery, reaped teardown, fresh-send recovery, timeout notice, and deterministic mock coverage for #46.
Out of Scope Changes check ✅ Passed The added MCP timeout layering and related config stay tied to the stall-recovery fix and the issue's hung-tool behavior.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/turn-stall-watchdog

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration.


Comment @coderabbitai help to get the list of available commands.

saucam and others added 3 commits June 29, 2026 03:05
#completeActiveTools() was called in the text_done event handler.
In the real Claude Agent SDK the committed assistant message (which
fires text_done) is emitted BEFORE the user/tool_result message
(which fires tool_complete). This meant any tool still in
#activeToolMsgIds at text_done time was immediately cancelled, then
tool_complete arrived but found the correlation maps cleared — so the
tool stayed permanently "cancelled — interrupted" in the UI.

The prior T8c fix removed the call from tool_start but kept it in
text_done, relying on the wrong assumption that the mock ordering
(tool_complete before text_done) matched the real SDK. The mock
tests passed while the real-world bug persisted.

Fix: remove #completeActiveTools() from text_done entirely. The
#consumeEvents finally block is the correct and sufficient cleanup
point — it runs on every turn end (normal, interrupted, or error)
and only touches tools whose tool_complete never arrived.

Adds T8(d) regression guard: tool_start → text_done → tool_complete
→ turn_done must produce "completed", not "cancelled".

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…turn

When a now-priority message is pushed mid-turn, the SDK ends the current
turn (emitting turn_done, often with isError) before starting the
continuation turn for the injected message. Previously, #consumeEvents
broke on that first turn_done and left the continuation turn unread —
the session went idle/error and the user had to send "please continue"
manually.

The fix adds #pendingMidTurnCount (incremented in #sendInner before each
pushMidTurn call, decremented in #consumeEvents when the intermediate
turn_done is absorbed). When a turn_done arrives with a pending count,
#consumeEvents flushes per-turn state (active tools, assistant stream,
thinking, stale approvals), records partial cost, re-asserts "thinking"
status, and continues the loop instead of breaking. The counter is
always reset to 0 in the finally block and on explicit interrupt() so no
orphaned count can block a future turn. A new T9 integration test drives
a custom TurnRun with pushMidTurn support and verifies the session ends
idle with the continuation message present and no "Error:" system message.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A session could get stuck in "replying" forever with no messages shown
(both Telegram and web UI) when a provider's event stream went silent
without a terminal turn_done — e.g. a Bash command or MCP gateway call
that never returns. #consumeEvents blocked in `for await (run.events)`
with no watchdog, so #activeRun/status never reset; every subsequent
send was then swallowed into the dead run via the wasWorking mid-turn
push path, and the user never got a reply.

This is a latent bug present on main as well (not introduced by the
multi-provider work) — but high severity, since any single hung tool
bricks the session until an explicit interrupt.

Fix:
- Stall watchdog in #consumeEvents: drive the iterator manually and race
  each pull against session.turnStallTimeoutMs (default 300s, 0 = off).
  On silence, force-recover via #recoverStalledRun.
- #recoverStalledRun: flush streaming UI, release pending approvals, drop
  the run, emit a clear "Turn timed out" notice, reset to idle, and hard
  teardown the provider to reap the (presumed hung) subprocess. Does NOT
  route through #teardownProvider (which awaits the session consumer —
  i.e. potentially itself), avoiding self-await deadlock.
- Liveness guard in #sendInner: a send onto an apparently-dead run
  recovers it and starts a fresh turn instead of silently queueing.
- Config: session.turnStallTimeoutMs + CODEOID_TURN_STALL_TIMEOUT_MS.
- MockSessionProvider: opt-in `stall` mode + close-on-teardown (mirrors
  ClaudeProvider) for deterministic offline testing.
- Tests (T9): watchdog recovers + reaps; next send works (no permanent
  wedge); watchdog-off leaves the turn blocked (no false recovery).

Verified: typecheck clean, biome clean, 617/617 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@saucam
saucam force-pushed the fix/turn-stall-watchdog branch from 7304cc5 to f792a91 Compare June 29, 2026 01:06
@saucam
saucam changed the base branch from fix/file-explorer-session-switch to main June 29, 2026 01:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/config.ts`:
- Line 435: The CODEOID_TURN_STALL_TIMEOUT_MS override is currently applied
after RootSchema.safeParse(), so it can bypass the non-negative validation and
disable the stall watchdog in Session. Update the config flow in src/config.ts
so this override is validated with the same non-negative schema used for
session.turnStallTimeoutMs, or re-run config validation after the ENV_OVERRIDES
loop. Make sure the fix is applied in the config parsing path that handles
RootSchema and ENV_OVERRIDES so environment precedence still works without
allowing invalid values.

In `@src/daemon/session.ts`:
- Around line 1625-1637: The stall watchdog in the iterator loop is still racing
`iter.next()` against `stallMs`, which can fire during a valid manual approval
wait after `tool_start`. Update the `session.ts` logic around the
`stall`/`Promise.race` block so the watchdog is paused or bypassed while
awaiting user approval, and only resumes once the approval flow completes. Use
the existing iterator handling in this section to ensure `approve()` waits are
not treated as stalls and do not reset the run.
- Around line 1643-1646: Events from an abandoned run can still be processed
after `#recoverStalledRun`() starts a fresh turn, causing mixed state. In the
event loop inside `#sendInner`, add a run-ownership check before handling
next.value so any late event from the previous run is ignored. Use the existing
run/session identifiers around the iter.next() handling to verify the event
still belongs to the current run before updating `#lastEventAt` or dispatching the
event.
- Around line 1753-1756: The stalled-session message in `#recoverStalledRun`()
should not tell users to resend because when it is reached from `#sendInner` the
current message already continues into a fresh turn. Update the system text
built by this method to use neutral wording that fits both recovery paths, and
adjust the send-path invocation in `#sendInner` so it doesn’t surface a
duplicate-send prompt.

In `@src/tests/session-integration.test.ts`:
- Around line 181-200: The waitForStatus helper can miss a status transition if
session.status changes before the watcher is attached, causing a timeout even
though the target was reached. Update waitForStatus to close this race by
re-checking session.status immediately after session.attach(watcher) (or by
attaching first and then reading state), and resolve/clean up if the target
status is already set. Use the waitForStatus function and
session.attach/session.detach logic to place the fix.
- Around line 1063-1075: The test is checking broadcasts from a client that is
never attached to the session, so received stays empty and the timeout assertion
is meaningless. In session-integration.test.ts, wire the makeClient() result
into the session before sending the turn, using the existing session setup
around makeSession and send so received reflects actual session.message
broadcasts. Then keep the stalledMsg lookup against received to validate that
the disabled watchdog path does not emit a timeout notice.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6d2843f7-b0c1-4576-b6ee-30fc32415d1d

📥 Commits

Reviewing files that changed from the base of the PR and between 21ba97b and f792a91.

📒 Files selected for processing (4)
  • src/config.ts
  • src/daemon/providers/mock/session-provider.ts
  • src/daemon/session.ts
  • src/tests/session-integration.test.ts

Comment thread src/config.ts
Comment thread src/daemon/session.ts Outdated
Comment thread src/daemon/session.ts
Comment thread src/daemon/session.ts
Comment thread src/tests/session-integration.test.ts
Comment thread src/tests/session-integration.test.ts
Four findings from CodeRabbit on PR #47, all valid:

- config: env overrides are applied after RootSchema.safeParse, so
  CODEOID_TURN_STALL_TIMEOUT_MS=-1 bypassed z.number().min(0) and
  silently disabled the watchdog (stallMs > 0 → false). Re-validate the
  merged config through RootSchema after the override loop — also guards
  the autoRotate percentage bounds and every other constrained override.
- watchdog: a pending manual tool approval is a legitimate indefinite
  silent period (provider blocks on canUseTool). Pause the stall race
  while status is waiting_approval / #pendingApprovals is non-empty so a
  slow human approval isn't mistaken for a hung stream.
- consumer: add a run-ownership check before processing an event, so a
  late event from a run abandoned by #sendInner's liveness-guard recovery
  can't leak into the fresh turn.
- message: when #recoverStalledRun is invoked from #sendInner the saved
  message is retried in a fresh turn — say so, instead of telling the
  user to resend (which would duplicate).

Tests: +2 config (override applies; negative rejected) and +1 integration
(watchdog stays paused through a pending approval). 620/620 pass, biome clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 81.07%. Comparing base (21ba97b) to head (4f7a33b).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #47      +/-   ##
==========================================
+ Coverage   80.62%   81.07%   +0.44%     
==========================================
  Files          55       55              
  Lines        7418     7593     +175     
==========================================
+ Hits         5981     6156     +175     
  Misses       1437     1437              
Flag Coverage Δ
daemon 81.07% <100.00%> (+0.44%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/config.ts 89.62% <100.00%> (+0.70%) ⬆️
src/daemon/providers/claude/index.ts 97.36% <100.00%> (+0.08%) ⬆️
src/daemon/providers/mock/session-provider.ts 100.00% <100.00%> (ø)
src/daemon/session.ts 70.20% <100.00%> (+2.83%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

saucam and others added 2 commits June 29, 2026 05:25
- waitForStatus: close the race where the status flips to the target
  between the initial check and session.attach() — re-check current
  status after attaching so the helper can't miss the only broadcast and
  time out.
- Attach the test client in the watchdog-disabled and approval-pause
  tests. `received` was never populated (client created but not
  attached), so the "no timeout notice" assertions passed vacuously even
  if the disabled / paused watchdog path had wrongly emitted one.

620/620 pass, biome clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the SDK signal a hung MCP call instead of relying solely on the
session stall watchdog. The watchdog is a coarse, provider-agnostic
last-resort; the precise fix for the case we actually hit (an
unresponsive MCP gateway) is the SDK's own per-server tool-call timeout.

- Apply session.mcpToolTimeoutMs (default 120000) to external/user MCP
  servers via each server's SDK `timeout`. A hung call now returns an SDK
  error event the turn loop acts on, rather than going silent. Explicit
  per-server timeouts are preserved; the in-process memory server is left
  untouched; 0 disables (use SDK default).
- Default (120s) sits BELOW turnStallTimeoutMs (300s) so the SDK fires
  first and the watchdog stays a true backstop.
- Config: session.mcpToolTimeoutMs + CODEOID_MCP_TOOL_TIMEOUT_MS.
- Tests: withMcpToolTimeout (inject / don't-override / no-op) + config
  (default ordering + env override). 625/625 pass, biome clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/daemon/providers/claude/index.ts (1)

643-648: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Validate the enriched server config with a Zod schema instead of double-casting.

This helper is still operating on runtime-loaded config, but cfg as unknown as Record<string, unknown> and the final cast back to McpServerConfig skip validation entirely. Please run the timeout-enriched object back through the MCP server schema (or extract parseMcpServerConfig into a reusable Zod schema) before returning it. As per coding guidelines, "Use Zod for validation of runtime data and configuration".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/daemon/providers/claude/index.ts` around lines 643 - 648, The server
config enrichment logic in the loop over Object.entries(servers) is bypassing
validation by double-casting through unknown and back to McpServerConfig. Update
this path to validate the timeout-enriched object with the existing MCP server
Zod schema instead of casting, ideally by reusing or extracting the schema
behind parseMcpServerConfig. Keep the runtime behavior the same, but ensure the
final value assigned to out[name] is the parsed/validated result rather than an
unchecked object.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/tests/config.test.ts`:
- Around line 161-173: The timeout-order contract is only covered for defaults,
not env overrides; update the config validation in src/config.ts so the session
schema enforces mcpToolTimeoutMs is always below turnStallTimeoutMs with a
cross-field Zod refinement. Add a regression test in src/tests/config.test.ts
using loadConfig for an invalid override pair that sets
CODEOID_MCP_TOOL_TIMEOUT_MS above turnStallTimeoutMs and asserts validation
fails, while keeping the existing default-order test for the Session config.

---

Nitpick comments:
In `@src/daemon/providers/claude/index.ts`:
- Around line 643-648: The server config enrichment logic in the loop over
Object.entries(servers) is bypassing validation by double-casting through
unknown and back to McpServerConfig. Update this path to validate the
timeout-enriched object with the existing MCP server Zod schema instead of
casting, ideally by reusing or extracting the schema behind
parseMcpServerConfig. Keep the runtime behavior the same, but ensure the final
value assigned to out[name] is the parsed/validated result rather than an
unchecked object.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 507cd6d0-b238-4db1-852c-f2c88b157f07

📥 Commits

Reviewing files that changed from the base of the PR and between a260f6c and 2c59b2e.

📒 Files selected for processing (4)
  • src/config.ts
  • src/daemon/providers/claude/index.ts
  • src/tests/config.test.ts
  • src/tests/provider-claude.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/config.ts

Comment thread src/tests/config.test.ts
CodeRabbit: the "SDK signals first" layering was only locked in by the
default values. An env override / config file could set the MCP timeout
at or above the stall timeout, so the coarse watchdog would force-recover
before the SDK's clean per-tool error fired — silently breaking the
documented contract.

Add a cross-field Zod refinement on SessionSchema. Because env overrides
are re-validated through RootSchema after they're applied, this catches a
bad override pair too, not just file config. Opt-out cases are exempt:
turnStallTimeoutMs=0 (watchdog off → nothing to race) and
mcpToolTimeoutMs=0 (use SDK default → relationship moot). The error
message names the fix (lower one, or set either to 0).

Tests: reject an out-of-order override pair; allow MCP >= stall when the
watchdog is disabled. Adjusted the existing stall-override test to stay
above the 120s MCP default. 627/627 pass, biome clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@saucam
saucam merged commit 8505395 into main Jun 29, 2026
5 checks 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.

Session wedges permanently (stuck "replying", no messages) when provider event stream stalls

1 participant