mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
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:
parent
d8083221a8
commit
795913d3b0
2 changed files with 141 additions and 0 deletions
|
|
@ -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"})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue