fix(kanban): gate kanban_schedule like the other lifecycle tools

Follow-up to the in-process schedule tool:

- run-lifecycle guard: an unbound/malformed HERMES_KANBAN_RUN_ID now refuses
  kanban_schedule, so a stale worker cannot park a card a successor owns
- goal_mode tasks are refused: `scheduled` ends the goal loop like `blocked`,
  so parking would bypass the completion judge (#38696)
- stop nudge treats kanban_schedule as a handoff
- Codex app-server workers get it via EXPOSED_TOOLS
- cron/AGENTS.md lists it with the worker toolset

Refs #108554, #108566
This commit is contained in:
unsupportedpastels
2026-10-01 02:44:12 +00:00
committed by Mark S.
parent 8e00bfdf06
commit bd394e2a4b
6 changed files with 42 additions and 3 deletions

View File

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

View File

@@ -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",

View File

@@ -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 <p>`; default-off, in `CONFIGURABLE_TOOLSETS`) get
the full set plus `kanban_list`/`kanban_unblock` for board routing. The check_fn reads the schema

View File

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

View File

@@ -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."""

View File

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