diff --git a/hermes_cli/kanban_db.py b/hermes_cli/kanban_db.py index c30772e061..579084c1a8 100644 --- a/hermes_cli/kanban_db.py +++ b/hermes_cli/kanban_db.py @@ -2653,15 +2653,23 @@ def complete_task( return False if acceptance is not None and not record_acceptance(conn, task_id, acceptance): return False - trow = conn.execute("SELECT status, claim_lock FROM tasks WHERE id = ?", (task_id,)).fetchone() + trow = conn.execute( + "SELECT status, claim_lock, worker_pid, worker_started_at FROM tasks WHERE id = ?", + (task_id,), + ).fetchone() prior_status = trow["status"] if trow else None - # Refuse to close a live worker's run without proof of ownership - # (expected_run_id) or an explicit human override (force=True). + # Refuse to close a LIVE worker's run without proof of ownership + # (expected_run_id) or an explicit human override (force=True). "Live" + # means the spawned worker process still exists: a claim whose worker + # is gone (or a library claim that never spawned one) has no run to + # protect, so manual completion keeps working there. if ( expected_run_id is None and not force and prior_status == "running" and trow["claim_lock"] is not None + and trow["worker_pid"] + and _worker_alive(trow["worker_pid"], trow["worker_started_at"]) ): raise LiveClaimError(task_id) sql = """ diff --git a/tests/hermes_cli/test_kanban_complete_live_claim_guard.py b/tests/hermes_cli/test_kanban_complete_live_claim_guard.py index 471d482c65..ef8224e77f 100644 --- a/tests/hermes_cli/test_kanban_complete_live_claim_guard.py +++ b/tests/hermes_cli/test_kanban_complete_live_claim_guard.py @@ -10,11 +10,13 @@ row while that worker kept executing. The guard mirrors ``request_review``'s: a from __future__ import annotations +import os from pathlib import Path import pytest from hermes_cli import kanban_db as kb +from hermes_cli import kanban_db_dispatch as kbd from hermes_cli import kanban_db_connect as kbc @@ -31,9 +33,12 @@ def conn(tmp_path, monkeypatch): yield c -def _claimed_running_task(conn) -> tuple[str, int]: +def _claimed_running_task(conn, *, live_worker: bool = True) -> tuple[str, int]: tid = kb.create_task(conn, title="live", assignee="coder") assert kb.claim_task(conn, tid, claimer=kb._claimer_id()) is not None + if live_worker: + # This process stands in for the spawned worker: alive, fingerprinted. + kbd._set_worker_pid(conn, tid, os.getpid()) return tid, kb._current_run_id(conn, tid) @@ -56,6 +61,15 @@ def test_claimless_complete_refuses_live_run_until_forced(conn): assert run["ended_at"] is not None and run["outcome"] == "completed" +def test_claimless_complete_of_claim_without_live_worker_unchanged(conn): + """A claim whose worker never spawned (or is gone) protects no live run: the + library / CLI flow that claims and then completes keeps working.""" + tid, run_id = _claimed_running_task(conn, live_worker=False) + assert kb.complete_task(conn, tid, result="done") is True + run = conn.execute("SELECT ended_at FROM task_runs WHERE id = ?", (run_id,)).fetchone() + assert run["ended_at"] is not None + + def test_claimless_complete_of_unclaimed_card_unchanged(conn): """The legitimate manual flow — completing a card nobody is working on — needs no proof.""" tid = kb.create_task(conn, title="admin", assignee="coder")