Repository navigation
fix(cron): tri-state fire-claim heartbeat — lock timeout is 'unverifiable', not ownership loss - #97565
fix(cron): tri-state fire-claim heartbeat — lock timeout is 'unverifiable', not ownership loss#97565sycamoregroupltd wants to merge 1 commit into
Conversation
…able', not ownership loss heartbeat_fire_claim() returned False both when ownership was positively observed lost AND when the per-job fire fence could not be acquired within _JOBS_LOCK_TIMEOUT_SECONDS (30s). The scheduler heartbeat loop treated that single False as ownership loss and interrupted a healthy run; under cgroup memory/CPU starvation (PSI full 26-37%, swap exhausted) a 30s lock wait is routine, so healthy runs were killed and recorded as cron FAILUREs. Change the contract to tri-state Optional[bool]: - True = claim still owned by expected_owner and refreshed - False = ownership positively observed changed (claim absent/other owner) - None = could not verify (fence lock unavailable) — NOT evidence of loss Callers: - scheduler fire-claim heartbeat: False interrupts immediately; None feeds the existing _FIRE_CLAIM_HEARTBEAT_GRACE_SECONDS grace path (same as exceptions), so a run is interrupted only when ownership is positively observed to have changed or verification has failed past the grace window. - initial validation: None fails closed as 'could not be validated' (not 'ownership lost'). - shutdown probes (_fire_claim_ownership_lost, shutdown mark_job_run): only True counts as owned; None is not treated as loss. Tests: 3 new regression tests cover None-not-killed-immediately, bounded grace cancellation, and initial-None fail-closed. Full cron suite: 1035 passed; the one failure (test_scheduler_cron_session_isolation execute_code guard) is pre-existing on upstream/main and unrelated.
andrexibiza
left a comment
There was a problem hiding this comment.
Reviewed exact head 474eb4ea1653e1defda9490d834b8b429ad5b23e against live main@ac6c8028e00d01ee2f299ba7fd03329c7f10382d; this is currently a clean 1-commit/3-file branch directly on main. I read the actual patch plus the surrounding fire-fence, cancellation, delivery/finalization paths; the exact-head workflows; open #95307; direct predecessor #95432; and the complementary generation-fencing work tracked by #88688.
There is good work here. Splitting heartbeat_fire_claim() into owned / positively-lost / unverifiable is the right semantic direction. The initial None path now refuses to start without falsely claiming takeover, the shutdown probes stop using truthiness as ownership proof, and the hosted Python/e2e/macOS/Windows/lint lanes are green. The remaining blockers are at the exact boundary this PR is trying to repair.
P1 — sustained None is still converted into the exact positive ownership-loss signal.
In cron/scheduler.py::_heartbeat_loop (the hunk beginning around @@ -6978), None correctly enters the grace path, but after _FIRE_CLAIM_HEARTBEAT_GRACE_SECONDS that path calls lost_ownership.set(). The same event is then passed into run_job() as the fire-claim cancellation event. run_job::_abort_if_fire_claim_lost() consumes any set event by calling agent.interrupt("Cron fire claim ownership was lost") and raising RuntimeError("... lost its durable fire claim ownership").
So the new state machine is only tri-state at the probe. At the execution sink it collapses back to two states:
None for long enough -> lost_ownership event -> "ownership was lost".
That is not just wording. A healthy execution whose fence remains unavailable/contented through the grace window is still terminated through the same authority-loss channel as an observed successor owner. The incident class is therefore delayed rather than closed for long-running contention. The new test_unverifiable_fire_claim_cancels_after_bounded_grace currently codifies that collapse by asserting only that the loss event becomes set; it never asserts the exact terminal reason or exercises the real fire fence.
There is an important other side here before simply choosing “skip None forever.” _fire_job_lock() deliberately fails closed when the cross-process fire-fence backend is unavailable, and the same acquired=False surface also represents lock acquisition failure/contention. Those are not equivalent facts either. A busy fence held by the legitimate owner can itself be evidence that a replacement owner is excluded; a missing/broken cross-process locking backend is genuinely unverifiable. Optional[bool] still cannot carry that distinction.
Required fix: keep uncertainty distinct from observed loss end-to-end. If policy requires aborting after bounded unverifiability, use a separate typed cancellation/settlement reason (unverifiable / indeterminate, not lost) so no downstream path can claim takeover without a False observation. Better still, make the probe result explicit enough to distinguish at least OWNED, LOST, FENCE_BUSY, and UNAVAILABLE/UNKNOWN, because those states have different safe continuations. Add deterministic witnesses using the real fire-fence path: (1) legitimate fence contention that lasts across multiple heartbeat beats and past the configured grace, (2) unavailable locking backend, and (3) an actual owner replacement. Assert both effect suppression/continuation and the exact terminal reason. A real takeover must remain the only path that says ownership was lost.
Merge/interlock — this is a second implementation of the same open defect class already carried by #95432.
#95432 (BrunoBza) predates this PR, explicitly fixes reporter alex-frolov's #95307, changes the same cron/jobs.py + cron/scheduler.py surfaces, and introduces the same tri-state contract. Its semantic choice differs: a busy-fence None does not consume the exception grace budget, and its dedicated real-store regression suite exercises a long fenced delivery, post-delivery bookkeeping, startup contention, and a genuine takeover control. This PR is much fresher and is cleanly based on current main, but it should not silently become a parallel owner of the same repair.
Please converge these into one canonical landing object: preserve BrunoBza's implementation/test credit and alex-frolov's reporter/root-cause lineage, absorb the strongest real-fence witnesses, and make the contention-vs-backend-unavailable distinction explicit rather than choosing between two incomplete None policies. #88688 is complementary long-term work: exact ownership generation/CAS is the broader architecture that should eventually remove these ambiguous probes; it is not a duplicate of this incident repair.
Acceptance blocker — exact head is not green.
For this exact SHA:
- CI
33226106068: failure — https://github.com/NousResearch/hermes-agent/actions/runs/33226106068 Check contributors / check-attributionjob99030104601: failure at “Check for unmapped contributor emails” — https://github.com/NousResearch/hermes-agent/actions/runs/33226106068/job/99030104601- required-check aggregate
99030582376: failure as a consequence — https://github.com/NousResearch/hermes-agent/actions/runs/33226106068/job/99030582376 - Docker
33226105599: success — https://github.com/NousResearch/hermes-agent/actions/runs/33226105599 - Nix
33226105712: success — https://github.com/NousResearch/hermes-agent/actions/runs/33226105712
The sole commit is authored as Frank Spencer <frank@frankspencer.dev>; the current tree has no mapping for that email, matching the attribution failure. Preserve Frank's authorship and add the repository contributor mapping rather than rewriting history, then require the corrected exact head to go fully green.
The core insight here is strong: “could not verify” is not “lost.” Carry that distinction all the way through cancellation and settlement, reconcile the earlier carrier instead of competing with it, and get the exact commit green. That closes the class instead of moving the false-positive boundary three minutes downstream. 🚀
|
Confirming this diagnosis from a production gateway, and adding the piece I think is missing: the fail-closed "could not verify" branch is triggered routinely, not only under a same-job race. Environment: single Both times the fence timeout and the ownership-lost line land in the same second, and the timeout is exactly Triggers observed: (a) two jobs firing in the same tick, each in its own worker (13:37:15 above); (b) a worker spawning for another job while this one is mid-run (13:17:15). In every case the victim was a job whose Mechanism as I read it: a worker process spawned for job A runs the tick for all jobs. Its in-flight guard ( I can't tell from the logs whether the claim record is actually overwritten by the failed acquire or whether the running worker's sampled flag flips on its own re-read of the fence; if it's the latter, the failed acquire is destructive on its own and worth asserting in a test. Side note for other reporters: the work is not lost — the digest was delivered both times; only the status/dedup bookkeeping is wrong ( Mitigation on our side (no code change): moving data collection into a pre-run |
|
The fix for this bug landed on main via #109310 (merge 9a60a7f): Thanks — the tri-state/unverifiable classification was a correct reading of the failure. The landed fix removes the fence wait from the heartbeat entirely, so |
Problem
heartbeat_fire_claim()incron/jobs.py:3376returnsFalsein two distinct situations:_JOBS_LOCK_TIMEOUT_SECONDS(30s) — a fail-closed "could not verify".cron/scheduler.py:6985collapses both intofire claim ownership lost; interrupting stale runand kills a healthy run, which is then recorded as a cron FAILURE.Under cgroup memory/CPU starvation (measured: PSI full avg10 26–37%,
memory.current == memory.high, swap exhausted), a 30s lock wait is routine — so a single heartbeat tick can false-positive "ownership lost" and kill a run that was never stolen.Fix
Change the contract to tri-state
Optional[bool]:True— claim still owned byexpected_ownerand refreshedFalse— ownership positively observed changed (claim absent/other owner)None— could not verify (fence lock unavailable) — NOT evidence of lossCallers updated:
Falseinterrupts immediately;Nonefeeds the existing_FIRE_CLAIM_HEARTBEAT_GRACE_SECONDSgrace path (same as exceptions), so a run is interrupted only when ownership is positively observed changed or verification has failed continuously past the grace window.Nonefails closed asFire claim ownership could not be validated...(not "lost")._fire_claim_ownership_lost, shutdownmark_job_runpaths): onlyTruecounts as owned;Noneis not treated as loss.Verification
tests/cron/test_script_claim_heartbeat.py:test_unverifiable_fire_claim_not_killed_immediately— the exact incident: None heartbeats must NOT interrupt a live run.test_unverifiable_fire_claim_cancels_after_bounded_grace— sustained None past grace still cancels.test_initially_unverifiable_fire_claim_finishes_without_running— initial None fails closed without claiming loss.tests/cron/suite: 1035 passed, 1 skipped. The single failure (test_run_job_cron_execute_code_deny_does_not_pollute_later_gateway_execute_code) is pre-existing onmain(verified by stash) and unrelated (execute-code approval session isolation).Related
heartbeat_fire_claim()returns False on lock timeout and the scheduler kills a healthy run.