fix(kanban): guard block_task against live-claim theft - #83728
Conversation
request_review() just gained an M1 guard (1810cfc): a caller with no expected_run_id must pass force=True before it clears claim_lock/worker_pid on a running, live-claimed task. block_task() has the identical shape (same optional expected_run_id, same unconditional claim clear) but never got the same guard — the dashboard's PATCH/bulk-update "blocked" transitions and a plain `hermes kanban block <id>` from a shell can silently steal an active worker's claim and end its in-flight run out from under it, with no ownership proof and no way to make the override explicit. Mirrors the M1 pattern: block_task() now refuses (returns False) when the task is running under a live claim and the caller supplies neither expected_run_id (worker ownership) nor force=True (explicit operator override). Dashboard PATCH/bulk-update pass force=True (explicit human action, matching request_review's dashboard wiring); `hermes kanban block` gains a --force flag mirroring request-review's. Also fixes three existing tests/fixtures whose block_task() calls modeled a worker blocking its own live-claimed task without ever proving ownership (expected_run_id was never realistic for that scenario before this guard existed): tests/hermes_cli/test_kanban_block_kinds.py now passes the current_run_id, and tests/tools/test_kanban_tools.py's worker_env fixture now sets HERMES_KANBAN_RUN_ID alongside HERMES_KANBAN_TASK like a real worker spawn does.
fix(kanban): guard block_task against live-claim theft — mirrors the request_review M1 guard correctly, and updating the worker-env test fixture with
|
Summary
request_review()just gained an M1 guard (commit1810cfc8dd, same day): a caller with noexpected_run_idmust passforce=Truebefore it clearsclaim_lock/worker_pidon a running, live-claimed task — otherwise the transition is refused instead of silently stealing the live worker's claim.block_task()has the identical shape (same optionalexpected_run_id, same unconditionalclaim_lock/worker_pidclear on therunning/readyupdate) but never got the same guard. Concretely:PATCH /tasks/{id}(single) andPOST /tasks/bulk(bulk) "blocked" transitions callkanban_db.block_task()with noexpected_run_idat all.hermes kanban block <id>from a shell resolvesexpected_run_idvia_worker_run_id_for(tid), which only returns non-Noneinside a worker's own execution context (matchingHERMES_KANBAN_TASK/HERMES_KANBAN_RUN_ID) — a human running the command from their own shell getsNone.So today, an operator (or a second/stale dashboard tab, or a scripted CLI caller) can mark a task
blockedwith zero ownership proof, silently clearing an actively-running worker's claim and ending its in-flight run out from under it — the exact class of bug M1 just closed forrequest_review(), left open here.Fix
Mirrors the M1 pattern exactly:
block_task()gains aforce: bool = Falseparameter. When the task isrunningunder a live claim (claim_lock IS NOT NULL) and the caller supplies neitherexpected_run_id(worker ownership) norforce=True(explicit operator override), the call now returnsFalseinstead of clearing the claim.PATCH/bulk-update passforce=True— an explicit human action, matching howrequest_review's dashboard wiring was already updated.hermes kanban blockgains a--forceflag, mirroringrequest-review's.Existing worker call sites (
cli.py's goal-loop_block(),tools/kanban_tools.py'skanban_blocktool) already passexpected_run_idand are unaffected.Test plan
test_block_task_refuses_to_clear_live_claim_without_ownership(mirrorsrequest_review's own M1 regression test): asserts a run-id-less, force-less call is refused with the live claim untouched, the worker-ownership path (expected_run_id) still works, andforce=Truestill works as an explicit override.hermes_cli/kanban_db.pyfix and confirmed the new test fails against pre-fix code (AssertionError: assert True is False).block_task()calls modeled a worker blocking its own live-claimed task without ever proving ownership (a shape that was never realistic before this guard existed, and is now correctly refused):tests/hermes_cli/test_kanban_block_kinds.py's two block-loop/dependency-routing tests now passexpected_run_id, andtests/tools/test_kanban_tools.py'sworker_envfixture now also setsHERMES_KANBAN_RUN_IDalongsideHERMES_KANBAN_TASK, matching how a real worker spawn's environment looks (kanban_db.py's dispatcher already sets both).test_kanban_core_functionality.py,test_kanban_blocked_sticky.py,test_kanban_review_lifecycle{,_complete}.py,test_kanban_block_kinds.py,test_kanban_review_surfaces.py,test_kanban_swarm.py,test_kanban_dashboard_plugin.py,test_kanban_tools.py): 136 passed, 1 skipped, 1 pre-existing failure unrelated to this change (test_review_tools_are_gated_and_visible_to_kanban_workersfails onmaintoo — missingacppackage in this environment, not caused by this diff).tests/stress/test_atypical_scenarios.pydirectly (its own scenario runner): 2 pre-existing failures (workspace_nonexistent_path,parent_in_different_status_states) reproduce identically onmainwithout this change — confirmed viagit stash, not a regression from this PR.ruff checkclean on all changed files.Scope note
complete_task()has the same shape and arguably deserves the same guard, but two open PRs (#73188, #81170) are already circling that specific function with different, not-fully-overlapping approaches — this PR is scoped toblock_task()only to avoid stepping on that in-flight work.