fix(cron): reconcile stale in-flight claim against executions ledger (t_8b5480b3) - #87259
Conversation
…(t_8b5480b3) The age-only stale-claim sweep (t_3778a491, already on main) force-releases an in-memory _running_job_ids claim only once it is older than max(2*interval, 30m). A leaked claim that is YOUNG (inside its allowance) while the durable executions ledger already proves the last run ended stays wedged: the job is returned as due every tick, _submit_with_guard short- circuits on 'already running', and next_run_at keeps fast-forwarding with no execution — the exact 2026-08-14 recurring-router incident (t_20e23f84), which survived a gateway restart because the in-memory age bound alone could not see a run the ledger had already finished. sweep_stale_inflight now reconciles each in-flight claim against the durable executions ledger (cron/executions.db): if the job's MOST RECENT execution row is terminal (completed/failed/unknown), the run provably ended, so the claim is stale by construction regardless of its in-memory age and is force- released. This is a persisted-state recovery path: the ledger is written by the worker that ran the job and read by ANY ticker process (including one that started AFTER the leak), so a leaked claim is recoverable without force-run/resume and without depending on which process holds it in memory. A ledger-terminal release is authoritative — it does not write a synthetic mark_job_run failure (the ledger already records the outcome). Added TestLedgerTerminalReconciliation (4 tests): young+terminal -> released (RED on main, GREEN here), no-ledger-row -> not released, running-row -> not released, old+terminal -> released once without synthetic failure.
058ef2f to
f73b7e5
Compare
fix(cron): reconcile stale in-flight claim against executions ledger (t_8b5480b3)
|
|
Salvaged via #88343 — your commit was cherry-picked with authorship preserved (you'll show as the author on main once it merges). One safety follow-up on top: the ledger-terminal release now verifies the terminal row belongs to THIS claim ( Thanks for the ledger-reconciliation idea — it closes the young-wedged-claim gap the age sweep couldn't! |
Follow-ups on the #87259 salvage: - cron/scheduler.py: the ledger-terminal reconciliation now requires the terminal execution row's claimed_at to be >= the in-memory claim's registration time (_running_since). Without this, the latest terminal row for a recurring job is usually the PREVIOUS run's outcome — a fresh claim in the try_register_running_job -> create_execution window (or a finished run whose worker finally block hasn't released yet) would be force-released and the job double-dispatched. Unparseable/missing claimed_at fails closed to the age-based bound. - cron/scheduler.py: take the _running_job_ids snapshot for the ledger query under _running_lock — list() over a set concurrently mutated by try_register/release_running_job can raise RuntimeError. - tests: existing reconciliation tests updated to the claimed_at contract; two new race-guard tests (previous-run terminal row never releases a fresh claim; missing claimed_at fails closed). Mutation-verified: removing the ownership guard fails both.
Follow-ups on the NousResearch#87259 salvage: - cron/scheduler.py: the ledger-terminal reconciliation now requires the terminal execution row's claimed_at to be >= the in-memory claim's registration time (_running_since). Without this, the latest terminal row for a recurring job is usually the PREVIOUS run's outcome — a fresh claim in the try_register_running_job -> create_execution window (or a finished run whose worker finally block hasn't released yet) would be force-released and the job double-dispatched. Unparseable/missing claimed_at fails closed to the age-based bound. - cron/scheduler.py: take the _running_job_ids snapshot for the ledger query under _running_lock — list() over a set concurrently mutated by try_register/release_running_job can raise RuntimeError. - tests: existing reconciliation tests updated to the claimed_at contract; two new race-guard tests (previous-run terminal row never releases a fresh claim; missing claimed_at fails closed). Mutation-verified: removing the ownership guard fails both.
Follow-ups on the NousResearch#87259 salvage: - cron/scheduler.py: the ledger-terminal reconciliation now requires the terminal execution row's claimed_at to be >= the in-memory claim's registration time (_running_since). Without this, the latest terminal row for a recurring job is usually the PREVIOUS run's outcome — a fresh claim in the try_register_running_job -> create_execution window (or a finished run whose worker finally block hasn't released yet) would be force-released and the job double-dispatched. Unparseable/missing claimed_at fails closed to the age-based bound. - cron/scheduler.py: take the _running_job_ids snapshot for the ledger query under _running_lock — list() over a set concurrently mutated by try_register/release_running_job can raise RuntimeError. - tests: existing reconciliation tests updated to the claimed_at contract; two new race-guard tests (previous-run terminal row never releases a fresh claim; missing claimed_at fails closed). Mutation-verified: removing the ownership guard fails both.
Follow-ups on the NousResearch#87259 salvage: - cron/scheduler.py: the ledger-terminal reconciliation now requires the terminal execution row's claimed_at to be >= the in-memory claim's registration time (_running_since). Without this, the latest terminal row for a recurring job is usually the PREVIOUS run's outcome — a fresh claim in the try_register_running_job -> create_execution window (or a finished run whose worker finally block hasn't released yet) would be force-released and the job double-dispatched. Unparseable/missing claimed_at fails closed to the age-based bound. - cron/scheduler.py: take the _running_job_ids snapshot for the ledger query under _running_lock — list() over a set concurrently mutated by try_register/release_running_job can raise RuntimeError. - tests: existing reconciliation tests updated to the claimed_at contract; two new race-guard tests (previous-run terminal row never releases a fresh claim; missing claimed_at fails closed). Mutation-verified: removing the ownership guard fails both.
Problem
4 recurring no_agent cron jobs (deterministic-verdict-router, kanban-scheduled-wake-scanner, review-required-auto-router, blocked-task-notifier) EAGAIN-failed at 12:50:05 on 2026-08-14 and then recorded zero executions for ~1h47m even after substrate recovery — while 100+ other jobs ran normally.
next_run_atkept advancing (jobs stayeddue) but_submit_with_guardnever dispatched them, and the wedge survived a gateway restart.Root cause (reviewer-validated)
The t_3778a491 age-based stale-claim sweep (already on main) force-releases an in-memory
_running_job_idsclaim only once it is older thanmax(2*interval, 30m). But a leaked claim can be young (inside its allowance) while the durable executions ledger already proves the last run ended. The age bound alone could not see a run the ledger had already finished — so the wedge survived restart until an operatorcron resumed the job. This is a persisted-state non-dispatch cause, not just the in-memory leak.Fix
sweep_stale_inflightnow reconciles each in-flight claim against the durable executions ledger (cron/executions.db):completed/failed/unknown), the run provably ended — the claim is stale by construction regardless of in-memory age, and is force-released so the recurring job re-dispatches on the next tick (no force-run, nocron resume).mark_job_run(success=False)(the ledger already records the outcome), so an honestcompleted/okstatus is never clobbered.Verification
TestLedgerTerminalReconciliation(4 tests): young+terminal -> released (clean behavioral RED on main, GREEN here); no-ledger-row -> not released; running-row -> not released; old+terminal -> released once without synthetic failure.tests/cron/: 713 passed, 1 skipped, 1 timing-flaky test that passes in isolation (unrelated to this change).