Files
hermes-agent/tests/hermes_cli/test_kanban_worker_pid_fingerprint.py
teknium1 db54f5448d fix(kanban): worker fingerprint carries a boot witness; an uncaptured fingerprint never authorizes a signal
#111617 review (andrexibiza P1 #3/#4, kvnloo nit):

- worker_started_at persisted only gateway.status.get_process_start_time(): on Linux that
  is /proc/<pid>/stat field 22, clock ticks since THIS boot. The threat is a row surviving
  a reboot, and that counter does not, so an unrelated process on a later boot with the
  same PID and the same tick value passed _start_times_agree(). The fingerprint is now
  "<gateway.drain_control.current_instantiation_epoch()>|<start>" (boot_id + PID-1 start,
  the witness the drain marker already uses); both halves must match. Integer values on
  rows written before this change keep the start-time-only comparison.
- A failed capture persisted NULL, which _pid_recycled treats as the legacy pre-fingerprint
  row and falls back to bare PID existence - a new spawn silently recreated the #89614/
  #99558 kill authority. A failed capture now persists UNVERIFIED_WORKER_FINGERPRINT: the
  claim is held while the PID is live (never released beside it, never SIGTERM/SIGKILLed
  by timeout, stale-claim, manual reclaim, archive or the terminal reaper) and reclaimed
  once it is gone. NULL stays legacy-only.
- Every tasks UPDATE that nulls worker_pid nulls worker_started_at too (archive_task and
  the reclaim/timeout/reopen paths): the fingerprint is part of the kill-authority tuple
  and must not outlive its pid.

Live (real sleeper child): reboot-shaped row (same pid, same tick, other boot id) ->
reclaimed to ready, child untouched; matching fingerprint -> SIGTERM delivered, exit -15.
tests/hermes_cli/test_kanban_worker_pid_fingerprint.py: +2 hostile tests, both red on base.

Not changed: the check-then-act window between _pid_recycled and kill (kvnloo P2) is
real but needs pidfd_open/pidfd_send_signal (Linux 5.3+) to close atomically; left as
the documented residual of "never kills a DETECTED recycled PID".
2026-09-16 00:35:00 -07:00

152 lines
7.2 KiB
Python

