diff --git a/dockerfiles/Dockerfile.hermes-agent b/dockerfiles/Dockerfile.hermes-agent index 98f93ee2..36072cff 100644 --- a/dockerfiles/Dockerfile.hermes-agent +++ b/dockerfiles/Dockerfile.hermes-agent @@ -629,6 +629,48 @@ source = source[:runs_start] + runs_source path.write_text(source) PY +# A manual evidence-based close must not expose a parked task to the dispatcher +# between separate `unblock` and `complete` commands. Upstream already permits +# direct completion from `blocked`; extend the same atomic path to `scheduled` +# and make that contract visible in CLI help so agents do not create a ready +# window that can launch redundant work. +RUN python - <<'PY' +from pathlib import Path + +db_path = Path("/opt/hermes/hermes_cli/kanban_db.py") +db_source = db_path.read_text() +state_before = ''' WHERE id = ? + AND status IN ('running', 'ready', 'blocked') +''' +state_after = ''' WHERE id = ? + AND status IN ('running', 'ready', 'blocked', 'scheduled') +''' +if db_source.count(state_before) != 2: + raise SystemExit( + "Hermes Kanban completion state gate changed: expected 2, " + f"found {db_source.count(state_before)}" + ) +# Only the manual path lacks an expected run id. A worker-owned completion must +# continue to match its live run and may not revive an already scheduled run. +db_path.write_text(db_source.replace(state_before, state_after, 1)) + +cli_path = Path("/opt/hermes/hermes_cli/kanban.py") +cli_source = cli_path.read_text() +help_before = 'p_complete = sub.add_parser("complete", help="Mark one or more tasks done")' +help_after = ( + 'p_complete = sub.add_parser(' + '"complete", ' + 'help="Atomically mark running, ready, blocked, or scheduled tasks done"' + ')' +) +if cli_source.count(help_before) != 1: + raise SystemExit( + "Hermes Kanban complete help changed: expected 1, " + f"found {cli_source.count(help_before)}" + ) +cli_path.write_text(cli_source.replace(help_before, help_after, 1)) +PY + COPY dockerfiles/hermes-python-sandbox-tool.py /opt/hermes/tools/python_sandbox_tool.py COPY dockerfiles/hermes-public-extract/__init__.py /opt/hermes/plugins/web/public_extract/__init__.py COPY dockerfiles/hermes-public-extract/plugin.yaml /opt/hermes/plugins/web/public_extract/plugin.yaml @@ -1409,6 +1451,10 @@ RUN cd /opt/hermes/web \ /opt/hermes/gateway/platforms/api_server.py \ && grep -Fq 'reasoning_effort=body.get("reasoning_effort")' \ /opt/hermes/gateway/platforms/api_server.py \ + && grep -Fq "status IN ('running', 'ready', 'blocked', 'scheduled')" \ + /opt/hermes/hermes_cli/kanban_db.py \ + && grep -Fq 'Atomically mark running, ready, blocked, or scheduled tasks done' \ + /opt/hermes/hermes_cli/kanban.py \ && grep -Fq 'routing_priority=body.get("routing_priority")' \ /opt/hermes/gateway/platforms/api_server.py \ && grep -Fq 'agent._hermes_explicit_model_pick' \ diff --git a/services/hermes/agent-configmap.yaml b/services/hermes/agent-configmap.yaml index 44e4f468..d82f96b4 100644 --- a/services/hermes/agent-configmap.yaml +++ b/services/hermes/agent-configmap.yaml @@ -50,6 +50,7 @@ data: - clarify - delegation - file + - kanban - memory - session_search - skills @@ -62,6 +63,7 @@ data: - clarify - delegation - file + - kanban - memory - session_search - skills @@ -246,6 +248,12 @@ data: those findings, finish any required repair and verification, and emit the task's final structured result itself. + When authoritative evidence warrants closing a `blocked` or `scheduled` + task, call `kanban_complete` or `hermes kanban complete` directly. That + operation is atomic for parked tasks. Never unblock and then complete in + separate operations: the gateway dispatcher can claim the transient + `ready` state and launch redundant work. + For an implementation or verification task, "review-ready" is a completed task outcome with evidence, not a reason to call `kanban_block`. Request a block only for a genuine external decision or unavailable capability. A diff --git a/services/hermes/kustomization.yaml b/services/hermes/kustomization.yaml index c2066ed1..4f29a2c7 100644 --- a/services/hermes/kustomization.yaml +++ b/services/hermes/kustomization.yaml @@ -4,7 +4,7 @@ kind: Kustomization namespace: hermes images: - name: registry.bstein.dev/bstein/hermes-agent - digest: sha256:8a4a33df755607f69bc8803181cc8d38edf65330343ec8bf94ac5e24ba8d5e58 + digest: sha256:37ebf720c783ae908a602916ffccf88d43d205a157957f5dc4b487867aee45e7 resources: - namespace.yaml - vault-serviceaccount.yaml diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 2c62d639..3744ea94 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -76,8 +76,16 @@ def test_chat_config_enables_real_research_compute_and_delegation(): def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle(): configmap = _documents(HERMES / "agent-configmap.yaml")[0] instructions = configmap["data"]["AGENTS.md"] + config = yaml.safe_load(configmap["data"]["config.yaml"]) - assert "Only the foreground durable worker owns its Kanban task lifecycle" in instructions + assert ( + "Only the foreground durable worker owns its Kanban task lifecycle" + in instructions + ) + assert "Never unblock and then complete" in instructions + assert "gateway dispatcher can claim the transient" in instructions + for platform in ("cli", "api_server"): + assert "kanban" in config["platform_toolsets"][platform] assert "must never complete, block, unblock, reclaim" in instructions assert "task's final structured result itself" in instructions assert "Atlas organization has private visibility" in instructions @@ -85,6 +93,19 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle(): assert "already supplied through `GIT_ASKPASS`" in instructions +def test_agent_image_completes_parked_kanban_tasks_atomically(): + dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-agent").read_text() + + assert ( + "AND status IN ('running', 'ready', 'blocked', 'scheduled')" + in dockerfile + ) + assert ( + "Atomically mark running, ready, blocked, or scheduled tasks done" + in dockerfile + ) + + def test_sandbox_shares_only_the_tenant_workspace_without_credentials(): sandbox_docs = _documents(HERMES / "chat-sandbox.yaml") deployments = [doc for doc in sandbox_docs if doc["kind"] == "Deployment"]