diff --git a/services/hermes/agent-deployment.yaml b/services/hermes/agent-deployment.yaml index d0c5495f..66514071 100644 --- a/services/hermes/agent-deployment.yaml +++ b/services/hermes/agent-deployment.yaml @@ -25,7 +25,7 @@ spec: ai.bstein.dev/execution: Hermes Kanban with durable direct Codex and Claude Code CLI workers ai.bstein.dev/model-policy: Jetson-assisted AUTO routing, low through xhigh, cross-provider fallback ai.bstein.dev/placement: rpi5 preferred; Jetson deferred until state storage is available - ai.bstein.dev/config-rev: "20260816-goal-completion-judge-v2" + ai.bstein.dev/config-rev: "20260816-review-findings-contract-v3" prometheus.io/scrape: "true" prometheus.io/path: /metrics prometheus.io/port: "9010" diff --git a/services/hermes/scripts/cli_lane_runner.py b/services/hermes/scripts/cli_lane_runner.py index 2ac3c99b..2b91501f 100644 --- a/services/hermes/scripts/cli_lane_runner.py +++ b/services/hermes/scripts/cli_lane_runner.py @@ -70,7 +70,22 @@ RESULT_SCHEMA: dict[str, Any] = { "changed_files": {"type": "array", "items": {"type": "string"}}, "tests_run": {"type": "array", "items": {"type": "string"}}, "artifacts": {"type": "array", "items": {"type": "string"}}, - "blockers": {"type": "array", "items": {"type": "string"}}, + "findings": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Defects, risks, or observations discovered by a review or diagnosis. " + "Findings do not prevent the assigned review or diagnosis from completing." + ), + }, + "blockers": { + "type": "array", + "items": {"type": "string"}, + "description": ( + "Concrete obstacles that prevent completion of the assigned task itself. " + "This must be empty when status is completed; review findings belong in findings." + ), + }, }, } @@ -277,7 +292,7 @@ Workspace: {workspace} Operate autonomously inside the workspace. Inspect before editing, preserve unrelated user changes, run proportionate tests, and do not claim completion without evidence. You have owner-level Kubernetes access in every namespace. Prefer Flux-tracked manifests for durable changes, but use kubectl, Flux, exec, port-forwarding, rollout operations, and existing Vault workflows when the objective or incident requires them. Persist any desired-state mutation back to Git. Do not force-push, hard-reset, clean untracked files, or expose credentials. -Return a final JSON object matching the supplied schema. Use status=incomplete when required work, tests, commands, commits, pushes, or verification are still running or remain to be done. Use status=blocked only for a concrete unresolved blocker. Never use status=completed for a progress report. List changed files, tests run, durable artifact paths, and blockers explicitly. +Return a final JSON object matching the supplied schema. Use status=incomplete when required work, tests, commands, commits, pushes, or verification are still running or remain to be done. Use status=blocked only when an obstacle prevents completion of the assigned task itself. Never use status=completed for a progress report. For review or diagnostic tasks, put defects and risks in findings; those findings can make the reviewed change unfit to ship without blocking completion of the review. The blockers array must be empty whenever status is completed. List changed files, tests run, durable artifact paths, findings, and task blockers explicitly. """ @@ -964,7 +979,13 @@ def execute_claim(board: str, task_id: str) -> None: "goal_turn": goal_turn, } if structured: - for key in ("changed_files", "tests_run", "artifacts", "blockers"): + for key in ( + "changed_files", + "tests_run", + "artifacts", + "findings", + "blockers", + ): value = structured.get(key) metadata[key] = value if isinstance(value, list) else [] diff --git a/testing/tests/test_hermes_cli_lane_goal.py b/testing/tests/test_hermes_cli_lane_goal.py index 48a864ea..7acadffc 100644 --- a/testing/tests/test_hermes_cli_lane_goal.py +++ b/testing/tests/test_hermes_cli_lane_goal.py @@ -53,6 +53,15 @@ def test_completed_pending_state_test_name_is_not_a_false_positive(): assert goal.unfinished_result_reason(result) is None +def test_completed_review_findings_are_not_task_blockers(): + result = _result( + summary="Review complete; the pull request is not merge-ready.", + findings=["A casting-table variant fails open."], + ) + + assert goal.unfinished_result_reason(result) is None + + class JudgeResponse: def __init__(self, verdict: str, reason: str): self.body = json.dumps( diff --git a/testing/tests/test_hermes_cli_lanes.py b/testing/tests/test_hermes_cli_lanes.py index 899cb07a..0a007388 100644 --- a/testing/tests/test_hermes_cli_lanes.py +++ b/testing/tests/test_hermes_cli_lanes.py @@ -946,6 +946,15 @@ def test_provider_commands_are_structured_unattended_and_capped(tmp_path: Path): assert "max" not in claude +def test_worker_contract_separates_review_findings_from_task_blockers(tmp_path: Path): + prompt = lanes.build_prompt("Review the change.", tmp_path) + + assert "put defects and risks in findings" in prompt + assert "blockers array must be empty whenever status is completed" in prompt + assert "findings" in lanes.RESULT_SCHEMA["properties"] + assert "assigned task itself" in lanes.RESULT_SCHEMA["properties"]["blockers"]["description"] + + @pytest.mark.parametrize( "command", [