Repository navigation
fix(cron): a completed run keeps its result when a fire-claim heartbeat sample misses (#113357, #105861, salvage #113364) - #114837
Conversation
…at sample misses Long-running cron jobs (>60 s, i.e. past one heartbeat interval) intermittently ended with last_status=error / "Interrupted by shutdown before terminal completion." while their output was complete. The fire-claim heartbeat thread took ONE sample that read the claim as not ours, latched lost_ownership, and _FireOwnership.lost() then trusted that latch without asking the store again — so a run whose claim still validated (the very condition under which _record_fire_ownership_lost writes that message) was recorded as interrupted, its output never saved. The reporter's own error string proves the miss was transient: it is only ever written when the owner re-validates True. Why this shape: - _FireOwnership.lost(): an explicit transport cancel stays terminal; a latched heartbeat miss is re-checked against the store, and a claim that still validates keeps the run's real outcome. The owner-fenced mark_job_run and fire_claim_fence remain the authority, so a genuinely re-owned claim still yields (no delivery, no terminal write over the new owner, ledger discard). An unreachable store after a latched miss stays fail-closed. - _heartbeat_loop: a miss is re-sampled once after _FIRE_CLAIM_MISS_CONFIRM_SECONDS before it latches. Latching cancels the live agent run and ends the lease refresh, so a single sample must not do that; a genuinely re-owned claim misses twice and latches ~1 s later. The issue's other hypothesis (worker process identity under a systemd-run --scope worker) is falsified by the code: the owner token is stored in fire_claim.by at claim time and compared to the stored value, never recomputed from _machine_id(), and the heartbeat thread runs in a copied context so it reads the same store. Slimmer redo of #113364 (same direction: revalidate before trusting the latch) without its production-dead isinstance(_CombinedCancelEvent) branch. Co-authored-by: KoNit-K <konit.block@protonmail.com>
૮ >ﻌ< ა ci reviewran on 62d65f8 — fix(cron): a completed run keeps its result when a fire-clai all good! |
ehz0ah
left a comment
There was a problem hiding this comment.
Request changes conclusion
GitHub does not permit this account to submit a formal change-request review, so this comment records the same blocking verdict.
The issue is valid, and the durable owner token remains the correct authority for delivery and terminal writes. The new two-sample rule reduces false latches, but a confirmed sample miss can still cancel work before the patch revalidates ownership.
I reproduced this on the exact head and after a clean merge into current main 01382698fc32ec7740b6a204d9b7a6abeac74d33. A real no_agent script was still owned in jobs.json. I forced only the first two heartbeat-thread samples to return false, then restored the real store check. The script was killed and the job ended with last_status=error and Script cancelled because cron fire ownership was lost.
The submitted focused suite passes 99 tests. Ruff and git diff --check pass. Hosted required checks are green. The inline finding is blocking because the production runner does not behave like the test stub after cancellation.
Verdict: REQUEST_CHANGES at 62d65f8342c248b93d3cd6d5bd881ad95286c589.
| job_id, expected_owner=owner): | ||
| last_confirmed = time.monotonic() | ||
| continue | ||
| lost_ownership.set() |
There was a problem hiding this comment.
After the second sampled False, this publishes lost_ownership to the real runner before durable ownership is revalidated. _run_job_script sees the combined cancel event, kills the subprocess, and returns Script cancelled because cron fire ownership was lost. _run_agent_with_watchdog likewise interrupts the agent. The later _FireOwnership.lost() recheck cannot restore work that was already stopped.
I reproduced this with a real no_agent script and an unchanged stored claim. I forced only the first two heartbeat-thread samples to return False, then let all later calls use the real heartbeat_fire_claim. The script was killed and the job ended last_status=error even though it still owned the claim. The same result survives a clean merge into current main. The new latched test hides this because its fake run_job observes the cancel event but returns success anyway. Please do not expose a cancellation signal to the runner until the durable ownership check fails, and cover the path with a real script or agent cancellation test.
A cron job that runs past one fire-claim heartbeat interval (>60 s) and finishes with a complete response is no longer recorded as
last_status: error/Interrupted by shutdown before terminal completion.when a single heartbeat sample reads the claim as lost.cron/scheduler.py::_FireOwnership.lost— an explicit transport cancel stays terminal; a latched heartbeat miss is re-checked against the store instead of being trusted as the verdict. A claim that still validates keeps the run's real outcome (ok, or the real error); the owner-fencedmark_job_run/fire_claim_fenceremain the authority, so a genuinely re-owned claim still yields (nothing delivered, no terminal write over the new owner, ledger rowFire claim ownership lost; stale result was discarded.). An unreachable store after a latched miss stays fail-closed.cron/scheduler.py::_run_with_fire_claim_heartbeat._heartbeat_loop— a miss is re-sampled once (_FIRE_CLAIM_MISS_CONFIRM_SECONDS, 1 s) before it latches. The latch cancels the live agent run and ends the lease refresh, so one sample must not do that; a genuinely re-owned claim misses twice and latches ~1 s later.tests/cron/test_fire_claim_lost_after_delivery.py— two invariant tests on the real store with the real heartbeat thread: one-sample miss never cancels the run and two-miss latch still records the completed run; a genuinely re-owned claim yields. Each seam proven to bite independently (revert A → latched case delivers nothing; revert B → one-sample case cancels the run; base fails both; the steal control passes everywhere).website/docs/developer-guide/cron-internals.md— fire-claim lease semantics during a run.Root cause: the fire-claim heartbeat thread latched
lost_ownershipon a single sample that readclaim["by"] != expected_owner, and_FireOwnership.lost()returned True on that latch without re-asking the store — so_record_fire_ownership_lost(which re-validates the owner and writes the "interrupted" message only when the owner still holds the claim) terminalized a completed run as an error and never saved its output.On the issue's hypothesis (b) — worker process identity under
systemd-run --scope: falsified by the code. The owner token is stored infire_claim.byat claim time (cron/jobs.py::claim_job_for_fire) and every check compares against that stored value (cron/jobs.py::_refresh_claim);_machine_id()is never recomputed for the comparison, and the heartbeat thread runs insidecontextvars.copy_context()so it reads the same profile store. The reporter's own error string is the proof the loss was transient: it is only written whenheartbeat_fire_claim(...)returns True at record time.Live repro
Real store under a temp
HERMES_HOME(claim_job_for_fire→run_one_job→mark_job_run/finish_execution/fire_claim_fenceall real); onlyrun_jobis stubbed with a complete 4-tuple andheartbeat_fire_claimreturns False for exactly N samples on the heartbeat thread (/tmp/batch9/cron-scheduler/probe_claim_loss_complete.py). Heartbeat interval 0.2 s, run 1 s.origin/main@ c62bd9flast_status=error,last_error="Interrupted by shutdown before terminal completion.", ledgerfailed, no output filecompleted, output file saved, record identical to the no-miss controlcompleted, output savedcompleted, output savedbyrewritten)Fire claim ownership lost; stale result was discarded.Tests:
scripts/run_tests.shovertests/cron/test_fire_claim_lost_after_delivery.py test_claim_job_for_fire.py test_cron_relay_run_forward.py test_parallel_pool.py test_script_claim_heartbeat.py test_shutdown_interrupt.py→ 6 files, 99 passed, 0 failed.Issues
Fixes #113357
Related to #105861 (thread not re-read in this lane; maintainer to confirm closure) — main already carried the post-delivery half (fall-through after a completed delivery); this closes the remaining atoms: the pre-delivery check right after
run_jobreturns, delivery-less jobs (deliver: none/ no target, wheredelivery_attemptedis False), and the one-sample latch itself.Salvages #113364 (@KoNit-K) — slimmer redo of the same direction (revalidate the owner before trusting the latch), credited as Co-authored-by.
Dropped hunks
_CombinedCancelEvent.fire_claim_is_set/other_cancel_is_setand theisinstance(self.claim_lost, _CombinedCancelEvent)branch inlost()— dead in production (claim_lostis always the rawthreading.Event; only the PR's third test passed a combined event). The transport-vs-heartbeat split it aimed for already exists on main as_FireOwnership.transport_cancelled(), which this PR uses. Its three monkeypatched tests replaced by two real-store invariant tests (guarded cases re-probed: completed run kept; incomplete/stolen still fenced out; transport cancel still interrupts — covered by the existingtest_transport_cancel_during_delivery_stays_fail_closed).Infographic