Files
hermes-agent/tests/hermes_cli/test_kanban_terminal_worker_reaper.py

150 lines
5.9 KiB
Python

"""Invariant: a worker that outlives its own terminal transition is still
reachable and gets reaped by the dispatcher (issue #111791).
``complete_task`` wipes ``tasks.worker_pid`` and the ``running``-only reclaim
sweeps never look at a ``done`` card again, so a worker that called
``kanban_complete`` and then hung (holding deleted ``state.db`` sidecar fds)
was invisible to every command. The closed ``task_runs`` row now keeps the pid
and its spawn fingerprint, and ``reap_terminal_workers`` ends such a worker on
the next tick — never a recycled PID, never a legacy row without a fingerprint.
"""
from __future__ import annotations
import os
import subprocess
import sys
import time
from pathlib import Path
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 conn(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
db_path = kb.kanban_db_path(board="default")
kb._INITIALIZED_PATHS.discard(str(db_path.resolve()))
kb.init_db()
with kbc.connect() as c:
yield c
def _sleeper():
proc = subprocess.Popen(
[sys.executable, "-c", "import time; time.sleep(120)"],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
time.sleep(0.2)
return proc
def _completed_card_with_worker(conn, proc, *, ended_ago: int = 600) -> tuple[str, int]:
tid = kb.create_task(conn, title="finished", assignee="coder")
kb.claim_task(conn, tid, claimer=kb._claimer_id())
run_id = kb._current_run_id(conn, tid)
kbd._set_worker_pid(conn, tid, proc.pid)
assert kb.complete_task(conn, tid, result="done", expected_run_id=run_id) is True
# Default: the run closed long enough ago that the reaper's grace window has passed.
conn.execute("UPDATE task_runs SET ended_at = ended_at - ? WHERE id=?", (ended_ago, run_id))
return tid, run_id
def test_worker_alive_after_completion_is_reaped_on_dispatch_tick(conn):
proc = _sleeper()
try:
tid, run_id = _completed_card_with_worker(conn, proc)
# The evidence the running-only sweeps lost lives on the closed run row.
run = conn.execute("SELECT worker_pid, worker_started_at, ended_at FROM task_runs WHERE id=?", (run_id,)).fetchone()
assert run["ended_at"] is not None and run["worker_pid"] == proc.pid and run["worker_started_at"] is not None
result = kbd.dispatch_once(conn, spawn_fn=lambda *a, **k: 0, dry_run=True, max_spawn=0)
assert result.reaped_terminal_workers == [tid]
assert proc.wait(timeout=10) is not None
run = conn.execute("SELECT worker_pid, worker_started_at FROM task_runs WHERE id=?", (run_id,)).fetchone()
assert run["worker_pid"] is None and run["worker_started_at"] is None
kinds = [r["kind"] for r in conn.execute("SELECT kind FROM task_events WHERE task_id=?", (tid,))]
assert "terminal_worker_reaped" in kinds
finally:
if proc.poll() is None:
proc.kill()
proc.wait()
def test_recycled_pid_and_legacy_row_are_never_signalled(conn):
stranger = _sleeper()
legacy = _sleeper()
try:
tid, run_id = _completed_card_with_worker(conn, stranger)
# PID reuse: the recorded fingerprint belongs to a process that no longer exists.
conn.execute("UPDATE task_runs SET worker_started_at = worker_started_at - 1000000 WHERE id=?", (run_id,))
_, legacy_run = _completed_card_with_worker(conn, legacy)
conn.execute("UPDATE task_runs SET worker_started_at = NULL WHERE id=?", (legacy_run,))
assert kbd.reap_terminal_workers(conn) == []
assert stranger.poll() is None and legacy.poll() is None
# Stale evidence is dropped so the row is not rescanned; the legacy row is left alone.
assert conn.execute("SELECT worker_pid FROM task_runs WHERE id=?", (run_id,)).fetchone()["worker_pid"] is None
assert conn.execute("SELECT worker_pid FROM task_runs WHERE id=?", (legacy_run,)).fetchone()["worker_pid"] == legacy.pid
finally:
for p in (stranger, legacy):
p.kill()
p.wait()
def test_fresh_terminal_run_is_left_alone_until_grace_passes(conn):
"""A worker is still alive for a moment after its own kanban_complete returns
(final turn, session persistence): a just-closed run is not signalled."""
proc = _sleeper()
signals = []
def signal_fn(pid, sig):
signals.append((pid, sig))
os.kill(pid, sig)
try:
tid, run_id = _completed_card_with_worker(conn, proc, ended_ago=0)
assert kbd.reap_terminal_workers(conn, signal_fn=signal_fn) == []
assert signals == [] and proc.poll() is None
run = conn.execute("SELECT worker_pid FROM task_runs WHERE id=?", (run_id,)).fetchone()
assert run["worker_pid"] == proc.pid # evidence kept for a later tick
conn.execute(
"UPDATE task_runs SET ended_at = ended_at - ? WHERE id=?",
(kbd.TERMINAL_WORKER_REAP_GRACE_SECONDS, run_id),
)
assert kbd.reap_terminal_workers(conn, signal_fn=signal_fn) == [tid]
assert [pid for pid, _ in signals] == [proc.pid]
finally:
proc.kill()
proc.wait()
def test_one_failing_row_does_not_abort_the_sweep(conn):
"""A signal failure on one run is logged and skipped; the other rows are still reaped."""
broken, healthy = _sleeper(), _sleeper()
try:
_completed_card_with_worker(conn, broken)
tid, _ = _completed_card_with_worker(conn, healthy)
def signal_fn(pid, sig):
if pid == broken.pid:
raise RuntimeError("boom")
healthy.kill()
assert kbd.reap_terminal_workers(conn, signal_fn=signal_fn) == [tid]
finally:
for p in (broken, healthy):
p.kill()
p.wait()