diff --git a/cli.py b/cli.py index bb91aa4fc37dc..105bc9e1b2b33 100644 --- a/cli.py +++ b/cli.py @@ -17389,10 +17389,10 @@ def _task_status() -> "str | None": except Exception: pass - def _block(reason: str) -> None: + def _block(reason: str, kind: str | None = None) -> None: c = _kb.connect() try: - _kb.block_task(c, task_id, reason=reason) + _kb.block_task(c, task_id, reason=reason, kind=kind) finally: try: c.close() diff --git a/hermes_cli/goals.py b/hermes_cli/goals.py index be99bbdc9e83e..b4da52dcfb65b 100644 --- a/hermes_cli/goals.py +++ b/hermes_cli/goals.py @@ -1750,7 +1750,8 @@ def _log(msg: str) -> None: try: block_fn( f"Goal-mode worker's output looked complete but it never " - f"called kanban_complete after a finalize nudge ({reason})." + f"called kanban_complete after a finalize nudge ({reason}).", + kind="transient", ) except Exception as exc: _log(f"kanban goal loop: block_fn failed ({exc})") @@ -1767,7 +1768,8 @@ def _log(msg: str) -> None: block_fn( f"Goal-mode worker exhausted its turn budget " f"({turns_used}/{max_turns}) without completing the task. " - f"Last judge verdict: {_truncate(reason, 300)}" + f"Last judge verdict: {_truncate(reason, 300)}", + kind="transient", ) except Exception as exc: _log(f"kanban goal loop: block_fn failed ({exc})") diff --git a/tests/hermes_cli/test_kanban_goal_mode.py b/tests/hermes_cli/test_kanban_goal_mode.py index 61ece645ff4bf..b626f33d780b5 100644 --- a/tests/hermes_cli/test_kanban_goal_mode.py +++ b/tests/hermes_cli/test_kanban_goal_mode.py @@ -128,8 +128,86 @@ def test_loop_stops_when_worker_already_completed(monkeypatch): assert turns == [] # no extra turns +def test_loop_blocks_budget_exhaustion_as_transient(monkeypatch): + _patch_judge(monkeypatch, ["continue"] * 3) + blocked = {} + def _block(reason, kind=None): + blocked.update(reason=reason, kind=kind) + res = goals.run_kanban_goal_loop( + task_id="t3", + goal_text="endless task", + run_turn=lambda p: "still going", + task_status_fn=lambda: "running", + block_fn=_block, + max_turns=3, + first_response="turn1", + ) + + assert res["outcome"] == "blocked_budget" + assert "turn budget" in blocked["reason"].lower() + assert blocked["kind"] == "transient" + + +def test_loop_blocks_failed_finalize_as_transient(monkeypatch): + _patch_judge(monkeypatch, ["done", "done"]) + blocked = {} + + def _block(reason, kind=None): + blocked.update(reason=reason, kind=kind) + + res = goals.run_kanban_goal_loop( + task_id="t5", + goal_text="task", + run_turn=lambda p: "still not finalizing", + task_status_fn=lambda: "running", + block_fn=_block, + max_turns=10, + first_response="looks done", + ) + + assert res["outcome"] == "blocked_budget" + assert "finalize" in blocked["reason"].lower() + assert blocked["kind"] == "transient" + + +def test_cli_goal_loop_forwards_block_kind(monkeypatch): + import types + + import cli as cli_mod + + class _Conn: + def close(self): + pass + + task = types.SimpleNamespace(title="task", body=None, goal_max_turns=3) + block_calls = [] + + monkeypatch.setenv("HERMES_KANBAN_TASK", "t1") + monkeypatch.setattr(kb, "connect", lambda: _Conn()) + monkeypatch.setattr(kb, "get_task", lambda conn, task_id: task) + monkeypatch.setattr( + kb, + "block_task", + lambda conn, task_id, **kwargs: block_calls.append((task_id, kwargs)) or True, + ) + + def _fake_loop(**kwargs): + kwargs["block_fn"]("budget", kind="transient") + + monkeypatch.setattr(goals, "run_kanban_goal_loop", _fake_loop) + fake_cli = types.SimpleNamespace( + agent=types.SimpleNamespace(), + conversation_history=[], + session_id=None, + ) + + cli_mod._run_kanban_goal_loop_q(fake_cli, "first") + + assert block_calls == [ + ("t1", {"reason": "budget", "kind": "transient"}), + ] # ---------------------------------------------------------------------------