feat(kanban): typed block reasons + unblock-loop breaker (#52848)

* feat(kanban): typed block reasons + unblock-loop breaker

Stops the kanban blocked-task loop: a worker blocks a task, a cron
unblocks it, the worker re-blocks for the same reason, repeat forever.

block_task now takes a typed kind and a persistent block_recurrences
counter on the tasks table:

- kind=dependency routes to todo (parent-gated, auto-resumed), never
  the human 'blocked' bucket a cron would keep unblocking.
- needs_input/capability/transient/untyped land in blocked; each
  same-cause re-block after an unblock increments block_recurrences,
  and at BLOCK_RECURRENCE_LIMIT (default 2) the task routes to triage
  for a human instead of blocked.
- unblock_task no longer resets block_recurrences (the amnesia that
  let the loop run unbounded); complete_task clears it on success.

Wired through the worker kanban_block tool (new kind arg) and the
hermes kanban block --kind CLI flag, both reporting where the task
actually landed. Docs + 11 new tests; 536 existing kanban tests green.

* test(kanban): make second-block notify test use a distinct block cause

test_notifier_second_blocked_delivers blocked the same task twice with
the same (untyped) reason, which now trips the new unblock-loop breaker
and routes the second block to triage instead of blocked — so only one
'blocked' notification fired. The test's actual intent is that TWO
distinct block cycles each notify; give the two cycles different kinds
(needs_input then capability) so they're genuinely separate blocks. The
same-cause loop→triage path is covered by test_kanban_block_kinds.py.
This commit is contained in:
Teknium 2026-06-25 21:46:58 -07:00 committed by GitHub
parent 43b8ba4181
commit 5b5c79a8ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 563 additions and 61 deletions

View file

@ -623,13 +623,20 @@ def _handle_block(args: dict, **kw) -> str:
if not reason or not str(reason).strip():
return tool_error("reason is required — explain what input you need")
reason = redact_sensitive_text(str(reason), force=True)
kind = args.get("kind")
board = args.get("board")
try:
kb, conn = _connect(board=board)
if kind is not None and kind not in kb.VALID_BLOCK_KINDS:
conn.close()
return tool_error(
f"kind must be one of {sorted(kb.VALID_BLOCK_KINDS)} (or omit it)"
)
try:
ok = kb.block_task(
conn, tid,
reason=reason,
kind=kind,
expected_run_id=_worker_run_id(tid),
)
if not ok:
@ -638,7 +645,15 @@ def _handle_block(args: dict, **kw) -> str:
f"running/ready)"
)
run = kb.latest_run(conn, tid)
return _ok(task_id=tid, run_id=run.id if run else None)
# Tell the worker where the task actually landed so it doesn't
# assume it's sitting in 'blocked' when routing sent it elsewhere.
landed = kb.get_task(conn, tid)
return _ok(
task_id=tid,
run_id=run.id if run else None,
status=landed.status if landed else "blocked",
block_kind=kind,
)
finally:
conn.close()
except ValueError as e:
@ -1192,11 +1207,16 @@ KANBAN_COMPLETE_SCHEMA = {
KANBAN_BLOCK_SCHEMA = {
"name": "kanban_block",
"description": (
"Transition the task to blocked because you need human input "
"to proceed. ``reason`` will be shown to the human on the "
"board and included in context when someone unblocks you. "
"Use for genuine blockers only — don't block on things you can "
"resolve yourself."
"Stop work on this task and route it according to WHY you're stuck. "
"Set ``kind`` to say which: 'dependency' (waiting on another task — "
"goes to todo and auto-resumes when that task finishes, no human "
"needed), 'needs_input' (you need a human decision/answer), "
"'capability' (a hard wall: no access, missing credentials, an action "
"no agent can do), or 'transient' (a flaky failure that may clear). "
"``reason`` is shown to the human on the board. If a task keeps "
"getting unblocked and re-blocked for the same reason, it is "
"auto-escalated to triage. Use for genuine blockers only — don't "
"block on things you can resolve yourself."
),
"parameters": {
"type": "object",
@ -1208,9 +1228,18 @@ KANBAN_BLOCK_SCHEMA = {
"reason": {
"type": "string",
"description": (
"What you need answered, in one or two sentences. "
"Don't paste the whole conversation; the human has "
"the board and can ask follow-ups via comments."
"What you need answered or what stopped you, in one or "
"two sentences. Don't paste the whole conversation; the "
"human has the board and can ask follow-ups via comments."
),
},
"kind": {
"type": "string",
"enum": ["dependency", "needs_input", "capability", "transient"],
"description": (
"Why you're blocked. 'dependency' waits in todo and "
"resumes automatically; the others surface to a human. "
"Omit only if none apply."
),
},
"board": _board_schema_prop(),