fix(execution): write the file heartbeat in place — os.replace races concurrent probe reads on Windows (ALP-951) - #356
Conversation
…concurrent probe reads on Windows (ALP-951) FileHeartbeatSink.beat() wrote a .tmp sibling then os.replace'd it onto the heartbeat path. On Windows, MoveFileEx(MOVEFILE_REPLACE_EXISTING) needs delete access on the destination, which CPython's open (FILE_SHARE_READ|WRITE, no FILE_SHARE_DELETE) denies for the duration of the watchdog probe's read_text — PermissionError WinError 5, ~30/day on monitor.heartbeat. beat() now performs a single in-place write_text; a probe read landing inside the truncate-write window sees an empty file, which FileHeartbeatProbe.age() already maps to None (startup-grace path, never a restart trigger). Docstrings now state the per-write-freshness + probe None-tolerance contract. Tests cover the held-open-handle collision, the no-.tmp-sibling contract, and the empty-file None path. Closes ALP-951. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ALP-951 FileHeartbeatSink os.replace races concurrent watchdog-probe reads on Windows — intermittent PermissionError WinError 5 (~30/day on monitor.heartbeat); write the beat in place
SymptomSince the ALP-941 deploy began writing the monitor's file heartbeat (2026-06-09 ~23:28 MT), — 28 times on 2026-06-10 (first at 00:20:41 MT) and 33 times on 2026-06-11 (through ~12:00 MT). Failures are isolated singles or pairs spaced exactly 15 s apart. Each failure is caught and the loop continues; no false watchdog restart has occurred yet (every 06-11 restart maps to a real ALP-946 wedge dump). Evidence (prod logs MT; code at file:line)
Root cause (confirmed)
Why it escaped tests
Scope — write the beat in place; the probe's existing None-tolerance is the atomicity contract(A) In-place write. (B) Docstrings. Update the module docstring ( (C) Tests. Update Acceptance criteria
Verification
One-time prod data cleanupNo bulk cleanup needed. The failures only skipped individual heartbeat refreshes; no DB state is involved. The stale Related
|
…c prefix is unobservable The watchdog restarts on a single stale probe, so a torn read parsing to an ancient epoch would be a false restart; beat()'s docstring now states the all-or-nothing visibility of the single small write that rules it out. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
FileHeartbeatSink.beat()now performs a single in-placewrite_textto the heartbeat path — no.tmpsibling, noos.replace. On Windows, the replace required delete access on the destination, which the watchdog probe's concurrentread_textdenies (CPython opens withoutFILE_SHARE_DELETE) →PermissionError [WinError 5], ~30/day onmonitor.heartbeat.FileHeartbeatProbe.age()already maps toNone(startup-grace path, never a restart trigger). Docstrings updated accordingly; no write-temp/os.replacereferences remain..tmp-sibling + exact-content contract, and the empty-file →Noneprobe path.beat()sits outside the per-tick try/except, so once its watchdog resumes probing, the first collision would have killed the process.Closes https://linear.app/alphamind-jatassi/issue/ALP-951/fileheartbeatsink-osreplace-races-concurrent-watchdog-probe-reads-on
Test plan
.github/workflows/ci.yml) green on the PR — lint on Linux + full pytest on Windowsruff check,ruff format --check,mypy,lint-imports)uv run pytest tests/execution/process_supervision/ -n auto— 15 passed🤖 Generated with Claude Code