You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Agent runs today can hang without bound, die without a user-visible outcome, and strand their session's queued messages until a gateway restart. These are not independent bugs: ~160 tracker reports cluster into a small set of structural gaps in how we own a run's lifecycle. This issue proposes the long-term design — four invariants and four implementation tracks — that retires the class instead of patching instances.
Problem to solve
Users report two experiences, both costing us real deployments to competing frameworks:
"Long multi-step tasks hang partway and never complete; I have to handhold."
"I come back to a session and the agent died on a turn; everything I sent since is silently queued."
Cross-referencing the open tracker with a source audit, these decompose into three root causes:
Root cause 1 — stall detection is heuristic and self-defeating, and detection ≠ recovery.
Config footgun: timeoutSeconds: 0 (the natural setting for long tasks) maps the run budget to MAX_TIMER_TIMEOUT_MS, which resolveLlmIdleTimeoutMs treats as "disable the LLM idle watchdog entirely" (src/agents/embedded-agent-runner/run/llm-idle-timeout.ts, runTimeoutMs >= MAX_TIMER_TIMEOUT_MS → 0). "Unlimited budget" silently means "zero stall protection".
Root cause 2 — zero durability: a process death loses the in-flight turn and every queued followup, with no resume.
The unhandled-rejection policy exits the process for any unclassified rejection (src/infra/unhandled-rejections.ts, final branch → exitWithTerminalRestore → process.exit(1)), and the turn pipeline contains many fire-and-forget promises. One stray rejection anywhere kills the gateway.
A deterministically-throwing queued item is never removed and is rescheduled forever — drainNextQueueItem removes the item only after run(next) resolves, and there is no per-item failure cap (src/utils/queue-helpers.ts, src/auto-reply/reply/queue/drain.ts).
Several terminal paths (fallback exhaustion, some abort routes) log without messaging the user, so honest deaths read as ghosting.
Proposed solution
Four invariants, then four tracks that implement them. The invariants are the contract; every track is judged against them.
I1 — Bounded liveness. Every run reaches a terminal state in bounded time, where "time" is measured as gap since last real forward-progress event, never raw wall clock and never synthetic heartbeats. I2 — Terminal ⇒ released + reported. Reaching a terminal state atomically releases the session lane/locks and emits exactly one user-visible outcome (or an explicitly-silent one for room_event/silent modes). No path may terminate a run without doing both. I3 — Restart-safe. The followup queue and a minimal turn-in-progress marker are persisted; boot reconciles interrupted runs into a resumable state instead of stranding them. I4 — Early stop ≠ done. A model stopping prematurely triggers bounded continuation, not silent acceptance.
Track A — single run-lifecycle owner (abort ⇒ release)
Give each reply run one closed state machine: admitted → running → terminal(completed | failed | aborted | timeout | interrupted), extending the existing terminal-outcome normalization (src/agents/agent-run-terminal-outcome.ts) rather than adding a parallel concept.
Lane/lock/admission release becomes a side effect of the terminal transition, performed in exactly one place. Today's scattered finally releases (followup-runner.ts, dispatch-from-config.ts) become assertions that the transition already ran. This makes released=0 structurally impossible: an abort that reaches terminalis the release.
Admission gains an evidence-staleness takeover: a "running" operation whose activity stamp (Track B) is older than the takeover threshold is reclaimable, extending the existing terminalRecovery path which today only covers already-terminal leftovers.
One watchdog poller compares now - lastEvent against a single phase-aware threshold table: model-stream gap, tool-execution gap, per-reasoning-model floors (thinking models get minutes, not 120s). timeoutSeconds: 0 keeps the budget unlimited but must never disable liveness — fix the llm-idle-timeout coupling.
On breach: warn the user first ("no activity for N min in tool X"), then interrupt through Track A's terminal transition — so recovery is guaranteed to release and report, with a diagnostic naming the stuck phase/tool instead of "aborted by user".
Move the followup queue and a minimal turn_in_progress marker into the shared state DB (state/openclaw.sqlite) per the repo storage mandate — the in-memory global map is existing migration debt.
Boot reconcile: runs marked running at startup transition to interrupted (Track A ⇒ lane released, outcome recorded), then the session receives one synthesized continuation turn ("previous turn was interrupted; do not re-execute completed tool calls"), bounded by a restart-loop guard (e.g. 3 resume-boots/60s → suspend and notify).
Rescope the unhandled-rejection policy: rejections attributable to an owning run fail that run terminally (Track A ⇒ release + notify) instead of process.exit(1); process exit remains only for truly unattributable/fatal states. The current classify-or-die policy makes every unannotated fire-and-forget promise a gateway-wide kill switch.
Never-silent terminals: every terminal outcome produces a user-visible message unless the turn is explicitly silent (room_event/silent modes). Audit the suppressing branches in followup-runner.ts (fallback exhaustion, abort routes).
Bounded continuation nudges: when the model replies with a pure intent acknowledgement ("I'll do X") without tool calls mid-task, inject a bounded continue nudge (cap ~2/turn). Deliberately small and capped — this is a re-prompt, not an autonomy loop.
Poison-item cap: per-item failure counter in the followup queue; after N consecutive failures drop the item with a user-visible notice instead of rescheduling forever.
Sequencing
A → B → C → D. A alone (abort ⇒ release + staleness takeover) removes the biggest amplifier and is small. B removes both false positives and false negatives at the root and deletes three ad-hoc mechanisms. C is the largest but rides existing SQLite/doctor machinery. D is independent polish that closes the loop on "works through to completion". Each track is separately PR-able and separately testable against the invariants.
Per-provider/per-tool timeout tuning. Doesn't help: the field data shows both over-firing (killing healthy long calls) and under-firing (fake heartbeats) from the same heuristics. The fix is what counts as evidence of liveness, not the constants.
Full workflow/checkpoint engine. Overkill for the reported pain. I3 needs a queue + one marker + a continuation turn, not a durable-execution framework.
Impact
Directly addresses the two most-reported reliability complaints (hung multi-step tasks; dead sessions with silently-queued messages) — the top reason cited by users actively migrating to competing frameworks.
Consolidates ~40+ open issues across six clusters into four owned tracks; each track lists the issues it retires so they can be closed with proof as tracks land.
Deletes code and concepts on net: three liveness mechanisms → one, scattered lane releases → one transition, in-memory queue → the mandated SQLite store.
Evidence/examples
Cluster → canonical open issues (non-exhaustive; full sweep covered ~160):
src/infra/unhandled-rejections.ts — final unclassified branch exits the process.
src/agents/embedded-agent-runner/run/llm-idle-timeout.ts — runTimeoutMs >= MAX_TIMER_TIMEOUT_MS returns 0 (idle watchdog disabled when budget is "unlimited").
src/auto-reply/reply/reply-run-registry.ts — takeover restricted to terminal leftovers; no staleness reclaim of a hung "running" op.
src/auto-reply/reply/queue/state.ts — FOLLOWUP_QUEUES in-memory global map; no persistence.
src/utils/queue-helpers.ts + src/auto-reply/reply/queue/drain.ts — item removed only after successful run; no per-item failure cap.
Additional information
Prior art: competing agent frameworks (e.g. Hermes) implement exactly this shape — event-stamped activity heartbeats with per-reasoning-model floors, staged user warning before interrupt, SQLite task claims with lease TTL + dead-worker reclaim, resume_pending sessions that receive a synthesized continuation turn on boot, and bounded anti-premature-stop nudges. Their users cite "never drops a task" as the primary switching reason; this design closes that gap with mechanisms consistent with our architecture rules (SQLite-first state, one canonical path, doctor-owned migration).
Summary
Agent runs today can hang without bound, die without a user-visible outcome, and strand their session's queued messages until a gateway restart. These are not independent bugs: ~160 tracker reports cluster into a small set of structural gaps in how we own a run's lifecycle. This issue proposes the long-term design — four invariants and four implementation tracks — that retires the class instead of patching instances.
Problem to solve
Users report two experiences, both costing us real deployments to competing frameworks:
Cross-referencing the open tracker with a source audit, these decompose into three root causes:
Root cause 1 — stall detection is heuristic and self-defeating, and detection ≠ recovery.
lastProgresswhile the tool subprocess is wedged, defeating the detector (claude-cli backend: active-tool heartbeat masks wedged subprocesses, defeats stuck-session detection #96168). On the Codex app-server, the idle watchdog is gated onactiveAppServerTurnRequests > 0, so a poisoned client is exactly the state it cannot see ([Bug]: Codex app-server session wedges indefinitely after a 429 rate-limit burst — terminal-idle watchdog still gated byactiveAppServerTurnRequests > 0(regression/incomplete fix of #82681) #89742, regression of Telegram session wedges indefinitely when codex turn-completion idle watchdog is gated by stuck activeAppServerTurnRequests counter #82681).release_lane released=0([Bug]: Stuck-session recovery never releases: status=aborted with released=0 on every event — wedges survive five correct-target aborts (~14h) and four ghost-target aborts (~16h) until an external restart or user /reset [2026.5.28] #92270, Conversational agent lane wedges indefinitely: ingress events never reconcile (re-delivery storm on reconnect) + abort_embedded_run leaves owner lock unreleased (released=0) #97538, [Bug]: Session write-lock held beyond run lifetime when agent run fails during tool execution #100872, Isolated cron lanes leak on claude-cli backend: queued_work_without_active_run → release_lane released=0, lanes accumulate until restart #89766).admitReplyTurnthen returnsactive-runindefinitely; there is no wall-clock/staleness takeover of a "running" operation —src/auto-reply/reply/reply-run-registry.tsreclaims only terminal (failed/timeout/killed) leftovers. One hang becomes a dead session.timeoutSeconds: 0(the natural setting for long tasks) maps the run budget toMAX_TIMER_TIMEOUT_MS, whichresolveLlmIdleTimeoutMstreats as "disable the LLM idle watchdog entirely" (src/agents/embedded-agent-runner/run/llm-idle-timeout.ts,runTimeoutMs >= MAX_TIMER_TIMEOUT_MS → 0). "Unlimited budget" silently means "zero stall protection".Root cause 2 — zero durability: a process death loses the in-flight turn and every queued followup, with no resume.
src/infra/unhandled-rejections.ts, final branch →exitWithTerminalRestore→process.exit(1)), and the turn pipeline contains many fire-and-forget promises. One stray rejection anywhere kills the gateway.src/auto-reply/reply/queue/state.ts,FOLLOWUP_QUEUES); active-run/abort state is in-memory; nothing persists "turn in progress". After restart, busy flags can even survive while dispatch never resumes ([Bug]: Session stuck in permanent busy state after mid-turn gateway restart — no auto-recovery, only /new or /reset clears it #92519: 301 assembles, 0 dispatches over 12h).Root cause 3 — no completion drive: an early model stop is accepted as final, and some failures are silent.
tool_useblocks after a timeout permanently poison the session (bug: compaction preserves orphaned tool_use blocks after request timeout, permanently breaking session #93321).drainNextQueueItemremoves the item only afterrun(next)resolves, and there is no per-item failure cap (src/utils/queue-helpers.ts,src/auto-reply/reply/queue/drain.ts).Proposed solution
Four invariants, then four tracks that implement them. The invariants are the contract; every track is judged against them.
I1 — Bounded liveness. Every run reaches a terminal state in bounded time, where "time" is measured as gap since last real forward-progress event, never raw wall clock and never synthetic heartbeats.
I2 — Terminal ⇒ released + reported. Reaching a terminal state atomically releases the session lane/locks and emits exactly one user-visible outcome (or an explicitly-silent one for
room_event/silent modes). No path may terminate a run without doing both.I3 — Restart-safe. The followup queue and a minimal turn-in-progress marker are persisted; boot reconciles interrupted runs into a resumable state instead of stranding them.
I4 — Early stop ≠ done. A model stopping prematurely triggers bounded continuation, not silent acceptance.
Track A — single run-lifecycle owner (abort ⇒ release)
admitted → running → terminal(completed | failed | aborted | timeout | interrupted), extending the existing terminal-outcome normalization (src/agents/agent-run-terminal-outcome.ts) rather than adding a parallel concept.finallyreleases (followup-runner.ts,dispatch-from-config.ts) become assertions that the transition already ran. This makesreleased=0structurally impossible: an abort that reachesterminalis the release.terminalRecoverypath which today only covers already-terminal leftovers.Track B — evidence-based liveness (
RunActivity)RunActivityrecord per run, stamped only by real events: model stream deltas, tool call start/result, CLI child stdout frames, MCP responses, compaction progress. Delete the mechanisms that fake or hide liveness: the CLI timer heartbeat that re-stamps progress while a tool is wedged (claude-cli backend: active-tool heartbeat masks wedged subprocesses, defeats stuck-session detection #96168), the app-serveractiveAppServerTurnRequestswatchdog gate ([Bug]: Codex app-server session wedges indefinitely after a 429 rate-limit burst — terminal-idle watchdog still gated byactiveAppServerTurnRequests > 0(regression/incomplete fix of #82681) #89742), and the hardcoded 120s threshold ([Bug]: Agent stall detector hard-coded 120s threshold kills legitimate long model calls on local vLLM #85826).now - lastEventagainst a single phase-aware threshold table: model-stream gap, tool-execution gap, per-reasoning-model floors (thinking models get minutes, not 120s).timeoutSeconds: 0keeps the budget unlimited but must never disable liveness — fix thellm-idle-timeoutcoupling.activeAppServerTurnRequests > 0(regression/incomplete fix of #82681) #89742, Codex app-server emits notification:turn/started then goes silent; embedded run wedges for the full stuck-session recovery window #85251, [Bug]: Agent stall detector hard-coded 120s threshold kills legitimate long model calls on local vLLM #85826, Stuck-session recovery aborts long-but-active agent runs at warnMs×3 (~6min) with misleading "Reply operation aborted by user" reason #88870, Stuck-session liveness monitor misclassifies healthy processing runs as stale during event-loop stalls #101670, Cron jobs stall during AI model calls (model_call:stream_progress never completes) #91892, [Bug]: Ollama remote provider streaming not consumed — model_call:started never progresses in chat sessions #94251, and the detection half of Diagnostic detects stalled_agent_run (recovery=none) after model_call:ended but never recovers #100213.Track C — durability (crash resume)
turn_in_progressmarker into the shared state DB (state/openclaw.sqlite) per the repo storage mandate — the in-memory global map is existing migration debt.runningat startup transition tointerrupted(Track A ⇒ lane released, outcome recorded), then the session receives one synthesized continuation turn ("previous turn was interrupted; do not re-execute completed tool calls"), bounded by a restart-loop guard (e.g. 3 resume-boots/60s → suspend and notify).process.exit(1); process exit remains only for truly unattributable/fatal states. The current classify-or-die policy makes every unannotated fire-and-forget promise a gateway-wide kill switch.Track D — completion drive
room_event/silent modes). Audit the suppressing branches infollowup-runner.ts(fallback exhaustion, abort routes).tool_useblocks are closed with synthetic error results so the session stays resumable (bug: compaction preserves orphaned tool_use blocks after request timeout, permanently breaking session #93321).Sequencing
A → B → C → D. A alone (abort ⇒ release + staleness takeover) removes the biggest amplifier and is small. B removes both false positives and false negatives at the root and deletes three ad-hoc mechanisms. C is the largest but rides existing SQLite/doctor machinery. D is independent polish that closes the loop on "works through to completion". Each track is separately PR-able and separately testable against the invariants.
Alternatives considered
Impact
Evidence/examples
Cluster → canonical open issues (non-exhaustive; full sweep covered ~160):
activeAppServerTurnRequests > 0(regression/incomplete fix of #82681) #89742, Codex app-server emits notification:turn/started then goes silent; embedded run wedges for the full stuck-session recovery window #85251, Codex app-server stalls after inter-session sessions_send timeout #90673, [Bug]: Codex bundled harness initialize still hangs in 2026.5.18 isolated cron — surfaces via #64744 timeout-wrapping as 'isolated agent setup timed out before runner start' #84567Code evidence (verified on current
main):src/infra/unhandled-rejections.ts— final unclassified branch exits the process.src/agents/embedded-agent-runner/run/llm-idle-timeout.ts—runTimeoutMs >= MAX_TIMER_TIMEOUT_MSreturns0(idle watchdog disabled when budget is "unlimited").src/auto-reply/reply/reply-run-registry.ts— takeover restricted to terminal leftovers; no staleness reclaim of a hung "running" op.src/auto-reply/reply/queue/state.ts—FOLLOWUP_QUEUESin-memory global map; no persistence.src/utils/queue-helpers.ts+src/auto-reply/reply/queue/drain.ts— item removed only after successful run; no per-item failure cap.Additional information
Prior art: competing agent frameworks (e.g. Hermes) implement exactly this shape — event-stamped activity heartbeats with per-reasoning-model floors, staged user warning before interrupt, SQLite task claims with lease TTL + dead-worker reclaim,
resume_pendingsessions that receive a synthesized continuation turn on boot, and bounded anti-premature-stop nudges. Their users cite "never drops a task" as the primary switching reason; this design closes that gap with mechanisms consistent with our architecture rules (SQLite-first state, one canonical path, doctor-owned migration).