Repository navigation
Conversation
…died The execution ledger stores a per-process start-time fingerprint (`process_started_at`) as a PID-reuse guard, and `_owner_is_live` treats ANY mismatch as proof the owner died -- which rewrites the attempt to `unknown`. That fingerprint is not stable enough to carry that weight: on macOS the kernel re-derives a live process's start time across a sleep/wake clock correction. Observed 2026-09-14 on a live gateway (single scheduler identity, single pid): `_process_start_time(os.getpid())` read 178940554382 for 2h21m (16:27:01 -> 18:47:53 -- both edges land exactly on `pmset -g log` sleep transitions) while the same live pid also reads 178940554282, and no process on the host matches the former value. 134 ledger rows carried the drifted value. Every one still in flight when a recovery pass ran was flipped to `unknown`, and because `finish_execution` only rewrites `claimed`/`running` rows the run's real terminal state was silently dropped: 25 of that day's 44 `unknown` rows belonged to two healthy jobs firing every 10 minutes. Only a *gross* mismatch actually proves reuse -- the pid must be recycled first and allocation is sequential, so a reused pid's start time lands minutes-to-hours away, never a second. A 5s tolerance keeps the reuse guard while surviving clock drift, and an unreadable fingerprint is now treated as unknown rather than death, matching the sibling gate `gateway.status._scoped_lock_owner_state`. Scope: the ledger's liveness gate only. The strict-equality start-time checks in `gateway/status.py` are deliberately left alone -- they gate process *termination*, where strictness is the safety property. Tests: `tests/cron/test_execution_ledger.py` (+3 cases). Non-vacuity: with `cron/executions.py` reverted, the drift and unreadable-fingerprint cases fail (1 == 0 assertions); the recycled-pid case passes both ways by design, since it exists to prove the tolerance does not over-reach. Full file: 28 passed.
Duplicate of #103249, which already replaces the exact start-time fingerprint match in |
Review: fix(cron) drifted start-time fingerprint must not prove owner diedSummary: Relaxes What changed:
Strengths: Excellent failure analysis with a principled threshold argument (sequential PID allocation ⇒ reuse lands minutes/hours away, never ~1s). The guard test ( Findings: One consideration, non-blocking: the tolerance (5s) vs. observed drift (1s) leaves 4s of unexplained headroom; a comment justifying the headroom (clock slew bounds) would strengthen it, but PID-reuse distance makes this safe in practice. Also: Verdict: Looks good to merge (after confirming CI runs green). Reviewed using Hermes-Agent |
|
The kanban dispatcher has the same class of bug this PR fixes for cron — the sleep/wake fingerprint drift also breaks Live occurrence (2026-09-21, one card,
{"stale_lock": "Boy-Of-MacBook-Air.local:11469", "worker_pid": 21366, "host_local": true, "heartbeat_stale": false, "termination_attempted": false, "terminated": true, "sigkill": false, "pid_recycled": true}Note
Where the kanban side diverges from cron: in Two additional observations from this incident, if useful:
Evidence preserved: board DB reclaim events, worker log (shows the live pid continuing to write after the reclaim), and the |
|
Follow-up: filed the kanban-side consumer of the same unstable fingerprint as #118326 (stale-claim reaper releases a live worker's claim after sleep/wake), so the kanban gap is trackable independently of this PR's cron-side fix. Cross-linking both ways. |
|
Independent measurement from the same call site ( Host: macOS, one scheduler identity throughout, no duplicate claimant. So the same live process fingerprints 2-3 s apart between calls (psutil reports a whole-second boot time, so the centisecond field re-derives). Relevant to this PR's boundary tests: the shared Locally I run the same comparator from the ledger path with an explicit, larger window: START_TIME_TOLERANCE_CENTISECONDS = 1000 # 10 s, far below any plausible PID-reuse interval
return start_time_fingerprints_match(started_at, current, tolerance=START_TIME_TOLERANCE_CENTISECONDS)Not attached as a competing PR — happy to adopt whatever window this one lands on (or to supply the test case as a patch here) so the ledger path and the runtime-status path don't end up with different tolerances by accident. |
What
cron.executions._owner_is_livetreats any mismatch between a ledger row's stored start-time fingerprint and the live fingerprint of the owning pid as proof that the owner died, andrecover_interrupted_executions()then rewrites the attempt tounknown.That fingerprint is a PID-reuse guard, not an identity proof, and it is not stable: on macOS the kernel re-derives a live process's start time across a sleep/wake clock correction.
Evidence (2026-09-14, one host, one scheduler)
process_id, onepid(the running gateway), one live process. There was no duplicate claimant._process_start_time(os.getpid())read 178940554382 for 2h21m —16:27:01 → 18:47:53, and both edges land exactly onpmset -g logsleep transitions.create_time()=1789405542.824212, stable over 300 consecutive samples), and no process on the host matches the drifted value.unknown.finish_executiononly rewritesclaimed/runningrows, the flip silently drops the run's real terminal state. Example wave at18:39:33: four jobs claimed within 54 ms; three were flipped tounknownby one recovery pass sharingfinished_at=…33.687332, while the fourth (committed after that pass's snapshot) recordedcompletednormally at…33.695897.unknownrows belonged to two healthy jobs firing every 10 minutes — a false "unknown whether side effects ran" for runs that completed fine.Fix
_FINGERPRINT_DRIFT_TOLERANCE = 500centiseconds (5s).None) is nowunknown, not death. That matches the sibling gategateway.status._scoped_lock_owner_stateand this module's own fail-safe rule ("inability to prove death must not rewrite state").Scope
Deliberately limited to the ledger's liveness gate. The strict-equality start-time checks in
gateway/status.py(_start_times_agree, used byterminate_pid(..., expected_start_time=...)) are not loosened: they gate process termination, where exactness is the safety property being protected.Tests
tests/cron/test_execution_ledger.py, +3 cases:test_recovery_keeps_live_owner_whose_start_time_driftedtest_recovery_leaves_an_unreadable_fingerprint_alonetest_recovery_still_marks_a_recycled_pid_deadWhole file: 28 passed. Non-vacuity verified by reverting only
cron/executions.pyand re-running the new cases.