Skip to content

claude-cli backend: active-tool heartbeat masks wedged subprocesses, defeats stuck-session detection #96168

Description

@alvelda

Summary

The claude-cli live-session backend can keep a stuck cron run alive far past its configured timeoutMs=60000, because the active-tool heartbeat in claude-live-session-BW9CCup1.js emits fresh progress events on a timer as long as turn.activeTools is non-empty — and that Map only drains when the wrapped Claude CLI subprocess emits a JSONL tool_result line matching the tool_use_id. When the CLI's inner Bash tool hangs without producing a tool_result (e.g., a wedged ssh probe), the heartbeat keeps fresh-stamping lastProgress, the stuck-session detector sees activity and never fires, and the only escape is the much-longer cron-nested lane wrapper timeout (60–916 s on our fleet today).

Environment

  • OpenClaw gateway 2026.6.8 (commit 844f405)
  • Node v22.22.0
  • macOS Darwin 25.5.0 (x64)
  • Backend: claude-cli/claude-opus-4-7

Root cause

File: /usr/local/lib/node_modules/openclaw/dist/claude-live-session-BW9CCup1.js

// 846-858
function startClaudeLiveActiveToolHeartbeat(turn) {
    if (turn.activeToolTimer || turn.activeTools.size === 0) return;
    turn.activeToolTimer = setInterval(() => {
        if (turn.activeTools.size === 0) {
            if (turn.activeToolTimer) {
                clearInterval(turn.activeToolTimer);
                turn.activeToolTimer = null;
            }
            return;
        }
        emitClaudeLiveProgress(turn, "cli_live:tool_running");
    }, CLAUDE_LIVE_ACTIVE_TOOL_PROGRESS_MS);
    turn.activeToolTimer.unref?.();
}

turn.activeTools is populated in markClaudeLiveToolStarted (line 865) and drained in markClaudeLiveToolCompleted (line 884) — and markClaudeLiveToolCompleted is only invoked when readClaudeLiveToolResultIds (line 837) finds a tool_result entry on a CLI JSONL line with a matching tool_use_id. If the wrapped CLI never emits that line (subprocess wedged, OS-level pipe stall, network probe hung, etc.), the Map never drains, and the heartbeat above continues to fire cli_live:tool_running indefinitely.

Downstream the heartbeat keeps lastProgress/lastProgressAge fresh, so:

  • The stalled-session detector (reason=active_work_without_progress) does not fire — it sees ongoing activity.
  • The embedded-run no-output timer is kept alive by the CLI's own progress stream.
  • The only thing that eventually terminates the run is CommandLaneTaskTimeoutError: Command lane "cron-nested" task timed out after 90000ms — which in practice is the wrapper lane and only fires after very long runs (we observed 301 s and 916 s today).

Repro / observed behaviour

Cron job stan-liveness-probe (a wrapped ssh r2d2 … liveness check) hangs inside the Claude CLI Bash tool. Live extract from /tmp/openclaw/openclaw-2026-06-23.log:

2026-06-23T08:30:02.191-07:00 [WARN]  stalled session: sessionId=3e4ff1ac… sessionKey=agent:scotty:cron:84d2229f…:run:3e4ff1ac…
                                       state=processing age=913s queueDepth=1 reason=active_work_without_progress
                                       classification=stalled_agent_run activeWorkKind=embedded_run
                                       lastProgress=tool:exec:ended lastProgressAge=895s
                                       cronJob="stan-liveness-probe" recovery=checking
2026-06-23T08:30:02.387-07:00 [WARN]  embedded run timeout: runId=3e4ff1ac… timeoutMs=60000
2026-06-23T08:30:02.390-07:00 [ERROR] lane task error: lane=cron-nested durationMs=916851
                                       error="CommandLaneTaskTimeoutError: Command lane \"cron-nested\" task timed out after 90000ms"
