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
- Have an isolated cron job whose run is interrupted by a gateway stop/crash mid-execution.
- Restart the gateway so startup recovery and task maintenance run.
- 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.
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
Steps to reproduce
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:The run step then reads a NEW clock for the task run id,
src/cron/service/timer.ts:1223:Nothing persists the cron store between
runningAtMs = startedAtand execution, so on crash the on-diskrunningAtMsis still the reservation value. On restart,markInterruptedStartupRuncopies that reservation value intolastRunAtMs,src/cron/service/ops.ts:147:The recovery reconciler then matches by
startedAt,src/tasks/task-registry.maintenance.ts:433:execution.startedAtis the run id's started-at;job.state.lastRunAtMsis the reservation now. They differ in production, soresolveCronJobStateRecoveryreturns 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 atsrc/cron/service/timer.ts:1732(const startedAt = state.deps.nowMs()). Manual runs are unaffected:prepareManualRunreserves and builds the run id from the samepreflight.now, sorunningAtMs === lastRunAtMs === startedAtthere. Existing recovery tests hide the bug because they use a fixed clock or hand-setlastRunAtMsequal to the run id's started-at.Related (distinct, not duplicates)
lastRunAtMs, the run id started-at, or the recovery match, so a genuinely-interrupted run still misses recovery once liveness is authoritative.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()andpreviewTaskRegistryMaintenance()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:
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.