From 9ee7da83fa2156bd10f744334299eb4bb2ddffb1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:15:22 -0700 Subject: [PATCH] fix(kanban): bind per-task affinity scope around the goal-loop judge call run_kanban_goal_loop judges every worker turn outside any agent turn, so on a relay target each judge call 400'd MissingSessionID and the loop burned its turn budget on transport-failed 'continue' verdicts. Bind kanban: around the call (same shape as the two handoff gates and kanban_specify), keeping an already-bound scope. Adds one invariant test on the loop. --- hermes_cli/goals.py | 10 +++++++- .../test_kanban_goal_judge_affinity.py | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index bf2e25ef70..2047c81c54 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -1646,7 +1646,15 @@ def run_kanban_goal_loop( _log(f"kanban goal loop: task {task_id} status={status!r}; stopping") return _result("stopped", f"status={status}") - verdict, reason, _parse_failed, _wait, _transport_failed = judge_goal(goal_text, last_response) + # The between-turns judge runs outside any agent turn: bind the per-task relay-affinity + # scope (same shape as the handoff gates) so the relay does not reject the call (#113669). + from agent.portal_tags import get_affinity_scope, reset_affinity_scope, set_affinity_scope + affinity_token = None if get_affinity_scope() else set_affinity_scope(f"kanban:{task_id}") + try: + verdict, reason, _parse_failed, _wait, _transport_failed = judge_goal(goal_text, last_response) + finally: + if affinity_token is not None: + reset_affinity_scope(affinity_token) if verdict == "wait": verdict = "continue" _log(f"kanban goal loop: turn {turns_used}/{max_turns} verdict={verdict} reason={_truncate(reason, 120)}") diff --git a/tests/hermes_cli/test_kanban_goal_judge_affinity.py b/tests/hermes_cli/test_kanban_goal_judge_affinity.py index c2d3e7d0c9..3e8d40bc31 100644 --- a/tests/hermes_cli/test_kanban_goal_judge_affinity.py +++ b/tests/hermes_cli/test_kanban_goal_judge_affinity.py @@ -85,3 +85,28 @@ def test_tool_gate_binds_per_task_affinity_scope(): kanban_tools._goal_gate("kanban_complete", _task("task-9"), "task-9", "ev") assert seen == ["kanban:task-9"] assert get_affinity_scope() is None + + +def test_goal_loop_judge_binds_per_task_affinity_scope(): + """The between-turns judge in run_kanban_goal_loop runs under kanban: too.""" + from hermes_cli import goals + + seen = [] + + def fake_judge(goal, last_response): + seen.append(get_affinity_scope()) + return ("continue", "not yet", False, None, False) + + with patch.object(goals, "judge_goal", side_effect=fake_judge): + result = goals.run_kanban_goal_loop( + task_id="task-7", + goal_text="goal", + run_turn=lambda prompt: "still working", + task_status_fn=lambda: "running", + block_fn=lambda msg: None, + max_turns=2, + first_response="first", + ) + assert result["outcome"] == "blocked_budget" + assert seen == ["kanban:task-7", "kanban:task-7"] + assert get_affinity_scope() is None