fix(kanban): live-claim guard keys on a live worker process, not on any claim

The first cut refused every claim-less complete of a running+claimed card,
which also refused the flows that have no worker to protect: a library or
CLI claim that never spawned a worker, and a worker whose process is gone
(12 sibling tests exercise exactly that shape). The guard now fires only
when tasks.worker_pid names a process that is still alive under its spawn
fingerprint (_worker_alive), which is the run the issue asked us to keep
open. Test updated to stand in as the live worker via _set_worker_pid.
This commit is contained in:
teknium1
2026-09-15 13:47:34 -07:00
committed by Teknium
parent 0959224313
commit 72916de360
2 changed files with 26 additions and 4 deletions
+11 -3
View File
@@ -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 = """
@@ -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")