Skip to content

cron: restart-interrupted run mislabeled lost instead of failed (recovery matches reservation timestamp vs run id started-at) #96161

Description

@yetval

Summary

A cron run interrupted by a gateway restart leaves its task-ledger row mislabeled. The durable cron-state recovery that should stamp the row "failed: cron: job interrupted by gateway restart" silently misses, and the row is instead swept to "lost / backing session missing". The cause is a clock divergence: the value persisted into the cron job state is the reservation timestamp, but the task run id encodes a later started-at timestamp, and recovery matches the two for equality.

Environment

  • Commit: 6f80552 (origin/main at time of filing)
  • Surface: cron service restart recovery + task-registry maintenance reconciler

Steps to reproduce

  1. Have an isolated cron job whose run is interrupted by a gateway stop/crash mid-execution.
  2. Restart the gateway so startup recovery and task maintenance run.
  3. Inspect the task row for that run (task list / task drill-down).

Expected

The interrupted run's task row is recovered to status "failed" with error "cron: job interrupted by gateway restart" (the durable cron-state recovery path that exists for exactly this case).

Actual

The durable recovery misses; the row is marked "lost" with error "backing session missing".

Root cause

The reserve step persists the reservation clock into runningAtMs, src/cron/service/timer.ts:1196:

const now = state.deps.nowMs();
for (const job of due) {
  job.state.runningAtMs = now;
}
await persist(state);            // disk: runningAtMs = reservation now

The run step then reads a NEW clock for the task run id, src/cron/service/timer.ts:1223:

const startedAt = state.deps.nowMs();   // a later read, > reservation now
job.state.runningAtMs = startedAt;       // in memory only, not persisted before the run
...
const taskRunId = tryCreateCronTaskRun({ state, job, startedAt }); // runId = cron:<jobId>:<startedAt>

Nothing persists the cron store between runningAtMs = startedAt and execution, so on crash the on-disk runningAtMs is still the reservation value. On restart, markInterruptedStartupRun copies that reservation value into lastRunAtMs, src/cron/service/ops.ts:147:

job.state.lastRunAtMs = runningAtMs;   // = reservation now, NOT startedAt

The recovery reconciler then matches by startedAt, src/tasks/task-registry.maintenance.ts:433:

if (!job || job.state.lastRunAtMs !== execution.startedAt) {
  return undefined;
}

execution.startedAt is the run id's started-at; job.state.lastRunAtMs is the reservation now. They differ in production, so resolveCronJobStateRecovery returns undefined. The run-log recovery leg also misses because an interrupted run never wrote a finished run-log row. Both legs fail, so the row falls through to the lost sweep.

Sibling surfaces

The startup catch-up path has the same divergence: reservation at src/cron/service/timer.ts:1697 (job.state.runningAtMs = now) versus the run at src/cron/service/timer.ts:1732 (const startedAt = state.deps.nowMs()). Manual runs are unaffected: prepareManualRun reserves and builds the run id from the same preflight.now, so runningAtMs === lastRunAtMs === startedAt there. Existing recovery tests hide the bug because they use a fixed clock or hand-set lastRunAtMs equal to the run id's started-at.

Related (distinct, not duplicates)

Real behavior proof

Behavior addressed: an interrupted cron run is not recovered to "failed: interrupted by gateway restart" and is instead marked "lost: backing session missing", solely because the persisted cron state timestamp differs from the run id timestamp.
Real environment tested: drove the production runTaskRegistryMaintenance() and previewTaskRegistryMaintenance() reconciler at commit 6f80552, with injected data-source seams (the cron store reader and the task list) and the real recovery logic. Two inputs differing only by a 7ms reservation-versus-started-at delta.
Exact steps or command run after this patch: seed one stale interrupted cron task whose run id encodes startedAt and a cron job whose persisted lastRunAtMs is the reservation value (startedAt minus 7ms), then run the reconciler and read the recovered count and the final task status/error; repeat with lastRunAtMs equal to startedAt as a control.
Evidence after fix:

# PRODUCTION CASE: persisted lastRunAtMs (reservation) != runId startedAt
runId startedAt=1782234099688  cron-store lastRunAtMs=1782234099681  delta=7ms
maintenance preview: recovered=0  reconciled=1
maintenance run:     recovered=0  reconciled=1
task final: status=lost  error="backing session missing"

# CONTROL CASE: lastRunAtMs == runId startedAt (fixed clock)
runId startedAt=1782234099688  cron-store lastRunAtMs=1782234099688  delta=0ms
maintenance run:     recovered=1  reconciled=0
task final: status=failed  error="cron: job interrupted by gateway restart"

Observed result after fix: a 7ms divergence between the persisted reservation timestamp and the run id started-at flips the interrupted run from correctly recovered ("failed: cron: job interrupted by gateway restart") to wrongly swept ("lost: backing session missing"); the only changed input is that one timestamp delta.
What was not tested: did not drive a full live gateway crash/restart cycle end to end; the divergence between the reservation persist and the run id started-at is shown from the current source rather than from a live crash. No fix is included in this report.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal backlog priority with limited blast radius.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: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