Skip to content

fix: stall watchdog no longer kills long-running tools - #104

Merged
saucam merged 2 commits into
mainfrom
fix/stall-watchdog-long-tools
Jul 5, 2026
Merged

fix: stall watchdog no longer kills long-running tools#104
saucam merged 2 commits into
mainfrom
fix/stall-watchdog-long-tools

Conversation

@saucam

@saucam saucam commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

The bug (user-reported workflow breaker)

The 300s turn-stall watchdog treats any provider-event silence as a wedged turn. Its core assumption — written in the code as "long-running tools still emit tool_progress / partial events" — is empirically false: a multi-minute Bash run, a Task subagent exploring a large codebase, or web research emits nothing until it completes. Result: actively-working sessions were force-recovered mid-task — subprocess reaped, tool calls cancelled, "Turn timed out — no activity for 300s. The session was reset".

Two paths fixed (shared #watchdogPaused() gate)

Silence is now only a stall signal while the model should be producing output (status thinking). The gate pauses during tool_running, waiting_approval, and pending approvals.

  1. #consumeEvents watchdog — the subtle part: the pause is re-checked when the timer fires, not just when it's armed. The timer arms while status is still thinking; a tool_start landing right after arming previously left the old timer counting down over a legitimately-silent tool execution and killing it the moment the window lapsed. (The debug trace showed exactly this: recovery fired while status was tool_running.) The in-flight iterator pull now persists across loop iterations, so the paused re-check loops back to re-await the same pull — no concurrent iter.next() against the queue.
  2. #sendInner liveness guard — sending a message into a >stallMs-silent run ("how's it going?" during a long exploration) force-recovered the run before queuing. It now respects the same gate: mid-tool sends queue mid-turn instead of killing in-flight work. (This guard also previously ignored pending approvals — sending during a >5min-unanswered approval prompt killed the run too.)

Wedge protection is NOT lost

  • Silence while thinking still recovers (existing T9 tests unchanged, green).
  • MCP calls keep their finer mcpToolTimeoutMs (fires first by config invariant); SDK built-ins carry their own tool-level timeouts.
  • A dead subprocess closes the event stream → the iterator ends → the turn recovers regardless of pause state.
  • The user can always interrupt a genuinely hung tool.

Tests

  • Long-silent tool execution survives past the stall window — this test fails on pre-fix code (recovery fired at window-lapse due to the arming race).
  • Mid-tool send is not treated as a stall (no "timed out … retried" recovery; note: the mock lacks pushMidTurn, so the assertion targets the stall-recovery signal specifically — the real ClaudeProvider queues mid-turn and the run survives).
  • Existing wedge-recovery / watchdog-disabled / approval-pause tests unchanged.
  • Full suite 833 pass, typecheck + biome clean.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved stall-watchdog behavior so expected event-stream silence no longer triggers stalled-run recovery.
    • Ensured long-running tool execution and pending approvals aren’t interrupted by timeouts, improving session liveness during mid-run user sends.
  • Documentation
    • Clarified the meaning of “silence” for the stall watchdog and explicitly documented when stall detection is paused.
  • Tests
    • Added regression tests covering stalled-run recovery avoidance during long-running tools and correct behavior when new messages arrive mid tool execution.

The 300s turn-stall watchdog treated ANY provider-event silence as a wedged
turn, but its core assumption ("long-running tools still emit tool_progress /
partial events") is false: a multi-minute Bash run, Task subagent, or web
research emits NOTHING until it completes. The watchdog was force-recovering
actively-working sessions — tearing down the subprocess, cancelling tool
calls, and telling the user "Turn timed out — no activity for 300s".

Two distinct paths had the bug:

1. #consumeEvents watchdog: silence is now only a stall signal while the
   MODEL should be producing output. A shared #watchdogPaused() gate (status
   tool_running, waiting_approval, or pending approvals) pauses it. Crucially
   the pause is re-checked WHEN THE TIMER FIRES, not just when it is armed —
   the timer arms while status is still "thinking", and a tool_start landing
   right after arming previously left the old timer counting down over a
   legitimately-silent tool execution. The in-flight iterator pull now
   persists across loop iterations so the paused re-check can loop back and
   re-await the same pull (no concurrent iter.next()).

2. #sendInner liveness guard: sending a message into a >stallMs-silent run
   (e.g. "how's it going?" during a long exploration) force-recovered the run
   before queuing. It now respects the same pause gate — mid-tool sends queue
   mid-turn instead of killing in-flight work.

Wedge protection is NOT lost: silence while thinking still recovers, MCP
calls have their own finer timeout (mcpToolTimeoutMs), SDK built-ins carry
tool-level timeouts, a dead subprocess closes the event stream (ending the
iterator), and the user can always interrupt.

Tests: long-silent tool execution survives past the stall window (regression
for the arming race — this test fails on the pre-fix code); mid-tool send is
not treated as a stall; existing T9 wedge-recovery / disabled-watchdog /
approval-pause tests unchanged and green. Full suite 833 pass.

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

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown

Review Change Stack

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: 0e12d67a-ecdb-49b6-9ca8-c945a732a26c

📥 Commits

Reviewing files that changed from the base of the PR and between 7d9654a and 0b47633.

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

📝 Walkthrough

Walkthrough

The session stall watchdog now pauses recovery during tool execution, waiting approval, and pending approvals through a new #watchdogPaused() check in Session. Config comments for turnStallTimeoutMs were updated, and integration tests were added for the pause behavior.

Changes

Stall Watchdog Pause Behavior

Layer / File(s) Summary
Watchdog pause implementation in Session
src/daemon/session.ts
Adds #watchdogPaused() and applies it in #sendInner and #consumeEvents to suppress stall recovery during tool execution, waiting approval, and pending approvals, while refining async iterator pull handling.
Config documentation updates
src/config.ts
Rewords turnStallTimeoutMs comments in SessionSchema and CodeoidConfig to clarify silence semantics and pause conditions during tool execution and pending approvals.
Integration tests for watchdog pause
src/tests/session-integration.test.ts
Adds tests confirming no stall recovery occurs during long-running tool execution or when send() arrives mid-tool-execution.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Session
  participant Watchdog as Stall Timer
  participant Tool as Tool Execution
  Client->>Session: send()
  Session->>Tool: start tool
  Tool-->>Session: tool_running / silence
  Watchdog->>Session: stall timeout fires
  Session->>Session: `#watchdogPaused`()
  alt paused
    Session-->>Watchdog: continue waiting
  else not paused
    Session->>Session: `#recoverStalledRun`()
  end
Loading

Possibly related PRs

  • saucam/codeoid#47: Shares the same session stall watchdog flow in #consumeEvents and #sendInner, including timeout and recovery behavior.
🚥 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 clearly matches the main change: the stall watchdog no longer treats long-running tools as stalled.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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/stall-watchdog-long-tools

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.

@codecov

codecov Bot commented Jul 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 76.35%. Comparing base (d5a12b3) to head (0b47633).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #104      +/-   ##
==========================================
+ Coverage   76.34%   76.35%   +0.01%     
==========================================
  Files          70       70              
  Lines       11539    11547       +8     
==========================================
+ Hits         8809     8817       +8     
  Misses       2730     2730              
Flag Coverage Δ
daemon 76.35% <100.00%> (+0.01%) ⬆️

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% <ø> (ø)
src/daemon/session.ts 75.22% <100.00%> (+0.12%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@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.

🧹 Nitpick comments (1)
src/tests/session-integration.test.ts (1)

1289-1333: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Strengthen assertion to catch duplicate/overlapping turns.

This test's own comment acknowledges "the mock lacks pushMidTurn, so the send legitimately starts a fresh turn" — but nothing here verifies that starting that fresh turn didn't leave the original tool-executing run orphaned. Asserting only on the absence of a "timed out/stalled" message wouldn't catch the scenario described in the session.ts #sendInner review comment (a second runTurn() call on the same provider while the first run/queue is never closed).

Consider adding expect(provider.capturedOpts.length).toBe(1) (or asserting on provider.teardownCount/queue state) to make an unintended duplicate turn fail loudly instead of passing silently.

🤖 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/tests/session-integration.test.ts` around lines 1289 - 1333, Strengthen
the session integration test in the "a send arriving mid-long-tool queues
instead of killing the run (liveness guard pauses too)" case so it also detects
duplicate or overlapping turns, not just stalled-message absence. Update the
test around session.send, makeSession, and MockSessionProvider assertions to
verify only one provider turn/run is active or started (for example via
provider.capturedOpts length, teardownCount, or equivalent queue state) so a
second runTurn on the same provider cannot silently orphan the original
tool-executing run.
🤖 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.

Nitpick comments:
In `@src/tests/session-integration.test.ts`:
- Around line 1289-1333: Strengthen the session integration test in the "a send
arriving mid-long-tool queues instead of killing the run (liveness guard pauses
too)" case so it also detects duplicate or overlapping turns, not just
stalled-message absence. Update the test around session.send, makeSession, and
MockSessionProvider assertions to verify only one provider turn/run is active or
started (for example via provider.capturedOpts length, teardownCount, or
equivalent queue state) so a second runTurn on the same provider cannot silently
orphan the original tool-executing run.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 191e1f4a-84cd-4127-9ca8-c0aa230313f9

📥 Commits

Reviewing files that changed from the base of the PR and between d5a12b3 and 7d9654a.

📒 Files selected for processing (3)
  • src/config.ts
  • src/daemon/session.ts
  • src/tests/session-integration.test.ts

Address CodeRabbit review on #104: asserting only the absence of a
"timed out" message could let an overlapping-turn regression pass
silently. Now also pins exactly two runTurn() invocations (first run +
the send's fresh turn, no third) and zero forced provider teardowns —
the stall-recovery path calls provider.teardown(), so the old bug shows
teardownCount >= 1 here. Values verified deterministic across runs.

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

saucam commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator Author

Applied CodeRabbit's nitpick in 0b47633: the mid-tool-send test now pins the post-send provider state — exactly two runTurn() invocations (the original run + the send's fresh turn) and teardownCount === 0, which is the key discriminator: the old stall-recovery path called provider.teardown(), so a regression fails loudly instead of passing on message-absence alone. Values verified deterministic across repeated runs. Full suite 833 pass.

@saucam
saucam merged commit e7e9369 into main Jul 5, 2026
5 checks passed
@saucam saucam mentioned this pull request Jul 6, 2026
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