fix(kanban): claim-less complete no longer closes a live worker's run

complete_task authorised a terminal transition by task status alone; the
`current_run_id = ?` fence only applied when the caller volunteered
expected_run_id (derived from HERMES_KANBAN_* env). A human at the CLI, an
orchestrator session or any env-less caller therefore marked a `running`
card done and _end_run closed the dispatcher worker's run row while that
worker kept executing (#111764).

Mirror the fence request_review already carries: a `running` task under a
live claim needs expected_run_id (worker ownership) or force=True (explicit
operator override), otherwise LiveClaimError. `hermes kanban complete
--force` and the dashboard's "mark done" (a human action) carry the override;
the kanban_complete tool reports a structured refusal. Completing `ready`,
`blocked` or `review` cards without a claim is unchanged, so the manual /
orchestrator flows PR #73188 pinned keep working.

Fixes #111764
This commit is contained in:
teknium1
2026-09-15 12:07:49 -07:00
committed by Teknium
parent c7f4bc5bd7
commit 0959224313
7 changed files with 118 additions and 8 deletions
+9 -2
View File
@@ -885,8 +885,15 @@ def _cmd_complete(args: argparse.Namespace) -> int:
fail_msg[tid] = gate_err
return False
fail_msg[tid] = f"cannot complete {tid} (unknown id or terminal state)"
return kb.complete_task(conn, tid, result=args.result, summary=summary, metadata=metadata,
expected_run_id=_worker_run_id_for(tid))
try:
return kb.complete_task(conn, tid, result=args.result, summary=summary, metadata=metadata,
expected_run_id=_worker_run_id_for(tid),
force=bool(getattr(args, "force", False)))
except kb.LiveClaimError:
fail_msg[tid] = (f"cannot complete {tid}: a live worker is running it. Wait for the "
f"worker, `hermes kanban reclaim {tid}` to release it, or re-run with "
f"--force to close its run and complete anyway.")
return False
return _bulk_apply(ids, op, lambda tid: f"Completed {tid}", fail_msg.__getitem__)
+31 -3
View File
@@ -2599,16 +2599,34 @@ class ArtifactPreservationError(RuntimeError):
"""Raised when a declared scratch deliverable cannot be preserved."""
class LiveClaimError(ValueError):
"""``complete_task`` refused: the task is ``running`` under a live claim and
the caller neither owns its run (``expected_run_id``) nor passed ``force``.
Completing anyway would close the worker's run row underneath a process
that is still executing. A ``ValueError`` so tool error handlers treat it
as recoverable."""
def __init__(self, task_id: str):
super().__init__(
f"{task_id} is running under a live worker claim; pass expected_run_id "
"(worker ownership) or force=True (explicit operator override) instead "
"of closing the live run"
)
def complete_task(
conn: sqlite3.Connection, task_id: str, *, result: Optional[str] = None,
summary: Optional[str] = None, metadata: Optional[dict] = None,
created_cards: Optional[Iterable[str]] = None, expected_run_id: Optional[int] = None,
fire_lifecycle_hook: bool = True,
fire_lifecycle_hook: bool = True, force: bool = False,
) -> bool:
"""``running|ready|blocked|review -> done``; records ``result``.
``ready`` is accepted for manual CLI completion, ``review`` for human
approval; with no active run the handoff fields survive via
approval. A ``running`` task under a live claim is only completed with
proof of ownership (``expected_run_id``) or ``force=True`` (explicit
operator override) — otherwise :class:`LiveClaimError`, the same fence
:func:`request_review` applies. With no active run the handoff fields survive via
:func:`_synthesize_ended_run`. ``summary`` (defaults to ``result``) and
``metadata`` land on the closing run for :func:`build_worker_context`.
``created_cards`` are verified first — a phantom id raises
@@ -2635,7 +2653,17 @@ def complete_task(
return False
if acceptance is not None and not record_acceptance(conn, task_id, acceptance):
return False
prior_status = _task_status(conn, task_id)
trow = conn.execute("SELECT status, claim_lock 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).
if (
expected_run_id is None
and not force
and prior_status == "running"
and trow["claim_lock"] is not None
):
raise LiveClaimError(task_id)
sql = """
UPDATE tasks
SET status = 'done',
+3
View File
@@ -284,6 +284,9 @@ _SPECS = [
_arg("--metadata",
help='JSON dict of structured facts (e.g. \'{"changed_files": [...], '
'"tests_run": 12}\'). Stored on the closing run.'),
_arg("--force", action="store_true",
help="Override the live-claim guard: complete a running, claimed task "
"even without owning its run (closes the worker's run)."),
], help="Mark one or more tasks done"),
_cmd("edit", [
_TASK_ID,