fix(issue-18): drain the previous heartbeat timer before handing over its pidfile

Review round 3 (planner-reviewer-codex-01, NOT PASS R3 at 7e1d3f0):
start_timer stopped a running timer with a bare TERM and overwrote the only
pidfile. A beat the old timer had already admitted - seq taken, recorded on
disk, held at the wire - stayed alive but undiscoverable, so a later
terminal publish drained only the replacement and the old beat could still
put a retained `running` on the broker after `completed`.

- heartbeat.start_timer: stop_timer(drain=MAM_HEARTBEAT_DRAIN_S). The old
  timer is drained or killed, and confirmed gone, before the pidfile names
  the new one. A terminal publish racing the replacement either drains the
  same old pid or meets a new timer whose beats the barrier refuses.
- test: an old timer held at the wire after admission is replaced, then
  the job completes; the old timer is gone before the handover and nothing
  it held follows the terminal (fails on 7e1d3f0).
- docs: MESSAGING 4.3 no longer says any state resets the idle clock;
  MESSAGING 2.5 and VERSIONS state the replacement rule; test count 26.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
This commit is contained in:
GodopuandClaude Opus 5 committed 2026-09-13 23:43:48 +09:00
1 parent 7e1d3f0815
commit ab842f1ee0
4 files changed
+98 -5

No files matched your search

@@ -258,10 +258,14 @@ def start_timer(job_id: str, registry_dir: str, interval: int,
A timer that already runs for the job is stopped first: a second `started`
for the same job (a retry of the same command) must not double the rate.
It is drained as at a terminal event - and killed if it does not exit -
before its pidfile is handed over (review round 3, codex R3): a beat it had
already admitted would otherwise still reach the wire after a terminal
publish that can find only the new timer through the pidfile.
``watch_pid`` binds the timer to a process (default: the nearest agent-CLI
ancestor of this process, if any); 0 disables the watch explicitly.
"""
stop_timer(job_id, registry_dir)
stop_timer(job_id, registry_dir, drain=drain_seconds())
if watch_pid is None:
watch_pid = nearest_agent_cli_ancestor()
cmd: List[str] = [python or sys.executable, "-m", "lib_py.delegation.heartbeat",