fix(kanban): restrict goal_mode kanban_block to genuine external blockers

The judge gate added for kanban_complete (Issue #38367, PR #38388) only
covers one of the two exit paths out of run_kanban_goal_loop(). The loop
treats status == "blocked" as terminal identically to "done" (and any
other status outside running/ready/done/blocked also stops the loop —
see goals.py's status dispatch). A goal_mode worker that has learned
kanban_complete is gated can simply call kanban_block(reason="anything")
to escape the loop with zero judge involvement, fully defeating the
intent of #38367's fix.

This is Issue #38696, filed as the explicit follow-up by a reviewer on
PR #38388: "kanban_complete is one way out; kanban_block is another...
A worker that learns the complete path is gated can shift to calling
block to escape the loop with the same effect."

Implements the issue's "Option B" (deterministic allowlist, no extra
judge LLM call) using the kind taxonomy that already exists in
kb.VALID_BLOCK_KINDS, rather than inventing a new judge_goal() outcome
type (judge_goal only returns done/continue/wait/skipped — there's no
"is this block legitimate" verdict to hook the issue's "Option A"
pseudocode onto without expanding the judge's contract).

goal_mode tasks may only block with kind in {dependency, needs_input} —
the two kinds that represent a genuine external blocker the worker
cannot resolve itself. `capability`, `transient`, and an unset kind are
rejected with a message directing the worker to kanban_complete instead,
which the judge now gates. Non-goal_mode tasks are completely unaffected.
This commit is contained in:
srojk34 2026-06-30 23:07:44 +03:00 committed by Teknium
parent d8083221a8
commit 795913d3b0
2 changed files with 141 additions and 0 deletions

View file

@ -713,6 +713,120 @@ def test_block_rejects_empty_reason(worker_env):
assert json.loads(out).get("error")
def _make_goal_mode_worker_env(monkeypatch, tmp_path):
"""Set up an isolated HERMES_HOME with one claimed goal_mode task,
matching the pattern used by the kanban_complete judge gate tests."""
from pathlib import Path as _Path
from hermes_cli import kanban_db as kb
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setenv("HERMES_PROFILE", "test-worker")
monkeypatch.delenv("HERMES_SESSION_ID", raising=False)
monkeypatch.setattr(_Path, "home", lambda: tmp_path)
kb._INITIALIZED_PATHS.clear()
kb.init_db()
conn = kb.connect()
try:
goal_task_id = kb.create_task(
conn, title="goal-mode-block-test", assignee="test-worker",
body="Must achieve X.", goal_mode=True,
)
kb.claim_task(conn, goal_task_id)
finally:
conn.close()
monkeypatch.setenv("HERMES_KANBAN_TASK", goal_task_id)
return goal_task_id
def test_block_goal_mode_rejects_missing_kind(monkeypatch, tmp_path):
"""A goal_mode worker calling kanban_block with no kind must not be able
to use it as an unguarded escape from the goal loop (Issue #38696,
sibling of the kanban_complete judge gate / Issue #38367)."""
from tools import kanban_tools as kt
from hermes_cli import kanban_db as kb
tid = _make_goal_mode_worker_env(monkeypatch, tmp_path)
out = kt._handle_block({"reason": "giving up"})
d = json.loads(out)
assert "error" in d
assert "goal_mode" in d["error"]
conn = kb.connect()
try:
assert kb.get_task(conn, tid).status == "running"
finally:
conn.close()
def test_block_goal_mode_rejects_disallowed_kind(monkeypatch, tmp_path):
"""`capability` / `transient` are valid kinds in general but must not
let a goal_mode worker exit the loop without going through the judge."""
from tools import kanban_tools as kt
from hermes_cli import kanban_db as kb
tid = _make_goal_mode_worker_env(monkeypatch, tmp_path)
for kind in ("capability", "transient"):
out = kt._handle_block({"reason": "blocked", "kind": kind})
d = json.loads(out)
assert "error" in d, f"kind={kind} should be rejected for goal_mode"
conn = kb.connect()
try:
assert kb.get_task(conn, tid).status == "running"
finally:
conn.close()
def test_block_goal_mode_allows_dependency_kind(monkeypatch, tmp_path):
"""`dependency` and `needs_input` represent a genuine external blocker
the worker cannot resolve itself these remain ungated.
`dependency` routes to status='todo' (not 'blocked') per block_task's
own kind-routing the goal loop still treats anything outside
running/ready/done/blocked as a stop, so this is still a legitimate,
judge-free exit; it's just not the literal 'blocked' status."""
from tools import kanban_tools as kt
from hermes_cli import kanban_db as kb
tid = _make_goal_mode_worker_env(monkeypatch, tmp_path)
out = kt._handle_block({"reason": "waiting on another task", "kind": "dependency"})
d = json.loads(out)
assert d.get("ok") is True
conn = kb.connect()
try:
assert kb.get_task(conn, tid).status == "todo"
finally:
conn.close()
def test_block_goal_mode_allows_needs_input_kind(monkeypatch, tmp_path):
from tools import kanban_tools as kt
from hermes_cli import kanban_db as kb
tid = _make_goal_mode_worker_env(monkeypatch, tmp_path)
out = kt._handle_block({"reason": "need a decision from the user", "kind": "needs_input"})
d = json.loads(out)
assert d.get("ok") is True
conn = kb.connect()
try:
assert kb.get_task(conn, tid).status == "blocked"
finally:
conn.close()
def test_block_non_goal_mode_task_unaffected_by_new_gate(worker_env):
"""The new gate only applies to goal_mode tasks — plain tasks must keep
blocking freely with no kind, exactly as before this fix."""
from tools import kanban_tools as kt
out = kt._handle_block({"reason": "need clarification"})
assert json.loads(out).get("ok") is True
def test_heartbeat_happy_path(worker_env):
from tools import kanban_tools as kt
out = kt._handle_heartbeat({"note": "progress"})

View file

@ -179,6 +179,9 @@ def _connect(board: Optional[str] = None):
return kb, kb.connect(board=board)
_GOAL_MODE_BLOCK_ALLOWED_KINDS = frozenset({"dependency", "needs_input"})
def _goal_judge_available() -> bool:
"""True when an auxiliary client is configured for the goal judge.
@ -685,6 +688,30 @@ def _handle_block(args: dict, **kw) -> str:
return tool_error(
f"kind must be one of {sorted(kb.VALID_BLOCK_KINDS)} (or omit it)"
)
# Goal-mode block gate (Issue #38696, sibling of the kanban_complete
# judge gate in #38367). kanban_block is a second exit path out of
# the goal loop — run_kanban_goal_loop() treats ANY `blocked` status
# as terminal, identically to `done`, regardless of kind. Without
# this, a worker that learns kanban_complete is gated can just call
# kanban_block(reason="anything") to escape the loop instead.
# Restrict goal_mode tasks to the kinds that represent a genuine
# external blocker the worker cannot resolve itself; `capability`
# and `transient` (or an unset kind) route back through
# kanban_complete, which the judge now gates.
task = kb.get_task(conn, tid)
if (
task
and task.goal_mode
and kind not in _GOAL_MODE_BLOCK_ALLOWED_KINDS
):
conn.close()
return tool_error(
f"goal_mode tasks can only block with kind in "
f"{sorted(_GOAL_MODE_BLOCK_ALLOWED_KINDS)} (got {kind!r}). "
f"If the task is actually finished or cannot proceed for "
f"another reason, call kanban_complete instead — the "
f"completion judge will evaluate it."
)
try:
ok = kb.block_task(
conn, tid,