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:<task_id>
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.
This commit is contained in:
teknium1
2026-09-18 03:15:22 -07:00
committed by Teknium
parent bb2ea2486d
commit 9ee7da83fa
2 changed files with 34 additions and 1 deletions

View File

@@ -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)}")

View File

@@ -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:<task_id> 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