fix(agents): add tool-activity heartbeat to keep subagent alive during tool calls#95536
Conversation
|
Codex review: needs maintainer review before merge. Reviewed July 11, 2026, 5:22 AM ET / 09:22 UTC. Summary PR surface: Source +134, Tests +361. Total +495 across 7 files. Reproducibility: yes. Current-main source shows the watchdog lacks tool activity, and the PR supplies a real after-fix run that crosses the 120-second boundary; the review did not independently execute the failing current-main scenario. Review metrics: none identified. Stored data model Root-cause cluster Members:
Proposal only: this assessment does not dispatch repair, suppress jobs, mutate sibling items, close, or merge anything. Merge readiness Overall follows the weaker of proof and patch quality, so missing proof can cap an otherwise strong patch. Rank-up moves:
Risk before merge
Maintainer options:
Next step before merge
Security Review detailsBest possible solution: Land this single run-scoped heartbeat implementation as the canonical fix after the two pending exact-head shards complete, preserving the absolute run timeout and closing the competing implementation path. Do we have a high-confidence way to reproduce the issue? Yes. Current-main source shows the watchdog lacks tool activity, and the PR supplies a real after-fix run that crosses the 120-second boundary; the review did not independently execute the failing current-main scenario. Is this the best way to solve the issue? Yes. A run-scoped signal at the shared ordinary and deferred tool execution boundaries is narrower and more complete than the competing completion-only approach, while retaining the existing absolute run budget. AGENTS.md: found and applied where relevant. Codex review notes: model internal, reasoning high; reviewed against 72cf43fa8092. Label changesLabel justifications:
Evidence reviewedPR surface: Source +134, Tests +361. Total +495 across 7 files. View PR surface stats
What I checked:
Likely related people:
What the crustacean ranks mean
Shiny media proof means a screenshot, video, or linked artifact directly shows the changed behavior. Runtime, network, CSP, and security claims still need visible diagnostics. How this review workflow works
Review history (3 earlier review cycles) |
|
Complementary observations on top of ClawSweeper's review: The WeakMap-based tool-activity heartbeat is the right shape — it avoids the global-state bugs that come with module-level flags. A few observations:
|
…ng, add wrapper-level persistence, wrap all tool executes - Move heartbeat to src/shared/ with simple pub/sub (no WeakMap keyed by signal) to fix the signal-mismatch between subscription and notification - Add wrapper-level lastToolActivityMs in llm-idle-timeout for cross-turn persistence, so tool completions between LLM turns reset the next idle timer - Subscribe armTimer per-stream for immediate reset; recordToolActivity at wrapper level for between-turn carry-over - Wrap all effectiveTools execute functions with notifyToolActivity to cover direct-tool paths (read, exec, web_fetch, etc.), not just cataloged tools - Update tests: heartbeat tests use new callback API; idle-timeout tests cover cross-turn persistence and effective-timeout accounting Addresses ClawSweeper P1 findings on PR openclaw#95536
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
The onToolActivity subscription is intentionally persistent at the stream wrapper level to bridge tool activity across provider-stream boundaries. The return value was never captured with intent to call it — drop the binding to satisfy noUnusedLocals. Related to openclaw#95536
…ocess-global Replace the process-global listener set with per-run scoped maps so that a tool completion on Run B cannot reset the idle watchdog of Run A. The last-activity timestamp is also per-run, stored in a shared Map that notifyToolActivity(runId) updates and streamWithIdleTimeout reads via getLastToolActivityMs(runId). Related to openclaw#95536
… state Per-run listener sets and last-activity timestamps had no cleanup path, leaking memory for every run id that passes through the heartbeat. Add clearToolActivityRun(runId) that removes both listeners and the timestamp entry for a given run. Related to openclaw#95536
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
17c273c to
7ebb696
Compare
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
|
@xydt-tanshanshan — I left a parallel comment on #94133 flagging that their 3 P1 blockers (signal-key mismatch, dropped between-stream activity, cataloged-only coverage) are all addressed by your runId-scoped design here. I think #95536 is the stronger candidate to clear the issue. Quick context: your patch already has the architectural pieces ClawSweeper keeps pointing at — If useful, I can draft a small repro harness script for you (Testbox-style or local Node) that:
That gives you redacted terminal output / log artifact for the PR body's I won't open a competing PR — would rather see one of these two land cleanly. Just say the word and I'll draft the harness against your branch. (Side note: NianJiuZst's earlier comment on this PR also flagged the cataloged-only coverage gap — your wrap of the shared |
Hey, thanks for the detailed analysis and the offer — much appreciated. A repro harness would be great. If you're willing to draft it, I'll run it against my branch and include the terminal output in the PR body's Real behavior proof section. I'll keep the script outside the openclaw repo (in my local contributions directory). Re: the cataloged-only coverage gap — good callout. The effectiveTools execute wrapper already addresses that, I'll call it out explicitly in the PR body update when I add the proof. |
|
@clawsweeper re-review |
1 similar comment
|
@clawsweeper re-review |
|
🦞🧹 I asked ClawSweeper to review this item again. |
…remature subagent idle timeout Subagent LLM idle watchdog tripped during long-running tool calls because tool completions did not reset the idle timer. Implement a per-run tool-activity heartbeat: 1. notifyToolActivity(runId) — published from embedded-runner tool execution wrappers (attempt.ts) whenever a tool completes 2. onToolActivity(runId, listener) — consumed by streamWithIdleTimeout to reset the idle watchdog on tool completion 3. getLastToolActivityMs(runId) — shared timestamp Map so pre-stream activity is visible to armTimer before the first stream iteration 4. clearToolActivityRun(runId) — wired into the run lifecycle finally block to clean up listener sets and timestamps when the run exits Scoped per-run via runId-keyed Maps to prevent concurrent runs from resetting each other's idle watchdogs. Related to openclaw#94124
…heartbeat tool wrappers
…rtbeat tool wrapper
Fire notifyToolActivity at tool start and every 60s via setInterval so long-running tools survive the 120s LLM idle watchdog. Co-Authored-By: Claude <[email protected]>
Co-Authored-By: Claude <[email protected]>
Adds copyCodeModeControlToolIdentity() to replicate the WeakSet membership that markCodeModeControlTool() sets. Without this, the heartbeat wrapper drops Code Mode identity for exec/wait tools, weakening before_tool_call policy and approval handling. Co-Authored-By: Claude <[email protected]>
resolveDeferredTool hydrates hidden catalog tools outside the effectiveTools.map() wrapper, so they bypassed the periodic notifyToolActivity heartbeat. Wrap the hydrated execute function with the same start/interval/complete notification pattern. Co-Authored-By: Claude <[email protected]>
What Problem This Solves
Subagent sessions are killed by the 120s LLM idle timeout while performing long-running tool calls, even though the subagent is actively collecting data and within its total
runTimeoutSecondsbudget. The LLM idle watchdog resets only on token arrival — tool completions do not signal it.Summary
streamWithIdleTimeout) only resets on LLM token arrival. During subagent tool calls (web_fetch, read, exec, etc.), the LLM stream is idle, so a subagent performing multi-minute data collection is killed at 120s despite being well within its 900s total run budget.notifyToolActivity(runId)— published at tool start, every 60s during execution viasetInterval, and on tool completion (success + error)onToolActivity(runId, listener)— consumed bystreamWithIdleTimeoutto reset idle watchdog on tool activitygetLastToolActivityMs(runId)— shared timestamp Map, visible toarmTimerbefore first stream iterationclearToolActivityRun(runId)— wired into run lifecyclefinallyblock (success, error, abort, cancel, restart)streamFirstArmDoneguard — pre-stream tool timestamp consumed once on the first bridged waitsrc/shared/tool-activity-heartbeat.ts(new) — per-run Maps for listeners + timestamps + cleanupsrc/agents/embedded-agent-runner/run/tool-activity-heartbeat.ts(new) — barrel re-exportsrc/agents/embedded-agent-runner/run/tool-activity-heartbeat.test.ts(new) — 11 testssrc/agents/embedded-agent-runner/run/llm-idle-timeout.ts— acceptrunId, heartbeat subscription, stale timestamp guard,streamFirstArmDonesrc/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts— 81 testssrc/agents/embedded-agent-runner/run/attempt.ts— wrapeffectiveToolswithnotifyToolActivity(start + 60s interval + complete),clearToolActivityRuninfinallysrc/agents/code-mode-control-tools.ts—copyCodeModeControlToolIdentityto preserve Code Mode tool marker through the wrapperChange Type
Scope
Linked Issue/PR
Motivation
When a subagent performs tool calls that take minutes, the LLM stream is idle. The 120s idle timeout kills the session even though the subagent is actively gathering data. The per-run scoping prevents concurrent runs from resetting each other's idle watchdogs, and the lifecycle cleanup prevents memory leaks in long-running gateways.
The fix chain:
notifyToolActivity(runId)on tool start + every 60s →onToolActivitycallback fires →armTimerresets the idle timer → watchdog stays satisfied. This is the exact production boundary atllm-idle-timeout.ts:327:onToolActivity(runId, armTimer).Real behavior proof (required for external PRs)
Behavior addressed: Subagent running a 130s tool call survives past the old 120s LLM idle-timeout boundary. The heartbeat fires at tool start, every 60s during execution, and on completion, preventing the watchdog from killing the subagent.
Real environment tested: Linux x86_64, Node v24.13.1, OpenClaw 2026.6.9 (commit
41558c1), branchfix/subagent-tool-heartbeat-94124, ZTE Qwen3-235B-A22B providerExact steps or command run after this patch:
Observed result after fix:
sleep 130(longer than old 120s idle window) completed successfully withstopReason=stop— no LLM idle timeout errortimeoutMs=120000was active during the subagent runnotifyToolActivityfired at tool start + every 60s viasetInterval+ on completion, resetting the idle timer each timeLONG_PROOF_OK, confirming the tool executed to completionWhat was not tested: The BEFORE behavior (subagent killed at 120s on current main) is documented in fix: Reset subagent session idle timeout on successful tool calls to prevent premature termination #94124 with multiple user reports across versions.
Root Cause
The LLM idle timeout watchdog in
streamWithIdleTimeoutobserved only LLM request activity (onLlmRequestActivity). No tool-completion path reset the watchdog. A single long-running tool (>120s) emitted no activity until after the watchdog expired.Regression Test Plan
src/agents/embedded-agent-runner/run/tool-activity-heartbeat.test.ts,llm-idle-timeout.test.tsUser-visible / Behavior Changes
Subagents executing long-running tool calls will no longer be killed mid-execution with "LLM idle timeout (120s): no response from model".
Security Impact (required)
Human Verification (required)
armTimerreset via heartbeat chain; per-run scoping; lifecycle cleanup; Code Mode identity preservation through wrapperCompatibility / Migration
Best-fix Verdict
notifyToolActivity→onToolActivity→armTimer(line 327). Periodic heartbeat viasetInterval(60s)handles single long tools. Code Mode identity preserved through wrapper.clearToolActivityRuninfinallyprevents Map leaks.AI Assistance 🤖 (required for AI-assisted PRs)
Risks and Mitigations
setIntervaladds a 60s periodic callback per tool execution. Overhead is one function call every 60s — negligible.interval.unref()prevents the timer from keeping the process alive.clearInterval(interval)+clearToolActivityRun(runId)in thefinallyblock ensures no leaks.Closes #94124