"""A recycled worker PID is never mistaken for our worker.
``tasks.worker_pid`` survives a reboot; the number can then belong to an unrelated process. Every
liveness decision (extend/defer the claim) and every kill (SIGTERM/SIGKILL on timeout or reclaim)
must require the spawn-time start fingerprint to match, never bare PID existence.
"""
import os
import signal
import time
import pytest
from hermes_cli import kanban_db as kb
from hermes_cli import kanban_db_connect as kbc
from hermes_cli import kanban_db_dispatch as kbd
@pytest.fixture
def board(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_KANBAN_CRASH_GRACE_SECONDS", "0")
conn = kbc.connect(tmp_path / "kanban.db")
try:
yield conn
finally:
conn.close()
def _claimed_running(conn, *, pid: int, started_at, max_runtime=None) -> str:
tid = kb.create_task(conn, title="job", assignee="worker", max_runtime_seconds=max_runtime)
kb.claim_task(conn, tid)
kbd._set_worker_pid(conn, tid, pid)
old = int(time.time()) - 3600
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET worker_started_at = ?, started_at = ?, claim_expires = ? WHERE id = ?",
(started_at, old, old, tid))
conn.execute("UPDATE task_runs SET started_at = ? WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
(old, tid))
return tid
def test_recycled_pid_is_reclaimed_without_being_signalled(board):
"""Our own live PID with a foreign fingerprint models a post-reboot recycle: the claim is released
(dead worker), no signal is sent, and max-runtime enforcement does not SIGTERM the stranger either."""
conn = board
killed = []
stranger_fingerprint = 1 # no live process started at tick 1
tid = _claimed_running(conn, pid=os.getpid(), started_at=stranger_fingerprint, max_runtime=1)
assert kbd._worker_alive(os.getpid(), stranger_fingerprint) is False
assert tid in kbd.enforce_max_runtime(conn, signal_fn=lambda pid, sig: killed.append((pid, sig)))
assert killed == []
task = kb.get_task(conn, tid)
assert task.status == "ready" and task.worker_pid is None
tid2 = _claimed_running(conn, pid=os.getpid(), started_at=stranger_fingerprint)
assert kb.release_stale_claims(conn, signal_fn=lambda pid, sig: killed.append((pid, sig))) == 1
assert killed == []
assert kb.get_task(conn, tid2).status == "ready"
def test_matching_fingerprint_keeps_the_live_worker(board):
"""The same PID with ITS OWN fingerprint (recorded at spawn) is our worker: the expired claim is
extended rather than reclaimed, and the timeout path signals it."""
from gateway.status import get_process_start_time
conn = board
killed = []
tid = _claimed_running(conn, pid=os.getpid(), started_at=get_process_start_time(os.getpid()))
assert kbd._worker_alive(os.getpid(), get_process_start_time(os.getpid())) is True
assert kb.release_stale_claims(conn) == 0
assert kb.get_task(conn, tid).status == "running"
kinds = [e.kind for e in kb.list_events(conn, tid)]
assert "claim_extended" in kinds
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET max_runtime_seconds = 1 WHERE id = ?", (tid,))
kbd.enforce_max_runtime(conn, signal_fn=lambda pid, sig: killed.append((pid, sig)))
assert killed and killed[0] == (os.getpid(), signal.SIGTERM)
def test_same_pid_and_start_tick_on_another_boot_is_foreign(board, monkeypatch):
"""A row that survived a reboot: the PID AND the boot-relative start tick both match a process on
this boot (the Linux start time is clock ticks since boot, so that recurs), but the persisted
instantiation epoch does not. The worker is foreign: claim released, zero signals."""
from gateway import drain_control
conn = board
killed = []
live_fingerprint = kbd._process_fingerprint(os.getpid())
assert live_fingerprint is not None and live_fingerprint.split("|", 1)[1] == str(
__import__("gateway.status", fromlist=["x"]).get_process_start_time(os.getpid()))
tid = _claimed_running(conn, pid=os.getpid(), started_at=live_fingerprint, max_runtime=1)
assert kbd._worker_alive(os.getpid(), live_fingerprint) is True
# Same PID, same start tick, different boot identity.
other_boot = "deadbeef-boot:1|" + live_fingerprint.split("|", 1)[1]
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET worker_started_at = ? WHERE id = ?", (other_boot, tid))
assert kbd._worker_alive(os.getpid(), other_boot) is False
assert tid in kbd.enforce_max_runtime(conn, signal_fn=lambda pid, sig: killed.append((pid, sig)))
assert killed == []
task = kb.get_task(conn, tid)
assert task.status == "ready" and task.worker_pid is None
# The same value re-derived on THIS boot still identifies our worker (the witness is stable
# within a boot, unlike the recorded epoch of a previous one).
drain_control.current_instantiation_epoch.cache_clear()
assert kbd._process_fingerprint(os.getpid()) == live_fingerprint
def test_unverified_fingerprint_capture_never_authorizes_a_signal(board, monkeypatch):
"""Fingerprint capture fails for a new spawn: the row is NOT a legacy NULL row. A live PID under
it is never SIGTERM/SIGKILLed by any reclaim/timeout path, and the claim is held (not released
beside the live process); once the PID is gone the claim is reclaimed normally."""
import gateway.status as status
conn = board
killed = []
monkeypatch.setattr(status, "_get_process_start_time", lambda pid: None)
tid = kb.create_task(conn, title="job", assignee="worker", max_runtime_seconds=1)
kb.claim_task(conn, tid)
kbd._set_worker_pid(conn, tid, os.getpid())
row = conn.execute("SELECT worker_started_at FROM tasks WHERE id = ?", (tid,)).fetchone()
assert row["worker_started_at"] == kbd.UNVERIFIED_WORKER_FINGERPRINT
monkeypatch.undo()
old = int(time.time()) - 3600
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET started_at = ?, claim_expires = ? WHERE id = ?", (old, old, tid))
conn.execute("UPDATE task_runs SET started_at = ? WHERE id = (SELECT current_run_id FROM tasks WHERE id = ?)",
(old, tid))
sig = lambda pid, s: killed.append((pid, s)) # noqa: E731
assert kbd.enforce_max_runtime(conn, signal_fn=sig) == []
assert kb.release_stale_claims(conn, signal_fn=sig) == 0
assert killed == []
assert kb.get_task(conn, tid).status == "running"
# An explicit operator reclaim releases the claim (human override) but still sends nothing.
assert kb.reclaim_task(conn, tid, reason="operator", signal_fn=sig) is True
assert killed == []
# The process is gone (a dead PID): the row is reclaimed like any dead worker, still no signal.
tid2 = kb.create_task(conn, title="job2", assignee="worker")
kb.claim_task(conn, tid2)
with kb.write_txn(conn):
conn.execute("UPDATE tasks SET worker_pid = ?, worker_started_at = ?, claim_expires = ? WHERE id = ?",
(os.getpid(), kbd.UNVERIFIED_WORKER_FINGERPRINT, old, tid2))
monkeypatch.setattr(kb, "_pid_alive", lambda pid: False)
assert kb.release_stale_claims(conn, signal_fn=sig) == 1
assert killed == [] and kb.get_task(conn, tid2).status == "ready"