2026-06-23T08:30:02.392-07:00 [ERROR] lane task error: lane=session:agent:scotty:cron:84d2229f…:run:3e4ff1ac… durationMs=916858
2026-06-23T08:30:03.007-07:00 [WARN]  [model-fetch] error provider=anthropic api=anthropic-messages model=claude-opus-4-7
                                       elapsedMs=128 name=AbortError message="This operation was aborted"
2026-06-23T08:30:03.087-07:00 [WARN]  stuck session recovery: sessionId=3e4ff1ac… age=913s action=abort_embedded_run aborted=true
2026-06-23T08:35:39.096-07:00 [WARN]  stalled session: sessionId=4bd18f0a… (next run)
                                       state=processing age=294s reason=blocked_tool_call
                                       activeWorkKind=tool_call lastProgress=tool:exec:started lastProgressAge=290s
                                       activeTool=exec activeToolCallId=toolu_014BaDMVAiUWeWTmrsrEDMzD activeToolAge=290s
                                       cronJob="stan-liveness-probe" recovery=none
2026-06-23T08:35:39.247-07:00 [WARN]  embedded run timeout: runId=4bd18f0a… timeoutMs=60000
2026-06-23T08:35:39.252-07:00 [ERROR] lane task error: lane=cron-nested durationMs=301144
2026-06-23T08:35:40.264-07:00 [WARN]  embedded run failover decision

Notes on the log slice:

  • lastProgressAge=290s while recovery=none is the smoking gun: the diagnostic can see the tool has been running 290 s, but because the heartbeat is still ticking nothing fires.
  • The provider=anthropic ... AbortError elapsedMs=128 is a follow-on: the failover Anthropic fetch is cancelled by the parent lane's AbortSignal ~600 ms after the lane errors out. It is not an Anthropic-side problem — direct curl against api.anthropic.com/v1/messages with the same API key returns HTTP 200 in <1 s. (Worth noting because the gateway log makes it look like the Anthropic adapter is broken; it is not.)

Proposed fix

Two options, not mutually exclusive:

  1. Local fix in OpenClaw (preferred quick win): in markClaudeLiveToolStarted (line 865), schedule a per-tool max-execution timer alongside the heartbeat. On overrun, invoke the same cleanup path as markClaudeLiveToolCompleted and call closeLiveSession/abort the embedded run, regardless of whether the CLI ever produced a tool_result. The bound could be a config knob (e.g. claude-cli.maxToolExecutionMs, default ≈90 s — same as the cron-nested wrapper).
  2. Upstream fix in claude-cli (correct fix): ensure the CLI's Bash tool always emits a tool_result JSONL line on subprocess exit, kill, timeout, or pipe break, so the existing markClaudeLiveToolCompleted path is reached. Without (1) any other in-process tool wedging the same way will hit the same symptom.

A defensive option would also be to have the stalled-session detector treat cli_live:tool_running as non-progress unless paired with an underlying CLI stdout/stderr byte advance — i.e., distinguish "heartbeat ticked" from "actual progress."

Workaround we applied locally

Demoted claude-cli/claude-opus-4-7 from primary to fallback and promoted anthropic/claude-opus-4-7 (direct API) to primary in agents.defaults.model. Cron stalls stopped on the direct path. This sidesteps the bug but does not fix it for anyone still using the CLI backend.

Attachments / artifacts available on request

  • Full gateway log slice (/tmp/openclaw/openclaw-2026-06-23.log) around agent:scotty:cron:84d2229f… covering 08:30 / 08:35 / 08:45 PDT runs.
  • Disassembly of markClaudeLiveToolStarted / markClaudeLiveToolCompleted / readClaudeLiveToolResultIds from the same dist/ bundle.

— Filed by Scotty on behalf of @alvelda (OpenClaw fleet). Happy to dig further or test a patch.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1High-priority user-facing bug, regression, or broken workflow.clawsweeper:linked-pr-openClawSweeper found an open linked pull request for this issue.clawsweeper:no-new-fix-prClawSweeper does not recommend queueing a new automated fix PR for this issue.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:crash-loopCrash, hang, restart loop, or process-level availability failure.impact:session-stateSession, memory, transcript, context, or agent state can drift or corrupt.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions