Kanban goal-mode judge gate fixes and feature
NousResearch/hermes-agent · cluster 38367 · cron
Data as of
Nothing open on GitHub. Fix merged in #55408. Check it covers your case.
The short answer
- Known?
- Yes. 10 related items grouped here, 4 issues already closed.
- Fix event
- A fix was merged to main in PR #55408. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
partially resolved, deciding PR #55408. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and PRs concern adding and correcting an external judge gate that controls task completion in kanban goal-mode, with recurring focus on a tuple-unpacking bug where judge_goal()'s 4-tuple return value was being unpacked as 3 values, causing crashes or fail-open behavior that allowed workers to bypass the judge.
Open issues0
Open PRs0
Fixes on record
-
#55408 fix(kanban): gate goal_mode task completion with auxiliary judge
files:
tests/tools/test_kanban_tools.py,tools/kanban_tools.pyShow the diff
-
tests/tools/test_kanban_tools.py@@ -589,11 +589,108 @@ def test_complete_retry_with_corrected_created_cards_succeeds(worker_env): })) assert ok.get("ok") is True + +def test_complete_goal_mode_rejected_by_judge(monkeypatch, tmp_path): + """Goal-mode tasks must pass the auxiliary judge before completion. + Regression for #38367: workers bypassing the judge via early kanban_complete.""" + from pathlib import Path as _Path + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + # Set up isolated HERMES_HOME + 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: - assert kb.get_task(conn, worker_env).status == "done" + goal_task_id = kb.create_task( + conn, title="goal-mode-test", assignee="test-worker", + body="Must achieve X with verified evidence.", goal_mode=True + ) + kb.claim_task(conn, goal_task_id) + finally: + conn.close() + monkeypatch.setenv("HERMES_KANBAN_TASK", goal_task_id) + + # Mock the judge to reject the completion. The gate only runs when a + # judge is reachable, so force the availability probe True as well. + def mock_judge_goal(goal, last_response, *, timeout=30.0, subgoals=None): + return "continue", "missing verification evidence", False + + monkeypatch.setattr("tools.kanban_tools.judge_goal", mock_judge_goal) + monkeypatch.setattr("tools.kanban_tools._goal_judge_available", lambda: True) + + # Attempt to complete should be rejected + out = kt._handle_complete({"summary": "I did some stuff but not X"}) + d = json.loads(out) + assert "error" in d + assert "Goal completion rejected by judge" in d["error"] + assert "missing verification evidence" in d["error"] + assert f"parents=[{goal_task_id}]" in d["error"] + + # Verify the task is NOT completed in the DB + conn2 = kb.connect() + try: + task = kb.get_task(conn2, goal_task_id) + assert task.status == "running" # Should still be running, not done + finally: + conn2.close() + + +def test_complete_goal_mode_allows_when_judge_unavailable(monkeypatch, tmp_path): + """Fail-open: an unreachable judge must not wedge a goal_mode worker. + + judge_goal returns a "continue" verdict when no auxiliary model is + configured, which is indistinguishable from a real "not done" judgment. + The gate probes availability first, so completion proceeds rather than + being rejected forever when no judge can be reached.""" + from pathlib import Path as _Path + from hermes_cli import kanban_db as kb + from tools import kanban_tools as kt + + 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-test", assignee="test-worker", + body="Must achieve X with verified evidence.", goal_mode=True + ) + kb.claim_task(conn, goal_task_id) finally: conn.close() + monkeypatch.setenv("HERMES_KANBAN_TASK", goal_task_id) + + # No judge reachable. judge_goal must not even be consulted; if it were, + # this stub would reject — so reaching "done" proves the probe short-circuit. + def fail_if_called(goal, last_response, *, timeout=30.0, subgoals=None): + raise AssertionError("judge_goal must not run when no judge is available") + + monkeypatch.setattr("tools.kanban_tools.judge_goal", fail_if_called) + monkeypatch.setattr("tools.kanban_tools._goal_judge_available", lambda: False) + + out = kt._handle_complete({"summary": "done enough"}) + d = json.loads(out) + assert d.get("ok") is True + + conn2 = kb.connect() + try: + assert kb.get_task(conn2, goal_task_id).status == "done" + finally: + conn2.close() def test_block_happy_path(worker_env): -
tools/kanban_tools.py@@ -34,6 +34,7 @@ from typing import Any, Optional from agent.redact import redact_sensitive_text +from hermes_cli.goals import judge_goal from tools.registry import registry, tool_error from hermes_cli.config import cfg_get, load_config @@ -178,6 +179,27 @@ def _connect(board: Optional[str] = None): return kb, kb.connect(board=board) +def _goal_judge_available() -> bool: + """True when an auxiliary client is configured for the goal judge. + + ``judge_goal`` is fail-open at the source: when no auxiliary model can + be reached it returns a ``"continue"`` verdict that is indistinguishable + from a real "not done yet" judgment. The completion gate must not treat + that as a rejection, or an unconfigured/degraded auxiliary model would + wedge every ``goal_mode`` worker (it could never close its own task). + + So we probe availability first and only enforce the gate when a judge is + actually reachable. This mirrors the same client lookup ``judge_goal`` + performs internally. + """ + try: + from agent.auxiliary_client import get_text_auxiliary_client + client, model = get_text_auxiliary_client("goal_judge") + except Exception: + return False + return client is not None and bool(model) + + # --------------------------------------------------------------------------- # Runtime-activity → board-heartbeat bridge (#31752) # --------------------------------------------------------------------------- @@ -567,6 +589,37 @@ def _handle_complete(args: dict, **kw) -> str: try: kb, conn = _connect(board=board) try: + # Goal-mode pre-completion judge gate (Issue #38367). + # Prevent workers from bypassing the auxiliary judge by + # calling kanban_complete before acceptance criteria are met. + # Only enforce when a judge is actually reachable — see + # _goal_judge_available for why an unavailable judge fails open. + task = kb.get_task(conn, tid) + if task and task.goal_mode and _goal_judge_available(): + verdict = "done" + reason = "" + try: + verdict, reason, _ = judge_goal( + goal=f"{task.title}\n\n{task.body or ''}".strip(), + last_response=(summary or result or "").strip(), + ) + except Exception as judge_exc: + # Defensive: judge_goal swallows its own errors, but if + # it ever raises, fail open rather than wedge the worker. + logger.warning( + "goal judge check failed, allowing completion: %s", + judge_exc, + exc_info=True, + ) + if verdict != "done": + return tool_error( + f"Goal completion rejected by judge: {reason}. " + f"To proceed, either: (1) provide explicit acceptance " + f"evidence in your summary matching the task's criteria, " + f"or (2) create continuation tasks with parents=[{tid}] " + f"and keep this task alive." + ) + try: ok = kb.complete_task( conn, tid,
-
5 PRs closed without merging
From the close comments: duplicate 2 · superseded by another PR 2 · merged via another change 1
-
#38388 fix(kanban): gate goal_mode task completion with auxiliary judge · merged via another change →
#55408 -
#59284 fix(kanban): goal-gate judge unpack matches judge_goal's 4-tuple (completes #38367) · duplicate →
#57276 -
#59849 fix(tools): honor goal judge verdict in kanban_complete gate · duplicate →
#57276,#58066 -
#61498 fix(tools): unpack all 4 judge_goal() return values in kanban complete gate · superseded by another PR →
#57276,#61500,#61490 -
#61510 fix(kanban): unpack goal judge wait result · superseded by another PR →
#57276
Closed issues · 4
- Closed#38367[Bug] goal_mode does not prevent premature kanban_complete — worker tool call bypasses judge
- Closed#46706[Feature]: Add a reviewed Kanban goal mode where workers cannot bypass the external judge
- Closed#59762kanban_complete` goal-mode judge gate never rejects: 3-target unpack of 4-tuple `judge_goal()` raises `ValueError`, caught by the fail-open handler
- Closed#61490Bug: kanban_tools.py L605 — judge_goal 3-value unpack crashes orchestrator in --goal mode
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.16: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- cron
- Merged PRs
- #55408
- Closed issues
- 4
- Attempts
- 5 PRs closed without merging
- Fix link checked
- 30 Jun 2026