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:
@@ -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
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user