diff --git a/agent/kanban_stop.py b/agent/kanban_stop.py index dbea5572f0..439c7b125b 100644 --- a/agent/kanban_stop.py +++ b/agent/kanban_stop.py @@ -22,6 +22,7 @@ from agent.delegation_context import owned_kanban_task _TERMINAL_KANBAN_TOOLS = frozenset({ "kanban_complete", "kanban_block", + "kanban_schedule", "kanban_request_review", "kanban_request_changes", }) diff --git a/agent/transports/hermes_tools_mcp_server.py b/agent/transports/hermes_tools_mcp_server.py index d528778696..048417b379 100644 --- a/agent/transports/hermes_tools_mcp_server.py +++ b/agent/transports/hermes_tools_mcp_server.py @@ -60,7 +60,8 @@ EXPOSED_TOOLS: tuple[str, ...] = ( "vision_analyze", "image_generate", "skill_view", "skills_list", "text_to_speech", # Kanban handoff tools: stateless (read HERMES_KANBAN_TASK, write kanban.db). # Without them a codex-runtime worker can't report completion and hangs. - "kanban_complete", "kanban_block", "kanban_request_review", "kanban_request_changes", "kanban_comment", + "kanban_complete", "kanban_block", "kanban_schedule", "kanban_request_review", + "kanban_request_changes", "kanban_comment", "kanban_heartbeat", "kanban_show", "kanban_list", # Orchestrator-only (the kanban tool gates them on HERMES_KANBAN_TASK unset). "kanban_create", "kanban_unblock", "kanban_link", diff --git a/cron/AGENTS.md b/cron/AGENTS.md index 522e712141..f9ec35d457 100644 --- a/cron/AGENTS.md +++ b/cron/AGENTS.md @@ -102,7 +102,8 @@ zero outside a kanban task (footprint ladder rung 3). reopen-review, block, unblock, archive, tail`, plus `watch, stats, runs, log, assignees, heartbeat, notify-*, dispatch, daemon, gc`. Argparse alias dispatch must accept both `list` and `ls` (root). - **Toolset:** `tools/kanban_tools.py` — `kanban_show, kanban_complete, kanban_request_review, - kanban_request_changes, kanban_block, kanban_heartbeat, kanban_comment, kanban_create, kanban_link, + kanban_request_changes, kanban_block, kanban_schedule, kanban_heartbeat, kanban_comment, + kanban_create, kanban_link, kanban_attach, kanban_attach_url, kanban_attachments`; platforms whose saved selection enables `kanban` (`hermes tools enable kanban --platform
`; default-off, in `CONFIGURABLE_TOOLSETS`) get the full set plus `kanban_list`/`kanban_unblock` for board routing. The check_fn reads the schema diff --git a/tests/agent/test_kanban_stop.py b/tests/agent/test_kanban_stop.py index 6b1cc9fe22..9bb95a4804 100644 --- a/tests/agent/test_kanban_stop.py +++ b/tests/agent/test_kanban_stop.py @@ -106,6 +106,7 @@ def test_no_nudge_after_kanban_complete(clear_kanban_env): [ ("kanban_request_review", "build worker handing off for same-card review"), ("kanban_request_changes", "review agent sending the card back"), + ("kanban_schedule", "worker parking the card on a timed wait"), ], ) def test_no_nudge_after_handoff_tool(clear_kanban_env, tool_name, who): diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py index 9fb0f172ee..ec2ecbaa47 100644 --- a/tests/tools/test_kanban_tools.py +++ b/tests/tools/test_kanban_tools.py @@ -254,6 +254,7 @@ def test_unbound_worker_cannot_mutate_card(monkeypatch, worker_env): for handler, args in [ (kt._handle_complete, {"summary": "stale worker says done"}), (kt._handle_block, {"reason": "stale worker blocks"}), + (kt._handle_schedule, {"reason": "stale worker parks"}), (kt._handle_request_review, {"summary": "stale worker hands off"}), (kt._handle_request_changes, {"reason": "stale worker requests changes"}), ]: @@ -290,6 +291,8 @@ def test_malformed_run_id_refused_but_nonlifecycle_allowed(monkeypatch, worker_e # Run-lifecycle mutations are refused on a malformed run id. out = json.loads(kt._handle_complete({"summary": "stale worker says done"})) assert "refused" in out.get("error", "") + out = json.loads(kt._handle_schedule({"reason": "stale worker parks"})) + assert "refused" in out.get("error", "") # Non-lifecycle tools are NOT gated: heartbeat still extends the claim. out = json.loads(kt._handle_heartbeat({})) @@ -491,6 +494,31 @@ def test_block_goal_mode_rejects_disallowed_kind(monkeypatch, tmp_path): conn.close() +def test_schedule_goal_mode_refused(monkeypatch, tmp_path): + """``scheduled`` ends the goal loop like ``blocked``, so a goal_mode worker + must not use kanban_schedule to exit without the completion judge.""" + from tools import kanban_tools as kt + from hermes_cli import kanban_db as kb + from hermes_cli import kanban_db_connect as kbc + + tid = _make_goal_mode_worker_env(monkeypatch, tmp_path) + d = json.loads(kt._handle_schedule({"reason": "waiting for CI"})) + assert "goal_mode" in d.get("error", "") + + conn = kbc.connect() + try: + assert kb.get_task(conn, tid).status == "running" + finally: + conn.close() + + +def test_schedule_exposed_to_codex_runtime_workers(): + """Codex app-server workers only reach Hermes tools named in EXPOSED_TOOLS.""" + from agent.transports.hermes_tools_mcp_server import EXPOSED_TOOLS + + assert "kanban_schedule" in EXPOSED_TOOLS + + def test_block_dependency_without_open_parent_is_rekinded(worker_env): """kind=dependency with no incomplete parent must not park in todo; the tool reports the landed kind and tells the worker why.""" diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py index 4b04e12b20..5e81997a4a 100644 --- a/tools/kanban_tools.py +++ b/tools/kanban_tools.py @@ -109,7 +109,7 @@ def _check_kanban_orchestrator_mode() -> bool: # would silently skip the run-ownership CAS in kanban_db. Non-lifecycle tools # (heartbeat / attach / attach_url) do not terminate a run and are not gated. _RUN_LIFECYCLE_TOOLS = frozenset({ - "kanban_complete", "kanban_block", + "kanban_complete", "kanban_block", "kanban_schedule", "kanban_request_review", "kanban_request_changes", }) @@ -821,6 +821,13 @@ def _handle_schedule(args: dict, **kw) -> str: _check(raw_reason is None or isinstance(raw_reason, str), "reason must be a string") reason = _redact(raw_reason.strip()) if raw_reason and raw_reason.strip() else None with _board(args.get("board")) as (kb, conn): + # The goal loop treats ``scheduled`` as terminal like ``blocked``, so + # parking would bypass the completion judge (see kanban_block, #38696). + task = kb.get_task(conn, tid) + _check(not (task and task.goal_mode), + "goal_mode tasks cannot be scheduled: use kanban_block with kind " + f"in {sorted(_GOAL_MODE_BLOCK_ALLOWED_KINDS)} for a genuine external " + "blocker, or kanban_complete so the completion judge can evaluate it.") ok = kb.schedule_task( conn, tid, reason=reason, expected_run_id=_worker_run_id(tid)) _check(ok, f"could not schedule {tid} (unknown id or not in todo/ready/running/blocked)")