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
Storage: per-agent sqlite + JSON sessions.json; no custom plugins on the restart-recovery path
TL;DR
When a channel main-session turn wedges (no settlement event within a long window), and a side-effect trigger fires a gateway SIGUSR1 restart, the restart drain marks the wedged session entries in sessions.json with abortedLastRun: true plus an entry appended to restartRecoveryRuns. On the next boot, the recovery pass calls back into the same agent method for those entries unconditionally. There is no persistent per-session retry counter, no quarantine, no backoff across boots. The result is a deterministic death loop until systemd's StartLimitBurst is exhausted and operator intervention is required.
In-process retry orchestration exists (MAX_RECOVERY_RETRIES = 3 with RETRY_BACKOFF_MULTIPLIER = 2 and DEFAULT_RECOVERY_DELAY_MS = 5_000) but bounds retries within a single process invocation only — the counter is not persisted to the store. Each fresh boot is treated as a fresh retry budget.
Same architecture (and same gap) appears in the subagent-orphan-recovery path. An explicit log line there documents the intent: resume failed for ${childSessionKey}; abortedLastRun flag preserved for retry on next restart. The cross-boot preservation IS the retry mechanism by design — the missing piece is the ceiling.
Production incident (2026-06-21)
Two concurrent Telegram channel main-session turns started ~1s apart (a DM lane + a group-topic lane). Both claude-cli / claude-opus-4-7.
Both turns hung 559 s with no settlement. Gateway emitted cli_live:stream_progress ticks from claude-cli every ~30 s through the entire hang — the runtime kept reporting stream progress but no tool-call and no text block ever settled.
eventLoopDelayP99Ms climbed to ~68 s during the hang (steady state pre-incident was sub-second).
A scheduled isolated cron's setup-timeout watchdog fired with reason: "cron.isolated_agent_setup_timeout". That triggered a gateway SIGUSR1 restart.
Restart drain marked both wedged channel sessions: abortedLastRun: true, one {runId, lifecycleGeneration} appended to restartRecoveryRuns.
Next boot: same hang. Same SIGUSR1. Same re-mark. restartRecoveryRuns.length incremented to 2.
6 cycles total before systemd exhausted StartLimitBurst and the unit refused to restart.
Recovery: offline edit of sessions.json to strip restartRecovery* fields on both entries, systemctl --user reset-failed openclaw-gateway, restart.
Field-level evidence in sessions.json (post-incident, pre-repair)
restartRecoveryRuns.length increments by 1 per failed resume.
It is not used as a retry-ceiling input. The resume gate checks only status === "running" && abortedLastRun === true.
The success path clears abortedLastRun = false. The failure path is log-only — no field increment, no quarantine timestamp, no persistent attempt counter.
Reproducer (no patches required)
Run the gateway under systemd --user (or any supervisor with auto-restart enabled).
Schedule an isolated cron that natural-fires every ~5 minutes.
Trigger a channel main-session turn with a sufficiently large prompt to encourage Opus to deliberate ≥30 s without emitting either a tool call or text.
While that turn is hanging, the cron's setup-timeout watchdog will eventually fire on event-loop wedge → SIGUSR1.
Inspect sessions.json after the first restart: the channel entry has abortedLastRun: true and one restartRecoveryRuns entry.
On subsequent boots, observe restartRecoveryRuns.length increment monotonically until StartLimitBurst is exhausted.
The reproduction is deterministic. The original cant_reproduce close on #86239 may have predated either the cron isolated-setup-timeout watchdog SIGUSR1 trigger or the default cron runMode flip from force → due in 2026.6.9 — both raise the probability of the coincidence required to trigger the loop.
Proposed fix shape
Add a persistent abortedLastRunAttempts integer field on session entries, parallel to abortedLastRun. Behavior:
Mark phase (every site that sets abortedLastRun = true — markRestartAbortedMainSessions, the from-locks scan, the expired-pending-delivery path): increment abortedLastRunAttempts by 1, default-initializing from 0.
Resume gate (the loop that picks entries to retry): require abortedLastRunAttempts < MAX_RECOVERY_RETRIES in addition to the current status === "running" && abortedLastRun === true predicate.
Overrun (when the resume gate excludes an entry that would otherwise be eligible): clear abortedLastRun = false, set quarantinedAt = <ISO timestamp>, set quarantineReason = "exceeded_restart_retry_budget", emit a structured WARN log line with the session key + the count.
Quarantine cleanup: an entry with quarantinedAt set is no longer eligible for restart-recovery. It may still receive new inbound traffic — the next user-driven turn clears quarantinedAt and resets abortedLastRunAttempts to 0 (or some operator-tunable behavior).
The same shape needs to land in the subagent-orphan-recovery path. Both share the architecture and both have the same gap.
MAX_RECOVERY_RETRIES (currently 3 in-process) is a reasonable cross-boot value too. Optionally make it configurable via recovery.maxAcrossBoots.
One new integer field in the session-entry shape (abortedLastRunAttempts).
Increment at every mark-true site (small number, documented in mark-phase functions).
One predicate addition in the resume gate.
One quarantine path (clear + timestamp + WARN).
Same shape applied to the subagent-orphan-recovery surface.
No new config required if MAX_RECOVERY_RETRIES is reused as the cross-boot ceiling. Add recovery.maxAcrossBoots config if you want operator override (low priority — the in-process value is reasonable).
Happy to scope a PR if the maintainers want concrete code rather than a description. If you'd prefer a different fix shape (e.g. wall-clock TTL on the restartRecovery* flag instead of an attempt counter), open to discussing — the operator-facing contract is what matters: don't death-loop the gateway when a session is genuinely wedged.
Sanity questions
Is there an existing operator knob I missed that would bound the cross-boot retries? I checked recovery.* config; MAX_RECOVERY_RETRIES exists but is in-process only. Did I overlook a persistence path?
For the subagent-orphan-recovery surface: same gap, or does that path already have a counter I missed?
Environment
2026.6.9(c645ec4)cliBackends.claude-cli(Anthropic Claude Code CLI)agents.list[main], modelclaude-cli/claude-opus-4-7systemd --user(openclaw-gateway.service,Restart=always,StartLimitBurst=5,StartLimitIntervalSec=60)sessions.json; no custom plugins on the restart-recovery pathTL;DR
When a channel main-session turn wedges (no settlement event within a long window), and a side-effect trigger fires a gateway SIGUSR1 restart, the restart drain marks the wedged session entries in
sessions.jsonwithabortedLastRun: trueplus an entry appended torestartRecoveryRuns. On the next boot, the recovery pass calls back into the sameagentmethod for those entries unconditionally. There is no persistent per-session retry counter, no quarantine, no backoff across boots. The result is a deterministic death loop untilsystemd'sStartLimitBurstis exhausted and operator intervention is required.In-process retry orchestration exists (
MAX_RECOVERY_RETRIES = 3withRETRY_BACKOFF_MULTIPLIER = 2andDEFAULT_RECOVERY_DELAY_MS = 5_000) but bounds retries within a single process invocation only — the counter is not persisted to the store. Each fresh boot is treated as a fresh retry budget.Same architecture (and same gap) appears in the subagent-orphan-recovery path. An explicit log line there documents the intent:
resume failed for ${childSessionKey}; abortedLastRun flag preserved for retry on next restart. The cross-boot preservation IS the retry mechanism by design — the missing piece is the ceiling.Production incident (2026-06-21)
claude-cli/claude-opus-4-7.cli_live:stream_progressticks fromclaude-clievery ~30 s through the entire hang — the runtime kept reporting stream progress but no tool-call and no text block ever settled.eventLoopDelayP99Msclimbed to ~68 s during the hang (steady state pre-incident was sub-second).reason: "cron.isolated_agent_setup_timeout". That triggered a gateway SIGUSR1 restart.abortedLastRun: true, one{runId, lifecycleGeneration}appended torestartRecoveryRuns.restartRecoveryRuns.lengthincremented to 2.systemdexhaustedStartLimitBurstand the unit refused to restart.sessions.jsonto striprestartRecovery*fields on both entries,systemctl --user reset-failed openclaw-gateway, restart.Field-level evidence in
sessions.json(post-incident, pre-repair)Each affected entry (anonymized):
restartRecoveryRuns.lengthincrements by 1 per failed resume.status === "running" && abortedLastRun === true.abortedLastRun = false. The failure path is log-only — no field increment, no quarantine timestamp, no persistent attempt counter.Reproducer (no patches required)
systemd --user(or any supervisor with auto-restart enabled).sessions.jsonafter the first restart: the channel entry hasabortedLastRun: trueand onerestartRecoveryRunsentry.restartRecoveryRuns.lengthincrement monotonically untilStartLimitBurstis exhausted.The reproduction is deterministic. The original
cant_reproduceclose on #86239 may have predated either the cron isolated-setup-timeout watchdog SIGUSR1 trigger or the default cronrunModeflip fromforce→duein 2026.6.9 — both raise the probability of the coincidence required to trigger the loop.Proposed fix shape
Add a persistent
abortedLastRunAttemptsinteger field on session entries, parallel toabortedLastRun. Behavior:abortedLastRun = true—markRestartAbortedMainSessions, the from-locks scan, the expired-pending-delivery path): incrementabortedLastRunAttemptsby 1, default-initializing from 0.abortedLastRunAttempts < MAX_RECOVERY_RETRIESin addition to the currentstatus === "running" && abortedLastRun === truepredicate.abortedLastRun = false, setquarantinedAt = <ISO timestamp>, setquarantineReason = "exceeded_restart_retry_budget", emit a structured WARN log line with the session key + the count.quarantinedAtset is no longer eligible for restart-recovery. It may still receive new inbound traffic — the next user-driven turn clearsquarantinedAtand resetsabortedLastRunAttemptsto 0 (or some operator-tunable behavior).The same shape needs to land in the subagent-orphan-recovery path. Both share the architecture and both have the same gap.
MAX_RECOVERY_RETRIES(currently 3 in-process) is a reasonable cross-boot value too. Optionally make it configurable viarecovery.maxAcrossBoots.Behavioral contract
attempts=1attempts=2attempts=3abortedLastRun=false,quarantinedAtset; WARN loggedattemptsreset to 0; new turn proceedsThe quarantine semantic prevents the death loop without losing the session transcript pointer (which manual deletion of the entry would lose).
Coordination concerns
restart gateway after isolated cron setup timeout) would amplify the death loop family without a circuit breaker upstream of the restart-recovery mark phase. Worth coordinating timing: ideally this issue's fix lands before fix: restart gateway after isolated cron setup timeout #89055 merges, or#89055's gateway-restart path is gated on a flag.sessions.jsonrecovery / corruption) is adjacent — both touch the same store. The patches should land in the same release window if possible to avoid operators having to apply offline repair twice.Related upstream issues
cant_reproduce2026-05-28, locked. Same root mechanism. This issue supersedes that conversation with a deterministic reproducer on2026.6.9 c645ec4.sessions.jsonrecovery / corruption family (adjacent operational concern).Scope sanity check
The narrow change is:
abortedLastRunAttempts).No new config required if
MAX_RECOVERY_RETRIESis reused as the cross-boot ceiling. Addrecovery.maxAcrossBootsconfig if you want operator override (low priority — the in-process value is reasonable).Happy to scope a PR if the maintainers want concrete code rather than a description. If you'd prefer a different fix shape (e.g. wall-clock TTL on the
restartRecovery*flag instead of an attempt counter), open to discussing — the operator-facing contract is what matters: don't death-loop the gateway when a session is genuinely wedged.Sanity questions
recovery.*config;MAX_RECOVERY_RETRIESexists but is in-process only. Did I overlook a persistence path?