fix(cron): roll back live scheduler state when persisting add/update/remove fails#99960
Merged
vincentkoc merged 3 commits intoJul 4, 2026
Merged
Conversation
Contributor
|
Codex review: stale review; fresh review needed. Summary Next step Review history (5 earlier review cycles)
|
vincentkoc
force-pushed
the
feat/cron-store-persist-rollback
branch
from
July 4, 2026 19:01
eb67e68 to
503e7e4
Compare
Member
|
Maintainer repair and pre-merge validation are complete.
No remaining proof gaps. |
Contributor
Dependency graph guard clearedThis PR no longer has blocked dependency graph changes. A future dependency graph change requires a fresh
|
vincentkoc
force-pushed
the
feat/cron-store-persist-rollback
branch
from
July 4, 2026 19:24
b5504a1 to
ae62c26
Compare
Member
|
Merged via squash.
|
This was referenced Jul 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What Problem This Solves
Fixes an issue where a cron job mutation that fails to persist still changes the live scheduler, so what the user was told no longer matches what the gateway does or what survives a restart.
cron add,cron update, andcron removemutate the in-memory cron store first and persist to SQLite afterwards. When the persist fails (e.g. disk full or a failed write transaction), the caller correctly receives an error — but the in-memory mutation stays behind in the running gateway:Both user surfaces converge on the same code path — the CLI issues the
cron.add/cron.update/cron.removegateway RPCs (src/cli/cron-cli.ts), whose handlers callCronService.add/update/remove(src/cron/service.ts), which delegate to the ops in this diff. The in-memory store is trusted indefinitely once loaded (ensureLoadedfast path), so none of this self-heals while the process lives.Why This Change Was Made
The narrowest fix that restores the invariant "if the caller was told the mutation failed, the runtime behaves as if it never happened": snapshot the mutable state before the mutation and restore it when the persist throws, then rethrow the original error unchanged.
The snapshot covers the whole store plus the catch-up deferral marker set, not just the touched job, because
recomputeNextRunsForMaintenanceruns inside these operations and mutatesnextRunAtMs/deferral state across all jobs — restoring only the touched job would leave sibling scheduler state ahead of disk. This matches the persist-before-mutate durability convention already documented in the task registry (src/tasks/task-registry.ts), which the cron service predates.Non-goal:
persist()has a sibling seam where a failed quarantine flush silently skips the save (src/cron/service/store.ts); fixing it changes error propagation for read-path callers that intentionally degrade and continue, so it is left to a separately scoped follow-up. Run-state bookkeeping persists (prepareManualRun,finishPreparedManualRun) are also untouched — the run already happened, so rollback semantics differ.User Impact
Operators get truthful outcomes from cron CRUD under storage failure: a failed
cron add/update/removenow restores the cron store and catch-up deferral markers touched by these operations, matching both the error the user saw and the state the next restart loads. No phantom runs of jobs that "failed to save", no silent reverts of edits, no deleted jobs resurrecting after a restart. Error reporting and the success paths are unchanged.Evidence
Real behavior proof (before/after, real
CronService+ real shared SQLite state DB)A three-phase driver script runs the real
CronServicefacade against the real shared SQLite state DB (OPENCLAW_STATE_DIRpointed at a scratch dir; separate OS processes per phase; no JS mocks or module patching). The durable-write failure is a genuineSQLITE_READONLYerror raised insidesaveCronJobsStore's real write transaction, by switching the live production connection to read-only state (PRAGMA query_only=1on the same cached handle the cron service persists through) between a successful seed and the mutations.Before (PR base, upstream/main): every failed mutation leaves the live scheduler lying, and a restart flips all three back:
After (this PR, same script, same failure): the live store stays consistent with the reported errors, and restart agrees:
Transcripts are from a Linux host, redacted only by using scratch paths. The driver script:
cron-proof.mts (run from a checkout root:
OPENCLAW_STATE_DIR=<scratch> node --import tsx cron-proof.mts seed|mutate|inspect)Failing-first tests
src/cron/service/ops.test.ts(cron service ops persist rollbackdescribe, persist failure injected viasaveCronJobsStorerejection): before the fix all five fail with the exact lying behaviors above — notably the recovery case shows the phantom job from a failed add being written to disk by the next successful persist:After the fix:
Full cron suite (local,
node scripts/run-vitest.mjs src/cron/, ~34s): 117 test files / 1237 tests pass. Production diff is confined tosrc/cron/service/ops.ts(snapshot + restore helpers, three call sites); no change to success-path behavior, event ordering, error types, or--jsonshapes.