Merge PR 15 (CLI lane decomposition) on top of PR 14 (Atlas SCM broker)
The distributed worker pool stacks on exactly two open pull requests, in this order: PR 14 supplies the broker-only SCM boundary the mediators route through and the hermes-scm-boundary-v2 ConfigMap they mount, and PR 15 supplies the cli_lane_* decomposition -- including canonical_run_id and the eligibility predicate on claim_ready -- that the coordinator depends on. Neither can be dropped without breaking a fixed P0 boundary, so both are carried here as prerequisites and this branch must not merge before them. PR 16 (agent image release lane) and PR 19 (full-handoff acceptance harness) are NOT prerequisites and are deliberately absent, so reviewing this branch no longer means approving them. PR 14 and PR 15 conflict with each other in nine paths. Each is resolved to the resolution already reviewed on this branch at 4d4cf1bd.
This commit is contained in:
commit
03b10ff8b1
@ -1544,6 +1544,36 @@ function RootRedirect() {
|
||||
}
|
||||
NODE
|
||||
|
||||
COPY dockerfiles/patch-hermes-execution-safety.py /tmp/patch-hermes-execution-safety.py
|
||||
COPY dockerfiles/hermes-execution-safety-regression.py /tmp/hermes-execution-safety-regression.py
|
||||
COPY dockerfiles/hermes_execution_patch_support.py /tmp/hermes_execution_patch_support.py
|
||||
COPY dockerfiles/patch_hermes_run_safety.py /tmp/patch_hermes_run_safety.py
|
||||
COPY dockerfiles/patch_hermes_decomposition_safety.py /tmp/patch_hermes_decomposition_safety.py
|
||||
COPY dockerfiles/hermes_execution_regression_support.py /tmp/hermes_execution_regression_support.py
|
||||
COPY dockerfiles/hermes_run_safety_regression.py /tmp/hermes_run_safety_regression.py
|
||||
COPY dockerfiles/hermes_decomposition_safety_regression.py /tmp/hermes_decomposition_safety_regression.py
|
||||
COPY dockerfiles/hermes_lane_compatibility_regression.py /tmp/hermes_lane_compatibility_regression.py
|
||||
COPY services/hermes/scripts/cli_lane_*.py /tmp/hermes-lane-regression/
|
||||
RUN HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \
|
||||
HERMES_COMPATIBILITY_MODE=legacy \
|
||||
/opt/hermes/.venv/bin/python /tmp/hermes_lane_compatibility_regression.py \
|
||||
&& /opt/hermes/.venv/bin/python /tmp/patch-hermes-execution-safety.py \
|
||||
&& HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \
|
||||
HERMES_COMPATIBILITY_MODE=patched \
|
||||
/opt/hermes/.venv/bin/python /tmp/hermes_lane_compatibility_regression.py \
|
||||
&& HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression \
|
||||
/opt/hermes/.venv/bin/python /tmp/hermes-execution-safety-regression.py \
|
||||
&& rm /tmp/patch-hermes-execution-safety.py \
|
||||
/tmp/hermes-execution-safety-regression.py \
|
||||
/tmp/hermes_execution_patch_support.py \
|
||||
/tmp/patch_hermes_run_safety.py \
|
||||
/tmp/patch_hermes_decomposition_safety.py \
|
||||
/tmp/hermes_execution_regression_support.py \
|
||||
/tmp/hermes_run_safety_regression.py \
|
||||
/tmp/hermes_decomposition_safety_regression.py \
|
||||
/tmp/hermes_lane_compatibility_regression.py \
|
||||
&& find /tmp/hermes-lane-regression -depth -delete
|
||||
|
||||
COPY dockerfiles/hermes-session-migrate.py /opt/hermes/bin/hermes-session-migrate
|
||||
|
||||
RUN cd /opt/hermes/web \
|
||||
@ -1602,6 +1632,8 @@ RUN cd /opt/hermes/web \
|
||||
/opt/hermes/agent/turn_context.py \
|
||||
/opt/hermes/agent/conversation_loop.py \
|
||||
/opt/hermes/hermes_cli/oneshot.py \
|
||||
/opt/hermes/hermes_cli/kanban_decompose.py \
|
||||
/opt/hermes/gateway/kanban_watchers.py \
|
||||
/opt/hermes/tools/delegate_tool.py \
|
||||
/opt/hermes/tools/web_tools.py \
|
||||
/opt/hermes/tools/python_sandbox_tool.py \
|
||||
|
||||
@ -5,3 +5,16 @@
|
||||
!dockerfiles/hermes-public-extract/**
|
||||
!dockerfiles/hermes-session-activity-panel.tsx
|
||||
!dockerfiles/hermes-session-migrate.py
|
||||
!dockerfiles/patch-hermes-execution-safety.py
|
||||
!dockerfiles/hermes-execution-safety-regression.py
|
||||
!dockerfiles/hermes_execution_patch_support.py
|
||||
!dockerfiles/patch_hermes_run_safety.py
|
||||
!dockerfiles/patch_hermes_decomposition_safety.py
|
||||
!dockerfiles/hermes_execution_regression_support.py
|
||||
!dockerfiles/hermes_run_safety_regression.py
|
||||
!dockerfiles/hermes_decomposition_safety_regression.py
|
||||
!dockerfiles/hermes_lane_compatibility_regression.py
|
||||
!services/
|
||||
!services/hermes/
|
||||
!services/hermes/scripts/
|
||||
!services/hermes/scripts/cli_lane_*.py
|
||||
|
||||
23
dockerfiles/hermes-execution-safety-regression.py
Normal file
23
dockerfiles/hermes-execution-safety-regression.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""Run the composable Hermes execution-safety build regressions."""
|
||||
|
||||
import unittest
|
||||
|
||||
from hermes_decomposition_safety_regression import DecompositionSafetyTests
|
||||
from hermes_run_safety_regression import ExactRunSafetyTests
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run both transaction domains and return a build-friendly status."""
|
||||
loader = unittest.defaultTestLoader
|
||||
suite = unittest.TestSuite(
|
||||
(
|
||||
loader.loadTestsFromTestCase(ExactRunSafetyTests),
|
||||
loader.loadTestsFromTestCase(DecompositionSafetyTests),
|
||||
)
|
||||
)
|
||||
result = unittest.TextTestRunner(verbosity=2).run(suite)
|
||||
return 0 if result.wasSuccessful() else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
120
dockerfiles/hermes_decomposition_safety_regression.py
Normal file
120
dockerfiles/hermes_decomposition_safety_regression.py
Normal file
@ -0,0 +1,120 @@
|
||||
"""Regress automatic decomposition against existing execution history."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from hermes_cli import kanban_db
|
||||
from hermes_cli import kanban_decompose
|
||||
from hermes_execution_regression_support import ExecutionSafetyTestCase
|
||||
|
||||
|
||||
class DecompositionSafetyTests(ExecutionSafetyTestCase):
|
||||
"""Verify only unexecuted triage roots may decompose automatically."""
|
||||
|
||||
def test_automatic_decomposer_skips_executed_triage_task(self) -> None:
|
||||
task_id = self._route_executed_task_to_triage()
|
||||
completions = self._client(
|
||||
{"fanout": False, "title": "must not run", "body": "must not run"}
|
||||
)
|
||||
before = len(kanban_db.list_tasks(self.connection))
|
||||
|
||||
outcome = kanban_decompose.decompose_task(task_id, automatic=True)
|
||||
|
||||
self.assertFalse(outcome.ok)
|
||||
self.assertIn("execution history", outcome.reason)
|
||||
self.assertEqual(completions.calls, 0)
|
||||
self.assertEqual(self._status(task_id), "triage")
|
||||
self.assertEqual(len(kanban_db.list_tasks(self.connection)), before)
|
||||
|
||||
def test_manual_decomposition_remains_available_after_execution(self) -> None:
|
||||
task_id = self._route_executed_task_to_triage()
|
||||
completions = self._client(
|
||||
{
|
||||
"fanout": False,
|
||||
"title": "operator-approved retry",
|
||||
"body": "Retain one bounded task.",
|
||||
"assignee": "default",
|
||||
}
|
||||
)
|
||||
|
||||
outcome = kanban_decompose.decompose_task(task_id, author="operator")
|
||||
|
||||
self.assertTrue(outcome.ok)
|
||||
self.assertFalse(outcome.fanout)
|
||||
self.assertEqual(completions.calls, 1)
|
||||
self.assertEqual(self._status(task_id), "ready")
|
||||
|
||||
def test_fresh_triage_task_still_auto_promotes(self) -> None:
|
||||
task_id = kanban_db.create_task(
|
||||
self.connection,
|
||||
title="fresh objective",
|
||||
triage=True,
|
||||
)
|
||||
completions = self._client(
|
||||
{
|
||||
"fanout": False,
|
||||
"title": "specified objective",
|
||||
"body": "One bounded task.",
|
||||
"assignee": "default",
|
||||
}
|
||||
)
|
||||
|
||||
outcome = kanban_decompose.decompose_task(task_id, automatic=True)
|
||||
|
||||
self.assertTrue(outcome.ok)
|
||||
self.assertEqual(completions.calls, 1)
|
||||
self.assertEqual(self._status(task_id), "ready")
|
||||
|
||||
def test_execution_history_gained_during_single_llm_call_blocks_commit(self) -> None:
|
||||
task_id = kanban_db.create_task(
|
||||
self.connection,
|
||||
title="concurrent single objective",
|
||||
triage=True,
|
||||
)
|
||||
completions = self._client(
|
||||
{
|
||||
"fanout": False,
|
||||
"title": "redundant rewrite",
|
||||
"body": "must not be committed",
|
||||
"assignee": "default",
|
||||
},
|
||||
before_response=lambda: self._insert_run(task_id),
|
||||
)
|
||||
|
||||
outcome = kanban_decompose.decompose_task(task_id, automatic=True)
|
||||
|
||||
self.assertFalse(outcome.ok)
|
||||
self.assertIn("gained execution history", outcome.reason)
|
||||
self.assertEqual(completions.calls, 1)
|
||||
self.assertEqual(self._status(task_id), "triage")
|
||||
|
||||
def test_execution_history_gained_during_fanout_llm_call_blocks_commit(self) -> None:
|
||||
task_id = kanban_db.create_task(
|
||||
self.connection,
|
||||
title="concurrent triage objective",
|
||||
triage=True,
|
||||
)
|
||||
|
||||
completions = self._client(
|
||||
{
|
||||
"fanout": True,
|
||||
"tasks": [
|
||||
{
|
||||
"title": "redundant child",
|
||||
"body": "must not be created",
|
||||
"assignee": "default",
|
||||
"parents": [],
|
||||
}
|
||||
],
|
||||
},
|
||||
before_response=lambda: self._insert_run(task_id),
|
||||
)
|
||||
before = len(kanban_db.list_tasks(self.connection))
|
||||
|
||||
outcome = kanban_decompose.decompose_task(task_id, automatic=True)
|
||||
|
||||
self.assertFalse(outcome.ok)
|
||||
self.assertIn("gained execution history", outcome.reason)
|
||||
self.assertEqual(completions.calls, 1)
|
||||
self.assertEqual(self._status(task_id), "triage")
|
||||
self.assertEqual(len(kanban_db.list_tasks(self.connection)), before)
|
||||
12
dockerfiles/hermes_execution_patch_support.py
Normal file
12
dockerfiles/hermes_execution_patch_support.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""Fail-closed helpers shared by Hermes source patches."""
|
||||
|
||||
|
||||
|
||||
def replace_once(source: str, before: str, after: str, label: str) -> str:
|
||||
"""Replace one anchored upstream fragment and fail closed on source drift."""
|
||||
count = source.count(before)
|
||||
if count != 1:
|
||||
raise SystemExit(
|
||||
f"Hermes {label} patch context changed: expected 1, found {count}"
|
||||
)
|
||||
return source.replace(before, after, 1)
|
||||
132
dockerfiles/hermes_execution_regression_support.py
Normal file
132
dockerfiles/hermes_execution_regression_support.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""Shared isolated database fixture for Hermes execution-safety regressions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
_HERMES_HOME = tempfile.TemporaryDirectory(prefix="hermes-execution-test-home-")
|
||||
os.environ["HERMES_HOME"] = _HERMES_HOME.name
|
||||
_LANE_SOURCE = Path(
|
||||
os.environ.get("HERMES_CLI_LANE_SOURCE", "/tmp/hermes-lane-regression")
|
||||
)
|
||||
sys.path.insert(0, str(_LANE_SOURCE))
|
||||
|
||||
from agent import auxiliary_client # noqa: E402
|
||||
from cli_lane_config import TerminalIdentity # noqa: E402
|
||||
from cli_lane_finalization import _finalize_document_db # noqa: E402
|
||||
from hermes_cli import kanban_db # noqa: E402
|
||||
from hermes_cli import kanban_decompose # noqa: E402
|
||||
|
||||
__all__ = ("ExecutionSafetyTestCase", "TerminalIdentity", "_finalize_document_db")
|
||||
|
||||
|
||||
class _FakeCompletions:
|
||||
def __init__(self, payload: dict, before_response=None) -> None:
|
||||
self.payload = payload
|
||||
self.before_response = before_response
|
||||
self.calls = 0
|
||||
|
||||
def create(self, **_kwargs):
|
||||
self.calls += 1
|
||||
if self.before_response is not None:
|
||||
self.before_response()
|
||||
return SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(
|
||||
message=SimpleNamespace(content=json.dumps(self.payload))
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, completions: _FakeCompletions) -> None:
|
||||
self.chat = SimpleNamespace(completions=completions)
|
||||
|
||||
|
||||
class ExecutionSafetyTestCase(unittest.TestCase):
|
||||
"""Exercise patched upstream APIs against an isolated real database."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.connection = kanban_db.connect()
|
||||
self.patches = [
|
||||
mock.patch.object(
|
||||
kanban_decompose,
|
||||
"_build_roster",
|
||||
return_value=([], {"default"}),
|
||||
),
|
||||
mock.patch.object(
|
||||
kanban_decompose,
|
||||
"_load_config",
|
||||
return_value={"kanban": {"auto_promote_children": True}},
|
||||
),
|
||||
mock.patch.object(
|
||||
auxiliary_client,
|
||||
"get_auxiliary_extra_body",
|
||||
return_value={},
|
||||
),
|
||||
]
|
||||
for patch in self.patches:
|
||||
patch.start()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
for patch in reversed(self.patches):
|
||||
patch.stop()
|
||||
self.connection.close()
|
||||
|
||||
def _client(self, payload: dict, before_response=None) -> _FakeCompletions:
|
||||
completions = _FakeCompletions(payload, before_response)
|
||||
client = _FakeClient(completions)
|
||||
patch = mock.patch.object(
|
||||
auxiliary_client,
|
||||
"get_text_auxiliary_client",
|
||||
return_value=(client, "test-decomposer"),
|
||||
)
|
||||
patch.start()
|
||||
self.addCleanup(patch.stop)
|
||||
return completions
|
||||
|
||||
def _status(self, task_id: str) -> str:
|
||||
task = kanban_db.get_task(self.connection, task_id)
|
||||
self.assertIsNotNone(task)
|
||||
return task.status
|
||||
|
||||
def _route_executed_task_to_triage(self) -> str:
|
||||
task_id = kanban_db.create_task(
|
||||
self.connection,
|
||||
title="already implemented parent",
|
||||
)
|
||||
for attempt in range(kanban_db.BLOCK_RECURRENCE_LIMIT):
|
||||
if attempt:
|
||||
self.assertTrue(kanban_db.unblock_task(self.connection, task_id))
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
self.assertTrue(
|
||||
kanban_db.block_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="same unavailable capability",
|
||||
kind="capability",
|
||||
)
|
||||
)
|
||||
self.assertEqual(self._status(task_id), "triage")
|
||||
self.assertEqual(
|
||||
len(kanban_db.list_runs(self.connection, task_id)),
|
||||
kanban_db.BLOCK_RECURRENCE_LIMIT,
|
||||
)
|
||||
return task_id
|
||||
|
||||
def _insert_run(self, task_id: str) -> None:
|
||||
with kanban_db.connect_closing() as connection:
|
||||
kanban_db._synthesize_ended_run(
|
||||
connection,
|
||||
task_id,
|
||||
outcome="reclaimed",
|
||||
summary="concurrent execution evidence",
|
||||
)
|
||||
120
dockerfiles/hermes_lane_compatibility_regression.py
Normal file
120
dockerfiles/hermes_lane_compatibility_regression.py
Normal file
@ -0,0 +1,120 @@
|
||||
"""Exercise mounted CLI scripts against the exact image Kanban API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
_HOME = tempfile.TemporaryDirectory(prefix="hermes-lane-compatibility-")
|
||||
os.environ["HERMES_HOME"] = _HOME.name
|
||||
os.environ["HERMES_CLI_LANE_CONCURRENCY"] = "1"
|
||||
lane_source = Path(os.environ["HERMES_CLI_LANE_SOURCE"])
|
||||
sys.path.insert(0, str(lane_source))
|
||||
|
||||
from cli_lane_capabilities import ( # noqa: E402
|
||||
detect_kanban_capabilities,
|
||||
initialize_kanban_capabilities,
|
||||
runtime_health,
|
||||
)
|
||||
from cli_lane_config import TerminalIdentity # noqa: E402
|
||||
from cli_lane_dispatch import recover_orphans # noqa: E402
|
||||
from cli_lane_finalization import _finalize_document_db # noqa: E402
|
||||
from hermes_cli import kanban_db # noqa: E402
|
||||
|
||||
|
||||
class MountedLaneCompatibilityTests(unittest.TestCase):
|
||||
"""Verify safe behavior before and after the image API patch."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.connection = kanban_db.connect()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.connection.close()
|
||||
|
||||
@staticmethod
|
||||
def _document(summary: str) -> dict:
|
||||
return {
|
||||
"result": json.dumps(
|
||||
{
|
||||
"status": "completed",
|
||||
"summary": summary,
|
||||
"changed_files": [],
|
||||
"tests_run": ["mixed source compatibility"],
|
||||
"artifacts": [],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
},
|
||||
sort_keys=True,
|
||||
),
|
||||
"summary": summary,
|
||||
"metadata": {"matrix": os.environ["HERMES_COMPATIBILITY_MODE"]},
|
||||
}
|
||||
|
||||
def test_exact_image_api_matrix(self) -> None:
|
||||
mode = os.environ["HERMES_COMPATIBILITY_MODE"]
|
||||
capabilities = initialize_kanban_capabilities(kanban_db)
|
||||
self.assertTrue(capabilities.exact_run_completion)
|
||||
self.assertEqual(capabilities.ready, mode == "patched")
|
||||
self.assertEqual(runtime_health()["state"], "ready" if mode == "patched" else "deferred")
|
||||
self.assertLess(Path(_HOME.name, "cli-lanes/runtime-health.json").stat().st_size, 2048)
|
||||
|
||||
def test_current_active_run_completion_stays_safe(self) -> None:
|
||||
task_id = kanban_db.create_task(self.connection, title="active accepted result")
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
run_id = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
outcome = _finalize_document_db(
|
||||
kanban_db,
|
||||
TerminalIdentity("default", task_id, run_id, "pending"),
|
||||
self._document("active result"),
|
||||
)
|
||||
self.assertEqual(outcome, "committed")
|
||||
self.assertEqual(kanban_db.get_task(self.connection, task_id).status, "done")
|
||||
|
||||
def test_ended_run_replay_is_enabled_only_after_patch(self) -> None:
|
||||
mode = os.environ["HERMES_COMPATIBILITY_MODE"]
|
||||
task_id = kanban_db.create_task(self.connection, title="ended accepted result")
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
run_id = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
self.assertTrue(
|
||||
kanban_db.block_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="simulate post-journal error",
|
||||
expected_run_id=run_id,
|
||||
)
|
||||
)
|
||||
outcome = _finalize_document_db(
|
||||
kanban_db,
|
||||
TerminalIdentity("default", task_id, run_id, "pending"),
|
||||
self._document("ended result"),
|
||||
)
|
||||
self.assertEqual(outcome, "committed" if mode == "patched" else "deferred")
|
||||
|
||||
def test_old_reclaim_api_is_never_called_unguarded(self) -> None:
|
||||
capabilities = detect_kanban_capabilities(kanban_db)
|
||||
if capabilities.exact_run_reclaim:
|
||||
self.skipTest("patched API exercises guarded reclaim in the exact-run suite")
|
||||
with mock.patch.object(
|
||||
kanban_db,
|
||||
"reclaim_task",
|
||||
side_effect=AssertionError("legacy unguarded reclaim called"),
|
||||
), mock.patch("cli_lane_dispatch.recover_pending_finalizations", return_value=0):
|
||||
recover_orphans()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Run the compatibility matrix for the selected exact image source."""
|
||||
result = unittest.TextTestRunner(verbosity=2).run(
|
||||
unittest.defaultTestLoader.loadTestsFromTestCase(MountedLaneCompatibilityTests)
|
||||
)
|
||||
return 0 if result.wasSuccessful() else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
467
dockerfiles/hermes_run_safety_regression.py
Normal file
467
dockerfiles/hermes_run_safety_regression.py
Normal file
@ -0,0 +1,467 @@
|
||||
"""Regress exact-run replay, provenance, and reclaim safety."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from unittest import mock
|
||||
|
||||
from hermes_cli import kanban_db
|
||||
from hermes_execution_regression_support import (
|
||||
ExecutionSafetyTestCase,
|
||||
TerminalIdentity,
|
||||
_finalize_document_db,
|
||||
)
|
||||
from cli_lane_files import state_path
|
||||
from cli_lane_records import _write_terminal_record
|
||||
from cli_lane_recovery import recover_pending_finalizations
|
||||
|
||||
|
||||
class ExactRunSafetyTests(ExecutionSafetyTestCase):
|
||||
"""Verify exact-run transactions reject every stale concurrent actor."""
|
||||
|
||||
def _lane_document(self, summary: str) -> dict:
|
||||
"""Return the DB-bearing portion of one accepted lane journal."""
|
||||
return {
|
||||
"result": summary,
|
||||
"summary": summary,
|
||||
"metadata": {"source": "exact-run-regression"},
|
||||
}
|
||||
|
||||
def test_lane_marks_replaced_ended_run_stale_without_mutation(self) -> None:
|
||||
task_id = kanban_db.create_task(self.connection, title="replaced journal")
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
first_run = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
self.assertTrue(
|
||||
kanban_db.block_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="first run ended after persistence",
|
||||
expected_run_id=first_run,
|
||||
)
|
||||
)
|
||||
self.assertTrue(kanban_db.unblock_task(self.connection, task_id))
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
second_run = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
self.assertTrue(
|
||||
kanban_db.block_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="replacement run also ended",
|
||||
expected_run_id=second_run,
|
||||
)
|
||||
)
|
||||
before = kanban_db.get_task(self.connection, task_id)
|
||||
|
||||
outcome = _finalize_document_db(
|
||||
kanban_db,
|
||||
TerminalIdentity("default", task_id, first_run, "pending"),
|
||||
self._lane_document("stale first result"),
|
||||
)
|
||||
|
||||
after = kanban_db.get_task(self.connection, task_id)
|
||||
self.assertEqual(outcome, "stale")
|
||||
self.assertEqual(after.status, before.status)
|
||||
self.assertEqual(after.current_run_id, before.current_run_id)
|
||||
self.assertEqual(after.result, before.result)
|
||||
self.assertEqual(after.completed_run_id, before.completed_run_id)
|
||||
|
||||
def test_lane_marks_nonexistent_ended_run_stale_without_mutation(self) -> None:
|
||||
task_id = kanban_db.create_task(self.connection, title="no such run")
|
||||
before = kanban_db.get_task(self.connection, task_id)
|
||||
|
||||
outcome = _finalize_document_db(
|
||||
kanban_db,
|
||||
TerminalIdentity("default", task_id, 42, "pending"),
|
||||
self._lane_document("orphan result"),
|
||||
)
|
||||
|
||||
after = kanban_db.get_task(self.connection, task_id)
|
||||
self.assertEqual(outcome, "stale")
|
||||
self.assertEqual(after.status, before.status)
|
||||
self.assertEqual(after.result, before.result)
|
||||
self.assertEqual(kanban_db.list_runs(self.connection, task_id), [])
|
||||
|
||||
def test_full_lane_recovery_conflicts_a_replaced_ended_run(self) -> None:
|
||||
task_id = kanban_db.create_task(self.connection, title="recovery conflict")
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
first_run = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
self.assertTrue(
|
||||
kanban_db.block_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="first accepted run ended",
|
||||
expected_run_id=first_run,
|
||||
)
|
||||
)
|
||||
self.assertTrue(kanban_db.unblock_task(self.connection, task_id))
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
second_run = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
self.assertTrue(
|
||||
kanban_db.block_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="replacement ended",
|
||||
expected_run_id=second_run,
|
||||
)
|
||||
)
|
||||
before = kanban_db.get_task(self.connection, task_id)
|
||||
state_file = state_path("default", task_id)
|
||||
pending, _record = _write_terminal_record(
|
||||
state_file,
|
||||
board="default",
|
||||
task_id=task_id,
|
||||
run_id=first_run,
|
||||
structured={
|
||||
"status": "completed",
|
||||
"summary": "accepted stale result",
|
||||
"changed_files": [],
|
||||
"tests_run": ["real SQLite recovery regression"],
|
||||
"artifacts": [],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
},
|
||||
summary="accepted stale result",
|
||||
metadata={"source": "exact-run-regression"},
|
||||
)
|
||||
|
||||
self.assertEqual(recover_pending_finalizations(), 0)
|
||||
|
||||
after = kanban_db.get_task(self.connection, task_id)
|
||||
self.assertEqual(after.status, before.status)
|
||||
self.assertEqual(after.current_run_id, before.current_run_id)
|
||||
self.assertEqual(after.result, before.result)
|
||||
self.assertFalse(pending.exists())
|
||||
conflicts = list(
|
||||
state_file.parent.glob(
|
||||
f"{state_file.stem}.run-{first_run}.terminal.conflict-*.json"
|
||||
)
|
||||
)
|
||||
self.assertEqual(len(conflicts), 1)
|
||||
|
||||
def test_latest_ended_run_can_replay_a_durable_completion(self) -> None:
|
||||
task_id = kanban_db.create_task(self.connection, title="journaled result")
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
run_id = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
self.assertTrue(
|
||||
kanban_db.block_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="legacy post-journal failure",
|
||||
kind="capability",
|
||||
expected_run_id=run_id,
|
||||
)
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
kanban_db, "_fire_kanban_lifecycle_hook"
|
||||
) as lifecycle:
|
||||
self.assertTrue(
|
||||
kanban_db.complete_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
result="durable terminal result",
|
||||
summary="durable terminal result",
|
||||
replay_ended_run_id=run_id,
|
||||
)
|
||||
)
|
||||
lifecycle.assert_called_once()
|
||||
self.assertEqual(lifecycle.call_args.args[:2], ("kanban_task_completed", task_id))
|
||||
self.assertEqual(lifecycle.call_args.kwargs["run_id"], run_id)
|
||||
task = kanban_db.get_task(self.connection, task_id)
|
||||
self.assertEqual(task.status, "done")
|
||||
self.assertEqual(task.completed_run_id, run_id)
|
||||
runs = kanban_db.list_runs(self.connection, task_id)
|
||||
self.assertEqual(len(runs), 1)
|
||||
self.assertEqual(runs[0].id, run_id)
|
||||
self.assertEqual(runs[0].status, "done")
|
||||
self.assertEqual(runs[0].outcome, "completed")
|
||||
self.assertEqual(runs[0].summary, "durable terminal result")
|
||||
completed_events = self.connection.execute(
|
||||
"SELECT run_id FROM task_events "
|
||||
"WHERE task_id = ? AND kind = 'completed' ORDER BY id",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
self.assertEqual([row["run_id"] for row in completed_events], [run_id])
|
||||
|
||||
def test_unchanged_triage_can_replay_its_exact_latest_ended_run(self) -> None:
|
||||
task_id = self._route_executed_task_to_triage()
|
||||
runs = kanban_db.list_runs(self.connection, task_id)
|
||||
run_id = runs[-1].id
|
||||
|
||||
self.assertTrue(
|
||||
kanban_db.complete_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
result="accepted before the loop breaker routed triage",
|
||||
summary="accepted before the loop breaker routed triage",
|
||||
replay_ended_run_id=run_id,
|
||||
)
|
||||
)
|
||||
|
||||
task = kanban_db.get_task(self.connection, task_id)
|
||||
self.assertEqual(task.status, "done")
|
||||
self.assertEqual(task.completed_run_id, run_id)
|
||||
updated_runs = kanban_db.list_runs(self.connection, task_id)
|
||||
self.assertEqual(len(updated_runs), kanban_db.BLOCK_RECURRENCE_LIMIT)
|
||||
self.assertEqual(updated_runs[-1].id, run_id)
|
||||
self.assertEqual(updated_runs[-1].outcome, "completed")
|
||||
|
||||
def test_triage_status_transition_prevents_stale_exact_run_replay(self) -> None:
|
||||
task_id = self._route_executed_task_to_triage()
|
||||
run_id = kanban_db.list_runs(self.connection, task_id)[-1].id
|
||||
self.assertTrue(
|
||||
kanban_db.specify_triage_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
title="operator changed the objective",
|
||||
author="operator",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
kanban_db.complete_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
result="stale accepted result",
|
||||
replay_ended_run_id=run_id,
|
||||
)
|
||||
)
|
||||
self.assertNotEqual(self._status(task_id), "done")
|
||||
|
||||
def test_decomposition_lineage_prevents_replay_even_if_status_returns(self) -> None:
|
||||
task_id = self._route_executed_task_to_triage()
|
||||
run_id = kanban_db.list_runs(self.connection, task_id)[-1].id
|
||||
children = kanban_db.decompose_triage_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
root_assignee="default",
|
||||
children=[{"title": "new child objective"}],
|
||||
author="operator",
|
||||
auto_promote=False,
|
||||
)
|
||||
self.assertEqual(len(children or []), 1)
|
||||
with kanban_db.write_txn(self.connection):
|
||||
self.connection.execute(
|
||||
"UPDATE tasks SET status = 'ready' WHERE id = ?",
|
||||
(task_id,),
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
kanban_db.complete_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
result="pre-decomposition accepted result",
|
||||
replay_ended_run_id=run_id,
|
||||
)
|
||||
)
|
||||
self.assertEqual(self._status(task_id), "ready")
|
||||
|
||||
def test_older_ended_run_cannot_complete_over_a_replacement(self) -> None:
|
||||
task_id = kanban_db.create_task(self.connection, title="replacement guard")
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
old_run = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
self.assertTrue(
|
||||
kanban_db.block_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="first run",
|
||||
kind="capability",
|
||||
expected_run_id=old_run,
|
||||
)
|
||||
)
|
||||
self.assertTrue(kanban_db.unblock_task(self.connection, task_id))
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
replacement_run = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
self.assertNotEqual(old_run, replacement_run)
|
||||
self.assertTrue(
|
||||
kanban_db.block_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="replacement run",
|
||||
kind="capability",
|
||||
expected_run_id=replacement_run,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
kanban_db.complete_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
result="stale result",
|
||||
replay_ended_run_id=old_run,
|
||||
)
|
||||
)
|
||||
self.assertIn(self._status(task_id), {"blocked", "triage"})
|
||||
|
||||
def test_equal_result_bytes_remain_bound_to_the_completing_run(self) -> None:
|
||||
task_id = kanban_db.create_task(self.connection, title="same result retries")
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
first_run = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
self.assertTrue(
|
||||
kanban_db.block_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="first attempt ended",
|
||||
expected_run_id=first_run,
|
||||
)
|
||||
)
|
||||
self.assertTrue(kanban_db.unblock_task(self.connection, task_id))
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
second_run = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
self.assertTrue(
|
||||
kanban_db.complete_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
result="identical result bytes",
|
||||
summary="second run wins",
|
||||
expected_run_id=second_run,
|
||||
)
|
||||
)
|
||||
|
||||
task = kanban_db.get_task(self.connection, task_id)
|
||||
self.assertEqual(task.completed_run_id, second_run)
|
||||
self.assertNotEqual(task.completed_run_id, first_run)
|
||||
runs = {run.id: run for run in kanban_db.list_runs(self.connection, task_id)}
|
||||
self.assertEqual(runs[first_run].outcome, "blocked")
|
||||
self.assertEqual(runs[second_run].outcome, "completed")
|
||||
|
||||
def test_exact_run_reclaim_succeeds_for_the_authoritative_run(self) -> None:
|
||||
task_id = kanban_db.create_task(self.connection, title="recover exact run")
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
run_id = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
|
||||
self.assertTrue(
|
||||
kanban_db.reclaim_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="invalid exact journal",
|
||||
expected_run_id=run_id,
|
||||
)
|
||||
)
|
||||
task = kanban_db.get_task(self.connection, task_id)
|
||||
self.assertEqual(task.status, "ready")
|
||||
self.assertIsNone(task.current_run_id)
|
||||
|
||||
def test_stale_reclaim_before_transaction_preserves_replacement_run(self) -> None:
|
||||
task_id = kanban_db.create_task(self.connection, title="replacement before txn")
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
old_run = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
self.assertTrue(
|
||||
kanban_db.block_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="old recovery run",
|
||||
expected_run_id=old_run,
|
||||
)
|
||||
)
|
||||
self.assertTrue(kanban_db.unblock_task(self.connection, task_id))
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
replacement = kanban_db.get_task(self.connection, task_id)
|
||||
replacement_run = replacement.current_run_id
|
||||
self.assertNotEqual(old_run, replacement_run)
|
||||
signals = []
|
||||
|
||||
self.assertFalse(
|
||||
kanban_db.reclaim_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="stale journal",
|
||||
expected_run_id=old_run,
|
||||
signal_fn=lambda *args: signals.append(args),
|
||||
)
|
||||
)
|
||||
latest = kanban_db.get_task(self.connection, task_id)
|
||||
self.assertEqual(latest.status, "running")
|
||||
self.assertEqual(latest.current_run_id, replacement_run)
|
||||
self.assertEqual(latest.claim_lock, replacement.claim_lock)
|
||||
self.assertEqual(signals, [])
|
||||
|
||||
def test_reclaim_update_guard_preserves_run_changed_inside_transaction(self) -> None:
|
||||
task_id = kanban_db.create_task(self.connection, title="replacement in txn")
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
old_run = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
replacement = {}
|
||||
|
||||
def install_replacement(*_args, **_kwargs):
|
||||
cursor = self.connection.execute(
|
||||
"INSERT INTO task_runs (task_id, status, claim_lock, started_at) "
|
||||
"VALUES (?, 'running', ?, strftime('%s','now'))",
|
||||
(task_id, "replacement-lock"),
|
||||
)
|
||||
replacement["run_id"] = int(cursor.lastrowid)
|
||||
self.connection.execute(
|
||||
"UPDATE tasks SET current_run_id = ?, claim_lock = ? WHERE id = ?",
|
||||
(replacement["run_id"], "replacement-lock", task_id),
|
||||
)
|
||||
return {}
|
||||
|
||||
with mock.patch.object(
|
||||
kanban_db,
|
||||
"_terminate_reclaimed_worker",
|
||||
side_effect=install_replacement,
|
||||
):
|
||||
self.assertFalse(
|
||||
kanban_db.reclaim_task(
|
||||
self.connection,
|
||||
task_id,
|
||||
reason="journal for old run",
|
||||
expected_run_id=old_run,
|
||||
)
|
||||
)
|
||||
|
||||
latest = kanban_db.get_task(self.connection, task_id)
|
||||
self.assertEqual(latest.status, "running")
|
||||
self.assertEqual(latest.current_run_id, replacement["run_id"])
|
||||
self.assertEqual(latest.claim_lock, "replacement-lock")
|
||||
|
||||
def test_concurrent_exact_reclaim_and_completion_have_one_winner(self) -> None:
|
||||
task_id = kanban_db.create_task(self.connection, title="concurrent finalizer")
|
||||
self.assertIsNotNone(kanban_db.claim_task(self.connection, task_id))
|
||||
run_id = kanban_db.get_task(self.connection, task_id).current_run_id
|
||||
barrier = threading.Barrier(2)
|
||||
outcomes = {}
|
||||
errors = []
|
||||
|
||||
def reclaim() -> None:
|
||||
try:
|
||||
with kanban_db.connect_closing() as connection:
|
||||
barrier.wait()
|
||||
outcomes["reclaim"] = kanban_db.reclaim_task(
|
||||
connection,
|
||||
task_id,
|
||||
reason="concurrent invalid journal",
|
||||
expected_run_id=run_id,
|
||||
)
|
||||
except BaseException as error: # pragma: no cover - assertion relay
|
||||
errors.append(error)
|
||||
|
||||
def finalize() -> None:
|
||||
try:
|
||||
with kanban_db.connect_closing() as connection:
|
||||
barrier.wait()
|
||||
outcomes["complete"] = kanban_db.complete_task(
|
||||
connection,
|
||||
task_id,
|
||||
result="durable winner",
|
||||
summary="durable winner",
|
||||
expected_run_id=run_id,
|
||||
)
|
||||
except BaseException as error: # pragma: no cover - assertion relay
|
||||
errors.append(error)
|
||||
|
||||
threads = [threading.Thread(target=reclaim), threading.Thread(target=finalize)]
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join(timeout=10)
|
||||
self.assertFalse(thread.is_alive())
|
||||
|
||||
self.assertEqual(errors, [])
|
||||
self.assertEqual(set(outcomes), {"reclaim", "complete"})
|
||||
self.assertEqual(sum(bool(value) for value in outcomes.values()), 1)
|
||||
latest = kanban_db.get_task(self.connection, task_id)
|
||||
self.assertIn(latest.status, {"done", "ready"})
|
||||
self.assertIsNone(latest.current_run_id)
|
||||
runs = kanban_db.list_runs(self.connection, task_id)
|
||||
self.assertEqual(len(runs), 1)
|
||||
self.assertIsNotNone(runs[0].ended_at)
|
||||
6
dockerfiles/patch-hermes-execution-safety.py
Normal file
6
dockerfiles/patch-hermes-execution-safety.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Apply the composable Hermes execution-safety source patches."""
|
||||
|
||||
# Import order is intentional: the decomposition DB contract builds on the
|
||||
# exact-run schema and transaction changes. Each module fails closed on drift.
|
||||
import patch_hermes_run_safety # noqa: F401
|
||||
import patch_hermes_decomposition_safety # noqa: F401
|
||||
262
dockerfiles/patch_hermes_decomposition_safety.py
Normal file
262
dockerfiles/patch_hermes_decomposition_safety.py
Normal file
@ -0,0 +1,262 @@
|
||||
"""Patch transactional automatic-decomposition execution-history guards."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_execution_patch_support import replace_once
|
||||
|
||||
source_root = Path(os.environ.get("HERMES_SOURCE_ROOT", "/opt/hermes"))
|
||||
db_path = source_root / "hermes_cli/kanban_db.py"
|
||||
db = db_path.read_text(encoding="utf-8")
|
||||
|
||||
specify_signature_before = '''def specify_triage_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
body: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
author: Optional[str] = None,
|
||||
) -> bool:
|
||||
'''
|
||||
specify_signature_after = '''def specify_triage_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
title: Optional[str] = None,
|
||||
body: Optional[str] = None,
|
||||
assignee: Optional[str] = None,
|
||||
author: Optional[str] = None,
|
||||
require_no_runs: bool = False,
|
||||
) -> bool:
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
specify_signature_before,
|
||||
specify_signature_after,
|
||||
"transactional specify signature",
|
||||
)
|
||||
|
||||
specify_guard_before = ''' if existing is None:
|
||||
return False
|
||||
sets: list[str] = ["status = 'todo'"]
|
||||
'''
|
||||
specify_guard_after = ''' if existing is None:
|
||||
return False
|
||||
if require_no_runs and conn.execute(
|
||||
"SELECT 1 FROM task_runs WHERE task_id = ? LIMIT 1",
|
||||
(task_id,),
|
||||
).fetchone() is not None:
|
||||
return False
|
||||
sets: list[str] = ["status = 'todo'"]
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
specify_guard_before,
|
||||
specify_guard_after,
|
||||
"transactional specify execution-history guard",
|
||||
)
|
||||
|
||||
decompose_signature_before = '''def decompose_triage_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
root_assignee: Optional[str],
|
||||
children: list[dict],
|
||||
author: Optional[str] = None,
|
||||
auto_promote: bool = True,
|
||||
) -> Optional[list[str]]:
|
||||
'''
|
||||
decompose_signature_after = '''def decompose_triage_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
root_assignee: Optional[str],
|
||||
children: list[dict],
|
||||
author: Optional[str] = None,
|
||||
auto_promote: bool = True,
|
||||
require_no_runs: bool = False,
|
||||
) -> Optional[list[str]]:
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
decompose_signature_before,
|
||||
decompose_signature_after,
|
||||
"transactional decomposition signature",
|
||||
)
|
||||
|
||||
decompose_guard_before = ''' if root_row["status"] != "triage":
|
||||
return None
|
||||
tenant = root_row["tenant"]
|
||||
'''
|
||||
decompose_guard_after = ''' if root_row["status"] != "triage":
|
||||
return None
|
||||
if require_no_runs and conn.execute(
|
||||
"SELECT 1 FROM task_runs WHERE task_id = ? LIMIT 1",
|
||||
(task_id,),
|
||||
).fetchone() is not None:
|
||||
return None
|
||||
tenant = root_row["tenant"]
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
decompose_guard_before,
|
||||
decompose_guard_after,
|
||||
"transactional decomposition execution-history guard",
|
||||
)
|
||||
db_path.write_text(db, encoding="utf-8")
|
||||
|
||||
decompose_path = source_root / "hermes_cli/kanban_decompose.py"
|
||||
decompose = decompose_path.read_text(encoding="utf-8")
|
||||
|
||||
helper_anchor = ''' if chosen not in valid_names:
|
||||
return default_assignee
|
||||
return chosen
|
||||
|
||||
|
||||
'''
|
||||
helper_replacement = helper_anchor + '''def _has_execution_history(task_id: str) -> bool:
|
||||
"""Return whether a task has ever entered a worker run."""
|
||||
with kb.connect_closing() as conn:
|
||||
return bool(kb.list_runs(conn, task_id))
|
||||
|
||||
|
||||
'''
|
||||
decompose = replace_once(
|
||||
decompose,
|
||||
helper_anchor,
|
||||
helper_replacement,
|
||||
"auto-decompose execution-history helper",
|
||||
)
|
||||
|
||||
signature_before = '''def decompose_task(
|
||||
task_id: str,
|
||||
*,
|
||||
author: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
) -> DecomposeOutcome:
|
||||
'''
|
||||
signature_after = '''def decompose_task(
|
||||
task_id: str,
|
||||
*,
|
||||
author: Optional[str] = None,
|
||||
timeout: Optional[int] = None,
|
||||
automatic: bool = False,
|
||||
) -> DecomposeOutcome:
|
||||
'''
|
||||
decompose = replace_once(
|
||||
decompose,
|
||||
signature_before,
|
||||
signature_after,
|
||||
"auto-decompose function signature",
|
||||
)
|
||||
|
||||
status_before = ''' if task.status != "triage":
|
||||
return DecomposeOutcome(
|
||||
task_id, False, f"task is not in triage (status={task.status!r})"
|
||||
)
|
||||
|
||||
cfg = _load_config()
|
||||
'''
|
||||
status_after = ''' if task.status != "triage":
|
||||
return DecomposeOutcome(
|
||||
task_id, False, f"task is not in triage (status={task.status!r})"
|
||||
)
|
||||
if automatic and _has_execution_history(task_id):
|
||||
return DecomposeOutcome(
|
||||
task_id,
|
||||
False,
|
||||
"task has execution history and requires deliberate manual triage",
|
||||
)
|
||||
|
||||
cfg = _load_config()
|
||||
'''
|
||||
decompose = replace_once(
|
||||
decompose,
|
||||
status_before,
|
||||
status_after,
|
||||
"auto-decompose preflight guard",
|
||||
)
|
||||
|
||||
single_before = ''' author=audit_author,
|
||||
)
|
||||
if not ok:
|
||||
'''
|
||||
single_after = ''' author=audit_author,
|
||||
require_no_runs=automatic,
|
||||
)
|
||||
if not ok:
|
||||
if automatic and _has_execution_history(task_id):
|
||||
return DecomposeOutcome(
|
||||
task_id,
|
||||
False,
|
||||
"task gained execution history and requires deliberate manual triage",
|
||||
)
|
||||
'''
|
||||
decompose = replace_once(
|
||||
decompose,
|
||||
single_before,
|
||||
single_after,
|
||||
"auto-decompose single-task commit guard",
|
||||
)
|
||||
|
||||
fanout_before = ''' author=audit_author,
|
||||
auto_promote=auto_promote,
|
||||
)
|
||||
'''
|
||||
fanout_after = ''' author=audit_author,
|
||||
auto_promote=auto_promote,
|
||||
require_no_runs=automatic,
|
||||
)
|
||||
'''
|
||||
decompose = replace_once(
|
||||
decompose,
|
||||
fanout_before,
|
||||
fanout_after,
|
||||
"auto-decompose transactional fanout guard",
|
||||
)
|
||||
|
||||
fanout_outcome_before = ''' if child_ids is None:
|
||||
return DecomposeOutcome(
|
||||
task_id, False, "task moved out of triage before decomposition",
|
||||
)
|
||||
'''
|
||||
fanout_outcome_after = ''' if child_ids is None:
|
||||
if automatic and _has_execution_history(task_id):
|
||||
return DecomposeOutcome(
|
||||
task_id,
|
||||
False,
|
||||
"task gained execution history and requires deliberate manual triage",
|
||||
)
|
||||
return DecomposeOutcome(
|
||||
task_id, False, "task moved out of triage before decomposition",
|
||||
)
|
||||
'''
|
||||
decompose = replace_once(
|
||||
decompose,
|
||||
fanout_outcome_before,
|
||||
fanout_outcome_after,
|
||||
"auto-decompose fanout rejection reason",
|
||||
)
|
||||
decompose_path.write_text(decompose, encoding="utf-8")
|
||||
|
||||
|
||||
watcher_path = source_root / "gateway/kanban_watchers.py"
|
||||
watcher = watcher_path.read_text(encoding="utf-8")
|
||||
call_before = ''' outcome = _decomp.decompose_task(
|
||||
tid, author="auto-decomposer",
|
||||
)
|
||||
'''
|
||||
call_after = ''' outcome = _decomp.decompose_task(
|
||||
tid,
|
||||
author="auto-decomposer",
|
||||
automatic=True,
|
||||
)
|
||||
'''
|
||||
watcher = replace_once(
|
||||
watcher,
|
||||
call_before,
|
||||
call_after,
|
||||
"gateway automatic-decomposition call",
|
||||
)
|
||||
watcher_path.write_text(watcher, encoding="utf-8")
|
||||
480
dockerfiles/patch_hermes_run_safety.py
Normal file
480
dockerfiles/patch_hermes_run_safety.py
Normal file
@ -0,0 +1,480 @@
|
||||
"""Patch exact-run completion, provenance, and reclaim transactions."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_execution_patch_support import replace_once
|
||||
|
||||
|
||||
|
||||
source_root = Path(os.environ.get("HERMES_SOURCE_ROOT", "/opt/hermes"))
|
||||
db_path = source_root / "hermes_cli/kanban_db.py"
|
||||
db = db_path.read_text(encoding="utf-8")
|
||||
|
||||
complete_signature_before = '''def complete_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
result: Optional[str] = None,
|
||||
summary: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
created_cards: Optional[Iterable[str]] = None,
|
||||
expected_run_id: Optional[int] = None,
|
||||
) -> bool:
|
||||
'''
|
||||
complete_signature_after = '''def complete_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
result: Optional[str] = None,
|
||||
summary: Optional[str] = None,
|
||||
metadata: Optional[dict] = None,
|
||||
created_cards: Optional[Iterable[str]] = None,
|
||||
expected_run_id: Optional[int] = None,
|
||||
replay_ended_run_id: Optional[int] = None,
|
||||
) -> bool:
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
complete_signature_before,
|
||||
complete_signature_after,
|
||||
"exact ended-run completion signature",
|
||||
)
|
||||
|
||||
complete_branch_before = ''' with write_txn(conn):
|
||||
if expected_run_id is None:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = 'done',
|
||||
result = ?,
|
||||
completed_at = ?,
|
||||
claim_lock = NULL,
|
||||
claim_expires= NULL,
|
||||
worker_pid = NULL,
|
||||
block_kind = NULL,
|
||||
block_recurrences = 0
|
||||
WHERE id = ?
|
||||
AND status IN ('running', 'ready', 'blocked', 'scheduled')
|
||||
""",
|
||||
(result, now, task_id),
|
||||
)
|
||||
else:
|
||||
'''
|
||||
complete_branch_after = ''' # Journal recovery may arrive after a legacy error path ended the
|
||||
# accepted run. Verify that it is still the latest run in this same
|
||||
# transaction; a replacement attempt must make the replay fail closed.
|
||||
with write_txn(conn):
|
||||
replayed_run_id = None
|
||||
if expected_run_id is not None and replay_ended_run_id is not None:
|
||||
raise ValueError("expected_run_id and replay_ended_run_id are mutually exclusive")
|
||||
if replay_ended_run_id is not None:
|
||||
replayed_run_id = int(replay_ended_run_id)
|
||||
# Replay is permitted only while the exact run-ending transition
|
||||
# remains the latest status mutation. Comments and diagnostics do
|
||||
# not invalidate durable accepted work, but an unblock, explicit
|
||||
# triage specification, decomposition, reclaim, or newer attempt
|
||||
# does. BEGIN IMMEDIATE keeps this lineage check and the guarded
|
||||
# task/run updates in one transaction.
|
||||
last_transition = conn.execute(
|
||||
"""
|
||||
SELECT kind, run_id
|
||||
FROM task_events
|
||||
WHERE task_id = ?
|
||||
AND kind IN (
|
||||
'archived', 'block_loop_detected', 'blocked', 'claimed',
|
||||
'completed', 'decomposed', 'dependency_wait', 'gave_up',
|
||||
'promoted', 'promoted_manual', 'reclaimed', 'scheduled',
|
||||
'specified', 'stale', 'timed_out', 'unblocked'
|
||||
)
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if (
|
||||
last_transition is None
|
||||
or last_transition["run_id"] != replayed_run_id
|
||||
or last_transition["kind"] not in {
|
||||
"block_loop_detected",
|
||||
"blocked",
|
||||
"reclaimed",
|
||||
"stale",
|
||||
"timed_out",
|
||||
"gave_up",
|
||||
}
|
||||
):
|
||||
return False
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = 'done',
|
||||
result = ?,
|
||||
completed_at = ?,
|
||||
claim_lock = NULL,
|
||||
claim_expires= NULL,
|
||||
worker_pid = NULL,
|
||||
block_kind = NULL,
|
||||
block_recurrences = 0
|
||||
WHERE id = ?
|
||||
AND status IN ('ready', 'blocked', 'triage')
|
||||
AND current_run_id IS NULL
|
||||
AND ? = (
|
||||
SELECT id FROM task_runs
|
||||
WHERE task_id = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM task_runs
|
||||
WHERE id = ?
|
||||
AND task_id = ?
|
||||
AND ended_at IS NOT NULL
|
||||
)
|
||||
""",
|
||||
(
|
||||
result,
|
||||
now,
|
||||
task_id,
|
||||
replayed_run_id,
|
||||
task_id,
|
||||
replayed_run_id,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
elif expected_run_id is None:
|
||||
cur = conn.execute(
|
||||
"""
|
||||
UPDATE tasks
|
||||
SET status = 'done',
|
||||
result = ?,
|
||||
completed_at = ?,
|
||||
claim_lock = NULL,
|
||||
claim_expires= NULL,
|
||||
worker_pid = NULL,
|
||||
block_kind = NULL,
|
||||
block_recurrences = 0
|
||||
WHERE id = ?
|
||||
AND status IN ('running', 'ready', 'blocked', 'scheduled')
|
||||
""",
|
||||
(result, now, task_id),
|
||||
)
|
||||
else:
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
complete_branch_before,
|
||||
complete_branch_after,
|
||||
"transactional exact ended-run completion",
|
||||
)
|
||||
|
||||
run_completion_before = ''' run_id = _end_run(
|
||||
conn, task_id,
|
||||
outcome="completed", status="done",
|
||||
summary=summary if summary is not None else result,
|
||||
metadata=metadata,
|
||||
)
|
||||
# If complete_task was called on a never-claimed task (ready or
|
||||
# blocked → done with no run in flight), synthesize a
|
||||
# zero-duration run so the handoff fields are persisted in
|
||||
# attempt history instead of silently lost.
|
||||
if run_id is None and (summary or metadata or result):
|
||||
run_id = _synthesize_ended_run(
|
||||
conn, task_id,
|
||||
outcome="completed",
|
||||
summary=summary if summary is not None else result,
|
||||
metadata=metadata,
|
||||
)
|
||||
'''
|
||||
run_completion_after = ''' if replayed_run_id is None:
|
||||
run_id = _end_run(
|
||||
conn, task_id,
|
||||
outcome="completed", status="done",
|
||||
summary=summary if summary is not None else result,
|
||||
metadata=metadata,
|
||||
)
|
||||
# If complete_task was called on a never-claimed task (ready or
|
||||
# blocked → done with no run in flight), synthesize a
|
||||
# zero-duration run so the handoff fields are persisted in
|
||||
# attempt history instead of silently lost.
|
||||
if run_id is None and (summary or metadata or result):
|
||||
run_id = _synthesize_ended_run(
|
||||
conn, task_id,
|
||||
outcome="completed",
|
||||
summary=summary if summary is not None else result,
|
||||
metadata=metadata,
|
||||
)
|
||||
else:
|
||||
run_id = replayed_run_id
|
||||
replayed = conn.execute(
|
||||
"""
|
||||
UPDATE task_runs
|
||||
SET status = 'done',
|
||||
outcome = 'completed',
|
||||
summary = ?,
|
||||
error = NULL,
|
||||
metadata = ?,
|
||||
claim_lock = NULL,
|
||||
claim_expires = NULL,
|
||||
worker_pid = NULL
|
||||
WHERE id = ?
|
||||
AND task_id = ?
|
||||
AND ended_at IS NOT NULL
|
||||
""",
|
||||
(
|
||||
summary if summary is not None else result,
|
||||
json.dumps(metadata, ensure_ascii=False) if metadata else None,
|
||||
run_id,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
if replayed.rowcount != 1:
|
||||
raise RuntimeError("exact ended-run completion lost its run identity")
|
||||
conn.execute(
|
||||
"UPDATE tasks SET completed_run_id = ? WHERE id = ?",
|
||||
(run_id, task_id),
|
||||
)
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
run_completion_before,
|
||||
run_completion_after,
|
||||
"exact completion run provenance",
|
||||
)
|
||||
|
||||
reclaim_before = '''def reclaim_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
signal_fn=None,
|
||||
) -> bool:
|
||||
"""Operator-driven reclaim: release the claim and reset to ``ready``.
|
||||
|
||||
Unlike :func:`release_stale_claims` which only acts on tasks whose
|
||||
``claim_expires`` has passed, this function reclaims immediately
|
||||
regardless of TTL. Intended for the dashboard/CLI recovery flow
|
||||
when an operator wants to abort a running worker without waiting
|
||||
for the TTL to expire (e.g. after seeing a hallucination warning).
|
||||
|
||||
Returns True if a reclaim happened, False if the task isn't in a
|
||||
reclaimable state (not running, or doesn't exist).
|
||||
"""
|
||||
row = conn.execute(
|
||||
"SELECT status, claim_lock, worker_pid FROM tasks WHERE id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
if row["status"] != "running" and row["claim_lock"] is None:
|
||||
# Nothing to reclaim — already ready / blocked / done.
|
||||
return False
|
||||
prev_lock = row["claim_lock"]
|
||||
termination = _terminate_reclaimed_worker(
|
||||
row["worker_pid"], prev_lock, signal_fn=signal_fn,
|
||||
)
|
||||
with write_txn(conn):
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
|
||||
"claim_expires = NULL, worker_pid = NULL "
|
||||
"WHERE id = ? AND status IN ('running', 'ready', 'blocked') "
|
||||
"AND claim_lock IS ?",
|
||||
(task_id, prev_lock),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
return False
|
||||
run_id = _end_run(
|
||||
conn, task_id,
|
||||
outcome="reclaimed", status="reclaimed",
|
||||
error=(
|
||||
f"manual_reclaim: {reason}" if reason
|
||||
else f"manual_reclaim lock={prev_lock}"
|
||||
),
|
||||
metadata=termination,
|
||||
)
|
||||
payload = {
|
||||
"manual": True,
|
||||
"reason": reason,
|
||||
"prev_lock": prev_lock,
|
||||
}
|
||||
payload.update(termination)
|
||||
_append_event(
|
||||
conn, task_id, "reclaimed",
|
||||
payload,
|
||||
run_id=run_id,
|
||||
)
|
||||
# Operator intervention — they've looked at the task, so the
|
||||
# consecutive-failures counter is now stale. Give the next retry
|
||||
# a fresh budget. (_clear_failure_counter opens its own write_txn,
|
||||
# so it runs after the enclosing one commits.)
|
||||
_clear_failure_counter(conn, task_id)
|
||||
return True
|
||||
'''
|
||||
reclaim_after = '''def reclaim_task(
|
||||
conn: sqlite3.Connection,
|
||||
task_id: str,
|
||||
*,
|
||||
reason: Optional[str] = None,
|
||||
signal_fn=None,
|
||||
expected_run_id: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""Operator-driven reclaim: release the claim and reset to ``ready``.
|
||||
|
||||
Unlike :func:`release_stale_claims` which only acts on tasks whose
|
||||
``claim_expires`` has passed, this function reclaims immediately
|
||||
regardless of TTL. Intended for the dashboard/CLI recovery flow
|
||||
when an operator wants to abort a running worker without waiting
|
||||
for the TTL to expire (e.g. after seeing a hallucination warning).
|
||||
|
||||
When ``expected_run_id`` is supplied, the run identity is checked after
|
||||
``BEGIN IMMEDIATE`` and included in the guarded update. A replacement run
|
||||
is therefore neither signalled nor reclaimed by stale recovery evidence.
|
||||
|
||||
Returns True if a reclaim happened, False if the task isn't in a
|
||||
reclaimable state (not running, or doesn't exist), or its run changed.
|
||||
"""
|
||||
with write_txn(conn):
|
||||
row = conn.execute(
|
||||
"SELECT status, claim_lock, worker_pid, current_run_id "
|
||||
"FROM tasks WHERE id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return False
|
||||
if expected_run_id is not None and row["current_run_id"] != expected_run_id:
|
||||
return False
|
||||
if row["status"] != "running" and row["claim_lock"] is None:
|
||||
# Nothing to reclaim — already ready / blocked / done.
|
||||
return False
|
||||
prev_lock = row["claim_lock"]
|
||||
termination = _terminate_reclaimed_worker(
|
||||
row["worker_pid"], prev_lock, signal_fn=signal_fn,
|
||||
)
|
||||
if expected_run_id is None:
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
|
||||
"claim_expires = NULL, worker_pid = NULL "
|
||||
"WHERE id = ? AND status IN ('running', 'ready', 'blocked') "
|
||||
"AND claim_lock IS ?",
|
||||
(task_id, prev_lock),
|
||||
)
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"UPDATE tasks SET status = 'ready', claim_lock = NULL, "
|
||||
"claim_expires = NULL, worker_pid = NULL "
|
||||
"WHERE id = ? AND status IN ('running', 'ready', 'blocked') "
|
||||
"AND claim_lock IS ? AND current_run_id = ?",
|
||||
(task_id, prev_lock, int(expected_run_id)),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
return False
|
||||
run_id = _end_run(
|
||||
conn, task_id,
|
||||
outcome="reclaimed", status="reclaimed",
|
||||
error=(
|
||||
f"manual_reclaim: {reason}" if reason
|
||||
else f"manual_reclaim lock={prev_lock}"
|
||||
),
|
||||
metadata=termination,
|
||||
)
|
||||
payload = {
|
||||
"manual": True,
|
||||
"reason": reason,
|
||||
"prev_lock": prev_lock,
|
||||
}
|
||||
payload.update(termination)
|
||||
_append_event(
|
||||
conn, task_id, "reclaimed",
|
||||
payload,
|
||||
run_id=run_id,
|
||||
)
|
||||
# Operator intervention — they've looked at the task, so the
|
||||
# consecutive-failures counter is now stale. Give the next retry
|
||||
# a fresh budget. (_clear_failure_counter opens its own write_txn,
|
||||
# so it runs after the enclosing one commits.)
|
||||
_clear_failure_counter(conn, task_id)
|
||||
return True
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
reclaim_before,
|
||||
reclaim_after,
|
||||
"transactional exact-run reclaim",
|
||||
)
|
||||
|
||||
task_field_before = ''' current_run_id: Optional[int] = None
|
||||
workflow_template_id: Optional[str] = None
|
||||
'''
|
||||
task_field_after = ''' current_run_id: Optional[int] = None
|
||||
# Durable provenance for the run that authoritatively completed this task.
|
||||
completed_run_id: Optional[int] = None
|
||||
workflow_template_id: Optional[str] = None
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
task_field_before,
|
||||
task_field_after,
|
||||
"completed-run task field",
|
||||
)
|
||||
|
||||
task_row_before = ''' current_run_id=(
|
||||
row["current_run_id"] if "current_run_id" in keys else None
|
||||
),
|
||||
workflow_template_id=(
|
||||
'''
|
||||
task_row_after = ''' current_run_id=(
|
||||
row["current_run_id"] if "current_run_id" in keys else None
|
||||
),
|
||||
completed_run_id=(
|
||||
row["completed_run_id"] if "completed_run_id" in keys else None
|
||||
),
|
||||
workflow_template_id=(
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
task_row_before,
|
||||
task_row_after,
|
||||
"completed-run row mapping",
|
||||
)
|
||||
|
||||
task_schema_before = ''' -- run is in-flight). Denormalised for cheap reads.
|
||||
current_run_id INTEGER,
|
||||
-- Forward-compat for v2 workflow routing. In v1 the kernel writes
|
||||
'''
|
||||
task_schema_after = ''' -- run is in-flight). Denormalised for cheap reads.
|
||||
current_run_id INTEGER,
|
||||
-- Immutable provenance for the task result's authoritative run.
|
||||
completed_run_id INTEGER,
|
||||
-- Forward-compat for v2 workflow routing. In v1 the kernel writes
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
task_schema_before,
|
||||
task_schema_after,
|
||||
"completed-run schema",
|
||||
)
|
||||
|
||||
task_migration_before = ''' if "current_run_id" not in cols:
|
||||
_add_column_if_missing(
|
||||
conn, "tasks", "current_run_id", "current_run_id INTEGER"
|
||||
)
|
||||
if "workflow_template_id" not in cols:
|
||||
'''
|
||||
task_migration_after = ''' if "current_run_id" not in cols:
|
||||
_add_column_if_missing(
|
||||
conn, "tasks", "current_run_id", "current_run_id INTEGER"
|
||||
)
|
||||
if "completed_run_id" not in cols:
|
||||
_add_column_if_missing(
|
||||
conn, "tasks", "completed_run_id", "completed_run_id INTEGER"
|
||||
)
|
||||
if "workflow_template_id" not in cols:
|
||||
'''
|
||||
db = replace_once(
|
||||
db,
|
||||
task_migration_before,
|
||||
task_migration_after,
|
||||
"completed-run migration",
|
||||
)
|
||||
|
||||
db_path.write_text(db, encoding="utf-8")
|
||||
25
scripts/tests/test_dashboard_render_support.py
Normal file
25
scripts/tests/test_dashboard_render_support.py
Normal file
@ -0,0 +1,25 @@
|
||||
import importlib.util
|
||||
|
||||
import pathlib
|
||||
|
||||
def load_module():
|
||||
path = pathlib.Path(__file__).resolve().parents[1] / "render" / "dashboards_render_atlas.py"
|
||||
spec = importlib.util.spec_from_file_location("scripts.dashboards_render_atlas", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def flatten_panels(panels):
|
||||
flat = []
|
||||
for panel in panels:
|
||||
flat.append(panel)
|
||||
flat.extend(panel.get("panels", []))
|
||||
return flat
|
||||
|
||||
__all__ = (
|
||||
"flatten_panels",
|
||||
"importlib",
|
||||
"load_module",
|
||||
"pathlib",
|
||||
)
|
||||
281
scripts/tests/test_dashboards_render_jobs.py
Normal file
281
scripts/tests/test_dashboards_render_jobs.py
Normal file
@ -0,0 +1,281 @@
|
||||
"""Atlas testing and jobs dashboard rendering contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_dashboard_render_support import (
|
||||
flatten_panels,
|
||||
load_module,
|
||||
)
|
||||
|
||||
|
||||
def test_testing_suite_variable_uses_configured_suite_values_only():
|
||||
mod = load_module()
|
||||
variable = mod.testing_suite_variable()
|
||||
configured_matcher = "|".join(mod.PLATFORM_TEST_SUITE_NAMES)
|
||||
legacy_names = {"bstein-home", "data-prepper", "titan-iac", "pegasus-health"}
|
||||
out_of_scope_names = {"arcanagon", "typhon"}
|
||||
|
||||
assert variable["allValue"] == configured_matcher
|
||||
assert not any(alias in variable["query"] for alias in legacy_names)
|
||||
assert not any(alias in variable["allValue"] for alias in legacy_names)
|
||||
assert not any(name in variable["query"] for name in out_of_scope_names)
|
||||
assert not any(name in variable["allValue"] for name in out_of_scope_names)
|
||||
assert [option["value"] for option in variable["options"]] == mod.PLATFORM_TEST_SUITE_NAMES
|
||||
assert "lesavka" in variable["allValue"]
|
||||
assert any(option["value"] == "lesavka" for option in variable["options"])
|
||||
assert not any(
|
||||
option["value"] in out_of_scope_names for option in variable["options"]
|
||||
)
|
||||
|
||||
def test_testing_dashboard_is_public_but_jobs_dashboard_remains_internal():
|
||||
mod = load_module()
|
||||
jobs = mod.build_jobs_dashboard()
|
||||
testing = mod.build_testing_dashboard()
|
||||
|
||||
assert jobs["folderUid"] == mod.PRIVATE_FOLDER
|
||||
assert jobs["editable"] is True
|
||||
assert testing["uid"] == "atlas-testing"
|
||||
assert testing["folderUid"] == mod.PUBLIC_DASHBOARD_FOLDER
|
||||
assert testing["editable"] is False
|
||||
|
||||
def test_jobs_dashboard_separates_current_gate_health_from_reliability():
|
||||
mod = load_module()
|
||||
dashboard = mod.build_jobs_dashboard()
|
||||
panels_by_title = {panel["title"]: panel for panel in flatten_panels(dashboard["panels"])}
|
||||
variables_by_name = {item["name"]: item for item in dashboard["templating"]["list"]}
|
||||
|
||||
test_variable = variables_by_name["test"]
|
||||
assert test_variable["query"].startswith("query_result(")
|
||||
assert "platform_quality:test_case_health_rate:percent_1h" in test_variable["query"]
|
||||
assert "platform_quality_gate_test_case_result" not in test_variable["query"]
|
||||
assert test_variable["regex"] == '/test="([^"]+)"/'
|
||||
branch_variable = variables_by_name["branch"]
|
||||
assert branch_variable["type"] == "custom"
|
||||
assert branch_variable["allValue"] == ".*"
|
||||
assert "platform_quality_gate_build_info" not in branch_variable["query"]
|
||||
assert "origin/main : origin/main" in branch_variable["query"]
|
||||
|
||||
assert "Current Gate Health (%)" in panels_by_title
|
||||
assert "Suite Freshness (24h)" in panels_by_title
|
||||
assert "Latest Gate Health by Suite" in panels_by_title
|
||||
assert "Latest Gate Checks Passing by Suite" not in panels_by_title
|
||||
assert "CI Run Success by Suite (24h)" in panels_by_title
|
||||
assert "CI Run Success by Suite (7d rolling)" in panels_by_title
|
||||
assert "Daily Run Volume (Selected Scope)" in panels_by_title
|
||||
assert "Coverage History by Suite" in panels_by_title
|
||||
assert "Files <=500 LOC History by Suite" in panels_by_title
|
||||
assert "Run Reliability History by Suite" not in panels_by_title
|
||||
assert "Current Gate Health by Suite" not in panels_by_title
|
||||
assert "Coverage & LOC Compliance History" not in panels_by_title
|
||||
assert "Run Status Mix (30d)" not in panels_by_title
|
||||
assert "Failures by Suite (24h)" not in panels_by_title
|
||||
assert "Success Rate by Suite (24h)" not in panels_by_title
|
||||
|
||||
current_gate_expr = panels_by_title["Latest Gate Health by Suite"]["targets"][0]["expr"]
|
||||
assert 'check)' in current_gate_expr
|
||||
assert "platform_quality:check_status:present_1h" in current_gate_expr
|
||||
assert "platform_quality:test_category_health_rate:percent_1h" in current_gate_expr
|
||||
assert "- 1" in current_gate_expr
|
||||
assert '.*_quality_gate_checks_total' not in current_gate_expr
|
||||
assert "last_over_time" not in current_gate_expr
|
||||
assert 'label_replace' not in current_gate_expr
|
||||
assert f'check=~"{mod.PLATFORM_TEST_ALWAYS_REQUIRED_CHECK_REGEX}"' in current_gate_expr
|
||||
assert f'suite=~"{mod.PLATFORM_TEST_SUPPLY_CHAIN_REQUIRED_MATCHER}"' in current_gate_expr
|
||||
assert 'check="supply_chain"' in current_gate_expr
|
||||
assert f'check=~"{mod.PLATFORM_TEST_STANDARD_CHECK_REGEX}"' not in current_gate_expr
|
||||
assert 'status=~"ok|passed|success|not_applicable|skipped|na|n/a"' in current_gate_expr
|
||||
assert 'status!~"ok|passed|success|not_applicable|skipped|na|n/a"' in current_gate_expr
|
||||
assert "unless on(suite, check)" in current_gate_expr
|
||||
assert "tlast_over_time" not in current_gate_expr
|
||||
assert panels_by_title["Current Gate Health (%)"]["gridPos"] == {"h": 4, "w": 3, "x": 0, "y": 0}
|
||||
current_gate_stat_expr = panels_by_title["Current Gate Health (%)"]["targets"][0]["expr"]
|
||||
assert "platform_quality:test_category_health_rate:percent_1h" in current_gate_stat_expr
|
||||
assert "- 1" not in current_gate_stat_expr
|
||||
assert panels_by_title["Suite Freshness (24h)"]["gridPos"] == {"h": 4, "w": 3, "x": 15, "y": 0}
|
||||
suite_freshness_expr = panels_by_title["Suite Freshness (24h)"]["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_runs:increase_24h" in suite_freshness_expr
|
||||
assert "max_over_time(platform_quality_gate_runs_total" not in suite_freshness_expr
|
||||
assert "[7d:1h]" in panels_by_title["CI Run Success Rate (7d)"]["targets"][0]["expr"]
|
||||
assert panels_by_title["Latest Gate Health by Suite"]["gridPos"]["w"] == 6
|
||||
assert panels_by_title["CI Run Success by Suite (24h)"]["gridPos"]["w"] == 6
|
||||
assert panels_by_title["Coverage by Suite (Latest, gate 95)"]["gridPos"] == {"h": 7, "w": 6, "x": 12, "y": 4}
|
||||
assert panels_by_title["Files <=500 LOC by Suite (Latest)"]["gridPos"] == {"h": 7, "w": 6, "x": 18, "y": 4}
|
||||
|
||||
reliability_panel = panels_by_title["CI Run Success by Suite (24h)"]
|
||||
reliability_expr = reliability_panel["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_runs:increase_24h" in reliability_expr
|
||||
assert "platform_quality_gate_runs_total" not in reliability_expr
|
||||
assert "> 0" in reliability_expr
|
||||
assert "- 1" in reliability_expr
|
||||
assert reliability_expr.startswith("sort(")
|
||||
assert reliability_panel["fieldConfig"]["defaults"]["mappings"] == [
|
||||
{"type": "value", "options": {"-1": {"text": "no runs"}}}
|
||||
]
|
||||
|
||||
rolling_panel = panels_by_title["CI Run Success by Suite (7d rolling)"]
|
||||
assert rolling_panel["type"] == "state-timeline"
|
||||
assert "[7d:1h]" in rolling_panel["targets"][0]["expr"]
|
||||
category_panel = panels_by_title["Test Category Health History"]
|
||||
assert category_panel["type"] == "state-timeline"
|
||||
assert "category" in category_panel["targets"][0]["expr"]
|
||||
assert category_panel["targets"][0]["format"] == "time_series"
|
||||
assert category_panel["targets"][0]["range"] is True
|
||||
assert "platform_quality:test_category_health_rate:percent_1h" in category_panel["targets"][0]["expr"]
|
||||
assert 'test!=""' not in category_panel["targets"][0]["expr"]
|
||||
assert f'category=~"{mod.PLATFORM_TEST_CATEGORY_REGEX}"' in category_panel["targets"][0]["expr"]
|
||||
assert "installer" not in mod.PLATFORM_TEST_CATEGORY_REGEX
|
||||
assert "Use the Suite filter" in category_panel["description"]
|
||||
assert "skipped tests are healthy" in category_panel["description"]
|
||||
|
||||
tests_failure_expr = panels_by_title["Tests Failure Rate"]["targets"][0]["expr"]
|
||||
assert "platform_quality:check_failed_flag:present_1h" in tests_failure_expr
|
||||
assert "platform_quality:check_seen_flag:present_1h" in tests_failure_expr
|
||||
assert "platform_quality:check_status:present_1h" not in tests_failure_expr
|
||||
|
||||
tests_healthy_expr = panels_by_title["Tests Healthy Rate"]["targets"][0]["expr"]
|
||||
assert "platform_quality:check_healthy_flag:present_1h" in tests_healthy_expr
|
||||
assert "platform_quality:check_seen_flag:present_1h" in tests_healthy_expr
|
||||
|
||||
problematic_expr = panels_by_title["Problematic Tests Over Time (Top failures)"]["targets"][0]["expr"]
|
||||
assert "platform_quality:test_case_status:count_1h" in problematic_expr
|
||||
assert "platform_quality_gate_test_case_result" not in problematic_expr
|
||||
assert "[24h:1h]" in problematic_expr
|
||||
assert ">= 2" in problematic_expr
|
||||
assert "@ end()" in problematic_expr
|
||||
|
||||
coverage_panel = panels_by_title["Coverage History by Suite"]
|
||||
loc_panel = panels_by_title["Files <=500 LOC History by Suite"]
|
||||
assert coverage_panel["type"] == "state-timeline"
|
||||
assert loc_panel["type"] == "state-timeline"
|
||||
assert coverage_panel["targets"][0]["expr"] != loc_panel["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_coverage_percent:latest_1h" in coverage_panel["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_source_files_total:latest_1h" in loc_panel["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_source_lines_over_500_total:latest_1h" in loc_panel["targets"][0]["expr"]
|
||||
assert "tlast_over_time" not in coverage_panel["targets"][0]["expr"]
|
||||
assert "tlast_over_time" not in loc_panel["targets"][0]["expr"]
|
||||
assert "platform_quality_gate_workspace_line_coverage_percent" not in panels_by_title["Coverage by Suite (Latest, gate 95)"]["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_coverage_percent:latest_1h" in panels_by_title["Avg Coverage (%)"]["targets"][0]["expr"]
|
||||
assert panels_by_title["Daily Run Volume (Selected Scope)"]["gridPos"] == {"h": 8, "w": 8, "x": 0, "y": 27}
|
||||
assert coverage_panel["gridPos"] == {"h": 8, "w": 8, "x": 8, "y": 27}
|
||||
assert loc_panel["gridPos"] == {"h": 8, "w": 8, "x": 16, "y": 27}
|
||||
|
||||
run_volume_panel = panels_by_title["Daily Run Volume (Selected Scope)"]
|
||||
assert run_volume_panel["fieldConfig"]["defaults"]["custom"]["drawStyle"] == "bars"
|
||||
assert "[$__interval]" not in run_volume_panel["targets"][0]["expr"]
|
||||
|
||||
def test_jobs_dashboard_bar_gauges_use_solid_threshold_colors():
|
||||
mod = load_module()
|
||||
dashboard = mod.build_jobs_dashboard()
|
||||
panels = flatten_panels(dashboard["panels"])
|
||||
bar_gauges = [panel for panel in panels if panel["type"] == "bargauge"]
|
||||
|
||||
assert bar_gauges
|
||||
assert all(panel["options"]["displayMode"] == "basic" for panel in bar_gauges)
|
||||
assert all(
|
||||
panel["fieldConfig"]["defaults"]["color"]["mode"] == "thresholds"
|
||||
for panel in bar_gauges
|
||||
)
|
||||
|
||||
reliability_panel = next(
|
||||
panel for panel in panels if panel["title"] == "CI Run Success by Suite (24h)"
|
||||
)
|
||||
threshold_steps = reliability_panel["fieldConfig"]["defaults"]["thresholds"]["steps"]
|
||||
|
||||
assert {"color": "dark-yellow", "value": 93} in threshold_steps
|
||||
assert {"color": "dark-blue", "value": 100} in threshold_steps
|
||||
def test_jobs_dashboard_collapses_heavy_drilldowns_for_light_first_paint():
|
||||
mod = load_module()
|
||||
dashboard = mod.build_jobs_dashboard()
|
||||
panels = dashboard["panels"]
|
||||
rows = [panel for panel in panels if panel["type"] == "row"]
|
||||
visible_query_panels = [panel for panel in panels if panel["type"] != "row"]
|
||||
nested_panels_by_title = {
|
||||
child["title"]: child
|
||||
for row in rows
|
||||
for child in row.get("panels", [])
|
||||
}
|
||||
|
||||
assert len(panels) == 18
|
||||
assert len(visible_query_panels) == 12
|
||||
assert sum(len(panel.get("targets", [])) for panel in visible_query_panels) == 12
|
||||
assert all(
|
||||
panel["title"] != "Coverage Gap to 95% by Suite"
|
||||
for panel in visible_query_panels
|
||||
)
|
||||
assert [row["title"] for row in rows] == [
|
||||
"CI Runs And Test Result History",
|
||||
"Check Failure Rates By Suite",
|
||||
"Check Healthy Rates By Suite",
|
||||
"Test Drilldowns And Problem Tests",
|
||||
"Telemetry Completeness And Branches",
|
||||
"SonarQube Project Health",
|
||||
]
|
||||
assert all(row["collapsed"] for row in rows)
|
||||
|
||||
assert "Coverage Failure Rate" in nested_panels_by_title
|
||||
assert "Supply Chain Healthy Rate" in nested_panels_by_title
|
||||
assert "Test Category Health History" in nested_panels_by_title
|
||||
assert "Selected Test Pass Rate History" in nested_panels_by_title
|
||||
assert "Coverage Metrics Present by Suite" in nested_panels_by_title
|
||||
assert "SonarQube API Up" in nested_panels_by_title
|
||||
|
||||
failure_rate_panel = nested_panels_by_title["Coverage Failure Rate"]
|
||||
assert failure_rate_panel["type"] == "state-timeline"
|
||||
assert failure_rate_panel["fieldConfig"]["defaults"]["unit"] == "percent"
|
||||
assert failure_rate_panel["fieldConfig"]["defaults"]["max"] == 100
|
||||
assert failure_rate_panel["fieldConfig"]["defaults"]["thresholds"]["steps"][0]["color"] == "dark-blue"
|
||||
assert "increase(" not in failure_rate_panel["targets"][0]["expr"]
|
||||
assert "platform_quality:check_failed_flag:present_1h" in failure_rate_panel["targets"][0]["expr"]
|
||||
assert "platform_quality:check_seen_flag:present_1h" in failure_rate_panel["targets"][0]["expr"]
|
||||
assert "platform_quality:check_status:present_1h" not in failure_rate_panel["targets"][0]["expr"]
|
||||
assert '.*_quality_gate_checks_total' not in failure_rate_panel["targets"][0]["expr"]
|
||||
assert "last_over_time" not in failure_rate_panel["targets"][0]["expr"]
|
||||
assert 'label_replace' not in failure_rate_panel["targets"][0]["expr"]
|
||||
assert "0 *" in failure_rate_panel["targets"][0]["expr"]
|
||||
assert "and on(suite)" not in failure_rate_panel["targets"][0]["expr"]
|
||||
|
||||
pass_rate_panel = nested_panels_by_title["Selected Test Pass Rate History"]
|
||||
assert pass_rate_panel["type"] == "state-timeline"
|
||||
assert "platform_quality:test_case_pass_rate:percent_1h" in pass_rate_panel["targets"][0]["expr"]
|
||||
assert "platform_quality_gate_test_case_result" not in pass_rate_panel["targets"][0]["expr"]
|
||||
|
||||
pass_fail_panel = nested_panels_by_title["Selected Test Pass/Fail History"]
|
||||
assert pass_fail_panel["fieldConfig"]["defaults"]["custom"]["drawStyle"] == "bars"
|
||||
assert all(
|
||||
"platform_quality:test_case_status:count_1h" in target["expr"]
|
||||
for target in pass_fail_panel["targets"]
|
||||
)
|
||||
|
||||
problematic_panel = nested_panels_by_title["Problematic Tests Over Time (Top failures)"]
|
||||
assert problematic_panel["type"] == "state-timeline"
|
||||
assert problematic_panel["gridPos"]["w"] == 24
|
||||
assert 'test!=""' in problematic_panel["targets"][0]["expr"]
|
||||
assert "vector(0)" not in problematic_panel["targets"][0]["expr"]
|
||||
assert problematic_panel["fieldConfig"]["defaults"]["thresholds"]["steps"] == [
|
||||
{"color": "dark-blue", "value": None},
|
||||
{"color": "dark-green", "value": 2},
|
||||
{"color": "dark-yellow", "value": 3},
|
||||
{"color": "dark-orange", "value": 5},
|
||||
{"color": "dark-red", "value": 8},
|
||||
]
|
||||
assert "rolling 24h failure count" in problematic_panel["description"]
|
||||
assert "at least two recent failures" in problematic_panel["description"]
|
||||
|
||||
sonar_mix_panel = nested_panels_by_title["Sonar Gate Status Mix (Selected)"]
|
||||
sonar_health_panel = nested_panels_by_title["Sonar Gate Health by Project"]
|
||||
assert sonar_mix_panel["gridPos"]["w"] == 4
|
||||
assert sonar_health_panel["gridPos"]["w"] == 8
|
||||
assert sonar_health_panel["type"] == "state-timeline"
|
||||
assert "platform_quality:sonar_gate_health_percent:latest_1h" in sonar_health_panel["targets"][0]["expr"]
|
||||
assert "sonarqube_project_quality_gate_pass" not in sonar_health_panel["targets"][0]["expr"]
|
||||
|
||||
branch_panel = nested_panels_by_title["Primary Branch Clean by Suite (7d)"]
|
||||
recent_branch_panel = nested_panels_by_title["Recent Branch Evidence by Suite (7d)"]
|
||||
assert branch_panel["gridPos"]["x"] == 12
|
||||
assert recent_branch_panel["gridPos"]["x"] == 18
|
||||
assert "[7d:1h]" in recent_branch_panel["targets"][0]["expr"]
|
||||
assert "[7d:1h]" in branch_panel["targets"][0]["expr"]
|
||||
assert branch_panel["fieldConfig"]["defaults"]["unit"] == "percent"
|
||||
assert "unless on(suite)" in branch_panel["targets"][0]["expr"]
|
||||
assert "> bool 0" in branch_panel["targets"][0]["expr"]
|
||||
assert branch_panel["targets"][0]["expr"].startswith("sort(")
|
||||
@ -1,22 +1,11 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
"""Atlas overview dashboard rendering contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
def load_module():
|
||||
path = pathlib.Path(__file__).resolve().parents[1] / "render" / "dashboards_render_atlas.py"
|
||||
spec = importlib.util.spec_from_file_location("scripts.dashboards_render_atlas", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def flatten_panels(panels):
|
||||
flat = []
|
||||
for panel in panels:
|
||||
flat.append(panel)
|
||||
flat.extend(panel.get("panels", []))
|
||||
return flat
|
||||
from test_dashboard_render_support import (
|
||||
flatten_panels,
|
||||
load_module,
|
||||
)
|
||||
|
||||
|
||||
def test_table_panel_options_and_filterable():
|
||||
@ -39,7 +28,6 @@ def test_table_panel_options_and_filterable():
|
||||
assert panel["options"]["showHeader"] is True
|
||||
assert panel["targets"][0]["format"] == "table"
|
||||
|
||||
|
||||
def test_node_filter_and_expr_helpers():
|
||||
mod = load_module()
|
||||
expr = mod.node_filter("titan-.*")
|
||||
@ -51,7 +39,6 @@ def test_node_filter_and_expr_helpers():
|
||||
assert "* 100, 0), 100)" in cpu_expr
|
||||
assert "node_memory_MemAvailable_bytes" in mem_expr
|
||||
|
||||
|
||||
def test_overview_availability_panel_uses_recorded_365d_rollup():
|
||||
mod = load_module()
|
||||
dashboard = mod.build_overview()
|
||||
@ -76,7 +63,6 @@ def test_overview_availability_panel_uses_recorded_365d_rollup():
|
||||
assert "daily rollup job publishes one annual sample" in panel["description"]
|
||||
assert "keeps it for up to 48 hours" in panel["description"]
|
||||
|
||||
|
||||
def test_overview_uses_readable_quality_power_and_gitops_panels():
|
||||
mod = load_module()
|
||||
dashboard = mod.build_overview()
|
||||
@ -195,7 +181,6 @@ def test_overview_uses_readable_quality_power_and_gitops_panels():
|
||||
assert "continuously sampled" in panels_by_title["Namespace GPU Utilization"]["description"]
|
||||
assert "Kubernetes shared-GPU allocations" in panels_by_title["Namespace GPU Utilization"]["description"]
|
||||
|
||||
|
||||
def test_gpu_node_panel_prefers_stable_process_metrics_and_covers_all_gpu_families():
|
||||
mod = load_module()
|
||||
dashboard = mod.build_gpu_dashboard()
|
||||
@ -212,7 +197,6 @@ def test_gpu_node_panel_prefers_stable_process_metrics_and_covers_all_gpu_famili
|
||||
assert "nvidia_process_gpu_sm_util_percent" in process_expr
|
||||
assert "DCGM_FI_DEV_GPU_UTIL" not in process_expr
|
||||
|
||||
|
||||
def test_overview_and_testing_panels_all_have_concise_descriptions():
|
||||
mod = load_module()
|
||||
|
||||
@ -221,7 +205,6 @@ def test_overview_and_testing_panels_all_have_concise_descriptions():
|
||||
assert all(panel.get("description") for panel in panels if panel["type"] != "row")
|
||||
assert "85.7" not in repr(dashboard)
|
||||
|
||||
|
||||
def test_render_configmap_writes(tmp_path):
|
||||
mod = load_module()
|
||||
mod.DASHBOARD_DIR = tmp_path / "dash"
|
||||
@ -236,183 +219,3 @@ def test_render_configmap_writes(tmp_path):
|
||||
content = (tmp_path / "cm.yaml").read_text()
|
||||
assert "kind: ConfigMap" in content
|
||||
assert f"{uid}.json" in content
|
||||
|
||||
|
||||
def test_testing_suite_variable_uses_configured_suite_values_only():
|
||||
mod = load_module()
|
||||
variable = mod.testing_suite_variable()
|
||||
configured_matcher = "|".join(mod.PLATFORM_TEST_SUITE_NAMES)
|
||||
legacy_names = {"bstein-home", "data-prepper", "titan-iac", "pegasus-health"}
|
||||
out_of_scope_names = {"arcanagon", "typhon"}
|
||||
|
||||
assert variable["allValue"] == configured_matcher
|
||||
assert not any(alias in variable["query"] for alias in legacy_names)
|
||||
assert not any(alias in variable["allValue"] for alias in legacy_names)
|
||||
assert not any(name in variable["query"] for name in out_of_scope_names)
|
||||
assert not any(name in variable["allValue"] for name in out_of_scope_names)
|
||||
assert [option["value"] for option in variable["options"]] == mod.PLATFORM_TEST_SUITE_NAMES
|
||||
assert "lesavka" in variable["allValue"]
|
||||
assert any(option["value"] == "lesavka" for option in variable["options"])
|
||||
assert not any(
|
||||
option["value"] in out_of_scope_names for option in variable["options"]
|
||||
)
|
||||
|
||||
|
||||
def test_testing_dashboard_is_public_but_jobs_dashboard_remains_internal():
|
||||
mod = load_module()
|
||||
jobs = mod.build_jobs_dashboard()
|
||||
testing = mod.build_testing_dashboard()
|
||||
|
||||
assert jobs["folderUid"] == mod.PRIVATE_FOLDER
|
||||
assert jobs["editable"] is True
|
||||
assert testing["uid"] == "atlas-testing"
|
||||
assert testing["folderUid"] == mod.PUBLIC_DASHBOARD_FOLDER
|
||||
assert testing["editable"] is False
|
||||
|
||||
|
||||
def test_jobs_dashboard_separates_current_gate_health_from_reliability():
|
||||
mod = load_module()
|
||||
dashboard = mod.build_jobs_dashboard()
|
||||
panels_by_title = {panel["title"]: panel for panel in flatten_panels(dashboard["panels"])}
|
||||
variables_by_name = {item["name"]: item for item in dashboard["templating"]["list"]}
|
||||
|
||||
test_variable = variables_by_name["test"]
|
||||
assert test_variable["query"].startswith("query_result(")
|
||||
assert "platform_quality:test_case_health_rate:percent_1h" in test_variable["query"]
|
||||
assert "platform_quality_gate_test_case_result" not in test_variable["query"]
|
||||
assert test_variable["regex"] == '/test="([^"]+)"/'
|
||||
branch_variable = variables_by_name["branch"]
|
||||
assert branch_variable["type"] == "custom"
|
||||
assert branch_variable["allValue"] == ".*"
|
||||
assert "platform_quality_gate_build_info" not in branch_variable["query"]
|
||||
assert "origin/main : origin/main" in branch_variable["query"]
|
||||
|
||||
assert "Current Gate Health (%)" in panels_by_title
|
||||
assert "Suite Freshness (24h)" in panels_by_title
|
||||
assert "Latest Gate Health by Suite" in panels_by_title
|
||||
assert "Latest Gate Checks Passing by Suite" not in panels_by_title
|
||||
assert "CI Run Success by Suite (24h)" in panels_by_title
|
||||
assert "CI Run Success by Suite (7d rolling)" in panels_by_title
|
||||
assert "Daily Run Volume (Selected Scope)" in panels_by_title
|
||||
assert "Coverage History by Suite" in panels_by_title
|
||||
assert "Files <=500 LOC History by Suite" in panels_by_title
|
||||
assert "Run Reliability History by Suite" not in panels_by_title
|
||||
assert "Current Gate Health by Suite" not in panels_by_title
|
||||
assert "Coverage & LOC Compliance History" not in panels_by_title
|
||||
assert "Run Status Mix (30d)" not in panels_by_title
|
||||
assert "Failures by Suite (24h)" not in panels_by_title
|
||||
assert "Success Rate by Suite (24h)" not in panels_by_title
|
||||
|
||||
current_gate_expr = panels_by_title["Latest Gate Health by Suite"]["targets"][0]["expr"]
|
||||
assert 'check)' in current_gate_expr
|
||||
assert "platform_quality:check_status:present_1h" in current_gate_expr
|
||||
assert "platform_quality:test_category_health_rate:percent_1h" in current_gate_expr
|
||||
assert "- 1" in current_gate_expr
|
||||
assert '.*_quality_gate_checks_total' not in current_gate_expr
|
||||
assert "last_over_time" not in current_gate_expr
|
||||
assert 'label_replace' not in current_gate_expr
|
||||
assert f'check=~"{mod.PLATFORM_TEST_ALWAYS_REQUIRED_CHECK_REGEX}"' in current_gate_expr
|
||||
assert f'suite=~"{mod.PLATFORM_TEST_SUPPLY_CHAIN_REQUIRED_MATCHER}"' in current_gate_expr
|
||||
assert 'check="supply_chain"' in current_gate_expr
|
||||
assert f'check=~"{mod.PLATFORM_TEST_STANDARD_CHECK_REGEX}"' not in current_gate_expr
|
||||
assert 'status=~"ok|passed|success|not_applicable|skipped|na|n/a"' in current_gate_expr
|
||||
assert 'status!~"ok|passed|success|not_applicable|skipped|na|n/a"' in current_gate_expr
|
||||
assert "unless on(suite, check)" in current_gate_expr
|
||||
assert "tlast_over_time" not in current_gate_expr
|
||||
assert panels_by_title["Current Gate Health (%)"]["gridPos"] == {"h": 4, "w": 3, "x": 0, "y": 0}
|
||||
current_gate_stat_expr = panels_by_title["Current Gate Health (%)"]["targets"][0]["expr"]
|
||||
assert "platform_quality:test_category_health_rate:percent_1h" in current_gate_stat_expr
|
||||
assert "- 1" not in current_gate_stat_expr
|
||||
assert panels_by_title["Suite Freshness (24h)"]["gridPos"] == {"h": 4, "w": 3, "x": 15, "y": 0}
|
||||
suite_freshness_expr = panels_by_title["Suite Freshness (24h)"]["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_runs:increase_24h" in suite_freshness_expr
|
||||
assert "max_over_time(platform_quality_gate_runs_total" not in suite_freshness_expr
|
||||
assert "[7d:1h]" in panels_by_title["CI Run Success Rate (7d)"]["targets"][0]["expr"]
|
||||
assert panels_by_title["Latest Gate Health by Suite"]["gridPos"]["w"] == 6
|
||||
assert panels_by_title["CI Run Success by Suite (24h)"]["gridPos"]["w"] == 6
|
||||
assert panels_by_title["Coverage by Suite (Latest, gate 95)"]["gridPos"] == {"h": 7, "w": 6, "x": 12, "y": 4}
|
||||
assert panels_by_title["Files <=500 LOC by Suite (Latest)"]["gridPos"] == {"h": 7, "w": 6, "x": 18, "y": 4}
|
||||
|
||||
reliability_panel = panels_by_title["CI Run Success by Suite (24h)"]
|
||||
reliability_expr = reliability_panel["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_runs:increase_24h" in reliability_expr
|
||||
assert "platform_quality_gate_runs_total" not in reliability_expr
|
||||
assert "> 0" in reliability_expr
|
||||
assert "- 1" in reliability_expr
|
||||
assert reliability_expr.startswith("sort(")
|
||||
assert reliability_panel["fieldConfig"]["defaults"]["mappings"] == [
|
||||
{"type": "value", "options": {"-1": {"text": "no runs"}}}
|
||||
]
|
||||
|
||||
rolling_panel = panels_by_title["CI Run Success by Suite (7d rolling)"]
|
||||
assert rolling_panel["type"] == "state-timeline"
|
||||
assert "[7d:1h]" in rolling_panel["targets"][0]["expr"]
|
||||
category_panel = panels_by_title["Test Category Health History"]
|
||||
assert category_panel["type"] == "state-timeline"
|
||||
assert "category" in category_panel["targets"][0]["expr"]
|
||||
assert category_panel["targets"][0]["format"] == "time_series"
|
||||
assert category_panel["targets"][0]["range"] is True
|
||||
assert "platform_quality:test_category_health_rate:percent_1h" in category_panel["targets"][0]["expr"]
|
||||
assert 'test!=""' not in category_panel["targets"][0]["expr"]
|
||||
assert f'category=~"{mod.PLATFORM_TEST_CATEGORY_REGEX}"' in category_panel["targets"][0]["expr"]
|
||||
assert "installer" not in mod.PLATFORM_TEST_CATEGORY_REGEX
|
||||
assert "Use the Suite filter" in category_panel["description"]
|
||||
assert "skipped tests are healthy" in category_panel["description"]
|
||||
|
||||
tests_failure_expr = panels_by_title["Tests Failure Rate"]["targets"][0]["expr"]
|
||||
assert "platform_quality:check_failed_flag:present_1h" in tests_failure_expr
|
||||
assert "platform_quality:check_seen_flag:present_1h" in tests_failure_expr
|
||||
assert "platform_quality:check_status:present_1h" not in tests_failure_expr
|
||||
|
||||
tests_healthy_expr = panels_by_title["Tests Healthy Rate"]["targets"][0]["expr"]
|
||||
assert "platform_quality:check_healthy_flag:present_1h" in tests_healthy_expr
|
||||
assert "platform_quality:check_seen_flag:present_1h" in tests_healthy_expr
|
||||
|
||||
problematic_expr = panels_by_title["Problematic Tests Over Time (Top failures)"]["targets"][0]["expr"]
|
||||
assert "platform_quality:test_case_status:count_1h" in problematic_expr
|
||||
assert "platform_quality_gate_test_case_result" not in problematic_expr
|
||||
assert "[24h:1h]" in problematic_expr
|
||||
assert ">= 2" in problematic_expr
|
||||
assert "@ end()" in problematic_expr
|
||||
|
||||
coverage_panel = panels_by_title["Coverage History by Suite"]
|
||||
loc_panel = panels_by_title["Files <=500 LOC History by Suite"]
|
||||
assert coverage_panel["type"] == "state-timeline"
|
||||
assert loc_panel["type"] == "state-timeline"
|
||||
assert coverage_panel["targets"][0]["expr"] != loc_panel["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_coverage_percent:latest_1h" in coverage_panel["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_source_files_total:latest_1h" in loc_panel["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_source_lines_over_500_total:latest_1h" in loc_panel["targets"][0]["expr"]
|
||||
assert "tlast_over_time" not in coverage_panel["targets"][0]["expr"]
|
||||
assert "tlast_over_time" not in loc_panel["targets"][0]["expr"]
|
||||
assert "platform_quality_gate_workspace_line_coverage_percent" not in panels_by_title["Coverage by Suite (Latest, gate 95)"]["targets"][0]["expr"]
|
||||
assert "platform_quality:suite_coverage_percent:latest_1h" in panels_by_title["Avg Coverage (%)"]["targets"][0]["expr"]
|
||||
assert panels_by_title["Daily Run Volume (Selected Scope)"]["gridPos"] == {"h": 8, "w": 8, "x": 0, "y": 27}
|
||||
assert coverage_panel["gridPos"] == {"h": 8, "w": 8, "x": 8, "y": 27}
|
||||
assert loc_panel["gridPos"] == {"h": 8, "w": 8, "x": 16, "y": 27}
|
||||
|
||||
run_volume_panel = panels_by_title["Daily Run Volume (Selected Scope)"]
|
||||
assert run_volume_panel["fieldConfig"]["defaults"]["custom"]["drawStyle"] == "bars"
|
||||
assert "[$__interval]" not in run_volume_panel["targets"][0]["expr"]
|
||||
|
||||
|
||||
def test_jobs_dashboard_bar_gauges_use_solid_threshold_colors():
|
||||
mod = load_module()
|
||||
dashboard = mod.build_jobs_dashboard()
|
||||
panels = flatten_panels(dashboard["panels"])
|
||||
bar_gauges = [panel for panel in panels if panel["type"] == "bargauge"]
|
||||
|
||||
assert bar_gauges
|
||||
assert all(panel["options"]["displayMode"] == "basic" for panel in bar_gauges)
|
||||
assert all(
|
||||
panel["fieldConfig"]["defaults"]["color"]["mode"] == "thresholds"
|
||||
for panel in bar_gauges
|
||||
)
|
||||
|
||||
reliability_panel = next(
|
||||
panel for panel in panels if panel["title"] == "CI Run Success by Suite (24h)"
|
||||
)
|
||||
threshold_steps = reliability_panel["fieldConfig"]["defaults"]["thresholds"]["steps"]
|
||||
|
||||
assert {"color": "dark-yellow", "value": 93} in threshold_steps
|
||||
assert {"color": "dark-blue", "value": 100} in threshold_steps
|
||||
@ -61,7 +61,22 @@ configMapGenerator:
|
||||
- ai_usage_exporter.py=scripts/ai_usage_exporter.py
|
||||
- claude=scripts/claude
|
||||
- claude_command_policy.py=scripts/claude_command_policy.py
|
||||
- cli_lane_board.py=scripts/cli_lane_board.py
|
||||
- cli_lane_capabilities.py=scripts/cli_lane_capabilities.py
|
||||
- cli_lane_config.py=scripts/cli_lane_config.py
|
||||
- cli_lane_dispatch.py=scripts/cli_lane_dispatch.py
|
||||
- cli_lane_evidence.py=scripts/cli_lane_evidence.py
|
||||
- cli_lane_execution.py=scripts/cli_lane_execution.py
|
||||
- cli_lane_files.py=scripts/cli_lane_files.py
|
||||
- cli_lane_finalization.py=scripts/cli_lane_finalization.py
|
||||
- cli_lane_goal.py=scripts/cli_lane_goal.py
|
||||
- cli_lane_prompt.py=scripts/cli_lane_prompt.py
|
||||
- cli_lane_provider.py=scripts/cli_lane_provider.py
|
||||
- cli_lane_quarantine.py=scripts/cli_lane_quarantine.py
|
||||
- cli_lane_records.py=scripts/cli_lane_records.py
|
||||
- cli_lane_recovery.py=scripts/cli_lane_recovery.py
|
||||
- cli_lane_retention.py=scripts/cli_lane_retention.py
|
||||
- cli_lane_routing.py=scripts/cli_lane_routing.py
|
||||
- cli_lane_runner.py=scripts/cli_lane_runner.py
|
||||
- codex=scripts/codex
|
||||
- configure_agent_clients.py=scripts/configure_agent_clients.py
|
||||
|
||||
82
services/hermes/scripts/cli_lane_board.py
Normal file
82
services/hermes/scripts/cli_lane_board.py
Normal file
@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded Kanban access and workspace resolution for Hermes CLI workers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from cli_lane_config import (
|
||||
BOARD_CORRUPTION_ERRORS,
|
||||
EXTERNAL_PREFIX,
|
||||
KANBAN_STORAGE_ATTEMPTS,
|
||||
WORKTREE_LOCK,
|
||||
)
|
||||
|
||||
|
||||
def _task_value(task: Any, name: str, default: Any = None) -> Any:
|
||||
return getattr(task, name, default)
|
||||
|
||||
def _task_context(kanban_db: Any, conn: Any, task_id: str) -> str:
|
||||
value = kanban_db.build_worker_context(conn, task_id)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return json.dumps(value, indent=2, default=str)
|
||||
|
||||
def _resolve_workspace(kanban_db: Any, conn: Any, task: Any, board: str) -> Path:
|
||||
# External coding lanes always receive their own linked worktree. A task
|
||||
# that does not resolve to a Git repository is blocked rather than sharing
|
||||
# a mutable checkout with another unattended worker.
|
||||
# Git worktree creation updates common-repository metadata. Serialize that
|
||||
# short materialization step while allowing the provider workers themselves
|
||||
# to run concurrently in independent worktrees.
|
||||
with WORKTREE_LOCK:
|
||||
value, branch_name = kanban_db._resolve_worktree_workspace(task, board=board)
|
||||
kanban_db.set_branch_name(conn, str(_task_value(task, "id")), branch_name)
|
||||
return Path(value).resolve()
|
||||
|
||||
def _record_board_access_error(board: str, error: Exception) -> None:
|
||||
"""Report one board access failure once while allowing other lanes to run."""
|
||||
failure_kind = "storage" if isinstance(error, (OSError, sqlite3.Error)) else "access"
|
||||
detail = f"{failure_kind} {type(error).__name__}: {error}"
|
||||
if BOARD_CORRUPTION_ERRORS.get(board) == detail:
|
||||
return
|
||||
print(
|
||||
f"temporarily skipping Kanban board {board!r}: {detail}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
BOARD_CORRUPTION_ERRORS[board] = detail
|
||||
|
||||
def _board_call(
|
||||
kanban_db: Any,
|
||||
board: str,
|
||||
operation: Callable[[Any], Any],
|
||||
) -> Any:
|
||||
"""Run one bounded Kanban operation on a fresh, promptly closed connection."""
|
||||
last_storage_error: Exception | None = None
|
||||
for attempt in range(KANBAN_STORAGE_ATTEMPTS):
|
||||
conn = None
|
||||
try:
|
||||
with kanban_db.scoped_current_board(board):
|
||||
conn = kanban_db.connect(board=board)
|
||||
result = operation(conn)
|
||||
BOARD_CORRUPTION_ERRORS.pop(board, None)
|
||||
return result
|
||||
except (OSError, sqlite3.Error) as error:
|
||||
last_storage_error = error
|
||||
_record_board_access_error(board, error)
|
||||
if attempt + 1 < KANBAN_STORAGE_ATTEMPTS:
|
||||
time.sleep(0.2 * (attempt + 1))
|
||||
finally:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
assert last_storage_error is not None
|
||||
raise last_storage_error
|
||||
|
||||
def _external(task: Any) -> bool:
|
||||
return str(_task_value(task, "assignee", "") or "").startswith(EXTERNAL_PREFIX)
|
||||
130
services/hermes/scripts/cli_lane_capabilities.py
Normal file
130
services/hermes/scripts/cli_lane_capabilities.py
Normal file
@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect and publish the Kanban API boundary required by CLI lanes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
from cli_lane_config import STATE_ROOT, utc_now
|
||||
from cli_lane_files import atomic_json, load_json
|
||||
|
||||
|
||||
_CAPABILITIES: KanbanCapabilities | None = None
|
||||
_CAPABILITY_SOURCE: tuple[int, int] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KanbanCapabilities:
|
||||
"""Safety-relevant keyword support exposed by the image's Kanban DB."""
|
||||
|
||||
exact_run_completion: bool
|
||||
ended_run_replay: bool
|
||||
exact_run_reclaim: bool
|
||||
|
||||
@property
|
||||
def ready(self) -> bool:
|
||||
"""Return whether dispatch and every recovery transition are safe."""
|
||||
return (
|
||||
self.exact_run_completion
|
||||
and self.ended_run_replay
|
||||
and self.exact_run_reclaim
|
||||
)
|
||||
|
||||
@property
|
||||
def deferred_features(self) -> tuple[str, ...]:
|
||||
"""List fixed, bounded capability names absent from the image API."""
|
||||
missing = []
|
||||
if not self.exact_run_completion:
|
||||
missing.append("exact-run-completion")
|
||||
if not self.ended_run_replay:
|
||||
missing.append("ended-run-replay")
|
||||
if not self.exact_run_reclaim:
|
||||
missing.append("exact-run-reclaim")
|
||||
return tuple(missing)
|
||||
|
||||
|
||||
def _explicit_keyword(function: Callable[..., Any], keyword: str) -> bool:
|
||||
"""Require an explicit keyword parameter rather than trusting ``**kwargs``."""
|
||||
try:
|
||||
parameter = inspect.signature(function).parameters.get(keyword)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return parameter is not None and parameter.kind in {
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
}
|
||||
|
||||
|
||||
def detect_kanban_capabilities(kanban_db: Any) -> KanbanCapabilities:
|
||||
"""Inspect the loaded image API without invoking a mutating DB operation."""
|
||||
complete_task = getattr(kanban_db, "complete_task", None)
|
||||
reclaim_task = getattr(kanban_db, "reclaim_task", None)
|
||||
return KanbanCapabilities(
|
||||
exact_run_completion=callable(complete_task)
|
||||
and _explicit_keyword(complete_task, "expected_run_id"),
|
||||
ended_run_replay=callable(complete_task)
|
||||
and _explicit_keyword(complete_task, "replay_ended_run_id"),
|
||||
exact_run_reclaim=callable(reclaim_task)
|
||||
and _explicit_keyword(reclaim_task, "expected_run_id"),
|
||||
)
|
||||
|
||||
|
||||
def _source_identity(kanban_db: Any) -> tuple[int, int]:
|
||||
"""Bind cached capability evidence to the two inspected callables."""
|
||||
return (
|
||||
id(getattr(kanban_db, "complete_task", None)),
|
||||
id(getattr(kanban_db, "reclaim_task", None)),
|
||||
)
|
||||
|
||||
|
||||
def _health_document(capabilities: KanbanCapabilities) -> dict[str, Any]:
|
||||
"""Return a bounded operator-readable compatibility health document."""
|
||||
return {
|
||||
"component": "cli-lane-runner",
|
||||
"state": "ready" if capabilities.ready else "deferred",
|
||||
"ready": capabilities.ready,
|
||||
"active_run_completion_safe": capabilities.exact_run_completion,
|
||||
"deferred_features": list(capabilities.deferred_features),
|
||||
"capabilities": asdict(capabilities),
|
||||
"observed_at": utc_now(),
|
||||
}
|
||||
|
||||
|
||||
def initialize_kanban_capabilities(
|
||||
kanban_db: Any,
|
||||
*,
|
||||
health_path: Path | None = None,
|
||||
) -> KanbanCapabilities:
|
||||
"""Detect startup compatibility and durably expose ready/deferred health."""
|
||||
global _CAPABILITIES, _CAPABILITY_SOURCE
|
||||
capabilities = detect_kanban_capabilities(kanban_db)
|
||||
_CAPABILITIES = capabilities
|
||||
_CAPABILITY_SOURCE = _source_identity(kanban_db)
|
||||
destination = health_path or STATE_ROOT / "runtime-health.json"
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_json(destination, _health_document(capabilities), 0o600)
|
||||
if not capabilities.ready:
|
||||
print(
|
||||
"CLI lane safety compatibility deferred; missing image APIs: "
|
||||
+ ", ".join(capabilities.deferred_features),
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return capabilities
|
||||
|
||||
|
||||
def kanban_capabilities(kanban_db: Any) -> KanbanCapabilities:
|
||||
"""Return startup evidence, redetecting only when the loaded API changes."""
|
||||
if _CAPABILITIES is None or _source_identity(kanban_db) != _CAPABILITY_SOURCE:
|
||||
return initialize_kanban_capabilities(kanban_db)
|
||||
return _CAPABILITIES
|
||||
|
||||
|
||||
def runtime_health(path: Path | None = None) -> dict[str, Any]:
|
||||
"""Read the bounded health document for probes and operator diagnostics."""
|
||||
document = load_json(path or STATE_ROOT / "runtime-health.json")
|
||||
return document if isinstance(document, dict) else {}
|
||||
196
services/hermes/scripts/cli_lane_config.py
Normal file
196
services/hermes/scripts/cli_lane_config.py
Normal file
@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Shared immutable configuration and result types for Hermes CLI lanes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cli_lane_goal
|
||||
|
||||
|
||||
DATA_ROOT = Path(os.environ.get("HERMES_HOME", "/opt/data"))
|
||||
SWITCHYARD_URL = os.environ.get(
|
||||
"HERMES_SWITCHYARD_URL",
|
||||
"http://hermes-switchyard.hermes.svc.cluster.local:9005/v1/chat/completions",
|
||||
)
|
||||
STATE_ROOT = DATA_ROOT / "cli-lanes"
|
||||
CODEX_BIN = DATA_ROOT / "tools/bin/codex"
|
||||
CLAUDE_BIN = DATA_ROOT / "tools/bin/claude"
|
||||
CLAUDE_SETTINGS = DATA_ROOT / "home/.claude/settings.json"
|
||||
RESULT_SCHEMA_PATH = STATE_ROOT / "worker-result.schema.json"
|
||||
EFFORTS = ("low", "medium", "high", "xhigh")
|
||||
EXTERNAL_PREFIX = "cli-"
|
||||
DEFAULT_CLAIM_TTL = 7 * 24 * 60 * 60
|
||||
DEFAULT_MAX_RUNTIME = 12 * 60 * 60
|
||||
HEARTBEAT_SECONDS = 20
|
||||
PROVIDER_HEALTH_MAX_AGE_SECONDS = 5 * 60
|
||||
PROVIDER_AUTH_FAILURE_MAX_AGE_SECONDS = 12 * 60 * 60
|
||||
KANBAN_STORAGE_ATTEMPTS = 5
|
||||
ARTIFACT_GC_INTERVAL_SECONDS = 5 * 60
|
||||
ARTIFACT_RETENTION_AGE_SECONDS = 30 * 24 * 60 * 60
|
||||
ARTIFACT_RETENTION_COUNT = 256
|
||||
ARTIFACT_RETENTION_BYTES = 128 * 1024 * 1024
|
||||
MAX_TERMINAL_RECORD_BYTES = 1024 * 1024
|
||||
QUARANTINE_HASH_BYTES = 64 * 1024
|
||||
SQLITE_RUN_ID_MAX = 2**63 - 1
|
||||
PROVIDER_HEALTH_PATHS = {
|
||||
"codex": DATA_ROOT / "provider-health/codex.json",
|
||||
"claude": DATA_ROOT / "provider-health/claude.json",
|
||||
}
|
||||
WORKTREE_LOCK = threading.Lock()
|
||||
BOARD_CORRUPTION_ERRORS: dict[str, str] = {}
|
||||
LAST_ARTIFACT_GC = 0.0
|
||||
CAPACITY_PATTERN = re.compile(
|
||||
r"(?:rate.?limit|capacity|overload|usage.?limit|quota|credit|exhaust|429|529|authentication|oauth|token.*expired)",
|
||||
re.I,
|
||||
)
|
||||
NO_CLAUDE_SESSION = "No conversation found with session ID:"
|
||||
CLAUDE_SESSION_COLLISION = "Session ID already in use"
|
||||
NO_CODEX_THREAD = "no rollout found for thread id"
|
||||
|
||||
|
||||
RESULT_SCHEMA: dict[str, Any] = {
|
||||
"type": "object",
|
||||
"additionalProperties": False,
|
||||
"required": [
|
||||
"status",
|
||||
"summary",
|
||||
"changed_files",
|
||||
"tests_run",
|
||||
"artifacts",
|
||||
"findings",
|
||||
"blockers",
|
||||
],
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": sorted(cli_lane_goal.RESULT_STATUSES),
|
||||
},
|
||||
"summary": {"type": "string"},
|
||||
"changed_files": {"type": "array", "items": {"type": "string"}},
|
||||
"tests_run": {"type": "array", "items": {"type": "string"}},
|
||||
"artifacts": {"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."
|
||||
),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Route:
|
||||
"""One provider/model/effort decision for an observable worker boundary."""
|
||||
|
||||
provider: str
|
||||
model: str
|
||||
effort: str
|
||||
profile: str
|
||||
classifier: str
|
||||
reason: str
|
||||
latency_ms: int
|
||||
fallback_chain: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessResult:
|
||||
"""Captured outcome from one provider CLI invocation."""
|
||||
|
||||
returncode: int
|
||||
output: str
|
||||
structured: dict[str, Any] | None
|
||||
capacity_failure: bool
|
||||
|
||||
|
||||
class TerminalFinalizationPending(RuntimeError):
|
||||
"""An accepted worker result is durable but not committed to Kanban yet."""
|
||||
|
||||
|
||||
def canonical_run_id(value: object) -> int | None:
|
||||
"""Return one positive SQLite-safe run ID from an int or canonical decimal."""
|
||||
if type(value) is int:
|
||||
candidate = value
|
||||
elif type(value) is str:
|
||||
# Bound conversion before ``int`` so an attacker cannot hand Python an
|
||||
# arbitrarily large decimal from an otherwise lexically valid name.
|
||||
if not re.fullmatch(r"[1-9][0-9]{0,18}", value):
|
||||
return None
|
||||
candidate = int(value)
|
||||
else:
|
||||
return None
|
||||
if not 1 <= candidate <= SQLITE_RUN_ID_MAX:
|
||||
return None
|
||||
return candidate
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TerminalIdentity:
|
||||
"""Replay authority derived only from a journal's directory and filename."""
|
||||
|
||||
board: str
|
||||
task_id: str
|
||||
run_id: int
|
||||
state: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Reject noncanonical identities before they can reach SQLite."""
|
||||
if type(self.run_id) is not int or canonical_run_id(self.run_id) is None:
|
||||
raise ValueError("terminal run identity is not a positive SQLite int64")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminalSnapshot:
|
||||
"""One inode-bound, bounded journal read held open across finalization."""
|
||||
|
||||
document: dict[str, Any]
|
||||
file_stat: os.stat_result
|
||||
descriptor: int
|
||||
directory_descriptor: int
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the pinned file and directory descriptors."""
|
||||
os.close(self.descriptor)
|
||||
os.close(self.directory_descriptor)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TerminalRecoverySnapshot:
|
||||
"""One raw directory entry pinned before recovery classifies its payload."""
|
||||
|
||||
document: dict[str, Any] | None
|
||||
file_stat: os.stat_result
|
||||
descriptor: int | None
|
||||
directory_descriptor: int
|
||||
prefix: bytes
|
||||
invalid_reason: str | None
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the descriptors retained across classification/quarantine."""
|
||||
if self.descriptor is not None:
|
||||
os.close(self.descriptor)
|
||||
self.descriptor = None
|
||||
if self.directory_descriptor >= 0:
|
||||
os.close(self.directory_descriptor)
|
||||
self.directory_descriptor = -1
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
173
services/hermes/scripts/cli_lane_dispatch.py
Normal file
173
services/hermes/scripts/cli_lane_dispatch.py
Normal file
@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Concurrent board recovery, claim, and dispatch loop for CLI workers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import concurrent.futures
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from cli_lane_board import _external, _record_board_access_error, _task_value
|
||||
from cli_lane_capabilities import initialize_kanban_capabilities, kanban_capabilities
|
||||
from cli_lane_config import (
|
||||
BOARD_CORRUPTION_ERRORS,
|
||||
DEFAULT_CLAIM_TTL,
|
||||
EXTERNAL_PREFIX,
|
||||
RESULT_SCHEMA,
|
||||
RESULT_SCHEMA_PATH,
|
||||
)
|
||||
from cli_lane_execution import execute_claim
|
||||
from cli_lane_files import atomic_json
|
||||
from cli_lane_recovery import _has_pending_finalization, recover_pending_finalizations
|
||||
from cli_lane_retention import maybe_gc_lane_artifacts
|
||||
|
||||
|
||||
def _board_slug(board: Any) -> str:
|
||||
if isinstance(board, dict):
|
||||
return str(board.get("slug") or board.get("id") or "")
|
||||
return str(getattr(board, "slug", None) or getattr(board, "id", None) or board)
|
||||
|
||||
def _connect_healthy_board(kanban_db: Any, board: str) -> Any | None:
|
||||
"""Open one board without letting localized storage faults stop other lanes."""
|
||||
try:
|
||||
return kanban_db.connect(board=board)
|
||||
except Exception as error:
|
||||
_record_board_access_error(board, error)
|
||||
return None
|
||||
|
||||
def recover_orphans() -> None:
|
||||
"""Return external running tasks to ready after a runner/pod restart."""
|
||||
from hermes_cli import kanban_db
|
||||
|
||||
recover_pending_finalizations()
|
||||
if not kanban_capabilities(kanban_db).exact_run_reclaim:
|
||||
return
|
||||
try:
|
||||
boards = kanban_db.list_boards(include_archived=False)
|
||||
except Exception as error:
|
||||
_record_board_access_error("board-registry", error)
|
||||
return
|
||||
for raw_board in boards:
|
||||
board = _board_slug(raw_board)
|
||||
if not board:
|
||||
continue
|
||||
with kanban_db.scoped_current_board(board):
|
||||
conn = _connect_healthy_board(kanban_db, board)
|
||||
if conn is None:
|
||||
continue
|
||||
try:
|
||||
for task in kanban_db.list_tasks(conn):
|
||||
if _external(task) and str(_task_value(task, "status", "")) == "running":
|
||||
task_id = str(_task_value(task, "id"))
|
||||
run_id = _task_value(task, "current_run_id", None)
|
||||
if not isinstance(run_id, int):
|
||||
continue
|
||||
if _has_pending_finalization(board, task_id, run_id):
|
||||
continue
|
||||
kanban_db.reclaim_task(
|
||||
conn,
|
||||
task_id,
|
||||
reason="direct CLI lane restarted; provider session will resume",
|
||||
expected_run_id=run_id,
|
||||
)
|
||||
BOARD_CORRUPTION_ERRORS.pop(board, None)
|
||||
except Exception as error:
|
||||
_record_board_access_error(board, error)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def claim_ready(active: set[tuple[str, str]], limit: int) -> list[tuple[str, str]]:
|
||||
"""Atomically claim external ready tasks across all non-archived boards."""
|
||||
from hermes_cli import kanban_db
|
||||
|
||||
claimed: list[tuple[str, str]] = []
|
||||
if limit <= 0:
|
||||
return claimed
|
||||
for raw_board in kanban_db.list_boards(include_archived=False):
|
||||
board = _board_slug(raw_board)
|
||||
if not board:
|
||||
continue
|
||||
with kanban_db.scoped_current_board(board):
|
||||
conn = _connect_healthy_board(kanban_db, board)
|
||||
if conn is None:
|
||||
continue
|
||||
try:
|
||||
kanban_db.recompute_ready(conn)
|
||||
tasks = kanban_db.list_tasks(conn)
|
||||
BOARD_CORRUPTION_ERRORS.pop(board, None)
|
||||
for task in tasks:
|
||||
task_id = str(_task_value(task, "id", ""))
|
||||
assignee = str(_task_value(task, "assignee", "") or "")
|
||||
if (
|
||||
task_id
|
||||
and not assignee
|
||||
and str(_task_value(task, "status", "")) == "ready"
|
||||
and kanban_db.assign_task(conn, task_id, "cli-auto")
|
||||
):
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
assignee = "cli-auto"
|
||||
if (
|
||||
not task_id
|
||||
or (board, task_id) in active
|
||||
or not assignee.startswith(EXTERNAL_PREFIX)
|
||||
or str(_task_value(task, "status", "")) != "ready"
|
||||
):
|
||||
continue
|
||||
try:
|
||||
result = kanban_db.claim_task(
|
||||
conn,
|
||||
task_id,
|
||||
ttl_seconds=DEFAULT_CLAIM_TTL,
|
||||
claimer="direct-cli-lane",
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if result is not None:
|
||||
claimed.append((board, task_id))
|
||||
if len(claimed) >= limit:
|
||||
return claimed
|
||||
except Exception as error:
|
||||
_record_board_access_error(board, error)
|
||||
finally:
|
||||
conn.close()
|
||||
return claimed
|
||||
|
||||
def main() -> int:
|
||||
"""Continuously bridge external Kanban lanes to provider CLIs."""
|
||||
from hermes_cli import kanban_db
|
||||
|
||||
RESULT_SCHEMA_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_json(RESULT_SCHEMA_PATH, RESULT_SCHEMA, 0o644)
|
||||
capabilities = initialize_kanban_capabilities(kanban_db)
|
||||
recover_orphans()
|
||||
workers = max(1, min(int(os.environ.get("HERMES_CLI_LANE_CONCURRENCY", "4")), 8))
|
||||
futures: dict[concurrent.futures.Future[None], tuple[str, str]] = {}
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
while True:
|
||||
for future in list(futures):
|
||||
if future.done():
|
||||
try:
|
||||
future.result()
|
||||
except Exception as error:
|
||||
print(f"worker future failed: {error}", file=sys.stderr, flush=True)
|
||||
del futures[future]
|
||||
recover_pending_finalizations()
|
||||
maybe_gc_lane_artifacts()
|
||||
active = set(futures.values())
|
||||
try:
|
||||
newly_claimed = (
|
||||
claim_ready(active, workers - len(futures))
|
||||
if capabilities.ready
|
||||
else []
|
||||
)
|
||||
BOARD_CORRUPTION_ERRORS.pop("board-registry", None)
|
||||
except Exception as error:
|
||||
_record_board_access_error("board-registry", error)
|
||||
newly_claimed = []
|
||||
for board, task_id in newly_claimed:
|
||||
future = pool.submit(execute_claim, board, task_id)
|
||||
futures[future] = (board, task_id)
|
||||
time.sleep(5)
|
||||
return 0
|
||||
285
services/hermes/scripts/cli_lane_evidence.py
Normal file
285
services/hermes/scripts/cli_lane_evidence.py
Normal file
@ -0,0 +1,285 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Immutable terminal evidence creation and identity-safe retirement."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import contextlib
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cli_lane_config import MAX_TERMINAL_RECORD_BYTES, TerminalIdentity, utc_now
|
||||
from cli_lane_files import _terminal_evidence_identity, state_path
|
||||
from cli_lane_records import _load_small_json, _terminal_record_valid
|
||||
|
||||
|
||||
def _rename_noreplace(
|
||||
source: str,
|
||||
destination: str,
|
||||
*,
|
||||
source_dir: int,
|
||||
destination_dir: int,
|
||||
) -> None:
|
||||
"""Atomically rename a directory entry without replacing a collision."""
|
||||
renameat2 = getattr(ctypes.CDLL(None, use_errno=True), "renameat2", None)
|
||||
if renameat2 is None:
|
||||
raise OSError(errno.ENOSYS, "renameat2 is unavailable")
|
||||
renameat2.argtypes = (
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_int,
|
||||
ctypes.c_char_p,
|
||||
ctypes.c_uint,
|
||||
)
|
||||
renameat2.restype = ctypes.c_int
|
||||
result = renameat2(
|
||||
source_dir,
|
||||
ctypes.c_char_p(os.fsencode(source)),
|
||||
destination_dir,
|
||||
ctypes.c_char_p(os.fsencode(destination)),
|
||||
1, # RENAME_NOREPLACE
|
||||
)
|
||||
if result != 0:
|
||||
error = ctypes.get_errno()
|
||||
raise OSError(error, os.strerror(error), source, destination)
|
||||
|
||||
def _retire_terminal_entry(
|
||||
path: Path,
|
||||
source_stat: os.stat_result,
|
||||
*,
|
||||
board_descriptor: int,
|
||||
quarantine_descriptor: int,
|
||||
authority_name: str | None = None,
|
||||
) -> str:
|
||||
"""Retire only the inode previously inspected; preserve any replacement."""
|
||||
try:
|
||||
current = os.stat(
|
||||
path.name,
|
||||
dir_fd=board_descriptor,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return "missing"
|
||||
if current.st_dev != source_stat.st_dev or current.st_ino != source_stat.st_ino:
|
||||
return "replacement"
|
||||
# A replacement can itself be staged repeatedly while recovery races a
|
||||
# writer. Keep every generation bound to the original canonical pending
|
||||
# name instead of hashing an intermediate .retire name and hiding it from
|
||||
# the next recovery pass.
|
||||
path_digest = hashlib.sha256(
|
||||
(authority_name or path.name).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
source_digest = hashlib.sha256(
|
||||
f"{source_stat.st_dev:x}\0{source_stat.st_ino:x}".encode()
|
||||
).hexdigest()[:16]
|
||||
for sequence in range(32):
|
||||
staging = f".retire.{path_digest}.{source_digest}.{sequence}"
|
||||
try:
|
||||
_rename_noreplace(
|
||||
path.name,
|
||||
staging,
|
||||
source_dir=board_descriptor,
|
||||
destination_dir=quarantine_descriptor,
|
||||
)
|
||||
except FileExistsError:
|
||||
continue
|
||||
break
|
||||
else:
|
||||
return "collision"
|
||||
staged = os.stat(
|
||||
staging,
|
||||
dir_fd=quarantine_descriptor,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
if staged.st_dev == source_stat.st_dev and staged.st_ino == source_stat.st_ino:
|
||||
os.unlink(staging, dir_fd=quarantine_descriptor)
|
||||
os.fsync(quarantine_descriptor)
|
||||
os.fsync(board_descriptor)
|
||||
return "retired"
|
||||
# The path changed after the pre-check but before the rename. Put the
|
||||
# replacement back without overwriting an even newer entry.
|
||||
try:
|
||||
_rename_noreplace(
|
||||
staging,
|
||||
path.name,
|
||||
source_dir=quarantine_descriptor,
|
||||
destination_dir=board_descriptor,
|
||||
)
|
||||
except FileExistsError:
|
||||
return "replacement-staged"
|
||||
os.fsync(quarantine_descriptor)
|
||||
os.fsync(board_descriptor)
|
||||
return "replacement"
|
||||
|
||||
def _write_json_noreplace(path: Path, value: dict[str, Any]) -> bool:
|
||||
"""Durably create one immutable JSON artifact without replacing a peer."""
|
||||
payload = (json.dumps(value, indent=2, sort_keys=True) + "\n").encode("utf-8")
|
||||
if len(payload) > MAX_TERMINAL_RECORD_BYTES + 4096:
|
||||
raise ValueError("terminal evidence exceeds the bounded artifact limit")
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.chmod(path.parent, 0o700, follow_symlinks=False)
|
||||
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
directory_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
create_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
directory = os.open(path.parent, directory_flags)
|
||||
temporary = f".{path.name}.{uuid.uuid4().hex}.tmp"
|
||||
temporary_stat = None
|
||||
descriptor = None
|
||||
try:
|
||||
descriptor = os.open(temporary, create_flags, 0o600, dir_fd=directory)
|
||||
temporary_stat = os.fstat(descriptor)
|
||||
with os.fdopen(descriptor, "wb") as stream:
|
||||
descriptor = None
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
written = os.fstat(stream.fileno())
|
||||
if stat.S_IMODE(written.st_mode) != 0o600 or written.st_nlink != 1:
|
||||
raise OSError("terminal evidence is not private and singly linked")
|
||||
try:
|
||||
_rename_noreplace(
|
||||
temporary,
|
||||
path.name,
|
||||
source_dir=directory,
|
||||
destination_dir=directory,
|
||||
)
|
||||
except FileExistsError:
|
||||
return False
|
||||
os.fsync(directory)
|
||||
return True
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
if temporary_stat is not None:
|
||||
with contextlib.suppress(OSError):
|
||||
_retire_terminal_entry(
|
||||
Path(temporary),
|
||||
temporary_stat,
|
||||
board_descriptor=directory,
|
||||
quarantine_descriptor=directory,
|
||||
)
|
||||
os.close(directory)
|
||||
|
||||
def _terminal_document_digest(document: dict[str, Any]) -> str:
|
||||
"""Return the stable identity for one exact accepted result document."""
|
||||
immutable = {
|
||||
key: document.get(key)
|
||||
for key in (
|
||||
"board",
|
||||
"task_id",
|
||||
"expected_run_id",
|
||||
"result",
|
||||
"summary",
|
||||
"metadata",
|
||||
)
|
||||
}
|
||||
canonical = json.dumps(immutable, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
def _terminal_evidence_path(
|
||||
identity: TerminalIdentity,
|
||||
state: str,
|
||||
document: dict[str, Any],
|
||||
) -> Path:
|
||||
"""Return a deterministic immutable prepared or conflict evidence path."""
|
||||
if state not in {"prepared", "conflict"}:
|
||||
raise ValueError(f"invalid terminal evidence state: {state}")
|
||||
base = state_path(identity.board, identity.task_id)
|
||||
digest = _terminal_document_digest(document)
|
||||
return base.with_name(
|
||||
f"{base.stem}.run-{identity.run_id}.terminal.{state}-{digest}.json"
|
||||
)
|
||||
|
||||
def _same_terminal_document(left: dict[str, Any], right: dict[str, Any]) -> bool:
|
||||
"""Compare the immutable result-bearing fields of terminal evidence."""
|
||||
keys = ("board", "task_id", "expected_run_id", "result", "summary", "metadata")
|
||||
return all(left.get(key) == right.get(key) for key in keys)
|
||||
|
||||
def _terminal_evidence_valid(
|
||||
document: dict[str, Any],
|
||||
identity: TerminalIdentity,
|
||||
state: str,
|
||||
) -> bool:
|
||||
"""Validate semantic terminal content plus its immutable evidence state."""
|
||||
if document.get("kanban_state") != state:
|
||||
return False
|
||||
semantic = dict(document)
|
||||
semantic_identity = identity
|
||||
if state == "conflict":
|
||||
semantic["kanban_state"] = "prepared"
|
||||
semantic_identity = TerminalIdentity(
|
||||
identity.board,
|
||||
identity.task_id,
|
||||
identity.run_id,
|
||||
"prepared",
|
||||
)
|
||||
return _terminal_record_valid(semantic, semantic_identity)
|
||||
|
||||
def _persist_prepared_evidence(
|
||||
identity: TerminalIdentity,
|
||||
document: dict[str, Any],
|
||||
) -> Path:
|
||||
"""Fsync an immutable accepted result before entering the DB boundary."""
|
||||
prepared = dict(document)
|
||||
prepared["kanban_state"] = "prepared"
|
||||
prepared["prepared_at"] = utc_now()
|
||||
path = _terminal_evidence_path(identity, "prepared", document)
|
||||
if _write_json_noreplace(path, prepared):
|
||||
return path
|
||||
if _terminal_evidence_identity(path) != TerminalIdentity(
|
||||
identity.board, identity.task_id, identity.run_id, "prepared"
|
||||
):
|
||||
raise OSError("prepared terminal evidence identity is invalid")
|
||||
existing = _load_small_json(path)
|
||||
prepared_identity = TerminalIdentity(
|
||||
identity.board,
|
||||
identity.task_id,
|
||||
identity.run_id,
|
||||
"prepared",
|
||||
)
|
||||
if not _terminal_evidence_valid(
|
||||
existing,
|
||||
prepared_identity,
|
||||
"prepared",
|
||||
) or not _same_terminal_document(existing, prepared):
|
||||
raise OSError("prepared terminal evidence path contains a conflicting result")
|
||||
return path
|
||||
|
||||
def _persist_conflict_evidence(
|
||||
identity: TerminalIdentity,
|
||||
document: dict[str, Any],
|
||||
reason: str,
|
||||
) -> Path:
|
||||
"""Preserve a losing valid result in full under deterministic retention."""
|
||||
conflict = dict(document)
|
||||
conflict["kanban_state"] = "conflict"
|
||||
conflict["conflict_reason"] = reason
|
||||
conflict["conflicted_at"] = utc_now()
|
||||
path = _terminal_evidence_path(identity, "conflict", document)
|
||||
if _write_json_noreplace(path, conflict):
|
||||
return path
|
||||
if _terminal_evidence_identity(path) != TerminalIdentity(
|
||||
identity.board, identity.task_id, identity.run_id, "conflict"
|
||||
):
|
||||
raise OSError("terminal conflict evidence identity is invalid")
|
||||
existing = _load_small_json(path)
|
||||
conflict_identity = TerminalIdentity(
|
||||
identity.board,
|
||||
identity.task_id,
|
||||
identity.run_id,
|
||||
"conflict",
|
||||
)
|
||||
if not _terminal_evidence_valid(
|
||||
existing,
|
||||
conflict_identity,
|
||||
"conflict",
|
||||
) or not _same_terminal_document(existing, conflict):
|
||||
raise OSError("terminal conflict evidence path contains a different result")
|
||||
return path
|
||||
429
services/hermes/scripts/cli_lane_execution.py
Normal file
429
services/hermes/scripts/cli_lane_execution.py
Normal file
@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Single-claim provider execution and durable Kanban result handoff."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cli_lane_goal
|
||||
from cli_lane_board import _board_call, _resolve_workspace, _task_context, _task_value
|
||||
from cli_lane_config import (
|
||||
DEFAULT_MAX_RUNTIME,
|
||||
TerminalFinalizationPending,
|
||||
)
|
||||
from cli_lane_files import (
|
||||
_terminal_identity,
|
||||
_terminal_path,
|
||||
atomic_json,
|
||||
load_json,
|
||||
state_path,
|
||||
)
|
||||
from cli_lane_finalization import _finalize_terminal_record, _recover_exact_run
|
||||
from cli_lane_prompt import build_prompt, git_handoff, workspace_artifacts
|
||||
from cli_lane_provider import run_provider
|
||||
from cli_lane_records import _persist_candidate, _write_terminal_record
|
||||
from cli_lane_recovery import _has_pending_finalization
|
||||
from cli_lane_routing import fresh_unavailable_provider, select_route
|
||||
|
||||
|
||||
def execute_claim(board: str, task_id: str) -> None:
|
||||
"""Execute one already-claimed task and commit its outcome to Kanban."""
|
||||
from hermes_cli import kanban_db
|
||||
|
||||
run_id = None
|
||||
with kanban_db.scoped_current_board(board):
|
||||
conn = kanban_db.connect(board=board)
|
||||
try:
|
||||
task = kanban_db.get_task(conn, task_id)
|
||||
if task is None:
|
||||
return
|
||||
run_id = _task_value(task, "current_run_id", None)
|
||||
assignee = str(_task_value(task, "assignee", "cli-auto") or "cli-auto")
|
||||
state_file = state_path(board, task_id)
|
||||
state = load_json(state_file)
|
||||
state.update(
|
||||
{
|
||||
"board": board,
|
||||
"task_id": task_id,
|
||||
"run_id": run_id,
|
||||
"assignee": assignee,
|
||||
}
|
||||
)
|
||||
atomic_json(state_file, state)
|
||||
log_path = Path(kanban_db.worker_log_path(task_id, board=board))
|
||||
workspace = _resolve_workspace(kanban_db, conn, task, board)
|
||||
kanban_db.set_workspace_path(conn, task_id, str(workspace))
|
||||
context = _task_context(kanban_db, conn, task_id)
|
||||
except Exception as error:
|
||||
failure_reason = (
|
||||
f"Direct CLI lane preparation failed: {type(error).__name__}: {error}"
|
||||
)
|
||||
conn.close()
|
||||
_board_call(
|
||||
kanban_db,
|
||||
board,
|
||||
lambda fresh: kanban_db.block_task(
|
||||
fresh,
|
||||
task_id,
|
||||
reason=failure_reason,
|
||||
kind="capability",
|
||||
expected_run_id=run_id,
|
||||
),
|
||||
)
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def heartbeat(note: str) -> bool:
|
||||
try:
|
||||
return bool(
|
||||
_board_call(
|
||||
kanban_db,
|
||||
board,
|
||||
lambda fresh: kanban_db.heartbeat_worker(
|
||||
fresh,
|
||||
task_id,
|
||||
note=note,
|
||||
expected_run_id=run_id,
|
||||
),
|
||||
)
|
||||
)
|
||||
except (OSError, sqlite3.Error):
|
||||
# The claim TTL is deliberately long. Keep the expensive
|
||||
# provider process alive during a transient volume stall and
|
||||
# retry on the next heartbeat instead of losing its work.
|
||||
return True
|
||||
|
||||
def comment(body: str) -> None:
|
||||
with contextlib.suppress(OSError, sqlite3.Error):
|
||||
_board_call(
|
||||
kanban_db,
|
||||
board,
|
||||
lambda fresh: kanban_db.add_comment(
|
||||
fresh,
|
||||
task_id,
|
||||
"cli-lane-runner",
|
||||
body,
|
||||
),
|
||||
)
|
||||
# Route state is also written to the durable lane-state file;
|
||||
# a later heartbeat or terminal result remains authoritative.
|
||||
|
||||
try:
|
||||
previous_route = state.get("current_route")
|
||||
excluded_provider = (
|
||||
fresh_unavailable_provider() if assignee == "cli-auto" else None
|
||||
)
|
||||
unavailable_provider = excluded_provider
|
||||
route = select_route(
|
||||
context,
|
||||
assignee,
|
||||
exclude_provider=excluded_provider,
|
||||
exclude_reason="is unavailable according to fresh native health"
|
||||
if excluded_provider
|
||||
else None,
|
||||
)
|
||||
if excluded_provider:
|
||||
comment(
|
||||
f"Provider health guard excluded {excluded_provider} before automatic routing.",
|
||||
)
|
||||
comment(
|
||||
f"CLI route: {route.provider}/{route.model} at {route.effort}; classifier={route.classifier}; {route.reason}",
|
||||
)
|
||||
resume_handoff = ""
|
||||
if (
|
||||
isinstance(previous_route, dict)
|
||||
and previous_route.get("provider")
|
||||
and previous_route.get("provider") != route.provider
|
||||
):
|
||||
try:
|
||||
previous_output = log_path.read_text(encoding="utf-8")[-10000:]
|
||||
except OSError:
|
||||
previous_output = "Previous provider log was unavailable after restart."
|
||||
resume_handoff = git_handoff(workspace, previous_output)
|
||||
comment(
|
||||
f"Restart-time provider change: {previous_route.get('provider')} -> {route.provider}; explicit workspace handoff attached.",
|
||||
)
|
||||
prompt = build_prompt(context, workspace, resume_handoff)
|
||||
max_runtime = int(
|
||||
_task_value(task, "max_runtime_seconds", 0) or DEFAULT_MAX_RUNTIME
|
||||
)
|
||||
goal_mode = bool(_task_value(task, "goal_mode", False))
|
||||
goal_max_turns = max(1, int(_task_value(task, "goal_max_turns", 1) or 1))
|
||||
goal_turn = max(1, int(state.get("goal_turn", 0) or 0) + 1)
|
||||
deadline = time.monotonic() + max_runtime
|
||||
while True:
|
||||
state["goal_turn"] = goal_turn
|
||||
atomic_json(state_file, state)
|
||||
remaining = max(1, int(deadline - time.monotonic()))
|
||||
result = run_provider(
|
||||
route,
|
||||
prompt,
|
||||
workspace,
|
||||
state,
|
||||
state_file,
|
||||
log_path,
|
||||
heartbeat,
|
||||
remaining,
|
||||
)
|
||||
candidate_file = None
|
||||
if result.structured:
|
||||
candidate_file = _persist_candidate(
|
||||
state,
|
||||
state_file,
|
||||
dict(result.structured),
|
||||
route=route,
|
||||
returncode=result.returncode,
|
||||
goal_turn=goal_turn,
|
||||
)
|
||||
if result.capacity_failure:
|
||||
unavailable_provider = route.provider
|
||||
retry_context = (
|
||||
context
|
||||
+ "\n\nRouting boundary: the first provider failed from capacity/authentication. "
|
||||
+ "Select the alternate hosted provider at an appropriate effort."
|
||||
)
|
||||
alternate = "claude" if route.provider == "codex" else "codex"
|
||||
fallback = select_route(
|
||||
retry_context,
|
||||
f"cli-{alternate}-{route.effort}",
|
||||
)
|
||||
comment(
|
||||
f"Provider fallback: {route.provider} -> {fallback.provider}; Jetson reclassified the retry boundary.",
|
||||
)
|
||||
result = run_provider(
|
||||
fallback,
|
||||
build_prompt(
|
||||
context,
|
||||
workspace,
|
||||
git_handoff(workspace, result.output),
|
||||
),
|
||||
workspace,
|
||||
state,
|
||||
state_file,
|
||||
log_path,
|
||||
heartbeat,
|
||||
max(1, int(deadline - time.monotonic())),
|
||||
)
|
||||
route = fallback
|
||||
if result.structured:
|
||||
candidate_file = _persist_candidate(
|
||||
state,
|
||||
state_file,
|
||||
dict(result.structured),
|
||||
route=route,
|
||||
returncode=result.returncode,
|
||||
goal_turn=goal_turn,
|
||||
)
|
||||
structured = result.structured
|
||||
if structured:
|
||||
structured = dict(structured)
|
||||
structured["artifacts"] = workspace_artifacts(
|
||||
workspace,
|
||||
structured.get("artifacts"),
|
||||
)
|
||||
metadata = {
|
||||
"executor": "direct-cli-lane",
|
||||
"provider": route.provider,
|
||||
"model": route.model,
|
||||
"effort": route.effort,
|
||||
"classifier": route.classifier,
|
||||
"state_file": str(state_file),
|
||||
"codex_thread_id": state.get("codex_thread_id"),
|
||||
"claude_session_id": state.get("claude_session_id"),
|
||||
"goal_mode": goal_mode,
|
||||
"goal_turn": goal_turn,
|
||||
}
|
||||
if candidate_file is not None:
|
||||
metadata["candidate_file"] = str(candidate_file)
|
||||
if structured:
|
||||
for key in (
|
||||
"changed_files",
|
||||
"tests_run",
|
||||
"artifacts",
|
||||
"findings",
|
||||
"blockers",
|
||||
):
|
||||
value = structured.get(key)
|
||||
metadata[key] = value if isinstance(value, list) else []
|
||||
|
||||
completion_problem = None
|
||||
if structured and result.returncode == 0:
|
||||
completion_problem = cli_lane_goal.unfinished_result_reason(structured)
|
||||
if (
|
||||
structured.get("status") == "completed"
|
||||
and completion_problem is None
|
||||
and goal_mode
|
||||
):
|
||||
heartbeat("local goal-completion judge active")
|
||||
rejection_history = state.get("goal_rejections", [])
|
||||
if not isinstance(rejection_history, list):
|
||||
rejection_history = []
|
||||
judge_context = context
|
||||
if goal_turn > 1 or rejection_history:
|
||||
judge_context += (
|
||||
"\n\nAuthoritative Hermes goal-controller evidence: "
|
||||
f"current turn {goal_turn}/{goal_max_turns}; prior rejected "
|
||||
f"reports: {json.dumps(rejection_history[-5:])}."
|
||||
)
|
||||
accepted, judge_reason = cli_lane_goal.judge_goal_completion(
|
||||
judge_context,
|
||||
structured,
|
||||
)
|
||||
metadata["goal_judge_reason"] = judge_reason
|
||||
if not accepted:
|
||||
completion_problem = f"local goal judge requested continuation: {judge_reason}"
|
||||
|
||||
if (
|
||||
structured
|
||||
and structured.get("status") == "completed"
|
||||
and result.returncode == 0
|
||||
and completion_problem is None
|
||||
):
|
||||
terminal_file = _terminal_path(state_file, run_id, "pending")
|
||||
metadata["terminal_record"] = str(
|
||||
_terminal_path(state_file, run_id, "committed")
|
||||
)
|
||||
try:
|
||||
terminal_file, terminal_record = _write_terminal_record(
|
||||
state_file,
|
||||
board=board,
|
||||
task_id=task_id,
|
||||
run_id=run_id,
|
||||
structured=structured,
|
||||
summary=str(structured.get("summary") or "Completed"),
|
||||
metadata=metadata,
|
||||
)
|
||||
outcome = _finalize_terminal_record(
|
||||
kanban_db,
|
||||
terminal_file,
|
||||
terminal_record,
|
||||
)
|
||||
except Exception as error:
|
||||
identity = _terminal_identity(terminal_file)
|
||||
replayable = bool(
|
||||
identity is not None
|
||||
and _has_pending_finalization(
|
||||
identity.board,
|
||||
identity.task_id,
|
||||
identity.run_id,
|
||||
)
|
||||
)
|
||||
if not replayable:
|
||||
_recover_exact_run(
|
||||
kanban_db,
|
||||
identity,
|
||||
f"persistence raised {type(error).__name__}",
|
||||
)
|
||||
raise TerminalFinalizationPending(
|
||||
"accepted worker result remains outside the generic block path; "
|
||||
f"terminal replayable={replayable}; persistence/finalization "
|
||||
f"raised {type(error).__name__}"
|
||||
) from error
|
||||
if outcome != "committed":
|
||||
raise TerminalFinalizationPending(
|
||||
"accepted worker result is durably journaled but "
|
||||
f"Kanban finalization is {outcome}"
|
||||
)
|
||||
break
|
||||
|
||||
can_continue = (
|
||||
goal_mode
|
||||
and completion_problem is not None
|
||||
and goal_turn < goal_max_turns
|
||||
and deadline - time.monotonic() > 30
|
||||
)
|
||||
if can_continue:
|
||||
rejection_history = state.get("goal_rejections", [])
|
||||
if not isinstance(rejection_history, list):
|
||||
rejection_history = []
|
||||
state["goal_rejections"] = [
|
||||
*rejection_history[-4:],
|
||||
completion_problem,
|
||||
]
|
||||
goal_turn += 1
|
||||
comment(
|
||||
f"Goal completion rejected; continuing turn {goal_turn}/{goal_max_turns}: {completion_problem}",
|
||||
)
|
||||
escalation_context = (
|
||||
context
|
||||
+ "\n\nThe previous worker turn missed its completion quality mark: "
|
||||
+ completion_problem
|
||||
+ "\nSelect a route that can finish and verify the remaining work."
|
||||
)
|
||||
excluded = unavailable_provider
|
||||
if excluded is None and assignee == "cli-auto":
|
||||
excluded = fresh_unavailable_provider()
|
||||
next_route = select_route(
|
||||
escalation_context,
|
||||
assignee,
|
||||
exclude_provider=excluded,
|
||||
exclude_reason="is unavailable according to fresh native health"
|
||||
if excluded
|
||||
else None,
|
||||
)
|
||||
comment(
|
||||
f"Goal route {goal_turn}/{goal_max_turns}: "
|
||||
f"{next_route.provider}/{next_route.model} at {next_route.effort}; "
|
||||
f"classifier={next_route.classifier}; {next_route.reason}",
|
||||
)
|
||||
handoff = (
|
||||
git_handoff(workspace, result.output)
|
||||
if next_route.provider != route.provider
|
||||
else ""
|
||||
)
|
||||
route = next_route
|
||||
prompt = build_prompt(
|
||||
context,
|
||||
workspace,
|
||||
handoff
|
||||
+ "\nGoal-loop continuation: the previous final report was rejected because "
|
||||
+ completion_problem
|
||||
+ ". Reinspect live state, finish the outstanding work, and return new final evidence.",
|
||||
)
|
||||
continue
|
||||
|
||||
reason = completion_problem
|
||||
if reason is None and structured:
|
||||
blockers = structured.get("blockers", [])
|
||||
reason = "; ".join(str(item) for item in blockers)
|
||||
reason = reason or str(structured.get("summary") or "")
|
||||
if reason is None:
|
||||
reason = result.output[-4000:]
|
||||
failure_reason = reason or (
|
||||
f"{route.provider} worker failed with exit {result.returncode}"
|
||||
)
|
||||
failure_kind = (
|
||||
"transient" if result.capacity_failure else "capability"
|
||||
)
|
||||
_board_call(
|
||||
kanban_db,
|
||||
board,
|
||||
lambda fresh, failure_reason=failure_reason, failure_kind=failure_kind: kanban_db.block_task(
|
||||
fresh,
|
||||
task_id,
|
||||
reason=failure_reason,
|
||||
kind=failure_kind,
|
||||
expected_run_id=run_id,
|
||||
),
|
||||
)
|
||||
break
|
||||
except TerminalFinalizationPending as error:
|
||||
comment(str(error))
|
||||
except Exception as error:
|
||||
failure_reason = f"Direct CLI lane failed: {type(error).__name__}: {error}"
|
||||
_board_call(
|
||||
kanban_db,
|
||||
board,
|
||||
lambda fresh: kanban_db.block_task(
|
||||
fresh,
|
||||
task_id,
|
||||
reason=failure_reason,
|
||||
kind="capability",
|
||||
expected_run_id=run_id,
|
||||
),
|
||||
)
|
||||
173
services/hermes/scripts/cli_lane_files.py
Normal file
173
services/hermes/scripts/cli_lane_files.py
Normal file
@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable path and filesystem primitives for Hermes CLI lane journals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cli_lane_config import STATE_ROOT, TerminalIdentity, canonical_run_id
|
||||
|
||||
|
||||
def atomic_json(path: Path, value: dict[str, Any], mode: int = 0o600) -> None:
|
||||
"""Durably replace a small state document without following temp symlinks."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
os.chmod(path.parent, 0o700, follow_symlinks=False)
|
||||
temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = None
|
||||
try:
|
||||
descriptor = os.open(temporary, flags, mode)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||
descriptor = None
|
||||
json.dump(value, stream, indent=2, sort_keys=True)
|
||||
stream.write("\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.chmod(temporary, mode, follow_symlinks=False)
|
||||
os.replace(temporary, path)
|
||||
_fsync_directory(path.parent)
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
temporary.unlink()
|
||||
|
||||
def _fsync_directory(directory: Path) -> None:
|
||||
"""Persist directory-entry changes after an atomic rename or quarantine."""
|
||||
flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(directory, flags)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, flags)
|
||||
with os.fdopen(descriptor, "r", encoding="utf-8") as stream:
|
||||
value = json.load(stream)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return {}
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
def state_path(board: str, task_id: str) -> Path:
|
||||
safe_board = re.sub(r"[^a-zA-Z0-9_.-]+", "-", board)
|
||||
safe_task = re.sub(r"[^a-zA-Z0-9_.-]+", "-", task_id)
|
||||
return STATE_ROOT / safe_board / f"{safe_task}.json"
|
||||
|
||||
def _result_path(state_file: Path, run_id: Any, sequence: int) -> Path:
|
||||
"""Return a unique provider-result path; completed turns are never reused."""
|
||||
safe_run = re.sub(r"[^a-zA-Z0-9_.-]+", "-", str(run_id or "unknown"))
|
||||
return state_file.with_name(
|
||||
f"{state_file.stem}.run-{safe_run}.provider-{sequence}.result.json"
|
||||
)
|
||||
|
||||
def _candidate_path(state_file: Path, run_id: Any, sequence: int) -> Path:
|
||||
"""Return the durable path for one exact structured worker response."""
|
||||
safe_run = re.sub(r"[^a-zA-Z0-9_.-]+", "-", str(run_id or "unknown"))
|
||||
return state_file.with_name(
|
||||
f"{state_file.stem}.run-{safe_run}.candidate-{sequence}.json"
|
||||
)
|
||||
|
||||
def _terminal_path(
|
||||
state_file: Path,
|
||||
run_id: Any,
|
||||
state: str = "pending",
|
||||
) -> Path:
|
||||
"""Return the identity-bound replay path for an accepted terminal response."""
|
||||
if state not in {"pending", "committed"}:
|
||||
raise ValueError(f"invalid terminal journal state: {state}")
|
||||
canonical = canonical_run_id(run_id)
|
||||
if type(run_id) is not int or canonical is None:
|
||||
raise ValueError("terminal run ID must be a positive SQLite int64")
|
||||
return state_file.with_name(
|
||||
f"{state_file.stem}.run-{canonical}.terminal.{state}.json"
|
||||
)
|
||||
|
||||
_TERMINAL_NAME = re.compile(
|
||||
r"^(?P<task>[a-zA-Z0-9_.-]+)\.run-(?P<run>[0-9]+)\."
|
||||
r"terminal\.(?P<state>pending|committed)\.json$"
|
||||
)
|
||||
_TERMINAL_EVIDENCE_NAME = re.compile(
|
||||
r"^(?P<task>[a-zA-Z0-9_.-]+)\.run-(?P<run>[0-9]+)\."
|
||||
r"terminal\.(?P<state>prepared|conflict)-(?P<digest>[a-f0-9]{32})\.json$"
|
||||
)
|
||||
_RETIRE_NAME = re.compile(
|
||||
r"^\.retire\.(?P<path_digest>[a-f0-9]{16})\."
|
||||
r"(?P<source_digest>[a-f0-9]{16})\.(?P<sequence>[0-9]+)$"
|
||||
)
|
||||
_LEGACY_RETIRE_NAME = re.compile(r"^\.retire\.[a-f0-9]{32}\.[0-9]+$")
|
||||
_SAFE_BOARD = re.compile(r"^[a-zA-Z0-9_.-]+$")
|
||||
|
||||
|
||||
def _terminal_identity(path: Path) -> TerminalIdentity | None:
|
||||
"""Parse replay authority lexically before opening a journal or board."""
|
||||
try:
|
||||
relative = path.relative_to(STATE_ROOT)
|
||||
except ValueError:
|
||||
return None
|
||||
if len(relative.parts) != 2:
|
||||
return None
|
||||
board, filename = relative.parts
|
||||
match = _TERMINAL_NAME.fullmatch(filename)
|
||||
run_id = canonical_run_id(match.group("run")) if match else None
|
||||
try:
|
||||
board_stat = path.parent.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
return None
|
||||
if (
|
||||
not match
|
||||
or run_id is None
|
||||
or not stat.S_ISDIR(board_stat.st_mode)
|
||||
or not _SAFE_BOARD.fullmatch(board)
|
||||
or board in {".", ".."}
|
||||
or match.group("task") in {".", ".."}
|
||||
):
|
||||
return None
|
||||
return TerminalIdentity(
|
||||
board=board,
|
||||
task_id=match.group("task"),
|
||||
run_id=run_id,
|
||||
state=match.group("state"),
|
||||
)
|
||||
|
||||
def _terminal_evidence_identity(path: Path) -> TerminalIdentity | None:
|
||||
"""Parse immutable prepared/conflict evidence authority from its path."""
|
||||
try:
|
||||
relative = path.relative_to(STATE_ROOT)
|
||||
except ValueError:
|
||||
return None
|
||||
if len(relative.parts) != 2:
|
||||
return None
|
||||
board, filename = relative.parts
|
||||
match = _TERMINAL_EVIDENCE_NAME.fullmatch(filename)
|
||||
run_id = canonical_run_id(match.group("run")) if match else None
|
||||
try:
|
||||
board_stat = path.parent.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
return None
|
||||
if (
|
||||
not match
|
||||
or run_id is None
|
||||
or not stat.S_ISDIR(board_stat.st_mode)
|
||||
or not _SAFE_BOARD.fullmatch(board)
|
||||
or board in {".", ".."}
|
||||
or match.group("task") in {".", ".."}
|
||||
):
|
||||
return None
|
||||
return TerminalIdentity(
|
||||
board=board,
|
||||
task_id=match.group("task"),
|
||||
run_id=run_id,
|
||||
state=match.group("state"),
|
||||
)
|
||||
330
services/hermes/scripts/cli_lane_finalization.py
Normal file
330
services/hermes/scripts/cli_lane_finalization.py
Normal file
@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exact-run database completion and terminal evidence publication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cli_lane_board import _board_call, _external, _record_board_access_error, _task_value
|
||||
from cli_lane_capabilities import kanban_capabilities
|
||||
from cli_lane_config import (
|
||||
TerminalIdentity,
|
||||
TerminalRecoverySnapshot,
|
||||
TerminalSnapshot,
|
||||
canonical_run_id,
|
||||
utc_now,
|
||||
)
|
||||
from cli_lane_evidence import (
|
||||
_persist_conflict_evidence,
|
||||
_persist_prepared_evidence,
|
||||
_retire_terminal_entry,
|
||||
_same_terminal_document,
|
||||
_write_json_noreplace,
|
||||
)
|
||||
from cli_lane_files import _terminal_identity, _terminal_path, state_path
|
||||
from cli_lane_records import (
|
||||
_load_terminal_json,
|
||||
_open_small_json_snapshot,
|
||||
_open_terminal_snapshot,
|
||||
_terminal_record_valid,
|
||||
)
|
||||
|
||||
|
||||
def _recover_exact_run(
|
||||
kanban_db: Any,
|
||||
identity: TerminalIdentity | None,
|
||||
reason: str,
|
||||
) -> bool:
|
||||
"""Make an exact external run retryable after journal recovery fails."""
|
||||
if identity is None or canonical_run_id(identity.run_id) is None:
|
||||
return False
|
||||
if not kanban_capabilities(kanban_db).exact_run_reclaim:
|
||||
return False
|
||||
|
||||
def operation(conn: Any) -> bool:
|
||||
task = kanban_db.get_task(conn, identity.task_id)
|
||||
if task is None:
|
||||
return False
|
||||
if (
|
||||
str(_task_value(task, "status", "")) != "running"
|
||||
or _task_value(task, "current_run_id", None) != identity.run_id
|
||||
or not _external(task)
|
||||
):
|
||||
return False
|
||||
return bool(
|
||||
kanban_db.reclaim_task(
|
||||
conn,
|
||||
identity.task_id,
|
||||
reason=f"terminal journal recovery failed ({reason}); exact run may retry",
|
||||
expected_run_id=identity.run_id,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
recovered = bool(_board_call(kanban_db, identity.board, operation))
|
||||
except Exception as error:
|
||||
_record_board_access_error(identity.board, error)
|
||||
return False
|
||||
if recovered:
|
||||
print(
|
||||
f"reclaimed {identity.board}/{identity.task_id} run {identity.run_id} "
|
||||
f"after terminal journal recovery: {reason}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return recovered
|
||||
|
||||
def _retire_snapshot(
|
||||
path: Path,
|
||||
snapshot: TerminalSnapshot,
|
||||
*,
|
||||
authority_name: str | None = None,
|
||||
) -> str:
|
||||
"""Retire only the directory entry still naming an opened snapshot inode."""
|
||||
return _retire_terminal_entry(
|
||||
path,
|
||||
snapshot.file_stat,
|
||||
board_descriptor=snapshot.directory_descriptor,
|
||||
quarantine_descriptor=snapshot.directory_descriptor,
|
||||
authority_name=authority_name,
|
||||
)
|
||||
|
||||
def _retire_snapshot_after_db(path: Path, snapshot: TerminalSnapshot) -> str:
|
||||
"""Keep a committed DB result recoverable across retirement fsync errors."""
|
||||
try:
|
||||
return _retire_snapshot(path, snapshot)
|
||||
except OSError as error:
|
||||
print(
|
||||
f"terminal journal retirement deferred after DB commit: "
|
||||
f"{type(error).__name__}: {error}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return "deferred"
|
||||
|
||||
def _discard_evidence(path: Path) -> str:
|
||||
"""Identity-safely remove one immutable prepared evidence artifact."""
|
||||
snapshot = _open_small_json_snapshot(path)
|
||||
if snapshot is None:
|
||||
return "missing"
|
||||
try:
|
||||
return _retire_snapshot(path, snapshot)
|
||||
finally:
|
||||
snapshot.close()
|
||||
|
||||
def _promote_prepared_evidence(
|
||||
identity: TerminalIdentity,
|
||||
document: dict[str, Any],
|
||||
prepared: Path,
|
||||
*,
|
||||
discard_prepared: bool = True,
|
||||
) -> tuple[Path, str]:
|
||||
"""Publish the DB-winning result without overwriting first-writer evidence."""
|
||||
committed = _terminal_path(
|
||||
state_path(identity.board, identity.task_id),
|
||||
identity.run_id,
|
||||
"committed",
|
||||
)
|
||||
committed_document = dict(document)
|
||||
committed_document["kanban_state"] = "committed"
|
||||
committed_document["committed_at"] = utc_now()
|
||||
state = "committed"
|
||||
if not _write_json_noreplace(committed, committed_document):
|
||||
committed_identity = TerminalIdentity(
|
||||
identity.board,
|
||||
identity.task_id,
|
||||
identity.run_id,
|
||||
"committed",
|
||||
)
|
||||
existing = _load_terminal_json(committed, committed_identity)
|
||||
if (
|
||||
not _terminal_record_valid(existing, committed_identity)
|
||||
or existing.get("kanban_state") != "committed"
|
||||
or not _same_terminal_document(existing, committed_document)
|
||||
):
|
||||
_persist_conflict_evidence(
|
||||
identity,
|
||||
document,
|
||||
"canonical committed evidence already contains a different result",
|
||||
)
|
||||
state = "conflict"
|
||||
if discard_prepared:
|
||||
_discard_evidence(prepared)
|
||||
return committed, state
|
||||
|
||||
def _finalize_document_db(
|
||||
kanban_db: Any,
|
||||
identity: TerminalIdentity,
|
||||
document: dict[str, Any],
|
||||
) -> str:
|
||||
"""Apply one validated exact-run result to Kanban under DB run guards."""
|
||||
if canonical_run_id(identity.run_id) is None:
|
||||
return "stale"
|
||||
|
||||
def operation(conn: Any) -> str:
|
||||
task = kanban_db.get_task(conn, identity.task_id)
|
||||
if task is None:
|
||||
return "stale"
|
||||
status = str(_task_value(task, "status", ""))
|
||||
if status == "done":
|
||||
return (
|
||||
"committed"
|
||||
if (
|
||||
_task_value(task, "completed_run_id", None) == identity.run_id
|
||||
and str(_task_value(task, "result", "") or "")
|
||||
== document["result"]
|
||||
)
|
||||
else "stale"
|
||||
)
|
||||
current_run_id = _task_value(task, "current_run_id", None)
|
||||
completion_guard: dict[str, int]
|
||||
if status == "running" and current_run_id == identity.run_id:
|
||||
if not kanban_capabilities(kanban_db).exact_run_completion:
|
||||
return "deferred"
|
||||
completion_guard = {"expected_run_id": identity.run_id}
|
||||
elif status in {"ready", "blocked", "triage"} and current_run_id is None:
|
||||
# The journal may have survived an older post-persistence error
|
||||
# path that ended its run. The patched DB verifies atomically that
|
||||
# this is still the latest ended run before allowing completion.
|
||||
if not kanban_capabilities(kanban_db).ended_run_replay:
|
||||
return "deferred"
|
||||
completion_guard = {"replay_ended_run_id": identity.run_id}
|
||||
else:
|
||||
return "stale"
|
||||
completed = kanban_db.complete_task(
|
||||
conn,
|
||||
identity.task_id,
|
||||
result=document["result"],
|
||||
summary=document["summary"],
|
||||
metadata=document["metadata"],
|
||||
**completion_guard,
|
||||
)
|
||||
if completed:
|
||||
return "committed"
|
||||
# A duplicate finalizer can lose the guarded UPDATE to an identical
|
||||
# first writer. Re-read rather than reporting a false pending state.
|
||||
latest = kanban_db.get_task(conn, identity.task_id)
|
||||
latest_status = (
|
||||
str(_task_value(latest, "status", "")) if latest is not None else ""
|
||||
)
|
||||
if latest_status == "done":
|
||||
return (
|
||||
"committed"
|
||||
if (
|
||||
_task_value(latest, "completed_run_id", None) == identity.run_id
|
||||
and str(_task_value(latest, "result", "") or "")
|
||||
== document["result"]
|
||||
)
|
||||
else "stale"
|
||||
)
|
||||
# A guarded completion can be retried only while this exact live run
|
||||
# still owns the task. An ended, replaced, or nonexistent run cannot
|
||||
# become authoritative later and must converge to conflict evidence.
|
||||
if (
|
||||
latest_status == "running"
|
||||
and _task_value(latest, "current_run_id", None) == identity.run_id
|
||||
):
|
||||
return "pending"
|
||||
return "stale"
|
||||
|
||||
return str(_board_call(kanban_db, identity.board, operation))
|
||||
|
||||
def _resolve_pending_after_winner(
|
||||
path: Path,
|
||||
identity: TerminalIdentity,
|
||||
winning_document: dict[str, Any],
|
||||
) -> bool:
|
||||
"""Retire duplicates and preserve differing replacements as conflicts."""
|
||||
for _attempt in range(8):
|
||||
snapshot = _open_terminal_snapshot(path, identity)
|
||||
if snapshot is None:
|
||||
return False
|
||||
document = snapshot.document
|
||||
if not _terminal_record_valid(document, identity):
|
||||
snapshot.close()
|
||||
return False
|
||||
differing = not _same_terminal_document(document, winning_document)
|
||||
try:
|
||||
if differing:
|
||||
_persist_conflict_evidence(
|
||||
identity,
|
||||
document,
|
||||
"different valid result lost the exact-run first-writer race",
|
||||
)
|
||||
retirement = _retire_snapshot_after_db(path, snapshot)
|
||||
finally:
|
||||
snapshot.close()
|
||||
if retirement in {"retired", "missing"}:
|
||||
if differing:
|
||||
print(
|
||||
f"preserved terminal result conflict for {identity.board}/"
|
||||
f"{identity.task_id} run {identity.run_id}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return differing
|
||||
if retirement not in {"replacement", "replacement-staged", "collision"}:
|
||||
return differing
|
||||
return False
|
||||
|
||||
def _finalize_terminal_record(
|
||||
kanban_db: Any,
|
||||
path: Path,
|
||||
_record: dict[str, Any] | None = None,
|
||||
*,
|
||||
snapshot: TerminalRecoverySnapshot | None = None,
|
||||
) -> str:
|
||||
"""Commit one inode-pinned exact-run journal, or leave it safely pending."""
|
||||
identity = _terminal_identity(path)
|
||||
if identity is None or identity.state != "pending":
|
||||
if snapshot is not None:
|
||||
snapshot.close()
|
||||
return "invalid"
|
||||
pinned: TerminalSnapshot | TerminalRecoverySnapshot | None = snapshot
|
||||
if pinned is None:
|
||||
pinned = _open_terminal_snapshot(path, identity)
|
||||
if pinned is None or pinned.document is None:
|
||||
if pinned is not None:
|
||||
pinned.close()
|
||||
return "invalid"
|
||||
document = pinned.document
|
||||
try:
|
||||
if not _terminal_record_valid(document):
|
||||
return "invalid"
|
||||
if not _terminal_record_valid(document, identity):
|
||||
return "foreign"
|
||||
prepared = _persist_prepared_evidence(identity, document)
|
||||
outcome = _finalize_document_db(kanban_db, identity, document)
|
||||
if outcome == "committed":
|
||||
_committed, evidence_state = _promote_prepared_evidence(
|
||||
identity,
|
||||
document,
|
||||
prepared,
|
||||
)
|
||||
retirement = _retire_snapshot_after_db(path, pinned)
|
||||
elif outcome == "stale":
|
||||
_persist_conflict_evidence(
|
||||
identity,
|
||||
document,
|
||||
"valid terminal result no longer matches the authoritative task run",
|
||||
)
|
||||
_discard_evidence(prepared)
|
||||
retirement = _retire_snapshot_after_db(path, pinned)
|
||||
outcome = "conflict"
|
||||
evidence_state = "conflict"
|
||||
else:
|
||||
return outcome
|
||||
finally:
|
||||
pinned.close()
|
||||
if outcome == "committed" and retirement not in {"retired", "missing"}:
|
||||
_resolve_pending_after_winner(path, identity, document)
|
||||
if evidence_state == "conflict":
|
||||
print(
|
||||
f"terminal first-writer conflict recorded for {identity.board}/"
|
||||
f"{identity.task_id} run {identity.run_id}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return outcome
|
||||
117
services/hermes/scripts/cli_lane_prompt.py
Normal file
117
services/hermes/scripts/cli_lane_prompt.py
Normal file
@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Prompt, artifact, and structured-event helpers for CLI workers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cli_lane_goal
|
||||
from cli_lane_files import atomic_json
|
||||
|
||||
|
||||
def build_prompt(context: str, workspace: Path, handoff: str = "") -> str:
|
||||
"""Create a bounded worker contract with an explicit machine-readable result."""
|
||||
return f"""You are a durable coding worker managed by Hermes Kanban.
|
||||
|
||||
Work only on this objective and its acceptance criteria:
|
||||
|
||||
{context}
|
||||
|
||||
Workspace: {workspace}
|
||||
{handoff}
|
||||
|
||||
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 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.
|
||||
"""
|
||||
|
||||
def git_handoff(workspace: Path, prior_output: str) -> str:
|
||||
"""Build an explicit cross-provider handoff without transferring hidden state."""
|
||||
def read_git(*args: str) -> str:
|
||||
completed = subprocess.run(
|
||||
["git", "-C", str(workspace), *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
return (completed.stdout or completed.stderr).strip()[-12000:]
|
||||
|
||||
return (
|
||||
"\nCross-provider handoff from a failed or exhausted worker:\n"
|
||||
f"Git status:\n{read_git('status', '--short', '--branch')}\n\n"
|
||||
f"Current diff summary:\n{read_git('diff', '--stat')}\n\n"
|
||||
f"Prior worker tail:\n{prior_output[-10000:]}\n"
|
||||
"Reinspect the workspace and verify all inherited claims before continuing."
|
||||
)
|
||||
|
||||
def workspace_artifacts(workspace: Path, values: Any) -> list[str]:
|
||||
"""Return existing regular artifacts contained by the task worktree."""
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
root = workspace.resolve()
|
||||
artifacts: list[str] = []
|
||||
for value in values:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
continue
|
||||
candidate = Path(value.strip()).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = root / candidate
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
resolved.relative_to(root)
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
continue
|
||||
if resolved.is_file():
|
||||
artifacts.append(str(resolved))
|
||||
return list(dict.fromkeys(artifacts))
|
||||
|
||||
def _extract_json(value: Any) -> dict[str, Any] | None:
|
||||
if isinstance(value, dict) and value.get("status") in cli_lane_goal.RESULT_STATUSES:
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
candidates = [value]
|
||||
match = re.search(r"\{.*\}", value, re.DOTALL)
|
||||
if match:
|
||||
candidates.append(match.group(0))
|
||||
for candidate in candidates:
|
||||
try:
|
||||
parsed = json.loads(candidate)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if isinstance(parsed, dict) and parsed.get("status") in cli_lane_goal.RESULT_STATUSES:
|
||||
return parsed
|
||||
return None
|
||||
|
||||
def _event_payload(provider: str, line: str, state: dict[str, Any], state_file: Path) -> dict[str, Any] | None:
|
||||
"""Persist provider session identifiers before interpreting final output."""
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
if not isinstance(event, dict):
|
||||
return None
|
||||
if provider == "codex" and event.get("type") == "thread.started":
|
||||
thread_id = event.get("thread_id") or event.get("thread", {}).get("id")
|
||||
if thread_id:
|
||||
state["codex_thread_id"] = str(thread_id)
|
||||
atomic_json(state_file, state)
|
||||
if provider == "claude" and event.get("session_id"):
|
||||
state["claude_session_id"] = str(event["session_id"])
|
||||
atomic_json(state_file, state)
|
||||
for key in ("structured_output", "result", "output", "text"):
|
||||
parsed = _extract_json(event.get(key))
|
||||
if parsed:
|
||||
return parsed
|
||||
item = event.get("item")
|
||||
if isinstance(item, dict):
|
||||
for key in ("text", "content"):
|
||||
parsed = _extract_json(item.get(key))
|
||||
if parsed:
|
||||
return parsed
|
||||
return None
|
||||
399
services/hermes/scripts/cli_lane_provider.py
Normal file
399
services/hermes/scripts/cli_lane_provider.py
Normal file
@ -0,0 +1,399 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Native Codex and Claude process lifecycle for Hermes CLI workers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import selectors
|
||||
import signal
|
||||
import subprocess
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import cli_lane_goal
|
||||
from cli_lane_config import (
|
||||
CAPACITY_PATTERN,
|
||||
CLAUDE_BIN,
|
||||
CLAUDE_SESSION_COLLISION,
|
||||
CLAUDE_SETTINGS,
|
||||
CODEX_BIN,
|
||||
DATA_ROOT,
|
||||
HEARTBEAT_SECONDS,
|
||||
NO_CLAUDE_SESSION,
|
||||
NO_CODEX_THREAD,
|
||||
ProcessResult,
|
||||
RESULT_SCHEMA,
|
||||
RESULT_SCHEMA_PATH,
|
||||
Route,
|
||||
utc_now,
|
||||
)
|
||||
from cli_lane_files import _result_path, atomic_json, load_json
|
||||
from cli_lane_prompt import _event_payload
|
||||
|
||||
|
||||
def stream_process(
|
||||
command: list[str],
|
||||
*,
|
||||
provider: str,
|
||||
cwd: Path,
|
||||
env: dict[str, str],
|
||||
log_path: Path,
|
||||
state: dict[str, Any],
|
||||
state_file: Path,
|
||||
heartbeat: Callable[[str], bool],
|
||||
max_runtime: int,
|
||||
) -> ProcessResult:
|
||||
"""Stream JSONL to Kanban logs while maintaining the authoritative lease."""
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=cwd,
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
start_new_session=True,
|
||||
)
|
||||
assert process.stdout is not None
|
||||
selector = selectors.DefaultSelector()
|
||||
selector.register(process.stdout, selectors.EVENT_READ)
|
||||
started = time.monotonic()
|
||||
last_heartbeat = 0.0
|
||||
known_descendants: dict[int, tuple[int, int]] = {}
|
||||
lines: list[str] = []
|
||||
structured: dict[str, Any] | None = None
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
forced_failure = ""
|
||||
with log_path.open("a", encoding="utf-8") as log:
|
||||
log.write(f"\n[{utc_now()}] starting {provider} worker\n")
|
||||
log.flush()
|
||||
while process.poll() is None:
|
||||
known_descendants.update(_descendant_processes(process.pid))
|
||||
now = time.monotonic()
|
||||
if now - started > max_runtime:
|
||||
_terminate_worker_process(process, known_descendants)
|
||||
forced_failure = "worker exceeded its maximum runtime"
|
||||
lines.append(forced_failure + "\n")
|
||||
break
|
||||
if now - last_heartbeat >= HEARTBEAT_SECONDS:
|
||||
if not heartbeat(f"{provider} worker active for {round(now - started)}s"):
|
||||
_terminate_worker_process(process, known_descendants)
|
||||
forced_failure = "Kanban lease was lost; provider process terminated"
|
||||
lines.append(forced_failure + "\n")
|
||||
break
|
||||
last_heartbeat = now
|
||||
for key, _ in selector.select(timeout=1.0):
|
||||
line = key.fileobj.readline()
|
||||
if not line:
|
||||
continue
|
||||
lines.append(line)
|
||||
if len(lines) > 4000:
|
||||
lines = lines[-4000:]
|
||||
log.write(line)
|
||||
log.flush()
|
||||
parsed = _event_payload(provider, line, state, state_file)
|
||||
structured = parsed or structured
|
||||
_terminate_worker_process(process, known_descendants)
|
||||
remainder = process.stdout.read()
|
||||
if remainder:
|
||||
lines.append(remainder)
|
||||
log.write(remainder)
|
||||
for line in remainder.splitlines():
|
||||
parsed = _event_payload(provider, line, state, state_file)
|
||||
structured = parsed or structured
|
||||
if forced_failure:
|
||||
log.write(forced_failure + "\n")
|
||||
log.write(f"\n[{utc_now()}] {provider} exit={process.returncode}\n")
|
||||
selector.close()
|
||||
output = "".join(lines)[-100000:]
|
||||
returncode = int(process.returncode if process.returncode is not None else 1)
|
||||
return ProcessResult(
|
||||
returncode=returncode,
|
||||
output=output,
|
||||
structured=structured,
|
||||
capacity_failure=returncode != 0 and bool(CAPACITY_PATTERN.search(output)),
|
||||
)
|
||||
|
||||
def _process_record(pid: int) -> tuple[int, int, int] | None:
|
||||
"""Return one Linux process's parent, group, and start-time identity."""
|
||||
try:
|
||||
raw = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
|
||||
fields = raw[raw.rfind(")") + 2 :].split()
|
||||
return int(fields[1]), int(fields[2]), int(fields[19])
|
||||
except (IndexError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
def _descendant_processes(root_pid: int) -> dict[int, tuple[int, int]]:
|
||||
"""Snapshot descendants as pid -> (process group, start-time identity)."""
|
||||
records: dict[int, tuple[int, int, int]] = {}
|
||||
for entry in Path("/proc").iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
pid = int(entry.name)
|
||||
record = _process_record(pid)
|
||||
if record is not None:
|
||||
records[pid] = record
|
||||
family = {root_pid}
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for pid, (parent, _, _) in records.items():
|
||||
if pid not in family and parent in family:
|
||||
family.add(pid)
|
||||
changed = True
|
||||
return {
|
||||
pid: (records[pid][1], records[pid][2])
|
||||
for pid in family
|
||||
if pid != root_pid and pid in records
|
||||
}
|
||||
|
||||
def _process_identity_matches(pid: int, start_time: int) -> bool:
|
||||
record = _process_record(pid)
|
||||
return record is not None and record[2] == start_time
|
||||
|
||||
def _signal_worker_tree(
|
||||
root_pid: int,
|
||||
descendants: dict[int, tuple[int, int]],
|
||||
sig: signal.Signals,
|
||||
) -> None:
|
||||
"""Signal captured descendants across terminal-created process groups."""
|
||||
groups = {root_pid}
|
||||
for pid, (process_group, start_time) in descendants.items():
|
||||
if _process_identity_matches(pid, start_time):
|
||||
groups.add(process_group)
|
||||
for process_group in groups:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.killpg(process_group, sig)
|
||||
for pid, (_, start_time) in descendants.items():
|
||||
if not _process_identity_matches(pid, start_time):
|
||||
continue
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
os.kill(pid, sig)
|
||||
|
||||
def _terminate_worker_process(
|
||||
process: subprocess.Popen[str],
|
||||
known_descendants: dict[int, tuple[int, int]] | None = None,
|
||||
) -> None:
|
||||
"""Reap a worker and terminal descendants that created their own groups."""
|
||||
descendants = dict(known_descendants or {})
|
||||
descendants.update(_descendant_processes(process.pid))
|
||||
if process.poll() is None:
|
||||
_signal_worker_tree(process.pid, descendants, signal.SIGTERM)
|
||||
with contextlib.suppress(subprocess.TimeoutExpired):
|
||||
process.wait(timeout=10)
|
||||
|
||||
# Provider terminal tools create their own sessions, so killing only the
|
||||
# native CLI's process group leaves test/build subprocesses orphaned. The
|
||||
# identity check prevents a reused PID from targeting an unrelated worker.
|
||||
_signal_worker_tree(process.pid, descendants, signal.SIGKILL)
|
||||
if process.poll() is None:
|
||||
process.wait(timeout=10)
|
||||
|
||||
def _base_env() -> dict[str, str]:
|
||||
"""Build a worker environment while preserving Vault-backed CLI homes."""
|
||||
env = os.environ.copy()
|
||||
env.update(
|
||||
{
|
||||
"HOME": str(DATA_ROOT / "home"),
|
||||
"CODEX_HOME": os.environ.get("CODEX_HOME", "/runtime-access/codex"),
|
||||
"CLAUDE_CONFIG_DIR": os.environ.get(
|
||||
"CLAUDE_CONFIG_DIR", "/runtime-access/claude"
|
||||
),
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
)
|
||||
env["PATH"] = f"{DATA_ROOT / 'tools/bin'}:/opt/coordinator:" + env.get("PATH", "")
|
||||
return env
|
||||
|
||||
def _codex_command(route: Route, prompt: str, workspace: Path, state: dict[str, Any], result_file: Path) -> list[str]:
|
||||
base = [
|
||||
str(CODEX_BIN),
|
||||
"--dangerously-bypass-approvals-and-sandbox",
|
||||
"--dangerously-bypass-hook-trust",
|
||||
"exec",
|
||||
]
|
||||
thread_id = str(state.get("codex_thread_id") or "")
|
||||
common = [
|
||||
"--json",
|
||||
"-m",
|
||||
route.model,
|
||||
"-c",
|
||||
f'model_reasoning_effort="{route.effort}"',
|
||||
"--output-schema",
|
||||
str(RESULT_SCHEMA_PATH),
|
||||
"-o",
|
||||
str(result_file),
|
||||
]
|
||||
if thread_id:
|
||||
return [*base, "resume", *common, thread_id, prompt]
|
||||
return [
|
||||
*base,
|
||||
*common,
|
||||
"-C",
|
||||
str(workspace),
|
||||
prompt,
|
||||
]
|
||||
|
||||
def _claude_command(route: Route, prompt: str, state: dict[str, Any], resume: bool) -> list[str]:
|
||||
session_id = str(state["claude_session_id"])
|
||||
session = ["--resume", session_id] if resume else ["--session-id", session_id]
|
||||
denied = [
|
||||
"Bash(git push --force *)",
|
||||
"Bash(git reset --hard *)",
|
||||
"Bash(git clean -f *)",
|
||||
]
|
||||
return [
|
||||
str(CLAUDE_BIN),
|
||||
"--dangerously-skip-permissions",
|
||||
"--autocompact",
|
||||
"auto",
|
||||
"--settings",
|
||||
str(CLAUDE_SETTINGS),
|
||||
"--disallowedTools",
|
||||
*denied,
|
||||
"--model",
|
||||
route.model,
|
||||
"--effort",
|
||||
route.effort,
|
||||
"--output-format",
|
||||
"stream-json",
|
||||
"--verbose",
|
||||
"--json-schema",
|
||||
json.dumps(RESULT_SCHEMA, separators=(",", ":")),
|
||||
*session,
|
||||
"-p",
|
||||
prompt,
|
||||
]
|
||||
|
||||
def run_provider(
|
||||
route: Route,
|
||||
prompt: str,
|
||||
workspace: Path,
|
||||
state: dict[str, Any],
|
||||
state_file: Path,
|
||||
log_path: Path,
|
||||
heartbeat: Callable[[str], bool],
|
||||
max_runtime: int,
|
||||
) -> ProcessResult:
|
||||
"""Run or resume one provider session using its pinned structured CLI."""
|
||||
state.setdefault("attempts", []).append({"route": asdict(route), "started_at": utc_now()})
|
||||
if route.provider == "claude" and not state.get("claude_session_id"):
|
||||
# The reservation is durable before Claude starts, closing the crash gap.
|
||||
state["claude_session_id"] = str(uuid.uuid4())
|
||||
state["current_route"] = asdict(route)
|
||||
state["updated_at"] = utc_now()
|
||||
env = _base_env()
|
||||
if route.provider == "codex":
|
||||
result_sequence = max(0, int(state.get("result_sequence", 0) or 0)) + 1
|
||||
result_file = _result_path(state_file, state.get("run_id"), result_sequence)
|
||||
while result_file.exists():
|
||||
result_sequence += 1
|
||||
result_file = _result_path(
|
||||
state_file,
|
||||
state.get("run_id"),
|
||||
result_sequence,
|
||||
)
|
||||
result_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
result_file.touch(mode=0o600, exist_ok=False)
|
||||
state["result_sequence"] = result_sequence
|
||||
state["current_result_file"] = str(result_file)
|
||||
atomic_json(state_file, state)
|
||||
command = _codex_command(route, prompt, workspace, state, result_file)
|
||||
result = stream_process(
|
||||
command,
|
||||
provider="codex",
|
||||
cwd=workspace,
|
||||
env=env,
|
||||
log_path=log_path,
|
||||
state=state,
|
||||
state_file=state_file,
|
||||
heartbeat=heartbeat,
|
||||
max_runtime=max_runtime,
|
||||
)
|
||||
if (
|
||||
state.get("codex_thread_id")
|
||||
and result.returncode != 0
|
||||
and NO_CODEX_THREAD in result.output.lower()
|
||||
):
|
||||
state.pop("codex_thread_id", None)
|
||||
result_sequence += 1
|
||||
result_file = _result_path(
|
||||
state_file,
|
||||
state.get("run_id"),
|
||||
result_sequence,
|
||||
)
|
||||
while result_file.exists():
|
||||
result_sequence += 1
|
||||
result_file = _result_path(
|
||||
state_file,
|
||||
state.get("run_id"),
|
||||
result_sequence,
|
||||
)
|
||||
result_file.touch(mode=0o600, exist_ok=False)
|
||||
state["result_sequence"] = result_sequence
|
||||
state["current_result_file"] = str(result_file)
|
||||
atomic_json(state_file, state)
|
||||
result = stream_process(
|
||||
_codex_command(route, prompt, workspace, state, result_file),
|
||||
provider="codex",
|
||||
cwd=workspace,
|
||||
env=env,
|
||||
log_path=log_path,
|
||||
state=state,
|
||||
state_file=state_file,
|
||||
heartbeat=heartbeat,
|
||||
max_runtime=max_runtime,
|
||||
)
|
||||
file_result = load_json(result_file)
|
||||
if file_result.get("status") in cli_lane_goal.RESULT_STATUSES:
|
||||
result.structured = file_result
|
||||
with contextlib.suppress(OSError):
|
||||
result_file.chmod(0o600)
|
||||
return result
|
||||
|
||||
atomic_json(state_file, state)
|
||||
resume = bool(state.get("claude_started"))
|
||||
result = stream_process(
|
||||
_claude_command(route, prompt, state, resume),
|
||||
provider="claude",
|
||||
cwd=workspace,
|
||||
env=env,
|
||||
log_path=log_path,
|
||||
state=state,
|
||||
state_file=state_file,
|
||||
heartbeat=heartbeat,
|
||||
max_runtime=max_runtime,
|
||||
)
|
||||
if resume and result.returncode == 1 and NO_CLAUDE_SESSION in result.output:
|
||||
result = stream_process(
|
||||
_claude_command(route, prompt, state, False),
|
||||
provider="claude",
|
||||
cwd=workspace,
|
||||
env=env,
|
||||
log_path=log_path,
|
||||
state=state,
|
||||
state_file=state_file,
|
||||
heartbeat=heartbeat,
|
||||
max_runtime=max_runtime,
|
||||
)
|
||||
elif not resume and result.returncode == 1 and CLAUDE_SESSION_COLLISION in result.output:
|
||||
result = stream_process(
|
||||
_claude_command(route, prompt, state, True),
|
||||
provider="claude",
|
||||
cwd=workspace,
|
||||
env=env,
|
||||
log_path=log_path,
|
||||
state=state,
|
||||
state_file=state_file,
|
||||
heartbeat=heartbeat,
|
||||
max_runtime=max_runtime,
|
||||
)
|
||||
if result.returncode == 0 or NO_CLAUDE_SESSION not in result.output:
|
||||
state["claude_started"] = True
|
||||
atomic_json(state_file, state)
|
||||
return result
|
||||
221
services/hermes/scripts/cli_lane_quarantine.py
Normal file
221
services/hermes/scripts/cli_lane_quarantine.py
Normal file
@ -0,0 +1,221 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded diagnostic quarantine for malformed terminal journals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from cli_lane_config import QUARANTINE_HASH_BYTES, STATE_ROOT, TerminalIdentity, TerminalRecoverySnapshot
|
||||
from cli_lane_evidence import _retire_terminal_entry
|
||||
from cli_lane_records import _read_bounded
|
||||
|
||||
|
||||
def _quarantine_terminal(
|
||||
path: Path,
|
||||
identity: TerminalIdentity | None,
|
||||
reason: str,
|
||||
*,
|
||||
snapshot: TerminalRecoverySnapshot | None = None,
|
||||
) -> Path:
|
||||
"""Record bounded metadata, then retire only the inspected journal inode."""
|
||||
board_dir = path.parent
|
||||
try:
|
||||
board_stat = board_dir.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
board_stat = None
|
||||
if board_stat is None or not stat.S_ISDIR(board_stat.st_mode):
|
||||
print(
|
||||
f"refused terminal journal path through unsafe board directory: {path}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return path
|
||||
board_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
board_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
create_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
board_descriptor = None
|
||||
quarantine_descriptor = None
|
||||
source_descriptor = None
|
||||
source_stat = None
|
||||
destination = path
|
||||
retirement = "not-attempted"
|
||||
owns_source = snapshot is None
|
||||
try:
|
||||
relative = path.relative_to(STATE_ROOT)
|
||||
except ValueError:
|
||||
relative = Path()
|
||||
already_quarantined = (
|
||||
len(relative.parts) == 3 and relative.parts[1] == "quarantine"
|
||||
)
|
||||
try:
|
||||
if snapshot is None:
|
||||
board_descriptor = os.open(board_dir, board_flags)
|
||||
source_stat = os.stat(
|
||||
path.name,
|
||||
dir_fd=board_descriptor,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
else:
|
||||
board_descriptor = snapshot.directory_descriptor
|
||||
source_descriptor = snapshot.descriptor
|
||||
source_stat = snapshot.file_stat
|
||||
pinned_board = os.fstat(board_descriptor)
|
||||
if (
|
||||
pinned_board.st_dev != board_stat.st_dev
|
||||
or pinned_board.st_ino != board_stat.st_ino
|
||||
):
|
||||
raise OSError("terminal recovery board identity changed")
|
||||
safe_source = stat.S_ISREG(source_stat.st_mode) and source_stat.st_nlink == 1
|
||||
digest = None
|
||||
hashed_bytes = 0
|
||||
hash_complete = False
|
||||
if safe_source and snapshot is None:
|
||||
source_descriptor = os.open(
|
||||
path.name,
|
||||
file_flags,
|
||||
dir_fd=board_descriptor,
|
||||
)
|
||||
opened_stat = os.fstat(source_descriptor)
|
||||
if (
|
||||
opened_stat.st_dev != source_stat.st_dev
|
||||
or opened_stat.st_ino != source_stat.st_ino
|
||||
or opened_stat.st_nlink != 1
|
||||
):
|
||||
raise OSError("terminal journal identity changed while opening")
|
||||
# Keep this descriptor open through retirement. The staging entry
|
||||
# must match the exact fstat-verified inode, not merely a path stat
|
||||
# captured before a concurrent replacement.
|
||||
source_stat = opened_stat
|
||||
prefix = _read_bounded(source_descriptor, QUARANTINE_HASH_BYTES)
|
||||
hashed_bytes = len(prefix)
|
||||
hash_complete = opened_stat.st_size <= QUARANTINE_HASH_BYTES
|
||||
digest = hashlib.sha256(prefix).hexdigest()
|
||||
elif safe_source and source_descriptor is not None:
|
||||
prefix = snapshot.prefix
|
||||
hashed_bytes = len(prefix)
|
||||
hash_complete = (
|
||||
source_stat.st_size <= QUARANTINE_HASH_BYTES
|
||||
and hashed_bytes == source_stat.st_size
|
||||
)
|
||||
digest = hashlib.sha256(prefix).hexdigest()
|
||||
source_kind = (
|
||||
"regular"
|
||||
if safe_source
|
||||
else "hardlink"
|
||||
if stat.S_ISREG(source_stat.st_mode) and source_stat.st_nlink != 1
|
||||
else "non-regular"
|
||||
)
|
||||
diagnostic = {
|
||||
"hash_complete": hash_complete,
|
||||
"hashed_bytes": hashed_bytes,
|
||||
"original_name": path.name,
|
||||
"reason": reason,
|
||||
"sha256": digest,
|
||||
"size": source_stat.st_size,
|
||||
"source_kind": source_kind,
|
||||
}
|
||||
payload = (json.dumps(diagnostic, sort_keys=True) + "\n").encode("utf-8")
|
||||
fingerprint = (
|
||||
(digest or hashlib.sha256(path.name.encode("utf-8")).hexdigest())[:16]
|
||||
)
|
||||
safe_reason = (
|
||||
re.sub(r"[^a-zA-Z0-9_.-]+", "-", reason).strip("-") or "invalid"
|
||||
)
|
||||
if already_quarantined:
|
||||
# A staged artifact already lives in the board's one bounded
|
||||
# quarantine directory. Reuse it so adversarial replacements can
|
||||
# never drive recursive quarantine/quarantine/... authority.
|
||||
quarantine_descriptor = os.dup(board_descriptor)
|
||||
quarantine_dir = board_dir
|
||||
else:
|
||||
with contextlib.suppress(FileExistsError):
|
||||
os.mkdir("quarantine", 0o700, dir_fd=board_descriptor)
|
||||
quarantine_descriptor = os.open(
|
||||
"quarantine",
|
||||
board_flags,
|
||||
dir_fd=board_descriptor,
|
||||
)
|
||||
quarantine_dir = board_dir / "quarantine"
|
||||
for sequence in range(32):
|
||||
name = f"{path.name}.{safe_reason}.{fingerprint}.{sequence}.quarantine"
|
||||
try:
|
||||
destination_descriptor = os.open(
|
||||
name,
|
||||
create_flags,
|
||||
0o600,
|
||||
dir_fd=quarantine_descriptor,
|
||||
)
|
||||
except FileExistsError:
|
||||
continue
|
||||
try:
|
||||
with os.fdopen(destination_descriptor, "wb") as target:
|
||||
target.write(payload)
|
||||
target.flush()
|
||||
os.fsync(target.fileno())
|
||||
target_stat = os.fstat(target.fileno())
|
||||
if (
|
||||
stat.S_IMODE(target_stat.st_mode) != 0o600
|
||||
or target_stat.st_nlink != 1
|
||||
):
|
||||
raise OSError("quarantine destination is not private and singly linked")
|
||||
except Exception:
|
||||
os.unlink(name, dir_fd=quarantine_descriptor)
|
||||
raise
|
||||
destination = quarantine_dir / name
|
||||
break
|
||||
else:
|
||||
raise OSError("could not reserve a unique quarantine destination")
|
||||
os.fsync(quarantine_descriptor)
|
||||
retirement = _retire_terminal_entry(
|
||||
path,
|
||||
source_stat,
|
||||
board_descriptor=board_descriptor,
|
||||
quarantine_descriptor=quarantine_descriptor,
|
||||
)
|
||||
except OSError as error:
|
||||
# A bad quarantine target must not make an attacker-controlled pending
|
||||
# path eligible for replay forever. The same identity-safe staging
|
||||
# protocol works in the board directory when quarantine is unusable.
|
||||
if board_descriptor is not None and source_stat is not None:
|
||||
try:
|
||||
retirement = _retire_terminal_entry(
|
||||
path,
|
||||
source_stat,
|
||||
board_descriptor=board_descriptor,
|
||||
quarantine_descriptor=(
|
||||
quarantine_descriptor
|
||||
if quarantine_descriptor is not None
|
||||
else board_descriptor
|
||||
),
|
||||
)
|
||||
except OSError:
|
||||
retirement = "deferred"
|
||||
print(
|
||||
f"terminal journal quarantine degraded safely: {type(error).__name__}: {error}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
finally:
|
||||
if owns_source and source_descriptor is not None:
|
||||
os.close(source_descriptor)
|
||||
if quarantine_descriptor is not None:
|
||||
os.close(quarantine_descriptor)
|
||||
if owns_source and board_descriptor is not None:
|
||||
os.close(board_descriptor)
|
||||
print(
|
||||
"quarantined terminal journal "
|
||||
f"{path.name}: {reason}; identity={identity or 'unparseable'}; "
|
||||
f"retirement={retirement}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return destination
|
||||
356
services/hermes/scripts/cli_lane_records.py
Normal file
356
services/hermes/scripts/cli_lane_records.py
Normal file
@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded inode-pinned reads and validation for Hermes terminal records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cli_lane_goal
|
||||
from cli_lane_config import (
|
||||
MAX_TERMINAL_RECORD_BYTES,
|
||||
QUARANTINE_HASH_BYTES,
|
||||
RESULT_SCHEMA,
|
||||
Route,
|
||||
TerminalIdentity,
|
||||
TerminalRecoverySnapshot,
|
||||
TerminalSnapshot,
|
||||
canonical_run_id,
|
||||
utc_now,
|
||||
)
|
||||
from cli_lane_files import (
|
||||
_candidate_path,
|
||||
_terminal_identity,
|
||||
_terminal_path,
|
||||
atomic_json,
|
||||
)
|
||||
|
||||
|
||||
def _read_bounded(descriptor: int, limit: int) -> bytes:
|
||||
"""Read at most ``limit`` bytes from a regular file descriptor."""
|
||||
chunks: list[bytes] = []
|
||||
remaining = max(0, limit)
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, min(64 * 1024, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
def _open_small_json_snapshot(path: Path) -> TerminalSnapshot | None:
|
||||
"""Open and pin one bounded, singly-linked JSON document."""
|
||||
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
directory_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
directory = None
|
||||
descriptor = None
|
||||
try:
|
||||
directory = os.open(path.parent, directory_flags)
|
||||
descriptor = os.open(path.name, file_flags, dir_fd=directory)
|
||||
opened = os.fstat(descriptor)
|
||||
if (
|
||||
not stat.S_ISREG(opened.st_mode)
|
||||
or opened.st_nlink != 1
|
||||
or opened.st_size > MAX_TERMINAL_RECORD_BYTES
|
||||
):
|
||||
return None
|
||||
payload = _read_bounded(descriptor, MAX_TERMINAL_RECORD_BYTES + 1)
|
||||
if len(payload) > MAX_TERMINAL_RECORD_BYTES:
|
||||
return None
|
||||
current = os.stat(
|
||||
path.name,
|
||||
dir_fd=directory,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
if (
|
||||
current.st_dev != opened.st_dev
|
||||
or current.st_ino != opened.st_ino
|
||||
or current.st_size != opened.st_size
|
||||
or len(payload) != opened.st_size
|
||||
):
|
||||
return None
|
||||
value = json.loads(payload.decode("utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
snapshot = TerminalSnapshot(value, opened, descriptor, directory)
|
||||
descriptor = None
|
||||
directory = None
|
||||
return snapshot
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return None
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
if directory is not None:
|
||||
os.close(directory)
|
||||
|
||||
def _open_terminal_recovery_snapshot(path: Path) -> TerminalRecoverySnapshot | None:
|
||||
"""Pin the entry recovery inspected, even when its payload is malformed."""
|
||||
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
directory_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
file_flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
directory = None
|
||||
descriptor = None
|
||||
try:
|
||||
directory = os.open(path.parent, directory_flags)
|
||||
observed = os.stat(
|
||||
path.name,
|
||||
dir_fd=directory,
|
||||
follow_symlinks=False,
|
||||
)
|
||||
if not stat.S_ISREG(observed.st_mode):
|
||||
snapshot = TerminalRecoverySnapshot(
|
||||
None,
|
||||
observed,
|
||||
None,
|
||||
directory,
|
||||
b"",
|
||||
"non-regular",
|
||||
)
|
||||
directory = None
|
||||
return snapshot
|
||||
try:
|
||||
descriptor = os.open(path.name, file_flags, dir_fd=directory)
|
||||
except OSError:
|
||||
snapshot = TerminalRecoverySnapshot(
|
||||
None,
|
||||
observed,
|
||||
None,
|
||||
directory,
|
||||
b"",
|
||||
"open-failed",
|
||||
)
|
||||
directory = None
|
||||
return snapshot
|
||||
opened = os.fstat(descriptor)
|
||||
if opened.st_dev != observed.st_dev or opened.st_ino != observed.st_ino:
|
||||
os.close(descriptor)
|
||||
descriptor = None
|
||||
snapshot = TerminalRecoverySnapshot(
|
||||
None,
|
||||
observed,
|
||||
None,
|
||||
directory,
|
||||
b"",
|
||||
"identity-changed-during-open",
|
||||
)
|
||||
directory = None
|
||||
return snapshot
|
||||
observed = opened
|
||||
if observed.st_nlink != 1:
|
||||
snapshot = TerminalRecoverySnapshot(
|
||||
None,
|
||||
observed,
|
||||
descriptor,
|
||||
directory,
|
||||
b"",
|
||||
"hardlinked",
|
||||
)
|
||||
descriptor = None
|
||||
directory = None
|
||||
return snapshot
|
||||
if observed.st_size > MAX_TERMINAL_RECORD_BYTES:
|
||||
prefix = _read_bounded(descriptor, QUARANTINE_HASH_BYTES)
|
||||
snapshot = TerminalRecoverySnapshot(
|
||||
None,
|
||||
observed,
|
||||
descriptor,
|
||||
directory,
|
||||
prefix,
|
||||
"oversized",
|
||||
)
|
||||
descriptor = None
|
||||
directory = None
|
||||
return snapshot
|
||||
payload = _read_bounded(descriptor, MAX_TERMINAL_RECORD_BYTES + 1)
|
||||
after_read = os.fstat(descriptor)
|
||||
unchanged = (
|
||||
after_read.st_dev == observed.st_dev
|
||||
and after_read.st_ino == observed.st_ino
|
||||
and after_read.st_size == observed.st_size
|
||||
and after_read.st_mtime_ns == observed.st_mtime_ns
|
||||
and after_read.st_ctime_ns == observed.st_ctime_ns
|
||||
and len(payload) == observed.st_size
|
||||
)
|
||||
document = None
|
||||
invalid_reason = "unstable-payload"
|
||||
if unchanged:
|
||||
try:
|
||||
parsed = json.loads(payload.decode("utf-8"))
|
||||
if isinstance(parsed, dict):
|
||||
document = parsed
|
||||
invalid_reason = None
|
||||
else:
|
||||
invalid_reason = "non-object-payload"
|
||||
except (UnicodeError, json.JSONDecodeError):
|
||||
invalid_reason = "malformed-payload"
|
||||
snapshot = TerminalRecoverySnapshot(
|
||||
document,
|
||||
after_read,
|
||||
descriptor,
|
||||
directory,
|
||||
payload[:QUARANTINE_HASH_BYTES],
|
||||
invalid_reason,
|
||||
)
|
||||
descriptor = None
|
||||
directory = None
|
||||
return snapshot
|
||||
except OSError:
|
||||
return None
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
if directory is not None:
|
||||
os.close(directory)
|
||||
|
||||
def _open_terminal_snapshot(
|
||||
path: Path,
|
||||
identity: TerminalIdentity,
|
||||
) -> TerminalSnapshot | None:
|
||||
"""Open a terminal journal only after its lexical identity is verified."""
|
||||
if _terminal_identity(path) != identity:
|
||||
return None
|
||||
return _open_small_json_snapshot(path)
|
||||
|
||||
def _load_terminal_json(path: Path, identity: TerminalIdentity) -> dict[str, Any]:
|
||||
"""Read one small, singly-linked journal through its validated directory."""
|
||||
snapshot = _open_terminal_snapshot(path, identity)
|
||||
if snapshot is None:
|
||||
return {}
|
||||
try:
|
||||
return snapshot.document
|
||||
finally:
|
||||
snapshot.close()
|
||||
|
||||
def _load_small_json(path: Path) -> dict[str, Any]:
|
||||
"""Read one bounded immutable evidence document without following links."""
|
||||
snapshot = _open_small_json_snapshot(path)
|
||||
if snapshot is None:
|
||||
return {}
|
||||
try:
|
||||
return snapshot.document
|
||||
finally:
|
||||
snapshot.close()
|
||||
|
||||
def _persist_candidate(
|
||||
state: dict[str, Any],
|
||||
state_file: Path,
|
||||
structured: dict[str, Any],
|
||||
*,
|
||||
route: Route,
|
||||
returncode: int,
|
||||
goal_turn: int,
|
||||
) -> Path:
|
||||
"""Persist every structured response before any judge can supersede it."""
|
||||
sequence = max(0, int(state.get("candidate_sequence", 0) or 0)) + 1
|
||||
path = _candidate_path(state_file, state.get("run_id"), sequence)
|
||||
while path.exists():
|
||||
sequence += 1
|
||||
path = _candidate_path(state_file, state.get("run_id"), sequence)
|
||||
atomic_json(
|
||||
path,
|
||||
{
|
||||
"board": state.get("board"),
|
||||
"task_id": state.get("task_id"),
|
||||
"expected_run_id": state.get("run_id"),
|
||||
"goal_turn": goal_turn,
|
||||
"provider": route.provider,
|
||||
"model": route.model,
|
||||
"effort": route.effort,
|
||||
"returncode": returncode,
|
||||
"structured": structured,
|
||||
"recorded_at": utc_now(),
|
||||
},
|
||||
)
|
||||
state["candidate_sequence"] = sequence
|
||||
state["last_candidate_file"] = str(path)
|
||||
atomic_json(state_file, state)
|
||||
return path
|
||||
|
||||
def _write_terminal_record(
|
||||
state_file: Path,
|
||||
*,
|
||||
board: str,
|
||||
task_id: str,
|
||||
run_id: Any,
|
||||
structured: dict[str, Any],
|
||||
summary: str,
|
||||
metadata: dict[str, Any],
|
||||
) -> tuple[Path, dict[str, Any]]:
|
||||
"""Journal an accepted result before attempting the Kanban transaction."""
|
||||
if type(run_id) is not int or canonical_run_id(run_id) is None:
|
||||
raise ValueError("terminal run ID must be a positive SQLite int64")
|
||||
path = _terminal_path(state_file, run_id)
|
||||
record = {
|
||||
"board": board,
|
||||
"task_id": task_id,
|
||||
"expected_run_id": run_id,
|
||||
"result": json.dumps(structured, sort_keys=True),
|
||||
"summary": summary,
|
||||
"metadata": metadata,
|
||||
"kanban_state": "pending",
|
||||
"recorded_at": utc_now(),
|
||||
}
|
||||
atomic_json(path, record)
|
||||
return path, record
|
||||
|
||||
def _terminal_record_valid(
|
||||
record: dict[str, Any],
|
||||
identity: TerminalIdentity | None = None,
|
||||
) -> bool:
|
||||
"""Reject malformed or non-terminal replay journals without side effects."""
|
||||
if not isinstance(record, dict):
|
||||
return False
|
||||
required_strings = ("board", "task_id", "result", "summary")
|
||||
if not all(
|
||||
isinstance(record.get(key), str) and record[key]
|
||||
for key in required_strings
|
||||
):
|
||||
return False
|
||||
expected_run_id = record.get("expected_run_id")
|
||||
if (
|
||||
type(expected_run_id) is not int
|
||||
or canonical_run_id(expected_run_id) is None
|
||||
):
|
||||
return False
|
||||
if not isinstance(record.get("metadata"), dict):
|
||||
return False
|
||||
try:
|
||||
structured = json.loads(record["result"])
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
return False
|
||||
if not isinstance(structured, dict):
|
||||
return False
|
||||
required = set(RESULT_SCHEMA["required"])
|
||||
if set(structured) != required:
|
||||
return False
|
||||
if (
|
||||
type(structured["status"]) is not str
|
||||
or structured["status"] not in cli_lane_goal.RESULT_STATUSES
|
||||
or type(structured["summary"]) is not str
|
||||
or not structured["summary"].strip()
|
||||
):
|
||||
return False
|
||||
for key in ("changed_files", "tests_run", "artifacts", "findings", "blockers"):
|
||||
value = structured[key]
|
||||
if type(value) is not list or any(type(item) is not str for item in value):
|
||||
return False
|
||||
valid = (
|
||||
structured["status"] == "completed"
|
||||
and structured["blockers"] == []
|
||||
and cli_lane_goal.unfinished_result_reason(structured) is None
|
||||
and record["summary"] == structured["summary"]
|
||||
and record.get("kanban_state") in {"pending", "prepared", "committed"}
|
||||
)
|
||||
if not valid or identity is None:
|
||||
return valid
|
||||
return (
|
||||
record.get("board") == identity.board
|
||||
and record.get("task_id") == identity.task_id
|
||||
and record.get("expected_run_id") == identity.run_id
|
||||
and record.get("kanban_state") == identity.state
|
||||
)
|
||||
460
services/hermes/scripts/cli_lane_recovery.py
Normal file
460
services/hermes/scripts/cli_lane_recovery.py
Normal file
@ -0,0 +1,460 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded replay of pending, prepared, and staged terminal journals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from cli_lane_board import _record_board_access_error
|
||||
from cli_lane_config import (
|
||||
STATE_ROOT,
|
||||
TerminalIdentity,
|
||||
TerminalRecoverySnapshot,
|
||||
canonical_run_id,
|
||||
)
|
||||
from cli_lane_evidence import (
|
||||
_persist_conflict_evidence,
|
||||
_persist_prepared_evidence,
|
||||
_same_terminal_document,
|
||||
_terminal_evidence_path,
|
||||
_terminal_evidence_valid,
|
||||
_write_json_noreplace,
|
||||
)
|
||||
from cli_lane_files import (
|
||||
_LEGACY_RETIRE_NAME,
|
||||
_RETIRE_NAME,
|
||||
_SAFE_BOARD,
|
||||
_terminal_evidence_identity,
|
||||
_terminal_identity,
|
||||
_terminal_path,
|
||||
state_path,
|
||||
)
|
||||
from cli_lane_finalization import (
|
||||
_finalize_document_db,
|
||||
_finalize_terminal_record,
|
||||
_promote_prepared_evidence,
|
||||
_recover_exact_run,
|
||||
_resolve_pending_after_winner,
|
||||
_retire_snapshot,
|
||||
_retire_snapshot_after_db,
|
||||
)
|
||||
from cli_lane_quarantine import _quarantine_terminal
|
||||
from cli_lane_records import (
|
||||
_load_small_json,
|
||||
_load_terminal_json,
|
||||
_open_terminal_recovery_snapshot,
|
||||
_terminal_record_valid,
|
||||
)
|
||||
|
||||
|
||||
def _terminal_entry_absent(path: Path) -> bool:
|
||||
"""Confirm absence through a nofollow directory descriptor."""
|
||||
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
directory_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
directory = None
|
||||
try:
|
||||
directory = os.open(path.parent, directory_flags)
|
||||
os.stat(path.name, dir_fd=directory, follow_symlinks=False)
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
if directory is not None:
|
||||
os.close(directory)
|
||||
|
||||
def _staged_terminal_authority(
|
||||
path: Path,
|
||||
snapshot: TerminalRecoverySnapshot,
|
||||
) -> tuple[TerminalIdentity, Path] | None:
|
||||
"""Bind a staged inode to the canonical pending name it was moved from."""
|
||||
try:
|
||||
relative = path.relative_to(STATE_ROOT)
|
||||
except ValueError:
|
||||
return None
|
||||
if len(relative.parts) == 2:
|
||||
board, filename = relative.parts
|
||||
elif len(relative.parts) == 3 and relative.parts[1] == "quarantine":
|
||||
board, _quarantine, filename = relative.parts
|
||||
else:
|
||||
return None
|
||||
match = _RETIRE_NAME.fullmatch(filename)
|
||||
legacy = _LEGACY_RETIRE_NAME.fullmatch(filename)
|
||||
document = snapshot.document
|
||||
if (
|
||||
(match is None and legacy is None)
|
||||
or document is None
|
||||
or not _SAFE_BOARD.fullmatch(board)
|
||||
or board in {".", ".."}
|
||||
or document.get("board") != board
|
||||
or not isinstance(document.get("task_id"), str)
|
||||
or type(document.get("expected_run_id")) is not int
|
||||
or canonical_run_id(document.get("expected_run_id")) is None
|
||||
):
|
||||
return None
|
||||
state = document.get("kanban_state")
|
||||
if state not in {"pending", "prepared", "committed", "conflict"}:
|
||||
return None
|
||||
identity = TerminalIdentity(
|
||||
board, document["task_id"], document["expected_run_id"], state
|
||||
)
|
||||
if state in {"pending", "committed"}:
|
||||
canonical = _terminal_path(
|
||||
state_path(identity.board, identity.task_id),
|
||||
identity.run_id,
|
||||
state,
|
||||
)
|
||||
valid = _terminal_record_valid(document, identity)
|
||||
else:
|
||||
canonical = _terminal_evidence_path(identity, state, document)
|
||||
valid = _terminal_evidence_valid(document, identity, state)
|
||||
board_dir = STATE_ROOT / board
|
||||
if (
|
||||
canonical.parent != board_dir
|
||||
or path.parent not in {board_dir, board_dir / "quarantine"}
|
||||
or (
|
||||
match is not None
|
||||
and hashlib.sha256(canonical.name.encode("utf-8")).hexdigest()[:16]
|
||||
!= match.group("path_digest")
|
||||
)
|
||||
or not valid
|
||||
):
|
||||
return None
|
||||
return identity, canonical
|
||||
|
||||
def _retirement_staging_paths() -> list[Path]:
|
||||
"""List every directory where identity-safe retirement can stage an inode."""
|
||||
paths = list(STATE_ROOT.glob("*/.retire.*"))
|
||||
paths.extend(STATE_ROOT.glob("*/quarantine/.retire.*"))
|
||||
return sorted(set(paths))
|
||||
|
||||
def _restore_staged_terminal_authority(
|
||||
identity: TerminalIdentity,
|
||||
canonical: Path,
|
||||
document: dict[str, Any],
|
||||
) -> None:
|
||||
"""Restore one hidden accepted artifact without executing it prematurely."""
|
||||
if identity.state == "pending":
|
||||
_persist_prepared_evidence(identity, document)
|
||||
return
|
||||
if _write_json_noreplace(canonical, document):
|
||||
return
|
||||
existing = _load_small_json(canonical)
|
||||
if identity.state in {"prepared", "conflict"}:
|
||||
valid = _terminal_evidence_valid(existing, identity, identity.state)
|
||||
else:
|
||||
valid = _terminal_record_valid(existing, identity)
|
||||
if valid and _same_terminal_document(existing, document):
|
||||
return
|
||||
if identity.state == "committed":
|
||||
_persist_conflict_evidence(
|
||||
identity,
|
||||
document,
|
||||
"staged committed evidence conflicts with the canonical first writer",
|
||||
)
|
||||
return
|
||||
raise OSError("staged terminal evidence path contains a conflicting result")
|
||||
|
||||
def _recover_retirement_staging() -> int:
|
||||
"""Promote valid hidden journals to durable prepared evidence."""
|
||||
recovered = 0
|
||||
for path in _retirement_staging_paths():
|
||||
snapshot = _open_terminal_recovery_snapshot(path)
|
||||
if snapshot is None:
|
||||
continue
|
||||
authority = _staged_terminal_authority(path, snapshot)
|
||||
if authority is None:
|
||||
try:
|
||||
_quarantine_terminal(
|
||||
path,
|
||||
None,
|
||||
"untrusted-retirement-staging",
|
||||
snapshot=snapshot,
|
||||
)
|
||||
finally:
|
||||
snapshot.close()
|
||||
continue
|
||||
identity, canonical = authority
|
||||
try:
|
||||
_restore_staged_terminal_authority(
|
||||
identity,
|
||||
canonical,
|
||||
snapshot.document or {},
|
||||
)
|
||||
retirement = _retire_snapshot(
|
||||
path,
|
||||
snapshot,
|
||||
authority_name=canonical.name,
|
||||
)
|
||||
except Exception as error:
|
||||
_record_board_access_error(identity.board, error)
|
||||
continue
|
||||
finally:
|
||||
snapshot.close()
|
||||
if retirement in {"retired", "missing"}:
|
||||
recovered += 1
|
||||
return recovered
|
||||
|
||||
def _drain_retirement_staging() -> int:
|
||||
"""Converge bounded replacement churn without spinning on storage failure."""
|
||||
recovered = 0
|
||||
previous: tuple[tuple[str, int, int, int, int], ...] | None = None
|
||||
for _attempt in range(16):
|
||||
surface = []
|
||||
for path in _retirement_staging_paths():
|
||||
try:
|
||||
observed = path.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
surface.append(
|
||||
(
|
||||
str(path),
|
||||
observed.st_dev,
|
||||
observed.st_ino,
|
||||
observed.st_size,
|
||||
observed.st_mtime_ns,
|
||||
)
|
||||
)
|
||||
signature = tuple(surface)
|
||||
if not signature or signature == previous:
|
||||
break
|
||||
previous = signature
|
||||
recovered += _recover_retirement_staging()
|
||||
return recovered
|
||||
|
||||
def _recover_prepared_finalizations(kanban_db: Any) -> int:
|
||||
"""Recover an accepted result staged before an interrupted DB boundary."""
|
||||
recovered = 0
|
||||
recovered_identities: set[tuple[str, str, int]] = set()
|
||||
prepared_paths = list(STATE_ROOT.glob("*/*.terminal.prepared-*.json"))
|
||||
|
||||
def durable_order(path: Path) -> tuple[int, str]:
|
||||
try:
|
||||
return path.stat(follow_symlinks=False).st_mtime_ns, str(path)
|
||||
except OSError:
|
||||
return 2**63 - 1, str(path)
|
||||
|
||||
# When no DB winner exists yet, the first durably prepared result owns the
|
||||
# run. The DB transaction remains the final arbiter across runner processes.
|
||||
for path in sorted(prepared_paths, key=durable_order):
|
||||
for _replacement_attempt in range(16):
|
||||
identity = _terminal_evidence_identity(path)
|
||||
snapshot = _open_terminal_recovery_snapshot(path)
|
||||
if snapshot is None:
|
||||
break
|
||||
document = snapshot.document
|
||||
if identity is None or identity.state != "prepared" or document is None:
|
||||
try:
|
||||
_quarantine_terminal(
|
||||
path,
|
||||
identity,
|
||||
snapshot.invalid_reason or "malformed-prepared-evidence",
|
||||
snapshot=snapshot,
|
||||
)
|
||||
finally:
|
||||
snapshot.close()
|
||||
continue
|
||||
expected = _terminal_evidence_path(identity, "prepared", document)
|
||||
if (
|
||||
expected.name != path.name
|
||||
or not _terminal_evidence_valid(document, identity, "prepared")
|
||||
):
|
||||
try:
|
||||
_quarantine_terminal(
|
||||
path,
|
||||
identity,
|
||||
"foreign-prepared-evidence",
|
||||
snapshot=snapshot,
|
||||
)
|
||||
finally:
|
||||
snapshot.close()
|
||||
continue
|
||||
try:
|
||||
outcome = _finalize_document_db(kanban_db, identity, document)
|
||||
except Exception as error:
|
||||
_record_board_access_error(identity.board, error)
|
||||
snapshot.close()
|
||||
break
|
||||
try:
|
||||
if outcome == "committed":
|
||||
_promote_prepared_evidence(
|
||||
identity,
|
||||
document,
|
||||
path,
|
||||
discard_prepared=False,
|
||||
)
|
||||
_retire_snapshot_after_db(path, snapshot)
|
||||
pending = _terminal_path(
|
||||
state_path(identity.board, identity.task_id),
|
||||
identity.run_id,
|
||||
"pending",
|
||||
)
|
||||
pending_identity = TerminalIdentity(
|
||||
identity.board,
|
||||
identity.task_id,
|
||||
identity.run_id,
|
||||
"pending",
|
||||
)
|
||||
_resolve_pending_after_winner(
|
||||
pending,
|
||||
pending_identity,
|
||||
document,
|
||||
)
|
||||
recovered_identity = (
|
||||
identity.board,
|
||||
identity.task_id,
|
||||
identity.run_id,
|
||||
)
|
||||
if recovered_identity not in recovered_identities:
|
||||
recovered_identities.add(recovered_identity)
|
||||
recovered += 1
|
||||
elif outcome == "stale":
|
||||
_persist_conflict_evidence(
|
||||
identity,
|
||||
document,
|
||||
"prepared result no longer matches the authoritative task run",
|
||||
)
|
||||
_retire_snapshot_after_db(path, snapshot)
|
||||
else:
|
||||
snapshot.close()
|
||||
break
|
||||
except Exception as error:
|
||||
_record_board_access_error(identity.board, error)
|
||||
snapshot.close()
|
||||
break
|
||||
snapshot.close()
|
||||
return recovered
|
||||
|
||||
def recover_pending_finalizations() -> int:
|
||||
"""Replay accepted exact-run results before scheduling more provider work."""
|
||||
from hermes_cli import kanban_db
|
||||
|
||||
_drain_retirement_staging()
|
||||
recovered = _recover_prepared_finalizations(kanban_db)
|
||||
for path in sorted(STATE_ROOT.glob("*/*.terminal.pending.json")):
|
||||
identity = _terminal_identity(path)
|
||||
last_invalid_reason = "malformed-name" if identity is None else None
|
||||
retired_invalid = False
|
||||
for _replacement_attempt in range(16):
|
||||
snapshot = _open_terminal_recovery_snapshot(path)
|
||||
if snapshot is None:
|
||||
if (
|
||||
identity is not None
|
||||
and retired_invalid
|
||||
and _terminal_entry_absent(path)
|
||||
):
|
||||
_recover_exact_run(
|
||||
kanban_db,
|
||||
identity,
|
||||
last_invalid_reason or "malformed-payload",
|
||||
)
|
||||
break
|
||||
record = snapshot.document or {}
|
||||
reason = None
|
||||
if identity is None:
|
||||
reason = "malformed-name"
|
||||
elif not _terminal_record_valid(record):
|
||||
reason = snapshot.invalid_reason or "malformed-payload"
|
||||
elif not _terminal_record_valid(record, identity):
|
||||
reason = "foreign-identity"
|
||||
if reason is not None:
|
||||
try:
|
||||
_quarantine_terminal(
|
||||
path,
|
||||
identity,
|
||||
reason,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
finally:
|
||||
snapshot.close()
|
||||
retired_invalid = True
|
||||
last_invalid_reason = reason
|
||||
continue
|
||||
assert identity is not None
|
||||
try:
|
||||
outcome = _finalize_terminal_record(
|
||||
kanban_db,
|
||||
path,
|
||||
record,
|
||||
snapshot=snapshot,
|
||||
)
|
||||
except Exception as error:
|
||||
_record_board_access_error(identity.board, error)
|
||||
break
|
||||
if outcome == "committed":
|
||||
recovered += 1
|
||||
break
|
||||
if outcome in {"invalid", "foreign"}:
|
||||
# The pathname changed after the recovery snapshot closed.
|
||||
# Reclassify and pin the new inode instead of quarantining it
|
||||
# using authority derived from the older entry.
|
||||
last_invalid_reason = outcome
|
||||
continue
|
||||
break
|
||||
else:
|
||||
print(
|
||||
f"terminal journal replacement churn deferred for {path.name}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
# A quarantine or identity-safe retirement performed above can itself
|
||||
# encounter replacement churn. Promote every resulting hidden generation
|
||||
# back to first-class prepared/conflict/committed evidence before this
|
||||
# recovery pass returns; DB replay, if still needed, occurs on the next
|
||||
# bounded dispatcher pass.
|
||||
_drain_retirement_staging()
|
||||
return recovered
|
||||
|
||||
def _has_pending_finalization(board: str, task_id: str, run_id: Any) -> bool:
|
||||
"""Keep an exact run claimed while its accepted result awaits replay."""
|
||||
if type(run_id) is not int or canonical_run_id(run_id) is None:
|
||||
return False
|
||||
path = _terminal_path(state_path(board, task_id), run_id, "pending")
|
||||
try:
|
||||
path.stat()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError:
|
||||
return False
|
||||
else:
|
||||
identity = _terminal_identity(path)
|
||||
if identity is not None:
|
||||
record = _load_terminal_json(path, identity)
|
||||
if _terminal_record_valid(record, identity):
|
||||
return True
|
||||
base = state_path(board, task_id)
|
||||
for prepared in base.parent.glob(
|
||||
f"{base.stem}.run-{run_id}.terminal.prepared-*.json"
|
||||
):
|
||||
identity = _terminal_evidence_identity(prepared)
|
||||
if identity is None or identity.state != "prepared":
|
||||
continue
|
||||
record = _load_small_json(prepared)
|
||||
if _terminal_evidence_valid(record, identity, "prepared"):
|
||||
return True
|
||||
staged_paths = list(base.parent.glob(".retire.*"))
|
||||
quarantine = base.parent / "quarantine"
|
||||
if quarantine.is_dir() and not quarantine.is_symlink():
|
||||
staged_paths.extend(quarantine.glob(".retire.*"))
|
||||
for staged in staged_paths:
|
||||
snapshot = _open_terminal_recovery_snapshot(staged)
|
||||
if snapshot is None:
|
||||
continue
|
||||
try:
|
||||
authority = _staged_terminal_authority(staged, snapshot)
|
||||
if (
|
||||
authority is not None
|
||||
and authority[0].board == board
|
||||
and authority[0].task_id == task_id
|
||||
and authority[0].run_id == run_id
|
||||
and authority[0].state in {"pending", "prepared", "committed"}
|
||||
):
|
||||
return True
|
||||
finally:
|
||||
snapshot.close()
|
||||
return False
|
||||
193
services/hermes/scripts/cli_lane_retention.py
Normal file
193
services/hermes/scripts/cli_lane_retention.py
Normal file
@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bounded age, count, and byte retention for non-pending lane artifacts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from cli_lane_config import (
|
||||
ARTIFACT_GC_INTERVAL_SECONDS,
|
||||
ARTIFACT_RETENTION_AGE_SECONDS,
|
||||
ARTIFACT_RETENTION_BYTES,
|
||||
ARTIFACT_RETENTION_COUNT,
|
||||
STATE_ROOT,
|
||||
)
|
||||
from cli_lane_evidence import _retire_terminal_entry, _terminal_evidence_valid
|
||||
from cli_lane_files import (
|
||||
_fsync_directory,
|
||||
_terminal_evidence_identity,
|
||||
_terminal_identity,
|
||||
)
|
||||
from cli_lane_quarantine import _quarantine_terminal
|
||||
from cli_lane_recovery import _staged_terminal_authority
|
||||
from cli_lane_records import _open_terminal_recovery_snapshot, _terminal_record_valid
|
||||
|
||||
|
||||
def _artifact_gc_candidates(board_dir: Path) -> list[Path]:
|
||||
"""List only bounded artifact classes; pending journals are excluded."""
|
||||
candidates: list[Path] = []
|
||||
patterns = (
|
||||
"*.provider-*.result.json",
|
||||
"*.candidate-*.json",
|
||||
"*.terminal.committed.json",
|
||||
"*.terminal.conflict-*.json",
|
||||
".retire.*",
|
||||
)
|
||||
for pattern in patterns:
|
||||
candidates.extend(board_dir.glob(pattern))
|
||||
quarantine = board_dir / "quarantine"
|
||||
if quarantine.is_dir():
|
||||
candidates.extend(quarantine.glob("*.quarantine"))
|
||||
candidates.extend(quarantine.glob(".retire.*"))
|
||||
return candidates
|
||||
|
||||
def _unlink_artifact_if_same(path: Path, observed: os.stat_result) -> bool:
|
||||
"""Remove only the exact artifact inode selected by retention."""
|
||||
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
directory_flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = None
|
||||
try:
|
||||
descriptor = os.open(path.parent, directory_flags)
|
||||
outcome = _retire_terminal_entry(
|
||||
path,
|
||||
observed,
|
||||
board_descriptor=descriptor,
|
||||
quarantine_descriptor=descriptor,
|
||||
)
|
||||
return outcome == "retired"
|
||||
except OSError:
|
||||
return False
|
||||
finally:
|
||||
if descriptor is not None:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _quarantine_invalid_retained_terminal(path: Path) -> tuple[bool, bool]:
|
||||
"""Audit retained committed/conflict evidence on the bounded GC cadence."""
|
||||
committed = path.name.endswith(".terminal.committed.json")
|
||||
conflict = ".terminal.conflict-" in path.name and path.name.endswith(".json")
|
||||
if not committed and not conflict:
|
||||
return False, False
|
||||
identity = (
|
||||
_terminal_identity(path) if committed else _terminal_evidence_identity(path)
|
||||
)
|
||||
snapshot = _open_terminal_recovery_snapshot(path)
|
||||
if snapshot is None:
|
||||
return True, False
|
||||
document = snapshot.document
|
||||
valid = False
|
||||
if identity is not None and document is not None:
|
||||
valid = (
|
||||
_terminal_record_valid(document, identity)
|
||||
if committed
|
||||
else _terminal_evidence_valid(document, identity, "conflict")
|
||||
)
|
||||
if valid:
|
||||
snapshot.close()
|
||||
return False, False
|
||||
try:
|
||||
_quarantine_terminal(
|
||||
path,
|
||||
identity,
|
||||
snapshot.invalid_reason or "invalid-retained-terminal-evidence",
|
||||
snapshot=snapshot,
|
||||
)
|
||||
finally:
|
||||
snapshot.close()
|
||||
try:
|
||||
path.stat(follow_symlinks=False)
|
||||
except FileNotFoundError:
|
||||
return True, True
|
||||
except OSError:
|
||||
return True, False
|
||||
return True, False
|
||||
|
||||
def gc_lane_artifacts(
|
||||
*,
|
||||
now: float | None = None,
|
||||
max_age_seconds: int = ARTIFACT_RETENTION_AGE_SECONDS,
|
||||
max_count: int = ARTIFACT_RETENTION_COUNT,
|
||||
max_bytes: int = ARTIFACT_RETENTION_BYTES,
|
||||
) -> int:
|
||||
"""Bound non-pending lane artifacts by age, count, and bytes per board."""
|
||||
current = time.time() if now is None else now
|
||||
removed = 0
|
||||
for board_dir in STATE_ROOT.iterdir() if STATE_ROOT.is_dir() else ():
|
||||
if not board_dir.is_dir() or board_dir.is_symlink():
|
||||
continue
|
||||
board_removed = 0
|
||||
entries: list[tuple[Path, os.stat_result]] = []
|
||||
for path in _artifact_gc_candidates(board_dir):
|
||||
classified, quarantined = _quarantine_invalid_retained_terminal(path)
|
||||
if classified:
|
||||
if quarantined:
|
||||
removed += 1
|
||||
board_removed += 1
|
||||
continue
|
||||
if path.name.startswith(".retire."):
|
||||
snapshot = _open_terminal_recovery_snapshot(path)
|
||||
if snapshot is not None:
|
||||
try:
|
||||
if _staged_terminal_authority(path, snapshot) is not None:
|
||||
# Accepted authority is first promoted/replayed by
|
||||
# recovery; retention never deletes it directly.
|
||||
continue
|
||||
finally:
|
||||
snapshot.close()
|
||||
try:
|
||||
file_stat = path.stat(follow_symlinks=False)
|
||||
except OSError:
|
||||
continue
|
||||
if not stat.S_ISREG(file_stat.st_mode):
|
||||
if _unlink_artifact_if_same(path, file_stat):
|
||||
removed += 1
|
||||
board_removed += 1
|
||||
continue
|
||||
if max_age_seconds >= 0 and current - file_stat.st_mtime > max_age_seconds:
|
||||
if _unlink_artifact_if_same(path, file_stat):
|
||||
removed += 1
|
||||
board_removed += 1
|
||||
else:
|
||||
entries.append((path, file_stat))
|
||||
retained_count = 0
|
||||
retained_bytes = 0
|
||||
for path, file_stat in sorted(
|
||||
entries, key=lambda item: item[1].st_mtime, reverse=True
|
||||
):
|
||||
exceeds_count = max_count >= 0 and retained_count >= max_count
|
||||
exceeds_bytes = (
|
||||
max_bytes >= 0 and retained_bytes + file_stat.st_size > max_bytes
|
||||
)
|
||||
if exceeds_count or exceeds_bytes:
|
||||
if _unlink_artifact_if_same(path, file_stat):
|
||||
removed += 1
|
||||
board_removed += 1
|
||||
continue
|
||||
retained_count += 1
|
||||
retained_bytes += file_stat.st_size
|
||||
if board_removed:
|
||||
_fsync_directory(board_dir)
|
||||
return removed
|
||||
|
||||
LAST_ARTIFACT_GC = 0.0
|
||||
|
||||
def maybe_gc_lane_artifacts(*, now: float | None = None) -> int:
|
||||
"""Run artifact retention on a bounded cadence, not every dispatcher tick."""
|
||||
global LAST_ARTIFACT_GC
|
||||
current = time.time() if now is None else now
|
||||
if current - LAST_ARTIFACT_GC < ARTIFACT_GC_INTERVAL_SECONDS:
|
||||
return 0
|
||||
LAST_ARTIFACT_GC = current
|
||||
try:
|
||||
return gc_lane_artifacts(now=current)
|
||||
except OSError as error:
|
||||
print(
|
||||
f"lane artifact retention deferred: {type(error).__name__}: {error}",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return 0
|
||||
161
services/hermes/scripts/cli_lane_routing.py
Normal file
161
services/hermes/scripts/cli_lane_routing.py
Normal file
@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Switchyard route selection and provider-health exclusion for CLI workers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
|
||||
from cli_lane_config import (
|
||||
EFFORTS,
|
||||
PROVIDER_AUTH_FAILURE_MAX_AGE_SECONDS,
|
||||
PROVIDER_HEALTH_MAX_AGE_SECONDS,
|
||||
PROVIDER_HEALTH_PATHS,
|
||||
SWITCHYARD_URL,
|
||||
Route,
|
||||
)
|
||||
from cli_lane_files import load_json
|
||||
|
||||
|
||||
def parse_assignee(assignee: str) -> tuple[str | None, str | None]:
|
||||
"""Parse external lane overrides while leaving cli-auto fully automatic."""
|
||||
value = str(assignee or "").strip().lower()
|
||||
if value == "cli-auto":
|
||||
return None, None
|
||||
match = re.fullmatch(r"cli-(codex|claude)-(low|medium|high|xhigh)", value)
|
||||
if not match:
|
||||
raise ValueError(f"unsupported external lane assignee: {assignee}")
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
def fresh_unavailable_provider(now: float | None = None) -> str | None:
|
||||
"""Return one recently proven-down provider for automatic route exclusion."""
|
||||
current = time.time() if now is None else now
|
||||
unavailable: list[str] = []
|
||||
for provider, path in PROVIDER_HEALTH_PATHS.items():
|
||||
health = load_json(path)
|
||||
try:
|
||||
age = current - path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
max_age = (
|
||||
PROVIDER_AUTH_FAILURE_MAX_AGE_SECONDS
|
||||
if health.get("authenticated") is False
|
||||
else PROVIDER_HEALTH_MAX_AGE_SECONDS
|
||||
)
|
||||
if (
|
||||
0 <= age <= max_age
|
||||
and health.get("state") == "unavailable"
|
||||
):
|
||||
unavailable.append(provider)
|
||||
# If both providers are down, keep the normal attempt/fallback path so the
|
||||
# card records authoritative current errors instead of trusting snapshots.
|
||||
return unavailable[0] if len(unavailable) == 1 else None
|
||||
|
||||
def _decode_worker_target(value: str) -> tuple[str, str, str]:
|
||||
"""Decode the selected model header emitted by a worker decision route."""
|
||||
parts = value.split("/", 3)
|
||||
if len(parts) != 4 or parts[0] != "worker":
|
||||
raise RuntimeError(f"invalid Switchyard worker target: {value}")
|
||||
provider, model, effort = parts[1:]
|
||||
if provider not in {"codex", "claude"} or effort not in EFFORTS:
|
||||
raise RuntimeError(f"unsupported Switchyard worker target: {value}")
|
||||
return provider, model, effort
|
||||
|
||||
def select_route(
|
||||
prompt: str,
|
||||
assignee: str,
|
||||
*,
|
||||
exclude_provider: str | None = None,
|
||||
exclude_reason: str | None = None,
|
||||
switchyard_url: str = SWITCHYARD_URL,
|
||||
open_request: Callable[..., Any] = urllib.request.urlopen,
|
||||
) -> Route:
|
||||
"""Ask Switchyard to select one native CLI worker at this boundary."""
|
||||
started = time.monotonic()
|
||||
manual_provider, manual_effort = parse_assignee(assignee)
|
||||
if manual_provider and manual_effort:
|
||||
route_id = f"atlas/worker/manual/{manual_provider}/{manual_effort}"
|
||||
source = "switchyard-manual"
|
||||
else:
|
||||
route_id = "atlas/worker/auto/maximum"
|
||||
source = "switchyard-classifier"
|
||||
context = prompt
|
||||
if exclude_provider:
|
||||
reason = exclude_reason or "failed or exhausted capacity at this boundary"
|
||||
context += (
|
||||
f"\n\nRouting constraint: the {exclude_provider} provider {reason}. "
|
||||
"Do not select it."
|
||||
)
|
||||
payload = json.dumps(
|
||||
{
|
||||
"model": route_id,
|
||||
"messages": [{"role": "user", "content": context}],
|
||||
"stream": False,
|
||||
"max_tokens": 1,
|
||||
}
|
||||
).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
switchyard_url,
|
||||
data=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with open_request(request, timeout=60) as response:
|
||||
selected = str(response.headers.get("x-model-router-selected-model") or "")
|
||||
rationale = str(response.headers.get("x-model-router-rationale") or "")
|
||||
response_body = response.read()
|
||||
except (OSError, urllib.error.URLError) as exc:
|
||||
raise RuntimeError(f"Switchyard worker routing failed: {exc}") from exc
|
||||
provider, model, effort = _decode_worker_target(selected)
|
||||
# Switchyard preserves the stable tier target in the selection header and
|
||||
# top-level response model. The worker broker's assistant content contains
|
||||
# the steward-resolved provider model required by the native CLI.
|
||||
try:
|
||||
response_document = json.loads(response_body)
|
||||
resolved_target = str(response_document["choices"][0]["message"]["content"] or "")
|
||||
resolved_provider, resolved_model, resolved_effort = _decode_worker_target(resolved_target)
|
||||
required_prefix = "gpt-" if provider == "codex" else "claude-"
|
||||
if (
|
||||
resolved_provider == provider
|
||||
and resolved_effort == effort
|
||||
and resolved_model.startswith(required_prefix)
|
||||
):
|
||||
model = resolved_model
|
||||
except (AttributeError, IndexError, KeyError, RuntimeError, TypeError, ValueError, json.JSONDecodeError):
|
||||
pass
|
||||
if exclude_provider and provider == exclude_provider:
|
||||
alternate = "claude" if provider == "codex" else "codex"
|
||||
guarded = select_route(
|
||||
prompt,
|
||||
f"cli-{alternate}-{effort}",
|
||||
switchyard_url=switchyard_url,
|
||||
open_request=open_request,
|
||||
)
|
||||
return Route(
|
||||
provider=guarded.provider,
|
||||
model=guarded.model,
|
||||
effort=guarded.effort,
|
||||
profile=guarded.profile,
|
||||
classifier=f"{source}-health-guard",
|
||||
reason=(
|
||||
f"Switchyard selected excluded {exclude_provider}; preserved "
|
||||
f"its {effort} effort on healthy-provider route. {guarded.reason}"
|
||||
),
|
||||
latency_ms=int((time.monotonic() - started) * 1000),
|
||||
fallback_chain=(),
|
||||
)
|
||||
return Route(
|
||||
provider=provider,
|
||||
model=model,
|
||||
effort=effort,
|
||||
profile=f"{provider}-{effort}",
|
||||
classifier=source,
|
||||
reason=rationale or f"Switchyard selected {selected}",
|
||||
latency_ms=int((time.monotonic() - started) * 1000),
|
||||
fallback_chain=(),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@ -24,10 +24,12 @@ def recover_running_tasks(kanban_db: Any) -> list[str]:
|
||||
if str(getattr(task, "status", "")) != "running":
|
||||
continue
|
||||
task_id = str(getattr(task, "id", ""))
|
||||
if not task_id or not kanban_db.reclaim_task(
|
||||
run_id = getattr(task, "current_run_id", None)
|
||||
if not task_id or not isinstance(run_id, int) or not kanban_db.reclaim_task(
|
||||
connection,
|
||||
task_id,
|
||||
reason=REASON,
|
||||
expected_run_id=run_id,
|
||||
):
|
||||
continue
|
||||
kanban_db.add_comment(
|
||||
|
||||
@ -27,11 +27,35 @@
|
||||
"services/hermes/scm-common/scripts/scm_broker_client.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker_io.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker_server.py",
|
||||
"services/hermes/scripts/execution_pool_client.py",
|
||||
"services/hermes/scripts/execution_pool_coordinator.py",
|
||||
"services/hermes/scripts/execution_pool_project.py",
|
||||
"services/hermes/scripts/execution_pool_protocol.py",
|
||||
"services/hermes/scripts/execution_pool_scm.py",
|
||||
"services/hermes/scripts/execution_pool_server.py",
|
||||
"services/hermes/scripts/execution_pool_worker.py",
|
||||
"services/hermes/scripts/hermes_coordinator.py",
|
||||
"services/hermes/scripts/node_account_audit.py",
|
||||
"services/hermes/scripts/node_account_hardening.py",
|
||||
"services/hermes/scripts/node_account_io.py",
|
||||
"services/hermes/scripts/stage_runtime_access.py",
|
||||
"services/hermes/scripts/cli_lane_board.py",
|
||||
"services/hermes/scripts/cli_lane_capabilities.py",
|
||||
"services/hermes/scripts/cli_lane_config.py",
|
||||
"services/hermes/scripts/cli_lane_dispatch.py",
|
||||
"services/hermes/scripts/cli_lane_evidence.py",
|
||||
"services/hermes/scripts/cli_lane_execution.py",
|
||||
"services/hermes/scripts/cli_lane_files.py",
|
||||
"services/hermes/scripts/cli_lane_finalization.py",
|
||||
"services/hermes/scripts/cli_lane_goal.py",
|
||||
"services/hermes/scripts/cli_lane_prompt.py",
|
||||
"services/hermes/scripts/cli_lane_provider.py",
|
||||
"services/hermes/scripts/cli_lane_quarantine.py",
|
||||
"services/hermes/scripts/cli_lane_records.py",
|
||||
"services/hermes/scripts/cli_lane_recovery.py",
|
||||
"services/hermes/scripts/cli_lane_retention.py",
|
||||
"services/hermes/scripts/cli_lane_routing.py",
|
||||
"services/hermes/scripts/cli_lane_runner.py",
|
||||
"testing/__init__.py",
|
||||
"testing/quality_contract.py",
|
||||
"testing/quality_docs.py",
|
||||
@ -57,11 +81,35 @@
|
||||
"services/mailu/scripts/mailu_sync_listener.py",
|
||||
"services/gitea/scripts/gitea_branch_protection_check.py",
|
||||
"services/hermes/scm-common/scripts",
|
||||
"services/hermes/scripts/execution_pool_client.py",
|
||||
"services/hermes/scripts/execution_pool_coordinator.py",
|
||||
"services/hermes/scripts/execution_pool_project.py",
|
||||
"services/hermes/scripts/execution_pool_protocol.py",
|
||||
"services/hermes/scripts/execution_pool_scm.py",
|
||||
"services/hermes/scripts/execution_pool_server.py",
|
||||
"services/hermes/scripts/execution_pool_worker.py",
|
||||
"services/hermes/scripts/hermes_coordinator.py",
|
||||
"services/hermes/scripts/node_account_audit.py",
|
||||
"services/hermes/scripts/node_account_hardening.py",
|
||||
"services/hermes/scripts/node_account_io.py",
|
||||
"services/hermes/scripts/stage_runtime_access.py",
|
||||
"services/hermes/scripts/cli_lane_board.py",
|
||||
"services/hermes/scripts/cli_lane_capabilities.py",
|
||||
"services/hermes/scripts/cli_lane_config.py",
|
||||
"services/hermes/scripts/cli_lane_dispatch.py",
|
||||
"services/hermes/scripts/cli_lane_evidence.py",
|
||||
"services/hermes/scripts/cli_lane_execution.py",
|
||||
"services/hermes/scripts/cli_lane_files.py",
|
||||
"services/hermes/scripts/cli_lane_finalization.py",
|
||||
"services/hermes/scripts/cli_lane_goal.py",
|
||||
"services/hermes/scripts/cli_lane_prompt.py",
|
||||
"services/hermes/scripts/cli_lane_provider.py",
|
||||
"services/hermes/scripts/cli_lane_quarantine.py",
|
||||
"services/hermes/scripts/cli_lane_records.py",
|
||||
"services/hermes/scripts/cli_lane_recovery.py",
|
||||
"services/hermes/scripts/cli_lane_retention.py",
|
||||
"services/hermes/scripts/cli_lane_routing.py",
|
||||
"services/hermes/scripts/cli_lane_runner.py",
|
||||
"testing/tests",
|
||||
"testing"
|
||||
],
|
||||
@ -148,11 +196,13 @@
|
||||
"services/*/scripts/tests/**/*.py",
|
||||
"services/gitea/scripts/gitea_branch_protection_check.py",
|
||||
"services/hermes/scm-common/scripts/*.py",
|
||||
"services/hermes/scripts/execution_pool_*.py",
|
||||
"services/hermes/scripts/hermes_coordinator.py",
|
||||
"services/hermes/scripts/node_account_audit.py",
|
||||
"services/hermes/scripts/node_account_hardening.py",
|
||||
"services/hermes/scripts/node_account_io.py",
|
||||
"services/hermes/scripts/stage_runtime_access.py",
|
||||
"services/hermes/scripts/cli_lane_*.py",
|
||||
"services/mailu/scripts/mailu_sync.py",
|
||||
"services/mailu/scripts/mailu_sync_listener.py"
|
||||
],
|
||||
@ -210,6 +260,30 @@
|
||||
"services/hermes/scripts/node_account_hardening.py",
|
||||
"services/hermes/scripts/node_account_io.py",
|
||||
"services/hermes/scripts/stage_runtime_access.py",
|
||||
"services/hermes/scripts/execution_pool_client.py",
|
||||
"services/hermes/scripts/execution_pool_coordinator.py",
|
||||
"services/hermes/scripts/execution_pool_project.py",
|
||||
"services/hermes/scripts/execution_pool_protocol.py",
|
||||
"services/hermes/scripts/execution_pool_scm.py",
|
||||
"services/hermes/scripts/execution_pool_server.py",
|
||||
"services/hermes/scripts/execution_pool_worker.py",
|
||||
"services/hermes/scripts/cli_lane_board.py",
|
||||
"services/hermes/scripts/cli_lane_capabilities.py",
|
||||
"services/hermes/scripts/cli_lane_config.py",
|
||||
"services/hermes/scripts/cli_lane_dispatch.py",
|
||||
"services/hermes/scripts/cli_lane_evidence.py",
|
||||
"services/hermes/scripts/cli_lane_execution.py",
|
||||
"services/hermes/scripts/cli_lane_files.py",
|
||||
"services/hermes/scripts/cli_lane_finalization.py",
|
||||
"services/hermes/scripts/cli_lane_goal.py",
|
||||
"services/hermes/scripts/cli_lane_prompt.py",
|
||||
"services/hermes/scripts/cli_lane_provider.py",
|
||||
"services/hermes/scripts/cli_lane_quarantine.py",
|
||||
"services/hermes/scripts/cli_lane_records.py",
|
||||
"services/hermes/scripts/cli_lane_recovery.py",
|
||||
"services/hermes/scripts/cli_lane_retention.py",
|
||||
"services/hermes/scripts/cli_lane_routing.py",
|
||||
"services/hermes/scripts/cli_lane_runner.py",
|
||||
"testing/quality_coverage.py"
|
||||
],
|
||||
"tracked_files": [
|
||||
@ -226,11 +300,35 @@
|
||||
"services/hermes/scm-common/scripts/scm_broker_client.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker_io.py",
|
||||
"services/hermes/scm-common/scripts/scm_broker_server.py",
|
||||
"services/hermes/scripts/execution_pool_client.py",
|
||||
"services/hermes/scripts/execution_pool_coordinator.py",
|
||||
"services/hermes/scripts/execution_pool_project.py",
|
||||
"services/hermes/scripts/execution_pool_protocol.py",
|
||||
"services/hermes/scripts/execution_pool_scm.py",
|
||||
"services/hermes/scripts/execution_pool_server.py",
|
||||
"services/hermes/scripts/execution_pool_worker.py",
|
||||
"services/hermes/scripts/hermes_coordinator.py",
|
||||
"services/hermes/scripts/node_account_audit.py",
|
||||
"services/hermes/scripts/node_account_hardening.py",
|
||||
"services/hermes/scripts/node_account_io.py",
|
||||
"services/hermes/scripts/stage_runtime_access.py",
|
||||
"services/hermes/scripts/cli_lane_board.py",
|
||||
"services/hermes/scripts/cli_lane_capabilities.py",
|
||||
"services/hermes/scripts/cli_lane_config.py",
|
||||
"services/hermes/scripts/cli_lane_dispatch.py",
|
||||
"services/hermes/scripts/cli_lane_evidence.py",
|
||||
"services/hermes/scripts/cli_lane_execution.py",
|
||||
"services/hermes/scripts/cli_lane_files.py",
|
||||
"services/hermes/scripts/cli_lane_finalization.py",
|
||||
"services/hermes/scripts/cli_lane_goal.py",
|
||||
"services/hermes/scripts/cli_lane_prompt.py",
|
||||
"services/hermes/scripts/cli_lane_provider.py",
|
||||
"services/hermes/scripts/cli_lane_quarantine.py",
|
||||
"services/hermes/scripts/cli_lane_records.py",
|
||||
"services/hermes/scripts/cli_lane_recovery.py",
|
||||
"services/hermes/scripts/cli_lane_retention.py",
|
||||
"services/hermes/scripts/cli_lane_routing.py",
|
||||
"services/hermes/scripts/cli_lane_runner.py",
|
||||
"testing/quality_contract.py",
|
||||
"testing/quality_docs.py",
|
||||
"testing/quality_hygiene.py",
|
||||
|
||||
26
testing/tests/conftest.py
Normal file
26
testing/tests/conftest.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Shared pytest boundaries for repository automation tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _patched_hermes_cli_db_contract(request, monkeypatch):
|
||||
"""Keep legacy lane test doubles focused on their original safety boundary."""
|
||||
module_name = request.module.__name__.rsplit(".", 1)[-1]
|
||||
if not module_name.startswith("test_hermes_cli_") or module_name.endswith(
|
||||
"capabilities"
|
||||
):
|
||||
return
|
||||
capability_module = sys.modules.get("cli_lane_capabilities")
|
||||
if capability_module is None:
|
||||
return
|
||||
safe = capability_module.KanbanCapabilities(True, True, True)
|
||||
for name, module in tuple(sys.modules.items()):
|
||||
if (
|
||||
name == "cli_lane_runner" or name.startswith("cli_lane_")
|
||||
) and hasattr(module, "kanban_capabilities"):
|
||||
monkeypatch.setattr(module, "kanban_capabilities", lambda _db: safe)
|
||||
253
testing/tests/test_hermes_agent_access.py
Normal file
253
testing/tests/test_hermes_agent_access.py
Normal file
@ -0,0 +1,253 @@
|
||||
"""Owner and Switchyard access-boundary contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
HERMES,
|
||||
SCRIPTS,
|
||||
_agent_deployment,
|
||||
yaml,
|
||||
)
|
||||
|
||||
|
||||
def test_owner_agent_has_pinned_dedicated_node_ssh_access():
|
||||
deployment = _agent_deployment()
|
||||
annotations = deployment["spec"]["template"]["metadata"]["annotations"]
|
||||
assert annotations[
|
||||
"vault.hashicorp.com/agent-inject-secret-node-ssh-private-key"
|
||||
] == "kv/data/atlas/hermes/developer-ssh"
|
||||
assert annotations[
|
||||
"vault.hashicorp.com/agent-inject-secret-node-ssh-config"
|
||||
] == "kv/data/atlas/hermes/developer-ssh"
|
||||
assert annotations[
|
||||
"vault.hashicorp.com/agent-inject-secret-node-ssh-known-hosts"
|
||||
] == "kv/data/atlas/hermes/developer-ssh"
|
||||
|
||||
init = next(
|
||||
item
|
||||
for item in deployment["spec"]["template"]["spec"]["initContainers"]
|
||||
if item["name"] == "init-config"
|
||||
)
|
||||
command = init["command"][2]
|
||||
assert "ln -s /runtime-access/node-ssh-config /opt/data/home/.ssh/config" in command
|
||||
assert (
|
||||
"ln -s /runtime-access/node-ssh-known-hosts /opt/data/home/.ssh/known_hosts"
|
||||
in command
|
||||
)
|
||||
assert "ln -s home/.ssh /opt/data/.ssh" in command
|
||||
assert "chmod 0700 /opt/data/home/.ssh" in command
|
||||
assert (
|
||||
"ln -s /runtime-access/node-ssh-private-key "
|
||||
"/opt/data/home/.ssh/id_ed25519_atlas_nodes"
|
||||
) in command
|
||||
|
||||
config = yaml.safe_load((HERMES / "agent-configmap.yaml").read_text())["data"]
|
||||
assert "ssh_config" not in config
|
||||
assert "ssh_known_hosts" not in config
|
||||
assert "ssh-ed25519" not in (HERMES / "agent-configmap.yaml").read_text()
|
||||
|
||||
resources = yaml.safe_load((HERMES / "kustomization.yaml").read_text())[
|
||||
"resources"
|
||||
]
|
||||
assert "node-ssh-access.yaml" in resources
|
||||
access = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "node-ssh-access.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
service_account = next(item for item in access if item["kind"] == "ServiceAccount")
|
||||
assert service_account["metadata"]["name"] == "hermes-node-ssh-access"
|
||||
provider = next(item for item in access if item["kind"] == "SecretProviderClass")
|
||||
assert provider["metadata"]["name"] == "hermes-node-ssh-access"
|
||||
assert provider["spec"]["provider"] == "vault"
|
||||
parameters = provider["spec"]["parameters"]
|
||||
assert parameters["roleName"] == "hermes-node-ssh"
|
||||
assert 'secretPath: "kv/data/atlas/hermes/developer-ssh"' in parameters["objects"]
|
||||
assert 'secretKey: "public_key"' in parameters["objects"]
|
||||
daemonset = next(item for item in access if item["kind"] == "DaemonSet")
|
||||
pod = daemonset["spec"]["template"]["spec"]
|
||||
assert pod["serviceAccountName"] == "hermes-node-ssh-access"
|
||||
assert pod["automountServiceAccountToken"] is True
|
||||
host_home = next(item for item in pod["volumes"] if item["name"] == "host-home")
|
||||
assert host_home["hostPath"] == {"path": "/home", "type": "Directory"}
|
||||
vault_secrets = next(
|
||||
item for item in pod["volumes"] if item["name"] == "vault-secrets"
|
||||
)
|
||||
assert vault_secrets["csi"]["driver"] == "secrets-store.csi.k8s.io"
|
||||
assert vault_secrets["csi"]["volumeAttributes"] == {
|
||||
"secretProviderClass": "hermes-node-ssh-access"
|
||||
}
|
||||
reconciler = pod["containers"][0]["args"][0]
|
||||
assert "cat /vault/secrets/node-ssh-public-key" in reconciler
|
||||
assert "grep -qxF" in reconciler
|
||||
assert "for user in atlas oceanus" in reconciler
|
||||
assert "/host-etc/passwd" in reconciler
|
||||
assert "chown \"${uid}:${gid}\"" in reconciler
|
||||
host_passwd = next(
|
||||
item for item in pod["volumes"] if item["name"] == "host-passwd"
|
||||
)
|
||||
assert host_passwd["hostPath"] == {"path": "/etc/passwd", "type": "File"}
|
||||
|
||||
|
||||
def test_owner_agent_tracks_no_ssh_identity_or_host_key_material():
|
||||
"""Vault references may be tracked; SSH identities and trust data may not."""
|
||||
forbidden = (
|
||||
"BEGIN OPENSSH PRIVATE KEY",
|
||||
"ssh-ed25519 AAAA",
|
||||
"ssh-rsa AAAA",
|
||||
"IdentityFile ",
|
||||
"UserKnownHostsFile ",
|
||||
"StrictHostKeyChecking ",
|
||||
"ssh_config:",
|
||||
"ssh_known_hosts:",
|
||||
)
|
||||
text_suffixes = {
|
||||
".conf",
|
||||
".json",
|
||||
".md",
|
||||
".py",
|
||||
".sh",
|
||||
".toml",
|
||||
".yaml",
|
||||
".yml",
|
||||
}
|
||||
tracked = "\n".join(
|
||||
path.read_text(encoding="utf-8")
|
||||
for path in HERMES.rglob("*")
|
||||
if path.is_file() and path.suffix in text_suffixes
|
||||
)
|
||||
for marker in forbidden:
|
||||
assert marker not in tracked
|
||||
|
||||
|
||||
def test_switchyard_has_a_dedicated_non_owner_identity_and_read_only_catalog():
|
||||
"""Routing must not inherit the owner agent's cluster-admin capability."""
|
||||
service_accounts = [
|
||||
item
|
||||
for item in yaml.safe_load_all(
|
||||
(HERMES / "vault-serviceaccount.yaml").read_text()
|
||||
)
|
||||
if item
|
||||
]
|
||||
assert any(
|
||||
item["kind"] == "ServiceAccount"
|
||||
and item["metadata"]["name"] == "hermes-switchyard"
|
||||
for item in service_accounts
|
||||
)
|
||||
|
||||
switchyard = yaml.safe_load(
|
||||
(HERMES / "switchyard-deployment.yaml").read_text()
|
||||
)
|
||||
switchyard_pod = switchyard["spec"]["template"]["spec"]
|
||||
assert switchyard_pod["serviceAccountName"] == "hermes-switchyard"
|
||||
agent_pod = _agent_deployment()["spec"]["template"]["spec"]
|
||||
for pod, container_name in (
|
||||
(switchyard_pod, "worker-route-broker"),
|
||||
(agent_pod, "claude-broker"),
|
||||
):
|
||||
container = next(item for item in pod["containers"] if item["name"] == container_name)
|
||||
catalog = next(
|
||||
item
|
||||
for item in container["volumeMounts"]
|
||||
if item["mountPath"] == "/routing-catalog"
|
||||
)
|
||||
assert catalog["readOnly"] is True
|
||||
|
||||
rbac = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "agent-rbac.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
binding = next(item for item in rbac if item["kind"] == "ClusterRoleBinding")
|
||||
assert binding["subjects"] == [
|
||||
{"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"}
|
||||
]
|
||||
|
||||
|
||||
def test_switchyard_active_state_uses_a_relocatable_rwx_claim():
|
||||
"""A stale node attachment must not strand the routing authority."""
|
||||
claims = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "switchyard-pvc.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
active_claim = next(
|
||||
item
|
||||
for item in claims
|
||||
if item["metadata"]["name"] == "hermes-switchyard-state-rwx"
|
||||
)
|
||||
assert active_claim["spec"]["accessModes"] == ["ReadWriteMany"]
|
||||
|
||||
deployment = yaml.safe_load((HERMES / "switchyard-deployment.yaml").read_text())
|
||||
strategy = deployment["spec"]["strategy"]
|
||||
assert strategy == {
|
||||
"type": "RollingUpdate",
|
||||
"rollingUpdate": {"maxSurge": 1, "maxUnavailable": 0},
|
||||
}
|
||||
pod = deployment["spec"]["template"]["spec"]
|
||||
state = next(item for item in pod["volumes"] if item["name"] == "state")
|
||||
assert state["persistentVolumeClaim"]["claimName"] == active_claim["metadata"][
|
||||
"name"
|
||||
]
|
||||
|
||||
|
||||
def test_worker_route_broker_accepts_pod_network_health_checks():
|
||||
"""Kubelet probes the pod IP, so the broker cannot bind to loopback only."""
|
||||
script = (SCRIPTS / "worker_route_broker.py").read_text()
|
||||
assert 'ThreadingHTTPServer(("0.0.0.0", PORT), Handler)' in script
|
||||
|
||||
|
||||
def test_switchyard_network_boundary_allows_vault_bootstrap():
|
||||
"""The pre-populate init container must reach Vault before routing starts."""
|
||||
documents = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "networkpolicy.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
isolation = next(
|
||||
item
|
||||
for item in documents
|
||||
if item.get("metadata", {}).get("name") == "hermes-switchyard-isolation"
|
||||
)
|
||||
assert any(
|
||||
rule.get("to")
|
||||
== [
|
||||
{
|
||||
"namespaceSelector": {
|
||||
"matchLabels": {"kubernetes.io/metadata.name": "vault"}
|
||||
},
|
||||
"podSelector": {"matchLabels": {"app": "vault"}},
|
||||
}
|
||||
]
|
||||
and rule.get("ports") == [{"protocol": "TCP", "port": 8200}]
|
||||
for rule in isolation["spec"]["egress"]
|
||||
)
|
||||
|
||||
|
||||
def test_switchyard_network_boundary_allows_metrics_scraping():
|
||||
"""VictoriaMetrics may scrape Switchyard without widening its API boundary."""
|
||||
documents = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "networkpolicy.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
isolation = next(
|
||||
item
|
||||
for item in documents
|
||||
if item.get("metadata", {}).get("name") == "hermes-switchyard-isolation"
|
||||
)
|
||||
assert any(
|
||||
rule.get("from")
|
||||
== [
|
||||
{
|
||||
"namespaceSelector": {
|
||||
"matchLabels": {
|
||||
"kubernetes.io/metadata.name": "monitoring"
|
||||
}
|
||||
},
|
||||
"podSelector": {"matchLabels": {"app": "server"}},
|
||||
}
|
||||
]
|
||||
and rule.get("ports") == [{"protocol": "TCP", "port": 9005}]
|
||||
for rule in isolation["spec"]["ingress"]
|
||||
)
|
||||
352
testing/tests/test_hermes_agent_layout.py
Normal file
352
testing/tests/test_hermes_agent_layout.py
Normal file
@ -0,0 +1,352 @@
|
||||
"""Agent deployment layout and runtime policy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
HERMES,
|
||||
Path,
|
||||
_agent_deployment,
|
||||
_services,
|
||||
client_config,
|
||||
json,
|
||||
migration,
|
||||
policy,
|
||||
pytest,
|
||||
yaml,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command",
|
||||
[
|
||||
"git push --force origin main",
|
||||
"git reset --hard HEAD~1",
|
||||
"git clean -fd",
|
||||
],
|
||||
)
|
||||
def test_claude_pretool_hook_blocks_hard_denies(command: str):
|
||||
assert policy.denial_reason(command)
|
||||
|
||||
|
||||
def test_claude_pretool_hook_allows_normal_engineering():
|
||||
assert policy.denial_reason("pytest -q testing/tests") is None
|
||||
assert policy.denial_reason("git push origin feature/hermes") is None
|
||||
assert policy.denial_reason("kubectl delete pod -n cassandra stuck-worker") is None
|
||||
assert policy.denial_reason("flux reconcile kustomization hermes") is None
|
||||
assert policy.denial_reason("vault kv get kv/atlas/hermes") is None
|
||||
|
||||
|
||||
def test_claude_settings_preserve_state_and_install_three_guardrail_layers(tmp_path: Path):
|
||||
state = tmp_path / ".claude.json"
|
||||
settings = tmp_path / "settings.json"
|
||||
state.write_text('{"promptQueueUseCount": 4}\n', encoding="utf-8")
|
||||
settings.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"theme": "dark",
|
||||
"permissions": {
|
||||
"deny": [
|
||||
"Bash(kubectl apply *)",
|
||||
"Bash(flux reconcile *)",
|
||||
"Bash(vault kv *)",
|
||||
"Bash(custom-owner-rule *)",
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
client_config.configure_claude_state(state)
|
||||
client_config.configure_claude_settings(settings)
|
||||
|
||||
state_value = json.loads(state.read_text())
|
||||
settings_value = json.loads(settings.read_text())
|
||||
assert state_value["promptQueueUseCount"] == 4
|
||||
assert state_value["bypassPermissionsModeAccepted"] is True
|
||||
assert settings_value["theme"] == "dark"
|
||||
assert "Bash(git reset --hard *)" in settings_value["permissions"]["deny"]
|
||||
assert "Bash(custom-owner-rule *)" in settings_value["permissions"]["deny"]
|
||||
assert "Bash(kubectl apply *)" not in settings_value["permissions"]["deny"]
|
||||
assert "Bash(flux reconcile *)" not in settings_value["permissions"]["deny"]
|
||||
assert "Bash(vault kv *)" not in settings_value["permissions"]["deny"]
|
||||
hook = settings_value["hooks"]["PreToolUse"][0]["hooks"][0]
|
||||
assert "claude_command_policy.py" in hook["command"]
|
||||
|
||||
|
||||
def test_legacy_state_is_archived_without_removing_provider_transcripts(tmp_path: Path):
|
||||
session = tmp_path / "home/.config/herdr/session.json"
|
||||
session.parent.mkdir(parents=True)
|
||||
session.write_text('{"agents":[{"agent":"claude","session_id":"abc"}]}', encoding="utf-8")
|
||||
binary = tmp_path / "tools/bin/herdr"
|
||||
binary.parent.mkdir(parents=True)
|
||||
binary.write_text("legacy", encoding="utf-8")
|
||||
(tmp_path / "home/.claude").mkdir()
|
||||
(tmp_path / "home/.codex").mkdir()
|
||||
|
||||
archive = migration.archive_legacy_state(tmp_path)
|
||||
|
||||
value = json.loads(archive.read_text())
|
||||
assert value["legacy_session"]["agents"][0]["session_id"] == "abc"
|
||||
assert (tmp_path / "home/.claude").is_dir()
|
||||
assert (tmp_path / "home/.codex").is_dir()
|
||||
assert not binary.exists()
|
||||
assert not (tmp_path / "home/.config/herdr").exists()
|
||||
|
||||
|
||||
def test_agent_uses_one_native_kanban_control_plane():
|
||||
configmap = yaml.safe_load((HERMES / "agent-configmap.yaml").read_text())
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
assert config["model"] == {
|
||||
"provider": "atlas-switchyard",
|
||||
"default": "atlas/auto/balanced",
|
||||
"model": "atlas/auto/balanced",
|
||||
}
|
||||
assert config["kanban"]["dispatch_in_gateway"] is True
|
||||
assert config["kanban"]["default_assignee"] == "cli-auto"
|
||||
assert config["plugins"]["enabled"] == ["auto-router"]
|
||||
|
||||
deployment = _agent_deployment()
|
||||
pod = deployment["spec"]["template"]["spec"]
|
||||
assert pod["enableServiceLinks"] is False
|
||||
names = {item["name"] for item in pod["containers"]}
|
||||
assert "cli-lane-runner" in names
|
||||
assert "terminal" in names
|
||||
assert not any("herdr" in name for name in names)
|
||||
rendered = (HERMES / "agent-deployment.yaml").read_text()
|
||||
assert "herdr server" not in rendered
|
||||
assert "herdr-dispatch" not in rendered
|
||||
|
||||
|
||||
def test_cli_lane_reserves_cpu_headroom_for_ui_and_auth():
|
||||
deployment = _agent_deployment()
|
||||
containers = {
|
||||
item["name"]: item
|
||||
for item in deployment["spec"]["template"]["spec"]["containers"]
|
||||
}
|
||||
lane = containers["cli-lane-runner"]
|
||||
environment = {item["name"]: item["value"] for item in lane["env"]}
|
||||
|
||||
assert environment["HERMES_CLI_LANE_CONCURRENCY"] == "2"
|
||||
assert lane["resources"] == {
|
||||
"requests": {"cpu": "100m", "memory": "256Mi"},
|
||||
"limits": {"cpu": "2", "memory": "6Gi"},
|
||||
}
|
||||
|
||||
|
||||
def test_agent_avoids_unhealthy_nodes_and_fits_its_remaining_capacity():
|
||||
"""Placement correction: keep the agent off nodes that cannot hold it.
|
||||
|
||||
titan-04 is cordoned after repeated kernel undervoltage and kubelet
|
||||
failure, and titan-19 was probe/Longhorn unstable under worker load, so
|
||||
both must join the existing hard exclusions. That leaves titan-05 as the
|
||||
healthy candidate, which is tight enough on requested CPU that the main
|
||||
container has to give back 50m to schedule there.
|
||||
"""
|
||||
pod = _agent_deployment()["spec"]["template"]["spec"]
|
||||
hostnames = next(
|
||||
item
|
||||
for item in pod["affinity"]["nodeAffinity"][
|
||||
"requiredDuringSchedulingIgnoredDuringExecution"
|
||||
]["nodeSelectorTerms"][0]["matchExpressions"]
|
||||
if item["key"] == "kubernetes.io/hostname"
|
||||
)
|
||||
|
||||
assert hostnames["operator"] == "NotIn"
|
||||
assert set(hostnames["values"]) >= {"titan-04", "titan-19"}
|
||||
|
||||
hermes = next(
|
||||
item for item in pod["containers"] if item["name"] == "hermes"
|
||||
)
|
||||
assert hermes["resources"]["requests"]["cpu"] == "300m"
|
||||
|
||||
|
||||
def test_agent_root_is_stock_dashboard_and_terminal_is_a_separate_path():
|
||||
deployment = _agent_deployment()
|
||||
pod = deployment["spec"]["template"]["spec"]
|
||||
containers = {item["name"]: item for item in pod["containers"]}
|
||||
assert "webui" not in containers
|
||||
|
||||
assert "dashboard" not in containers
|
||||
hermes = containers["hermes"]
|
||||
hermes_env = {item["name"]: item["value"] for item in hermes["env"]}
|
||||
assert hermes_env["HERMES_STREAM_STALE_TIMEOUT"] == "600"
|
||||
assert hermes_env["HERMES_API_CALL_STALE_TIMEOUT"] == "600"
|
||||
assert hermes["command"] == ["/bin/sh", "-ec"]
|
||||
startup = hermes["args"][0]
|
||||
assert ". /opt/data/.env" in startup
|
||||
assert "exec /init /opt/hermes/docker/main-wrapper.sh gateway run" in startup
|
||||
hermes_env = {item["name"]: item["value"] for item in hermes["env"]}
|
||||
assert hermes_env["HERMES_DASHBOARD"] == "1"
|
||||
assert hermes_env["HERMES_DASHBOARD_HOST"] == "127.0.0.1"
|
||||
assert hermes_env["HERMES_DASHBOARD_PORT"] == "9119"
|
||||
assert hermes_env["HERMES_TUI_AGENT_INIT_TIMEOUT_S"] == "180"
|
||||
assert hermes["securityContext"]["runAsUser"] == 0
|
||||
assert hermes["securityContext"]["runAsGroup"] == 0
|
||||
for probe_name in ("startupProbe", "readinessProbe", "livenessProbe"):
|
||||
probe = hermes[probe_name]
|
||||
assert probe["exec"]["command"] == [
|
||||
"curl",
|
||||
"-fsS",
|
||||
"http://127.0.0.1:9119/api/status",
|
||||
]
|
||||
|
||||
terminal = containers["terminal"]
|
||||
command = terminal["args"][0]
|
||||
assert "--base-path /terminal" in command
|
||||
assert "--check-origin" not in command
|
||||
assert "/usr/bin/tmux new-session -A" in command
|
||||
assert "--continue" in command
|
||||
assert "--yolo" in command
|
||||
terminal_env = {item["name"]: item["value"] for item in terminal["env"]}
|
||||
assert terminal_env["HERMES_TUI_AGENT_INIT_TIMEOUT_S"] == "180"
|
||||
|
||||
claude_broker = containers["claude-broker"]
|
||||
claude_env = {item["name"]: item["value"] for item in claude_broker["env"]}
|
||||
assert claude_env["HERMES_CLAUDE_BROKER_CONCURRENCY"] == "2"
|
||||
assert claude_broker["readinessProbe"]["tcpSocket"] == {"port": "claude-broker"}
|
||||
assert claude_broker["livenessProbe"]["tcpSocket"] == {"port": "claude-broker"}
|
||||
|
||||
args = containers["oauth2-proxy"]["args"]
|
||||
terminal_upstream = "--upstream=http://127.0.0.1:7681/terminal/"
|
||||
dashboard_upstream = "--upstream=http://127.0.0.1:9119/"
|
||||
assert terminal_upstream in args
|
||||
assert dashboard_upstream in args
|
||||
assert args.index(terminal_upstream) < args.index(dashboard_upstream)
|
||||
assert "--pass-host-header=false" in args
|
||||
assert "--cookie-refresh=19m" in args
|
||||
assert "--session-store-type=redis" in args
|
||||
assert any(
|
||||
arg.startswith("--redis-connection-url=redis://hermes-oauth-sessions.")
|
||||
for arg in args
|
||||
)
|
||||
|
||||
patch_init = next(
|
||||
item for item in pod["initContainers"]
|
||||
if item["name"] == "patch-tui-gateway"
|
||||
)
|
||||
assert patch_init["command"][-1] == "/patched/server.py"
|
||||
for name in ("hermes", "terminal"):
|
||||
mounts = containers[name]["volumeMounts"]
|
||||
assert {
|
||||
"name": "tui-gateway-patch",
|
||||
"mountPath": "/opt/hermes/tui_gateway/server.py",
|
||||
"subPath": "server.py",
|
||||
} in mounts
|
||||
|
||||
ingress_documents = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "agent-ingress.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
middlewares = {
|
||||
item["metadata"]["name"]: item
|
||||
for item in ingress_documents
|
||||
if item["kind"] == "Middleware"
|
||||
}
|
||||
assert middlewares["hermes-agent-terminal-slash"]["spec"]["redirectRegex"][
|
||||
"replacement"
|
||||
].endswith("/terminal/")
|
||||
assert middlewares["hermes-agent-stock-dashboard-headers"]["spec"]["headers"][
|
||||
"customRequestHeaders"
|
||||
]["Origin"] == "http://127.0.0.1:9119"
|
||||
ingresses = {
|
||||
item["metadata"]["name"]: item
|
||||
for item in ingress_documents
|
||||
if item["kind"] == "Ingress"
|
||||
}
|
||||
assert ingresses["hermes-agent-dashboard"]["metadata"]["annotations"][
|
||||
"traefik.ingress.kubernetes.io/router.middlewares"
|
||||
] == "hermes-hermes-agent-stock-dashboard-headers@kubernetescrd"
|
||||
assert ingresses["hermes-agent-terminal"]["metadata"]["annotations"][
|
||||
"traefik.ingress.kubernetes.io/router.middlewares"
|
||||
] == "hermes-hermes-agent-terminal-slash@kubernetescrd"
|
||||
|
||||
|
||||
def test_broker_services_survive_sibling_container_readiness_loss():
|
||||
services = _services()
|
||||
|
||||
for name in (
|
||||
"hermes-image-broker",
|
||||
"hermes-codex-broker",
|
||||
"hermes-local-image",
|
||||
"hermes-claude-broker",
|
||||
):
|
||||
assert services[name]["spec"]["publishNotReadyAddresses"] is True
|
||||
|
||||
|
||||
def test_cli_lane_domain_modules_are_all_mounted_with_the_runner():
|
||||
manifest = yaml.safe_load((HERMES / "kustomization.yaml").read_text())
|
||||
coordinator = next(
|
||||
item
|
||||
for item in manifest["configMapGenerator"]
|
||||
if item["name"] == "hermes-coordinator"
|
||||
)
|
||||
mounted = {entry.split("=", 1)[0] for entry in coordinator["files"]}
|
||||
expected = {
|
||||
f"cli_lane_{domain}.py"
|
||||
for domain in (
|
||||
"board",
|
||||
"config",
|
||||
"dispatch",
|
||||
"evidence",
|
||||
"execution",
|
||||
"files",
|
||||
"finalization",
|
||||
"goal",
|
||||
"prompt",
|
||||
"provider",
|
||||
"quarantine",
|
||||
"records",
|
||||
"recovery",
|
||||
"retention",
|
||||
"routing",
|
||||
"runner",
|
||||
)
|
||||
}
|
||||
assert expected <= mounted
|
||||
|
||||
|
||||
def test_cli_lane_config_refresh_does_not_restart_active_work():
|
||||
"""Keep projected script refreshes separate from pod lifecycle changes."""
|
||||
manifest = yaml.safe_load((HERMES / "kustomization.yaml").read_text())
|
||||
coordinator = next(
|
||||
item
|
||||
for item in manifest["configMapGenerator"]
|
||||
if item["name"] == "hermes-coordinator"
|
||||
)
|
||||
deployment = _agent_deployment()
|
||||
lane = next(
|
||||
item
|
||||
for item in deployment["spec"]["template"]["spec"]["containers"]
|
||||
if item["name"] == "cli-lane-runner"
|
||||
)
|
||||
mount = next(
|
||||
item for item in lane["volumeMounts"] if item["name"] == "coordinator"
|
||||
)
|
||||
|
||||
assert coordinator["options"]["disableNameSuffixHash"] is True
|
||||
assert mount == {
|
||||
"name": "coordinator",
|
||||
"mountPath": "/opt/coordinator",
|
||||
"readOnly": True,
|
||||
}
|
||||
assert "checksum/hermes-coordinator" not in deployment["spec"]["template"].get(
|
||||
"metadata", {}
|
||||
).get("annotations", {})
|
||||
|
||||
|
||||
def test_agent_dashboard_reconnects_all_transient_websockets():
|
||||
dockerfile = (
|
||||
HERMES.parents[1] / "dockerfiles/Dockerfile.hermes-agent"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "eventsRetryAttempt.current" in dockerfile
|
||||
assert "if (!unmounting) setVersion((v) => v + 1);" in dockerfile
|
||||
assert "events feed rejected (${ev.code}) — reload the page" in dockerfile
|
||||
assert 'url = await api.buildWsUrl("/api/pty", params);' in dockerfile
|
||||
assert 'url = await buildWsUrl("/api/events", { channel });' in dockerfile
|
||||
assert dockerfile.count("' await api.getSessions(1, 0,") == 2
|
||||
assert ".then(() => gw.connect())" in dockerfile
|
||||
assert 'api.getSessions(1, 0, profile ?? "")' in dockerfile
|
||||
assert "dashboard token rotated by a server restart" in dockerfile
|
||||
329
testing/tests/test_hermes_agent_runtime_patches.py
Normal file
329
testing/tests/test_hermes_agent_runtime_patches.py
Normal file
@ -0,0 +1,329 @@
|
||||
"""Pinned operator tooling and runtime patch contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
HERMES,
|
||||
Path,
|
||||
SCRIPTS,
|
||||
_agent_deployment,
|
||||
auth_patch,
|
||||
client_config,
|
||||
codex_runtime_patch,
|
||||
json,
|
||||
pytest,
|
||||
ttyd_patch,
|
||||
tui_gateway_patch,
|
||||
yaml,
|
||||
)
|
||||
|
||||
|
||||
def test_owner_agent_installs_the_pinned_operator_toolchain():
|
||||
script = (SCRIPTS / "install_agent_tools.sh").read_text()
|
||||
for value in [
|
||||
"flux",
|
||||
"helm",
|
||||
"kustomize",
|
||||
"jq",
|
||||
"yq",
|
||||
"gh",
|
||||
"vault",
|
||||
"sops",
|
||||
"age",
|
||||
"age-keygen",
|
||||
"k9s",
|
||||
"terraform",
|
||||
"go",
|
||||
"gofmt",
|
||||
]:
|
||||
assert value in script
|
||||
assert "go1.26.5.linux-arm64.tar.gz" in script
|
||||
assert (
|
||||
"fe4789e92b1f33358680864bbe8704289e7bb5fc207d80623c308935bd696d49"
|
||||
in script
|
||||
)
|
||||
assert script.count("sha256sum -c -") == 1
|
||||
|
||||
deployment = _agent_deployment()
|
||||
installer = next(
|
||||
item
|
||||
for item in deployment["spec"]["template"]["spec"]["initContainers"]
|
||||
if item["name"] == "install-agent-tools"
|
||||
)
|
||||
assert "/bin/sh /opt/coordinator/install_agent_tools.sh" in installer["command"][2]
|
||||
assert any(mount["name"] == "coordinator" for mount in installer["volumeMounts"])
|
||||
init_config = next(
|
||||
item
|
||||
for item in deployment["spec"]["template"]["spec"]["initContainers"]
|
||||
if item["name"] == "init-config"
|
||||
)
|
||||
init_command = init_config["command"][2]
|
||||
assert "# Hermes managed operator PATH." in init_command
|
||||
assert "/opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin" in init_command
|
||||
assert 'chmod 0644 "${profile_file}"' in init_command
|
||||
|
||||
|
||||
def test_owner_agent_uses_only_the_canonical_hostname():
|
||||
paths = [
|
||||
HERMES / "agent-configmap.yaml",
|
||||
HERMES / "agent-deployment.yaml",
|
||||
HERMES / "agent-ingress.yaml",
|
||||
Path(__file__).parents[2] / "scripts/ops/hermes_triage_monitor.py",
|
||||
]
|
||||
for path in paths:
|
||||
content = path.read_text()
|
||||
assert "agent.bstein.dev" not in content
|
||||
assert "agent.hermes.bstein.dev" in content
|
||||
|
||||
|
||||
def test_agent_reconnect_retains_complete_history_and_long_tool_budget():
|
||||
configmap = yaml.safe_load((HERMES / "agent-configmap.yaml").read_text())
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
display = config["display"]
|
||||
assert display["resume_exchanges"] >= 10000
|
||||
assert display["resume_max_user_chars"] >= 10000000
|
||||
assert display["resume_max_assistant_chars"] >= 10000000
|
||||
assert config["agent"]["max_turns"] == 180
|
||||
assert config["delegation"]["max_iterations"] == 120
|
||||
|
||||
|
||||
def test_auth_patch_honors_explicit_shared_store(tmp_path: Path):
|
||||
source = tmp_path / "auth.py"
|
||||
destination = tmp_path / "patched/auth.py"
|
||||
source.write_text(
|
||||
'from pathlib import Path\nimport os\n\ndef _auth_file_path() -> Path:\n path = get_hermes_home() / "auth.json"\n return path\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
auth_patch.patch(source, destination)
|
||||
content = destination.read_text()
|
||||
assert 'os.environ.get("HERMES_AUTH_FILE"' in content
|
||||
|
||||
|
||||
def test_auth_patch_fails_closed_on_upstream_drift(tmp_path: Path):
|
||||
source = tmp_path / "auth.py"
|
||||
source.write_text("def changed():\n pass\n", encoding="utf-8")
|
||||
with pytest.raises(RuntimeError, match="context changed"):
|
||||
auth_patch.patch(source, tmp_path / "patched.py")
|
||||
|
||||
|
||||
def test_codex_runtime_patch_uses_cli_and_forwards_route(tmp_path: Path):
|
||||
provider = tmp_path / "runtime_provider.py"
|
||||
provider.write_text(codex_runtime_patch.PROVIDER_BEFORE, encoding="utf-8")
|
||||
provider_out = tmp_path / "patched/runtime_provider.py"
|
||||
codex_runtime_patch.patch_provider(provider, provider_out)
|
||||
assert '"api_mode": "codex_app_server"' in provider_out.read_text()
|
||||
|
||||
session = tmp_path / "codex_app_server_session.py"
|
||||
session.write_text(
|
||||
codex_runtime_patch.SESSION_SIGNATURE_BEFORE
|
||||
+ codex_runtime_patch.SESSION_REQUEST_BEFORE,
|
||||
encoding="utf-8",
|
||||
)
|
||||
session_out = tmp_path / "patched/codex_app_server_session.py"
|
||||
codex_runtime_patch.patch_session(session, session_out)
|
||||
session_content = session_out.read_text()
|
||||
assert 'turn_params["model"] = model' in session_content
|
||||
assert 'turn_params["effort"] = effort' in session_content
|
||||
assert '"approvalPolicy": "never"' in session_content
|
||||
assert '"sandboxPolicy": {"type": "dangerFullAccess"}' in session_content
|
||||
|
||||
turn = tmp_path / "codex_runtime.py"
|
||||
turn.write_text(
|
||||
codex_runtime_patch.FALLBACK_CONTEXT_BEFORE
|
||||
+ codex_runtime_patch.TURN_BEFORE,
|
||||
encoding="utf-8",
|
||||
)
|
||||
turn_out = tmp_path / "patched/codex_runtime.py"
|
||||
codex_runtime_patch.patch_turn(turn, turn_out)
|
||||
turn_content = turn_out.read_text()
|
||||
assert "model=str(getattr(agent" in turn_content
|
||||
assert "build_cross_provider_codex_prompt" in turn_content
|
||||
|
||||
fallback = tmp_path / "chat_completion_helpers.py"
|
||||
fallback.write_text(
|
||||
codex_runtime_patch.FALLBACK_RESOLUTION_BEFORE,
|
||||
encoding="utf-8",
|
||||
)
|
||||
fallback_out = tmp_path / "patched/chat_completion_helpers.py"
|
||||
codex_runtime_patch.patch_fallback(fallback, fallback_out)
|
||||
fallback_content = fallback_out.read_text()
|
||||
assert 'agent.api_mode = "codex_app_server"' in fallback_content
|
||||
assert "agent._codex_cross_provider_fallback = True" in fallback_content
|
||||
|
||||
loop = tmp_path / "conversation_loop.py"
|
||||
loop.write_text(
|
||||
codex_runtime_patch.FALLBACK_DISPATCH_BEFORE
|
||||
+ codex_runtime_patch.RETRY_FALLBACK_DISPATCH_BEFORE
|
||||
+ codex_runtime_patch.STREAM_RECOVERY_BEFORE,
|
||||
encoding="utf-8",
|
||||
)
|
||||
loop_out = tmp_path / "patched/conversation_loop.py"
|
||||
codex_runtime_patch.patch_loop(loop, loop_out)
|
||||
loop_content = loop_out.read_text()
|
||||
assert loop_content.count('if agent.api_mode == "codex_app_server"') == 2
|
||||
assert "build_cross_provider_codex_prompt" in loop_content
|
||||
retry_dispatch = loop_content.index(
|
||||
"Fallback activation happens inside this retry loop"
|
||||
)
|
||||
api_kwargs = loop_content.find("agent._build_api_kwargs", retry_dispatch)
|
||||
assert api_kwargs == -1 or retry_dispatch < api_kwargs
|
||||
assert "Provider stream ended before a complete response" in loop_content
|
||||
assert "_is_transport_stub" in loop_content
|
||||
assert "rerouting ({truncated_tool_call_retries}/4)" in loop_content
|
||||
|
||||
auxiliary = tmp_path / "auxiliary_client.py"
|
||||
auxiliary.write_text(
|
||||
codex_runtime_patch.AUXILIARY_TOKEN_BEFORE,
|
||||
encoding="utf-8",
|
||||
)
|
||||
auxiliary_out = tmp_path / "patched/auxiliary_client.py"
|
||||
codex_runtime_patch.patch_auxiliary(auxiliary, auxiliary_out)
|
||||
auxiliary_content = auxiliary_out.read_text()
|
||||
assert 'os.environ.get("CODEX_HOME"' in auxiliary_content
|
||||
assert 'Path(codex_home).expanduser() / "auth.json"' in auxiliary_content
|
||||
assert "never creates a metered API-key lane" in auxiliary_content
|
||||
|
||||
|
||||
def test_agent_mounts_codex_auxiliary_runtime_patch():
|
||||
deployment = _agent_deployment()
|
||||
pod = deployment["spec"]["template"]["spec"]
|
||||
patch_init = next(
|
||||
item for item in pod["initContainers"]
|
||||
if item["name"] == "patch-codex-runtime"
|
||||
)
|
||||
assert patch_init["command"][-2:] == [
|
||||
"/opt/hermes/agent/auxiliary_client.py",
|
||||
"/patched/auxiliary_client.py",
|
||||
]
|
||||
expected_mount = {
|
||||
"name": "codex-runtime-patch",
|
||||
"mountPath": "/opt/hermes/agent/auxiliary_client.py",
|
||||
"subPath": "auxiliary_client.py",
|
||||
}
|
||||
containers = {item["name"]: item for item in pod["containers"]}
|
||||
for name in ("hermes", "terminal"):
|
||||
assert expected_mount in containers[name]["volumeMounts"]
|
||||
|
||||
|
||||
def test_codex_auxiliary_patch_reads_cli_token_without_copying_it(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
codex_home = tmp_path / ".codex"
|
||||
codex_home.mkdir()
|
||||
(codex_home / "auth.json").write_text(
|
||||
json.dumps({"tokens": {"access_token": "cli-access-token"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
source = tmp_path / "auxiliary_client.py"
|
||||
source.write_text(
|
||||
"import json, logging, os, time\n"
|
||||
"from pathlib import Path\n"
|
||||
"logger = logging.getLogger(__name__)\n"
|
||||
"def read_token():\n"
|
||||
" try:\n"
|
||||
" raise RuntimeError('Hermes provider store intentionally empty')\n"
|
||||
+ codex_runtime_patch.AUXILIARY_TOKEN_BEFORE,
|
||||
encoding="utf-8",
|
||||
)
|
||||
destination = tmp_path / "patched/auxiliary_client.py"
|
||||
codex_runtime_patch.patch_auxiliary(source, destination)
|
||||
namespace: dict = {}
|
||||
exec(compile(destination.read_text(), str(destination), "exec"), namespace)
|
||||
|
||||
assert namespace["read_token"]() == "cli-access-token"
|
||||
|
||||
|
||||
def test_codex_runtime_patch_fails_closed_on_upstream_drift(tmp_path: Path):
|
||||
source = tmp_path / "runtime_provider.py"
|
||||
source.write_text("def changed():\n pass\n", encoding="utf-8")
|
||||
with pytest.raises(RuntimeError, match="context changed"):
|
||||
codex_runtime_patch.patch_provider(source, tmp_path / "patched.py")
|
||||
|
||||
|
||||
def test_codex_runtime_migration_uses_owner_unsafe_mode(tmp_path: Path):
|
||||
config = tmp_path / "config.yaml"
|
||||
config.write_text("model: {}\n", encoding="utf-8")
|
||||
codex_home = tmp_path / ".codex"
|
||||
calls = []
|
||||
|
||||
class Report:
|
||||
errors = []
|
||||
|
||||
@staticmethod
|
||||
def summary():
|
||||
return "configured"
|
||||
|
||||
def migrate(value, **kwargs):
|
||||
calls.append((value, kwargs))
|
||||
return Report()
|
||||
|
||||
client_config.configure_codex_runtime(config, migrate, codex_home)
|
||||
|
||||
assert calls[0][1]["default_permission_profile"] is None
|
||||
assert calls[0][1]["codex_home"] == codex_home
|
||||
content = (codex_home / "config.toml").read_text(encoding="utf-8")
|
||||
assert 'approval_policy = "never"' in content
|
||||
assert 'sandbox_mode = "danger-full-access"' in content
|
||||
assert "default_permissions" not in content
|
||||
|
||||
|
||||
def test_codex_owner_permissions_replace_stale_profile(tmp_path: Path):
|
||||
config = tmp_path / "config.toml"
|
||||
config.write_text(
|
||||
'default_permissions = ":danger-no-sandbox"\n\n[features]\nhooks = true\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
client_config.configure_codex_owner_permissions(config)
|
||||
client_config.configure_codex_owner_permissions(config)
|
||||
|
||||
content = config.read_text(encoding="utf-8")
|
||||
assert content.count(client_config.OWNER_PERMISSIONS_BEGIN) == 1
|
||||
assert content.count('approval_policy = "never"') == 1
|
||||
assert content.count('sandbox_mode = "danger-full-access"') == 1
|
||||
assert "default_permissions" not in content
|
||||
assert "[features]\nhooks = true" in content
|
||||
|
||||
|
||||
def test_tui_gateway_patch_extends_and_bounds_agent_startup(tmp_path: Path):
|
||||
source = tmp_path / "server.py"
|
||||
destination = tmp_path / "patched/server.py"
|
||||
source.write_text(
|
||||
"import os\n\n" + tui_gateway_patch.BEFORE + "\ndef unchanged():\n pass\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
tui_gateway_patch.patch(source, destination)
|
||||
|
||||
content = destination.read_text(encoding="utf-8")
|
||||
assert "HERMES_TUI_AGENT_INIT_TIMEOUT_S" in content
|
||||
assert 'configured = 180.0' in content
|
||||
assert "return max(30.0, min(configured, 900.0))" in content
|
||||
assert "timeout: float | None = None" in content
|
||||
assert "ready.wait(timeout=wait_timeout)" in content
|
||||
assert "def unchanged():" in content
|
||||
|
||||
|
||||
def test_tui_gateway_patch_fails_closed_on_upstream_drift(tmp_path: Path):
|
||||
source = tmp_path / "server.py"
|
||||
source.write_text("def changed():\n pass\n", encoding="utf-8")
|
||||
with pytest.raises(RuntimeError, match="context changed"):
|
||||
tui_gateway_patch.patch(source, tmp_path / "patched.py")
|
||||
|
||||
|
||||
def test_ttyd_clipboard_and_reconnect_patch_remain_enabled():
|
||||
source = '<html><body><script>document.execCommand("copy")</script></body></html>'
|
||||
content = ttyd_patch.patch_html(source)
|
||||
assert 'id="atlas-ttyd-clipboard"' in content
|
||||
assert "navigator.clipboard.writeText(text)" in content
|
||||
assert "class AtlasRecoveringWebSocket" in content
|
||||
assert "window.location.reload()" in content
|
||||
assert "event.stopImmediatePropagation()" in content
|
||||
|
||||
|
||||
def test_ttyd_patch_fails_closed_on_upstream_drift():
|
||||
with pytest.raises(RuntimeError, match="context changed"):
|
||||
ttyd_patch.patch_html("<html><body>changed</body></html>")
|
||||
202
testing/tests/test_hermes_agent_security.py
Normal file
202
testing/tests/test_hermes_agent_security.py
Normal file
@ -0,0 +1,202 @@
|
||||
"""Agent authentication, networking, and Flux health contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
FLUX_HERMES,
|
||||
HERMES,
|
||||
KEYCLOAK,
|
||||
_agent_deployment,
|
||||
yaml,
|
||||
)
|
||||
|
||||
|
||||
def test_agent_image_runs_execution_safety_patch_and_regressions():
|
||||
dockerfiles = HERMES.parents[1] / "dockerfiles"
|
||||
dockerfile = (dockerfiles / "Dockerfile.hermes-agent").read_text(encoding="utf-8")
|
||||
dockerignore = (dockerfiles / "Dockerfile.hermes-agent.dockerignore").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
for name in (
|
||||
"patch-hermes-execution-safety.py",
|
||||
"hermes-execution-safety-regression.py",
|
||||
"hermes_execution_patch_support.py",
|
||||
"patch_hermes_run_safety.py",
|
||||
"patch_hermes_decomposition_safety.py",
|
||||
"hermes_execution_regression_support.py",
|
||||
"hermes_run_safety_regression.py",
|
||||
"hermes_decomposition_safety_regression.py",
|
||||
):
|
||||
assert f"COPY dockerfiles/{name}" in dockerfile
|
||||
assert f"!dockerfiles/{name}" in dockerignore
|
||||
assert "/opt/hermes/.venv/bin/python /tmp/patch-hermes-execution-safety.py" in (
|
||||
dockerfile
|
||||
)
|
||||
assert "/opt/hermes/.venv/bin/python /tmp/hermes-execution-safety-regression.py" in (
|
||||
dockerfile
|
||||
)
|
||||
assert "COPY services/hermes/scripts/cli_lane_*.py" in dockerfile
|
||||
assert "!services/hermes/scripts/cli_lane_*.py" in dockerignore
|
||||
assert "HERMES_CLI_LANE_SOURCE=/tmp/hermes-lane-regression" in dockerfile
|
||||
|
||||
|
||||
def test_agent_refreshes_routes_after_restoring_cli_logins():
|
||||
deployment = _agent_deployment()
|
||||
init_containers = {
|
||||
item["name"]: item
|
||||
for item in deployment["spec"]["template"]["spec"]["initContainers"]
|
||||
}
|
||||
configure = init_containers["configure-agent-clients"]
|
||||
command = configure["command"][-1]
|
||||
assert "configure_agent_clients.py" in command
|
||||
assert command.index("configure_agent_clients.py") < command.index(
|
||||
"hermes_coordinator.py --once"
|
||||
)
|
||||
env = {item["name"]: item["value"] for item in configure["env"]}
|
||||
assert env["HERMES_AUTH_FILE"] == "/runtime-access/hermes-auth.json"
|
||||
assert env["PYTHONPATH"] == "/opt/hermes"
|
||||
for name in ("bootstrap-coordinator", "configure-agent-clients"):
|
||||
route_env = {
|
||||
item["name"]: item["value"]
|
||||
for item in init_containers[name]["env"]
|
||||
}
|
||||
assert route_env["CODEX_HOME"] == "/runtime-access/codex"
|
||||
assert route_env["CLAUDE_CONFIG_DIR"] == "/runtime-access/claude"
|
||||
assert "/opt/data/tools/bin" in route_env["PATH"]
|
||||
|
||||
assert "patch-web-session-activity" in init_containers
|
||||
web_patch = init_containers["patch-web-session-activity"]
|
||||
assert "/opt/coordinator/patch_web_session_activity.py" in web_patch["command"]
|
||||
|
||||
containers = {
|
||||
item["name"]: item
|
||||
for item in deployment["spec"]["template"]["spec"]["containers"]
|
||||
}
|
||||
steward_env = {
|
||||
item["name"]: item["value"]
|
||||
for item in containers["model-steward"]["env"]
|
||||
}
|
||||
assert steward_env["CODEX_HOME"] == "/runtime-access/codex"
|
||||
assert steward_env["CLAUDE_CONFIG_DIR"] == "/runtime-access/claude"
|
||||
assert "/opt/data/tools/bin" in steward_env["PATH"]
|
||||
hermes_mounts = {
|
||||
(item["name"], item["mountPath"], item.get("subPath"))
|
||||
for item in containers["hermes"]["volumeMounts"]
|
||||
}
|
||||
assert (
|
||||
"web-server-patch",
|
||||
"/opt/hermes/hermes_cli/web_server.py",
|
||||
"web_server.py",
|
||||
) in hermes_mounts
|
||||
|
||||
|
||||
def test_flux_health_checks_follow_the_owner_oauth_sidecar():
|
||||
flux = yaml.safe_load(FLUX_HERMES.read_text())
|
||||
checks = {
|
||||
(item["kind"], item["name"])
|
||||
for item in flux["spec"]["healthChecks"]
|
||||
}
|
||||
assert ("Deployment", "hermes-agent") in checks
|
||||
assert ("DaemonSet", "hermes-node-ssh-access") in checks
|
||||
assert ("Deployment", "oauth2-proxy-hermes-agent") not in checks
|
||||
|
||||
|
||||
def test_agent_auth_is_bstein_group_and_email_bounded():
|
||||
deployment = _agent_deployment()
|
||||
oauth = next(
|
||||
item for item in deployment["spec"]["template"]["spec"]["containers"]
|
||||
if item["name"] == "oauth2-proxy"
|
||||
)
|
||||
args = oauth["args"]
|
||||
assert "--user-id-claim=sub" in args
|
||||
assert "--oidc-groups-claim=groups" in args
|
||||
assert "--allowed-group=/hermes-owner" in args
|
||||
assert "--authenticated-emails-file=/etc/oauth2-proxy/allowed-emails" in args
|
||||
script = (KEYCLOAK / "scripts/hermes_access_oidc_ensure.sh").read_text()
|
||||
assert 'group_name="hermes-owner"' in script
|
||||
assert "username=bstein&exact=true" in script
|
||||
assert '"full.path":"true"' in script
|
||||
|
||||
|
||||
def test_agent_network_boundary_allows_only_authenticated_and_metrics_surfaces():
|
||||
documents = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "networkpolicy.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
isolation = next(item for item in documents if item.get("metadata", {}).get("name") == "hermes-agent-isolation")
|
||||
assert isolation["spec"]["ingress"] == [
|
||||
{
|
||||
"from": [
|
||||
{
|
||||
"namespaceSelector": {
|
||||
"matchLabels": {
|
||||
"kubernetes.io/metadata.name": "traefik"
|
||||
}
|
||||
},
|
||||
"podSelector": {
|
||||
"matchLabels": {"app.kubernetes.io/name": "traefik"}
|
||||
},
|
||||
}
|
||||
],
|
||||
"ports": [{"protocol": "TCP", "port": 4180}],
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
{
|
||||
"podSelector": {
|
||||
"matchLabels": {"app": "hermes-chat-tenant"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"ports": [
|
||||
{"protocol": "TCP", "port": 9002},
|
||||
{"protocol": "TCP", "port": 9003},
|
||||
],
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
{
|
||||
"podSelector": {
|
||||
"matchLabels": {"app": "hermes-switchyard"}
|
||||
}
|
||||
}
|
||||
],
|
||||
"ports": [
|
||||
{"protocol": "TCP", "port": 9003},
|
||||
{"protocol": "TCP", "port": 9006},
|
||||
],
|
||||
},
|
||||
{
|
||||
"from": [
|
||||
{
|
||||
"namespaceSelector": {
|
||||
"matchLabels": {
|
||||
"kubernetes.io/metadata.name": "monitoring"
|
||||
}
|
||||
},
|
||||
"podSelector": {"matchLabels": {"app": "server"}},
|
||||
}
|
||||
],
|
||||
"ports": [{"protocol": "TCP", "port": 9010}],
|
||||
},
|
||||
]
|
||||
assert isolation["spec"]["egress"] == [{}]
|
||||
|
||||
|
||||
def test_owner_agent_has_cluster_admin_kubernetes_context():
|
||||
config = yaml.safe_load((HERMES / "agent-kubeconfig.yaml").read_text())
|
||||
assert config["current-context"] == "atlas-owner"
|
||||
assert config["contexts"][0]["context"]["namespace"] == "default"
|
||||
rbac_path = HERMES / "agent-rbac.yaml"
|
||||
documents = [item for item in yaml.safe_load_all(rbac_path.read_text()) if item]
|
||||
binding = next(item for item in documents if item["kind"] == "ClusterRoleBinding")
|
||||
assert binding["roleRef"] == {
|
||||
"apiGroup": "rbac.authorization.k8s.io",
|
||||
"kind": "ClusterRole",
|
||||
"name": "cluster-admin",
|
||||
}
|
||||
assert binding["subjects"] == [
|
||||
{"kind": "ServiceAccount", "name": "hermes-agent", "namespace": "hermes"}
|
||||
]
|
||||
75
testing/tests/test_hermes_chat_codex_broker.py
Normal file
75
testing/tests/test_hermes_chat_codex_broker.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Hermes chat codex broker contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
ModuleType,
|
||||
Path,
|
||||
_load_broker_module,
|
||||
base64,
|
||||
json,
|
||||
sys,
|
||||
time,
|
||||
)
|
||||
|
||||
|
||||
def test_codex_broker_refreshes_and_persists_first_party_oauth(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Expired ChatGPT OAuth refreshes in the canonical Codex CLI store."""
|
||||
module = _load_broker_module(
|
||||
"hermes_codex_refresh_broker", "codex_broker.py", monkeypatch
|
||||
)
|
||||
auth_dir = tmp_path / ".codex"
|
||||
auth_dir.mkdir()
|
||||
|
||||
def jwt(expires_at: float) -> str:
|
||||
payload = base64.urlsafe_b64encode(
|
||||
json.dumps({"exp": expires_at}).encode()
|
||||
).decode().rstrip("=")
|
||||
return f"header.{payload}.signature"
|
||||
|
||||
expired = jwt(time.time() - 60)
|
||||
live = jwt(time.time() + 3600)
|
||||
auth_path = auth_dir / "auth.json"
|
||||
auth_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"auth_mode": "chatgpt",
|
||||
"tokens": {
|
||||
"access_token": expired,
|
||||
"refresh_token": "refresh-old",
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
calls = []
|
||||
auth_module = ModuleType("hermes_cli.auth")
|
||||
|
||||
def refresh(access_token, refresh_token, *, timeout_seconds):
|
||||
calls.append((access_token, refresh_token, timeout_seconds))
|
||||
return {
|
||||
"access_token": live,
|
||||
"refresh_token": "refresh-new",
|
||||
"last_refresh": "2026-08-12T20:00:00Z",
|
||||
}
|
||||
|
||||
auth_module.refresh_codex_oauth_pure = refresh
|
||||
package = ModuleType("hermes_cli")
|
||||
package.auth = auth_module
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", package)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli.auth", auth_module)
|
||||
monkeypatch.setenv("CODEX_HOME", str(auth_dir))
|
||||
|
||||
assert module._access_token() == live
|
||||
persisted = json.loads(auth_path.read_text())
|
||||
assert persisted["tokens"]["access_token"] == live
|
||||
assert persisted["tokens"]["refresh_token"] == "refresh-new"
|
||||
assert persisted["last_refresh"] == "2026-08-12T20:00:00Z"
|
||||
assert calls == [(expired, "refresh-old", 30.0)]
|
||||
assert auth_path.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
# A healthy token is reused, so repeated routed turns do not spend a
|
||||
# refresh token or create a second billing/authentication path.
|
||||
assert module._access_token() == live
|
||||
assert len(calls) == 1
|
||||
@ -1,19 +1,15 @@
|
||||
"""Core configuration and workload contracts for Hermes chat."""
|
||||
"""Hermes chat config contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import yaml
|
||||
|
||||
from testing.tests.test_hermes_chat_support import (
|
||||
from test_hermes_chat_support import (
|
||||
HERMES,
|
||||
ROOT,
|
||||
_documents,
|
||||
yaml,
|
||||
)
|
||||
|
||||
|
||||
def test_chat_config_enables_real_research_compute_and_delegation():
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
@ -41,7 +37,6 @@ def test_chat_config_enables_real_research_compute_and_delegation():
|
||||
assert "terminal" not in toolsets
|
||||
assert "code_execution" not in toolsets
|
||||
|
||||
|
||||
def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
|
||||
configmap = _documents(HERMES / "agent-configmap.yaml")[0]
|
||||
instructions = configmap["data"]["AGENTS.md"]
|
||||
@ -61,30 +56,22 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
|
||||
assert "Atlas organization has private visibility" in instructions
|
||||
assert "may be public or private" in instructions
|
||||
assert "do not infer a\nrepository's visibility" in instructions
|
||||
assert "brokered Git for clone, fetch" in instructions
|
||||
assert "client carries no repository credential" in instructions
|
||||
assert "already supplied through `GIT_ASKPASS`" in instructions
|
||||
assert "Never call `kanban_show` without a known, non-empty task ID" in instructions
|
||||
assert "bounded ad-hoc inspection and acceptance checks may" in instructions
|
||||
assert "load implementation or TDD skills" in instructions
|
||||
assert "Never call `kanban_show` without\na known, non-empty task ID" in soul
|
||||
assert "must load a skill only when its workflow\nmaterially applies" in soul
|
||||
assert "no repository token is present in this pod" in soul
|
||||
assert "`scm.bstein.dev` is Forgejo/Gitea, not GitHub" in instructions
|
||||
assert "GitHub/`gh` skill for an Atlas remote" in instructions
|
||||
assert "load\n`$manage-atlas-pull-requests`" in instructions
|
||||
assert "Updates, merge,\napprove, close, delete, comments" in instructions
|
||||
assert "Leave every PR\nunmerged for Brad's review" in instructions
|
||||
assert "Use the Gitea pull-request merge endpoint" not in instructions
|
||||
assert "reconcile the open PR" not in instructions
|
||||
assert "use Flux reconciliation after" not in instructions
|
||||
assert "PR publication ends at the verified open draft" in instructions
|
||||
assert "runtime-only `GIT_ASKPASS`" in soul
|
||||
assert "`scm.bstein.dev` is Gitea, not GitHub" in instructions
|
||||
assert "Never load or follow a GitHub/`gh`" in instructions
|
||||
assert "/opt/coordinator/gitea_api.py METHOD /api/v1/..." in instructions
|
||||
assert "JENKINS_BASE_URL" in instructions
|
||||
assert "Do not\nuse `git reset --hard`" in instructions
|
||||
rendered = (HERMES / "agent-deployment.yaml").read_text()
|
||||
assert 'git config --global user.name "Hermes Agent"' in rendered
|
||||
assert 'git config --global user.email "hermes@bstein.dev"' in rendered
|
||||
|
||||
|
||||
def test_agent_image_completes_parked_kanban_tasks_atomically():
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-agent").read_text()
|
||||
|
||||
@ -100,7 +87,6 @@ def test_agent_image_completes_parked_kanban_tasks_atomically():
|
||||
assert "payload.get(\"status\") == \"blocked\"" in dockerfile
|
||||
assert "hermes-kanban-blocked-regression.py" 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"]
|
||||
@ -164,7 +150,6 @@ def test_sandbox_shares_only_the_tenant_workspace_without_credentials():
|
||||
f"hermes-chat-tenant-{ordinal}"
|
||||
)
|
||||
|
||||
|
||||
def test_gateway_image_honors_ui_model_and_caps_reasoning():
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-agent").read_text()
|
||||
assert (
|
||||
@ -223,7 +208,6 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
|
||||
assert 'params.has("resume")' in dockerfile
|
||||
assert "Open Telegram setup" in dockerfile
|
||||
|
||||
|
||||
def test_telegram_router_local_build_defaults_to_cluster_architecture():
|
||||
dockerfile = (
|
||||
ROOT / "dockerfiles" / "Dockerfile.hermes-chat-router"
|
||||
@ -233,7 +217,6 @@ def test_telegram_router_local_build_defaults_to_cluster_architecture():
|
||||
assert "ARG TARGETARCH=arm64" in dockerfile
|
||||
assert "GOOS=${TARGETOS} GOARCH=${TARGETARCH}" in dockerfile
|
||||
|
||||
|
||||
def test_chat_oauth_allows_stale_service_worker_retirement():
|
||||
documents = _documents(HERMES / "oauth2-proxy.yaml")
|
||||
deployment = next(
|
||||
@ -288,169 +271,3 @@ def test_chat_oauth_allows_stale_service_worker_retirement():
|
||||
assert agent_template_volume["configMap"]["name"] == (
|
||||
"hermes-chat-oauth-templates"
|
||||
)
|
||||
|
||||
|
||||
def test_webui_recovers_auth_and_labels_session_scoped_controls():
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
|
||||
|
||||
assert "res.status===401||res.status===403" in dockerfile
|
||||
assert "window.location.assign('/oauth2/start?rd='" in dockerfile
|
||||
assert "childrenExpanded?'▾ ':'▸ '" in dockerfile
|
||||
assert "'atlas/auto/maximum': 'Automatic · Maximum'" in dockerfile
|
||||
assert "hermes-webui-telegram-project-patch.py" in dockerfile
|
||||
telegram_project_patch = (
|
||||
ROOT / "dockerfiles" / "hermes-webui-telegram-project-patch.py"
|
||||
).read_text()
|
||||
assert "TELEGRAM_PROJECT_NAME = 'Telegram'" in telegram_project_patch
|
||||
assert "session_key.startswith('telegram-topic-')" in telegram_project_patch
|
||||
|
||||
router = (ROOT / "dockerfiles" / "hermes-webui-router.js").read_text()
|
||||
assert "'atlas/auto/fast':'AUTO · Fast'" in router
|
||||
assert "'atlas/manual/codex/sol':'Codex · SOL'" in router
|
||||
assert "'atlas/manual/claude/opus':'Claude · Opus'" in router
|
||||
assert "watchModelOptions('modelSelect')" in router
|
||||
assert "watchModelOptions('settingsModel')" in router
|
||||
|
||||
|
||||
def test_chat_voice_uses_private_jetson_services_and_shared_auto_route():
|
||||
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
|
||||
containers = statefulset["spec"]["template"]["spec"]["containers"]
|
||||
webui = next(item for item in containers if item["name"] == "webui")
|
||||
env = {item["name"]: item["value"] for item in webui["env"]}
|
||||
|
||||
assert env["HERMES_STT_URL"] == (
|
||||
"http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions"
|
||||
)
|
||||
assert env["HERMES_WEBUI_ATLAS_TTS_URL"] == (
|
||||
"http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech"
|
||||
)
|
||||
assert "hermes_stt_client.py" in env["HERMES_LOCAL_STT_COMMAND"]
|
||||
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
assert config["stt"] == {
|
||||
"enabled": True,
|
||||
"provider": "local_command",
|
||||
"local": {"model": "small", "language": "auto"},
|
||||
}
|
||||
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
|
||||
assert "hermes-webui-atlas-patch.py" in dockerfile
|
||||
assert "hermes-webui-atlas-voice.js" in dockerfile
|
||||
voice_script = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.js").read_text()
|
||||
assert "/api/transcribe/capability" in voice_script
|
||||
assert "/api/transcribe" in voice_script
|
||||
assert "/api/tts" in voice_script
|
||||
assert "speakResponse(generation)" in voice_script
|
||||
assert "window._splitForTTS(text,280)" in voice_script
|
||||
assert "pending=fetchSpeech(chunks[index+1])" in voice_script
|
||||
assert "restartSoon(token,450)" in voice_script
|
||||
assert "constraints.voiceIsolation=true" in voice_script
|
||||
assert "highpass.frequency.value=140" in voice_script
|
||||
assert "Math.max(0.04,noiseFloor*2.4+0.006)" in voice_script
|
||||
assert "while(preRoll.length>3) preRoll.shift()" in voice_script
|
||||
|
||||
stt_server = (ROOT / "dockerfiles" / "hermes-jetson-stt-server.py").read_text()
|
||||
assert "def _repetitive_token" in stt_server
|
||||
assert "compression_ratio_threshold=2.0" in stt_server
|
||||
assert "no_speech_threshold=0.5" in stt_server
|
||||
|
||||
|
||||
def test_voice_transcript_filter_removes_fan_hallucinations(monkeypatch):
|
||||
server_path = ROOT / "dockerfiles" / "hermes-jetson-stt-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_jetson_stt_server", server_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, "cgi", SimpleNamespace())
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"torch",
|
||||
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False)),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace())
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
result = {
|
||||
"segments": [
|
||||
{
|
||||
"text": " ththththththththth Testing.",
|
||||
"no_speech_prob": 0.12,
|
||||
"avg_logprob": -0.2,
|
||||
},
|
||||
{
|
||||
"text": " background hum",
|
||||
"no_speech_prob": 0.82,
|
||||
"avg_logprob": -0.9,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
assert module._clean_transcript(result) == "Testing."
|
||||
|
||||
|
||||
def test_voice_models_are_baked_and_runtime_has_no_public_egress():
|
||||
stt_dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-stt").read_text()
|
||||
tts_dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-tts").read_text()
|
||||
assert "ADD --checksum=sha256:aff26ae4" in stt_dockerfile
|
||||
assert "ADD --checksum=sha256:9ecf7799" in stt_dockerfile
|
||||
assert "--chmod=0444" in stt_dockerfile
|
||||
assert "chmod 0555 /opt/models /opt/models/whisper" in stt_dockerfile
|
||||
assert "HERMES_STT_CACHE=/opt/models/whisper" in stt_dockerfile
|
||||
assert "ADD --checksum=sha256:4cabf7c3" in tts_dockerfile
|
||||
assert "ADD --checksum=sha256:db42b97d" in tts_dockerfile
|
||||
assert tts_dockerfile.count("--chmod=0444") == 6
|
||||
assert "chmod 0555 /opt/models /opt/models/piper" in tts_dockerfile
|
||||
assert "HERMES_TTS_CACHE=/opt/models/piper" in tts_dockerfile
|
||||
tts_server = (ROOT / "dockerfiles" / "hermes-jetson-tts-server.py").read_text()
|
||||
assert "download_voice" not in tts_server
|
||||
assert "baked Piper voice is missing" in tts_server
|
||||
assert "session_options.intra_op_num_threads = ONNX_THREADS" in tts_server
|
||||
|
||||
policies = _documents(HERMES / "networkpolicy.yaml")
|
||||
voice_policy = next(
|
||||
item for item in policies if item["metadata"]["name"] == "hermes-private-voice"
|
||||
)
|
||||
assert voice_policy["spec"]["policyTypes"] == ["Ingress", "Egress"]
|
||||
assert not any(
|
||||
"ipBlock" in destination
|
||||
for rule in voice_policy["spec"]["egress"]
|
||||
for destination in rule.get("to", [])
|
||||
)
|
||||
|
||||
|
||||
def test_voice_workloads_have_deliberate_xavier_placement():
|
||||
documents = _documents(HERMES / "voice-deployment.yaml")
|
||||
deployments = {
|
||||
item["metadata"]["name"]: item
|
||||
for item in documents
|
||||
if item["kind"] == "Deployment"
|
||||
}
|
||||
stt = deployments["hermes-stt"]["spec"]["template"]["spec"]
|
||||
tts = deployments["hermes-tts"]["spec"]["template"]["spec"]
|
||||
|
||||
assert "@sha256:" in stt["containers"][0]["image"]
|
||||
assert "@sha256:" in tts["containers"][0]["image"]
|
||||
assert stt["nodeSelector"] == {"kubernetes.io/hostname": "titan-21"}
|
||||
assert tts["nodeSelector"] == {"kubernetes.io/hostname": "titan-21"}
|
||||
assert stt["automountServiceAccountToken"] is False
|
||||
assert tts["automountServiceAccountToken"] is False
|
||||
assert stt["enableServiceLinks"] is False
|
||||
assert tts["enableServiceLinks"] is False
|
||||
assert stt["runtimeClassName"] == "nvidia"
|
||||
assert stt["securityContext"]["supplementalGroups"] == [44]
|
||||
stt_resources = stt["containers"][0]["resources"]
|
||||
stt_env = {
|
||||
item["name"]: item["value"] for item in stt["containers"][0]["env"]
|
||||
}
|
||||
assert stt_env["NVIDIA_DRIVER_CAPABILITIES"] == "compute,utility"
|
||||
assert stt_resources["requests"]["nvidia.com/gpu.shared"] == 1
|
||||
assert stt_resources["limits"]["nvidia.com/gpu.shared"] == 1
|
||||
assert "nvidia.com/gpu.shared" not in tts["containers"][0]["resources"]["requests"]
|
||||
tts_env = {
|
||||
item["name"]: item["value"] for item in tts["containers"][0]["env"]
|
||||
}
|
||||
assert tts_env["HERMES_TTS_VOICE"] == "en_US-lessac-medium"
|
||||
assert tts_env["HERMES_TTS_ONNX_THREADS"] == "2"
|
||||
assert tts["containers"][0]["resources"]["limits"]["cpu"] == "4"
|
||||
assert all("hostPath" not in volume for volume in stt["volumes"])
|
||||
assert all("hostPath" not in volume for volume in tts["volumes"])
|
||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import importlib.util
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import yaml
|
||||
@ -163,7 +164,6 @@ def test_compact_image_edit_resolves_latest_tenant_artifact(tmp_path, monkeypatc
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_chat_reasoning_uses_switchyard_without_owner_credentials():
|
||||
"""Family pods use AUTO/manual routes without mounting owner credentials."""
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
@ -280,3 +280,105 @@ def test_chat_reasoning_uses_switchyard_without_owner_credentials():
|
||||
assert broker_ingress["from"][0]["podSelector"]["matchLabels"] == {
|
||||
"app": "hermes-chat-tenant"
|
||||
}
|
||||
def test_image_broker_returns_bytes_and_removes_owner_cache(tmp_path: Path, monkeypatch):
|
||||
"""The broker must not retain a family user's generated image."""
|
||||
broker_path = HERMES / "scripts" / "image_broker.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_image_broker", broker_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
generated = tmp_path / "generated.png"
|
||||
generated.write_bytes(b"\x89PNG\r\n\x1a\nprivate-image")
|
||||
|
||||
class Provider:
|
||||
def generate(self, prompt, aspect, **kwargs):
|
||||
assert prompt == "paint a blue sphere"
|
||||
assert aspect == "square"
|
||||
return {
|
||||
"success": True,
|
||||
"image": str(generated),
|
||||
"model": "gpt-image-2-high",
|
||||
"quality": "high",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(module, "_PROVIDER", Provider())
|
||||
result = module._generate(
|
||||
{
|
||||
"prompt": "paint a blue sphere",
|
||||
"aspect_ratio": "square",
|
||||
"model": "gpt-image-2-high",
|
||||
}
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image_b64"]
|
||||
assert "image" not in result
|
||||
assert not generated.exists()
|
||||
|
||||
def test_image_broker_auto_falls_back_to_local_and_honors_explicit_routes(
|
||||
monkeypatch,
|
||||
):
|
||||
"""AUTO is hosted-first while explicit local never calls the hosted lane."""
|
||||
broker_path = HERMES / "scripts" / "image_broker.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_image_router", broker_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
calls = []
|
||||
|
||||
def hosted(payload, model, prompt, aspect):
|
||||
calls.append(("hosted", model, prompt, aspect))
|
||||
return {"success": False, "error": "hosted refusal"}
|
||||
|
||||
def local(payload, timeout=1800.0):
|
||||
calls.append(("local", payload["model"], timeout))
|
||||
return {
|
||||
"success": True,
|
||||
"image_b64": "aW1hZ2U=",
|
||||
"model": "flux-2-klein-4b-local",
|
||||
"route": "local",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(module, "_generate_hosted", hosted)
|
||||
monkeypatch.setattr(module, "_local_request", local)
|
||||
auto = module._generate(
|
||||
{
|
||||
"prompt": "colorize this family photograph",
|
||||
"aspect_ratio": "portrait",
|
||||
"model": "atlas-image-auto-high",
|
||||
}
|
||||
)
|
||||
assert auto["success"] is True
|
||||
assert auto["route"] == "local"
|
||||
assert auto["hosted_fallback_reason"] == "hosted refusal"
|
||||
assert [call[0] for call in calls] == ["hosted", "local"]
|
||||
|
||||
calls.clear()
|
||||
explicit = module._generate(
|
||||
{
|
||||
"prompt": "make a local landscape",
|
||||
"aspect_ratio": "landscape",
|
||||
"model": "flux-2-klein-4b-local",
|
||||
}
|
||||
)
|
||||
assert explicit["route"] == "local"
|
||||
assert [call[0] for call in calls] == ["local"]
|
||||
|
||||
def test_image_broker_policy_is_narrow_and_operator_extensible(tmp_path: Path, monkeypatch):
|
||||
"""Family-photo restoration stays allowed while the hard boundary remains."""
|
||||
broker_path = HERMES / "scripts" / "image_broker.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_image_policy", broker_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
policy = tmp_path / "policy.json"
|
||||
policy.write_text('{"additional_blocked_phrases":["site-specific block"]}')
|
||||
monkeypatch.setattr(module, "POLICY_PATH", policy)
|
||||
|
||||
assert module._policy_error(
|
||||
"Colorize my baby photograph with a lighter natural skin tone"
|
||||
) is None
|
||||
assert "minors" in module._policy_error("Create a sexual image of a child")
|
||||
assert module._policy_error("A site-specific block request") == (
|
||||
"request is blocked by the operator image policy"
|
||||
)
|
||||
|
||||
295
testing/tests/test_hermes_chat_local_compute.py
Normal file
295
testing/tests/test_hermes_chat_local_compute.py
Normal file
@ -0,0 +1,295 @@
|
||||
"""Hermes chat local compute contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
HERMES,
|
||||
Path,
|
||||
ROOT,
|
||||
SimpleNamespace,
|
||||
_documents,
|
||||
importlib,
|
||||
json,
|
||||
tomllib,
|
||||
)
|
||||
|
||||
|
||||
def test_local_flux_runtime_and_gpu_handoff_are_flux_managed():
|
||||
"""FLUX and Wolf share titan-24 while text stays on titan-20."""
|
||||
deployment = _documents(HERMES / "local-image-deployment.yaml")[0]
|
||||
assert deployment["metadata"]["name"] == "hermes-local-image"
|
||||
pod = deployment["spec"]["template"]["spec"]
|
||||
assert pod["serviceAccountName"] == "hermes-gpu-runtime"
|
||||
local = next(item for item in pod["containers"] if item["name"] == "local-image")
|
||||
assert len(pod["containers"]) == 1
|
||||
assert local["resources"]["requests"]["nvidia.com/gpu.shared"] == 1
|
||||
assert local["ports"] == [{"name": "local-image", "containerPort": 9004}]
|
||||
assert any(mount["mountPath"] == "/models" for mount in local["volumeMounts"])
|
||||
model_env = {item["name"]: item["value"] for item in local["env"]}
|
||||
assert model_env["HERMES_LOCAL_IMAGE_LISTEN_PORT"] == "9004"
|
||||
assert model_env["HERMES_LOCAL_IMAGE_REVISION"] == (
|
||||
"e7b7dc27f91deacad38e78976d1f2b499d76a294"
|
||||
)
|
||||
assert model_env["HERMES_LOCAL_IMAGE_GPU_ACTIVITY_NODE"] == "titan-24"
|
||||
assert model_env["HERMES_LOCAL_IMAGE_GPU_ACTIVE_SM_PERCENT"] == "80"
|
||||
assert model_env["HERMES_LOCAL_IMAGE_GPU_MAX_EXTERNAL_MEMORY_BYTES"] == (
|
||||
"3221225472"
|
||||
)
|
||||
assert model_env["HERMES_LOCAL_IMAGE_OFFLOAD_MODE"] == "sequential"
|
||||
assert "nvidia-process-exporter-local.monitoring.svc.cluster.local" in model_env[
|
||||
"HERMES_LOCAL_IMAGE_GPU_ACTIVITY_URL"
|
||||
]
|
||||
models_volume = next(item for item in pod["volumes"] if item["name"] == "models")
|
||||
assert models_volume["persistentVolumeClaim"]["claimName"] == (
|
||||
"hermes-image-models"
|
||||
)
|
||||
|
||||
services = _documents(HERMES / "service.yaml")
|
||||
image_service = next(
|
||||
item for item in services if item["metadata"]["name"] == "hermes-local-image"
|
||||
)
|
||||
assert image_service["spec"]["selector"] == {"app": "hermes-local-image"}
|
||||
|
||||
handoff_services = _documents(HERMES / "model-gate-deployment.yaml")
|
||||
handoff = next(
|
||||
item
|
||||
for item in handoff_services
|
||||
if item["kind"] == "Service"
|
||||
and item["metadata"]["name"] == "hermes-gpu-handoff"
|
||||
)
|
||||
assert handoff["spec"]["ports"][0]["targetPort"] == "handoff"
|
||||
|
||||
ariadne = _documents(
|
||||
Path(__file__).parents[2]
|
||||
/ "services/maintenance/apps/ariadne-deployment.yaml"
|
||||
)[0]
|
||||
env = {
|
||||
item["name"]: item["value"]
|
||||
for item in ariadne["spec"]["template"]["spec"]["containers"][0]["env"]
|
||||
if "value" in item
|
||||
}
|
||||
assert env["GAME_MODE_OLLAMA_URL"] == (
|
||||
"http://hermes-gpu-handoff.hermes.svc.cluster.local:11434"
|
||||
)
|
||||
assert env["GAME_MODE_OLLAMA_MODEL"] == "flux-2-klein-4b-local"
|
||||
|
||||
for config_name in ("configmap.yaml", "agent-configmap.yaml", "chat-configmap.yaml"):
|
||||
config = _documents(HERMES / config_name)[0]["data"]["config.yaml"]
|
||||
assert "gpt-oss:20b" not in config
|
||||
assert "atlas-switchyard" in config
|
||||
switchyard = _documents(HERMES / "switchyard-configmap.yaml")[0]["data"][
|
||||
"routes.toml"
|
||||
]
|
||||
assert 'id = "qwen2.5:14b-instruct-q4_0"' in switchyard
|
||||
assert "qwen2.5:3b-instruct-q4_0" not in switchyard
|
||||
assert "route/local/qwen2.5-14b/medium" in switchyard
|
||||
assert "Anthropic and Claude name the same provider" in switchyard
|
||||
assert "OpenAI and Codex name the same provider" in switchyard
|
||||
assert "Choose across every configured Codex and Claude family" in switchyard
|
||||
assert "Claude Fable" in switchyard
|
||||
assert switchyard.count('Treat "think hard"') == 4
|
||||
assert switchyard.count("Never choose below the") >= 5
|
||||
switchyard_config = tomllib.loads(switchyard)
|
||||
routes = switchyard_config["routes"]
|
||||
configured_targets = switchyard_config["targets"]
|
||||
for route_name in ("auto_fast", "auto_balanced", "auto_deep", "auto_maximum"):
|
||||
leading_targets = set(routes[route_name]["targets"][:2])
|
||||
assert leading_targets == {"codex_sol_xhigh", "claude_opus_xhigh"}
|
||||
assert "max_output_tokens" not in routes[route_name]
|
||||
for route_name in ("auto_fast", "auto_balanced", "auto_deep", "auto_maximum"):
|
||||
targets = routes[route_name]["targets"]
|
||||
selector_targets = routes[route_name]["response_schema"]
|
||||
assert not any(target.startswith("local_") for target in targets)
|
||||
assert any("fable" in target for target in targets)
|
||||
assert "local_qwen" not in selector_targets
|
||||
assert "not eligible for foreground" in routes[route_name]["prompt"]
|
||||
for route_name in ("auto_deep", "auto_maximum"):
|
||||
targets = routes[route_name]["targets"]
|
||||
selector_targets = routes[route_name]["response_schema"]
|
||||
assert not any(target.endswith("_low") for target in targets)
|
||||
assert "_low" not in selector_targets
|
||||
maximum_targets = routes["auto_maximum"]["targets"]
|
||||
maximum_selector_targets = routes["auto_maximum"]["response_schema"]
|
||||
assert not any(target.endswith("_medium") for target in maximum_targets)
|
||||
assert "_medium" not in maximum_selector_targets
|
||||
assert "absolute high effort floor" in routes["auto_maximum"]["prompt"]
|
||||
assert "quality mark was missed" in routes["auto_balanced"]["prompt"]
|
||||
assert "raises the next boundary to xhigh" in routes["auto_maximum"]["prompt"]
|
||||
assert "Repeated quality misses require xhigh" in routes[
|
||||
"worker_auto_maximum"
|
||||
]["prompt"]
|
||||
assert any(
|
||||
target.startswith("local_")
|
||||
for target in routes["manual_local_qwen"]["targets"]
|
||||
)
|
||||
for route_name in (
|
||||
"manual_codex_luna",
|
||||
"manual_codex_terra",
|
||||
"manual_codex_sol",
|
||||
"manual_claude_haiku",
|
||||
"manual_claude_fable",
|
||||
"manual_claude_sonnet",
|
||||
"manual_claude_opus",
|
||||
):
|
||||
assert not any(
|
||||
target.startswith("local_") for target in routes[route_name]["targets"]
|
||||
)
|
||||
for provider, families in {
|
||||
"codex": ("luna", "terra", "sol"),
|
||||
"claude": ("haiku", "fable", "sonnet", "opus"),
|
||||
}.items():
|
||||
for family in families:
|
||||
for effort in ("low", "medium", "high", "xhigh"):
|
||||
route = routes[f"manual_{provider}_{family}_{effort}"]
|
||||
assert route["id"] == f"atlas/manual/{provider}/{family}/{effort}"
|
||||
assert route["targets"][0] == f"{provider}_{family}_{effort}"
|
||||
worker_target = f"worker_{provider}_{family}_{effort}"
|
||||
assert worker_target in routes["worker_auto_maximum"]["targets"]
|
||||
assert configured_targets[worker_target]["id"] == (
|
||||
f"worker/{provider}/{family}/{effort}"
|
||||
)
|
||||
for route_name in ("auto_fast", "auto_balanced"):
|
||||
prompt = routes[route_name]["prompt"]
|
||||
assert "image tool—not the conversational model" in prompt
|
||||
assert "Do not select a" in prompt
|
||||
assert "local Qwen or Claude target" in prompt
|
||||
assert "max_output_tokens" not in routes["worker_auto_maximum"]
|
||||
model_gate = _documents(HERMES / "model-gate-configmap.yaml")[0]["data"][
|
||||
"model_gate.py"
|
||||
]
|
||||
assert "qwen2.5:14b-instruct-q4_0" in model_gate
|
||||
|
||||
def test_titan20_serializes_classifier_and_local_chat_model_residency():
|
||||
"""Classifier and local chat share one serialized resident Qwen weight."""
|
||||
deployment = _documents(
|
||||
Path(__file__).parents[2] / "services/ai-llm/deployment.yaml"
|
||||
)[0]
|
||||
pod = deployment["spec"]["template"]["spec"]
|
||||
required = pod["affinity"]["nodeAffinity"][
|
||||
"requiredDuringSchedulingIgnoredDuringExecution"
|
||||
]["nodeSelectorTerms"][0]["matchExpressions"][0]
|
||||
assert required["values"] == ["titan-20"]
|
||||
container = pod["containers"][0]
|
||||
env = {item["name"]: item["value"] for item in container["env"]}
|
||||
assert env["OLLAMA_MAX_LOADED_MODELS"] == "1"
|
||||
assert env["OLLAMA_NUM_PARALLEL"] == "1"
|
||||
assert env["OLLAMA_KEEP_ALIVE"] == "-1"
|
||||
assert env["OLLAMA_CONTEXT_LENGTH"] == "8192"
|
||||
warm_command = " ".join(container["command"])
|
||||
assert "--keepalive=-1" not in warm_command
|
||||
models = next(item for item in pod["volumes"] if item["name"] == "models")
|
||||
assert models["persistentVolumeClaim"]["claimName"] == (
|
||||
"ollama-models-titan20"
|
||||
)
|
||||
|
||||
def test_local_image_gpu_guard_distinguishes_background_and_saturated_gpu(monkeypatch):
|
||||
"""Lease-idle desktop spikes may coexist, but saturation still blocks FLUX."""
|
||||
source = ROOT / "dockerfiles" / "hermes-local-image-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_local_image_server", source)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
monkeypatch.setattr(module, "GPU_ACTIVITY_NODE", "titan-24")
|
||||
monkeypatch.setattr(module, "GPU_ACTIVE_SM_PERCENT", 80.0)
|
||||
monkeypatch.setattr(module, "GPU_MAX_EXTERNAL_MEMORY_BYTES", 3 << 30)
|
||||
|
||||
idle = module._parse_gpu_activity(
|
||||
'\n'.join(
|
||||
[
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="host",process="Xorg"} 0',
|
||||
'nvidia_process_gpu_memory_used_bytes{node="titan-24",namespace="host",process="Xorg"} 1900000000',
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="game-stream",process="wolf"} 3',
|
||||
'nvidia_process_gpu_memory_used_bytes{node="titan-24",namespace="hermes",process="python"} 9000000000',
|
||||
]
|
||||
)
|
||||
)
|
||||
assert idle["interactive_active"] is False
|
||||
assert idle["external_gpu_memory_bytes"] == 1900000000
|
||||
assert idle["external_gpu_sm_percent"] == 3
|
||||
|
||||
background_spike = module._parse_gpu_activity(
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="host",process="sway"} 41\n'
|
||||
)
|
||||
assert background_spike["interactive_active"] is False
|
||||
|
||||
active = module._parse_gpu_activity(
|
||||
'nvidia_process_gpu_sm_util_percent{node="titan-24",namespace="host",process="steam"} 91\n'
|
||||
)
|
||||
assert active["interactive_active"] is True
|
||||
assert "91%" in active["gpu_guard_reason"]
|
||||
|
||||
def test_local_flux_renderer_uses_a_disposable_cuda_worker(monkeypatch):
|
||||
"""A completed render must not leave its CUDA context in the API process."""
|
||||
source = ROOT / "dockerfiles" / "hermes-local-image-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_local_image_worker", source)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
calls = []
|
||||
|
||||
def run(command, **kwargs):
|
||||
calls.append((command, kwargs))
|
||||
return SimpleNamespace(
|
||||
returncode=0,
|
||||
stdout=b'{"success":true,"route":"local","image_b64":"cG5n"}',
|
||||
stderr=b"",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(module.subprocess, "run", run)
|
||||
result = module._render({"prompt": "black cat", "aspect_ratio": "square"})
|
||||
|
||||
assert result["route"] == "local"
|
||||
command, options = calls[0]
|
||||
assert command[-1] == "--render-worker"
|
||||
assert json.loads(options["input"]) == {
|
||||
"prompt": "black cat",
|
||||
"aspect_ratio": "square",
|
||||
}
|
||||
assert options["timeout"] == module.RENDER_TIMEOUT_SECONDS
|
||||
assert options["check"] is False
|
||||
|
||||
def test_local_flux_uses_low_vram_offload_without_reducing_resolution():
|
||||
"""The shared 3080 lane must trade time, not image size, for headroom."""
|
||||
source = (ROOT / "dockerfiles" / "hermes-local-image-server.py").read_text()
|
||||
assert 'OFFLOAD_MODE = os.environ.get(' in source
|
||||
assert '"HERMES_LOCAL_IMAGE_OFFLOAD_MODE", "sequential"' in source
|
||||
assert "pipe.enable_sequential_cpu_offload()" in source
|
||||
assert '"square": (1024, 1024)' in source
|
||||
|
||||
def test_chat_auth_and_relay_are_pod_lifetime_only():
|
||||
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
|
||||
pod = statefulset["spec"]["template"]["spec"]
|
||||
containers = statefulset["spec"]["template"]["spec"]["containers"]
|
||||
|
||||
for name in ("hermes", "webui"):
|
||||
container = next(item for item in containers if item["name"] == name)
|
||||
env = {item["name"]: item["value"] for item in container["env"]}
|
||||
assert env["HERMES_AUTH_FILE"] == "/runtime-access/hermes-auth.json"
|
||||
mount = next(
|
||||
item for item in container["volumeMounts"] if item["name"] == "runtime-access"
|
||||
)
|
||||
assert mount["mountPath"] == "/runtime-access"
|
||||
assert "subPath" not in mount
|
||||
|
||||
hermes_env = {
|
||||
item["name"]: item["value"]
|
||||
for item in next(item for item in containers if item["name"] == "hermes")["env"]
|
||||
}
|
||||
runtime = next(item for item in pod["volumes"] if item["name"] == "runtime-access")
|
||||
assert runtime["emptyDir"] == {"medium": "Memory", "sizeLimit": "2Mi"}
|
||||
assert not any(item["name"] == "provider-auth" for item in pod["volumes"])
|
||||
init_command = next(
|
||||
item for item in pod["initContainers"] if item["name"] == "init-config"
|
||||
)["command"][2]
|
||||
for key in (
|
||||
"ANTHROPIC_API_KEY",
|
||||
"API_SERVER_KEY",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
"GITEA_TOKEN",
|
||||
"HERMES_IMAGE_BROKER_KEY",
|
||||
"OPENAI_API_KEY",
|
||||
):
|
||||
assert key in init_command
|
||||
assert "printf 'API_SERVER_KEY=%s" not in init_command
|
||||
assert hermes_env["AGENT_BROWSER_EXECUTABLE_PATH"].endswith("/chrome-linux/headless_shell")
|
||||
assert "--no-sandbox" in hermes_env["AGENT_BROWSER_ARGS"]
|
||||
@ -451,26 +451,3 @@ def test_codex_broker_refreshes_and_persists_first_party_oauth(
|
||||
# refresh token or create a second billing/authentication path.
|
||||
assert module._access_token() == live
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_claude_broker_uses_native_subscription_without_api_billing(monkeypatch):
|
||||
"""Claude traffic must use the native first-party CLI subscription lane."""
|
||||
module = _load_broker_module(
|
||||
"hermes_claude_broker", "claude_oauth_broker.py", monkeypatch
|
||||
)
|
||||
monkeypatch.setenv("ANTHROPIC_API_KEY", "must-not-leak")
|
||||
monkeypatch.setenv("CLAUDE_API_KEY", "must-not-leak")
|
||||
monkeypatch.setattr(
|
||||
module,
|
||||
"resolve_route",
|
||||
lambda route: "claude-fable-5" if "/fable/" in route else route,
|
||||
)
|
||||
|
||||
model, effort = module._route(
|
||||
"route/claude/fable/xhigh", {"output_config": {"effort": "xhigh"}}
|
||||
)
|
||||
|
||||
assert (model, effort) == ("claude-fable-5", "xhigh")
|
||||
assert "ANTHROPIC_API_KEY" not in module._claude_environment()
|
||||
assert "CLAUDE_API_KEY" not in module._claude_environment()
|
||||
assert module.CAPACITY_PATTERN.search("weekly usage limit exhausted")
|
||||
|
||||
24
testing/tests/test_hermes_chat_sandbox.py
Normal file
24
testing/tests/test_hermes_chat_sandbox.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Hermes chat sandbox contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
Path,
|
||||
ROOT,
|
||||
importlib,
|
||||
)
|
||||
|
||||
|
||||
def test_sandbox_executes_python_with_bounded_output(tmp_path: Path, monkeypatch):
|
||||
source = ROOT / "dockerfiles" / "hermes-chat-sandbox-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_chat_sandbox_server", source)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
monkeypatch.setattr(module, "WORKSPACE", tmp_path)
|
||||
|
||||
result = module._execute("import math\nprint(math.comb(10, 3))")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["stdout"] == "120\n"
|
||||
assert result["stderr"] == ""
|
||||
392
testing/tests/test_hermes_chat_sessions.py
Normal file
392
testing/tests/test_hermes_chat_sessions.py
Normal file
@ -0,0 +1,392 @@
|
||||
"""Hermes chat sessions contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
HERMES,
|
||||
Path,
|
||||
importlib,
|
||||
json,
|
||||
pytest,
|
||||
sqlite3,
|
||||
)
|
||||
|
||||
|
||||
def test_telegram_api_session_migration_is_bounded_and_idempotent(tmp_path: Path):
|
||||
"""Only named Telegram conversations receive presentation metadata."""
|
||||
module_path = HERMES / "scripts" / "migrate_telegram_api_sessions.py"
|
||||
spec = importlib.util.spec_from_file_location("migrate_telegram_sessions", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
state = tmp_path / "state.db"
|
||||
responses = tmp_path / "response_store.db"
|
||||
with sqlite3.connect(state) as connection:
|
||||
connection.execute(
|
||||
"""CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, source TEXT, session_key TEXT, chat_type TEXT,
|
||||
display_name TEXT, origin_json TEXT, title TEXT
|
||||
)"""
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO sessions (id, source) VALUES (?, ?)",
|
||||
(("telegram-session", "api_server"), ("other-session", "api_server")),
|
||||
)
|
||||
with sqlite3.connect(responses) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE conversations (name TEXT PRIMARY KEY, response_id TEXT NOT NULL)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE TABLE responses (response_id TEXT PRIMARY KEY, data TEXT NOT NULL, accessed_at REAL NOT NULL)"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO responses VALUES (?, ?, 0)",
|
||||
(
|
||||
("telegram-response", json.dumps({"session_id": "telegram-session"})),
|
||||
("other-response", json.dumps({"session_id": "other-session"})),
|
||||
),
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO conversations VALUES (?, ?)",
|
||||
(("telegram", "telegram-response"), ("unrelated", "other-response")),
|
||||
)
|
||||
|
||||
assert module.migrate(state, responses) == 1
|
||||
assert module.migrate(state, responses) == 0
|
||||
with sqlite3.connect(state) as connection:
|
||||
telegram = connection.execute(
|
||||
"SELECT session_key, chat_type, display_name, origin_json, title "
|
||||
"FROM sessions WHERE id = 'telegram-session'"
|
||||
).fetchone()
|
||||
other = connection.execute(
|
||||
"SELECT session_key, title FROM sessions WHERE id = 'other-session'"
|
||||
).fetchone()
|
||||
assert telegram[:3] == ("telegram", "private", "Telegram")
|
||||
assert json.loads(telegram[3]) == {
|
||||
"platform": "telegram",
|
||||
"session_key": "telegram",
|
||||
}
|
||||
assert telegram[4] == "Telegram · General"
|
||||
assert other == (None, None)
|
||||
|
||||
def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path):
|
||||
"""The DOM transcript includes events without polluting agent history."""
|
||||
module_path = HERMES / "scripts" / "patch_web_session_activity.py"
|
||||
spec = importlib.util.spec_from_file_location("patch_web_activity", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
source = tmp_path / "web_server.py"
|
||||
destination = tmp_path / "patched.py"
|
||||
source.write_text(
|
||||
"prefix\n"
|
||||
+ module.LATEST_ROWS_BEFORE
|
||||
+ module.LATEST_SELECTION_BEFORE
|
||||
+ module.HELPER_MARKER
|
||||
+ " db = object()\n"
|
||||
+ module.MESSAGES_BEFORE
|
||||
+ "suffix\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
module.patch(source, destination)
|
||||
patched = destination.read_text(encoding="utf-8")
|
||||
|
||||
assert "def _run_activity_messages(" in patched
|
||||
assert 'root = get_hermes_home() / "run-activity"' in patched
|
||||
assert "path.stat().st_size > 600_000" in patched
|
||||
assert "entries[-1_000:]" in patched
|
||||
assert "*_run_activity_messages(sid)" in patched
|
||||
assert "messages.sort(" in patched
|
||||
assert "limit: Optional[int] = None" in patched
|
||||
assert "total_messages = len(messages)" in patched
|
||||
assert "min(int(limit), 10_000)" in patched
|
||||
assert '"total_messages": total_messages' in patched
|
||||
assert "SELECT id, parent_session_id, started_at, ended_at" in patched
|
||||
assert "newest still-open member of the lineage" in patched
|
||||
assert "immediate objective parent" in patched
|
||||
assert "mixes unrelated workstreams" in patched
|
||||
assert "orphaned children" in patched
|
||||
assert 'item[0].get("ended_at") is None' in patched
|
||||
|
||||
def test_web_session_lineage_returns_to_resumed_parent():
|
||||
"""An ended reviewer must not strand the live view away from its parent."""
|
||||
module_path = HERMES / "scripts" / "patch_web_session_activity.py"
|
||||
spec = importlib.util.spec_from_file_location("patch_web_lineage", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
namespace: dict[str, object] = {}
|
||||
exec(
|
||||
"def select_active(sid, db, rows):\n" + module.LATEST_SELECTION_AFTER,
|
||||
namespace,
|
||||
)
|
||||
|
||||
class FakeDB:
|
||||
def get_session(self, session_id):
|
||||
return next((row for row in rows if row["id"] == session_id), None)
|
||||
|
||||
rows = [
|
||||
{
|
||||
"id": "umbrella",
|
||||
"parent_session_id": None,
|
||||
"started_at": 0.0,
|
||||
"ended_at": None,
|
||||
},
|
||||
{
|
||||
"id": "root",
|
||||
"parent_session_id": "umbrella",
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
},
|
||||
{
|
||||
"id": "review-1",
|
||||
"parent_session_id": "root",
|
||||
"started_at": 2.0,
|
||||
"ended_at": 3.0,
|
||||
},
|
||||
{
|
||||
"id": "review-2",
|
||||
"parent_session_id": "root",
|
||||
"started_at": 4.0,
|
||||
"ended_at": None,
|
||||
},
|
||||
]
|
||||
select_active = namespace["select_active"]
|
||||
|
||||
assert select_active("root", FakeDB(), rows) == (
|
||||
"review-2",
|
||||
["root", "review-2"],
|
||||
)
|
||||
assert select_active("review-1", FakeDB(), rows) == (
|
||||
"review-2",
|
||||
["root", "review-2"],
|
||||
)
|
||||
rows[2]["ended_at"] = 5.0
|
||||
rows[3]["ended_at"] = 5.0
|
||||
assert select_active("root", FakeDB(), rows) == ("root", ["root"])
|
||||
|
||||
rows[1]["ended_at"] = 9.0
|
||||
rows.append(
|
||||
{
|
||||
"id": "orphaned-review",
|
||||
"parent_session_id": "root",
|
||||
"started_at": 6.0,
|
||||
"ended_at": None,
|
||||
}
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"id": "accepted-review",
|
||||
"parent_session_id": "root",
|
||||
"started_at": 7.0,
|
||||
"ended_at": 8.0,
|
||||
}
|
||||
)
|
||||
assert select_active("review-1", FakeDB(), rows) == (
|
||||
"accepted-review",
|
||||
["root", "accepted-review"],
|
||||
)
|
||||
|
||||
def test_api_activity_patch_coalesces_streaming_heartbeats(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Token callbacks stay bounded while every tool transition is retained."""
|
||||
module_path = HERMES / "scripts" / "patch_api_server_sessions.py"
|
||||
spec = importlib.util.spec_from_file_location("patch_api_activity", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
activity_body = module.EVENT_CALLBACK_SIGNATURE_AFTER.split(
|
||||
" def _make_run_event_callback(", 1
|
||||
)[0]
|
||||
namespace: dict[str, object] = {}
|
||||
exec(
|
||||
"import hashlib, json, logging, os, time\n"
|
||||
"from pathlib import Path\n"
|
||||
"logger = logging.getLogger(__name__)\n"
|
||||
"def redact_sensitive_text(value): return value\n"
|
||||
"class ActivityRecorder:\n"
|
||||
+ activity_body,
|
||||
namespace,
|
||||
)
|
||||
recorder = namespace["ActivityRecorder"]()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
recorder._record_run_activity("session", "_thinking")
|
||||
recorder._record_run_activity("session", "reasoning.available")
|
||||
recorder._record_run_activity("session", "subagent.thinking")
|
||||
recorder._record_run_activity("session", "tool.started", tool_name="terminal")
|
||||
recorder._record_run_activity("session", "tool.completed", tool_name="terminal")
|
||||
|
||||
journals = list((tmp_path / "run-activity").glob("*.jsonl"))
|
||||
assert len(journals) == 1
|
||||
entries = [json.loads(line) for line in journals[0].read_text().splitlines()]
|
||||
assert [entry["activity_event"] for entry in entries] == [
|
||||
"_thinking",
|
||||
"tool.started",
|
||||
"tool.completed",
|
||||
]
|
||||
|
||||
def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path):
|
||||
"""Known standalone API workers move under Cassandra without data loss."""
|
||||
module_path = HERMES / "scripts" / "migrate_api_session_lineage.py"
|
||||
spec = importlib.util.spec_from_file_location("migrate_api_sessions", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
database = tmp_path / "state.db"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
|
||||
"parent_session_id TEXT, title TEXT, transcript TEXT, archived INTEGER DEFAULT 0)"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO sessions (id, source, parent_session_id, title, transcript) "
|
||||
"VALUES (?, 'tui', NULL, 'Cassandra', 'parent-data')",
|
||||
(module.LEGACY_CASSANDRA_PARENT,),
|
||||
)
|
||||
worker_id = next(iter(module.LEGACY_CASSANDRA_WORKERS))
|
||||
connection.execute(
|
||||
"INSERT INTO sessions (id, source, parent_session_id, title, transcript) "
|
||||
"VALUES (?, 'api_server', NULL, 'old', 'keep-me')",
|
||||
(worker_id,),
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO sessions (id, source, parent_session_id, title, transcript) "
|
||||
"VALUES (?, 'api_server', NULL, 'old smoke', 'keep-smoke')",
|
||||
((orphan_id,) for orphan_id in module.LEGACY_ORPHANED_SMOKE_SESSIONS),
|
||||
)
|
||||
|
||||
assert module.migrate(database) == 1 + len(module.LEGACY_ORPHANED_SMOKE_SESSIONS)
|
||||
assert module.migrate(database) == 0
|
||||
with sqlite3.connect(database) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT parent_session_id, title, transcript FROM sessions WHERE id = ?",
|
||||
(worker_id,),
|
||||
).fetchone()
|
||||
assert row == (
|
||||
module.LEGACY_CASSANDRA_PARENT,
|
||||
module.LEGACY_CASSANDRA_WORKERS[worker_id],
|
||||
"keep-me",
|
||||
)
|
||||
with sqlite3.connect(database) as connection:
|
||||
orphans = connection.execute(
|
||||
"SELECT id, archived, title, transcript FROM sessions "
|
||||
"WHERE id IN ({}) ORDER BY id".format(
|
||||
",".join("?" for _ in module.LEGACY_ORPHANED_SMOKE_SESSIONS)
|
||||
),
|
||||
tuple(module.LEGACY_ORPHANED_SMOKE_SESSIONS),
|
||||
).fetchall()
|
||||
assert orphans == sorted(
|
||||
(
|
||||
orphan_id,
|
||||
1,
|
||||
module.LEGACY_ORPHANED_SMOKE_SESSIONS[orphan_id],
|
||||
"keep-smoke",
|
||||
)
|
||||
for orphan_id in module.LEGACY_ORPHANED_SMOKE_SESSIONS
|
||||
)
|
||||
|
||||
def test_automated_triage_sessions_are_grouped_without_touching_interactive_runs(tmp_path: Path):
|
||||
"""Only the stable Ariadne contract moves below the triage parent."""
|
||||
module_path = HERMES / "scripts" / "migrate_api_session_lineage.py"
|
||||
spec = importlib.util.spec_from_file_location("migrate_triage_sessions", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
database = tmp_path / "state.db"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
|
||||
"parent_session_id TEXT, title TEXT, started_at REAL, archived INTEGER DEFAULT 0)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE TABLE messages (session_id TEXT, role TEXT, content TEXT)"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO sessions (id, source, started_at) VALUES (?, 'api_server', ?)",
|
||||
(("triage-run", 1.0), ("jenkins-run", 1.5), ("interactive-run", 2.0)),
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO messages (session_id, role, content) VALUES (?, 'user', ?)",
|
||||
(
|
||||
(
|
||||
"triage-run",
|
||||
module.TRIAGE_MESSAGE_PREFIXES[0]
|
||||
+ " Fix for incident sonar/bstein_home/python:S2208/finding-key.",
|
||||
),
|
||||
(
|
||||
"jenkins-run",
|
||||
module.TRIAGE_MESSAGE_PREFIXES[1]
|
||||
+ "\nAnalyze incident soteria/291 for the Jenkins job soteria.",
|
||||
),
|
||||
("interactive-run", "Please explain this alert to me."),
|
||||
),
|
||||
)
|
||||
|
||||
assert module.migrate(database, group_triage=True) == 2
|
||||
assert module.migrate(database, group_triage=True) == 0
|
||||
with sqlite3.connect(database) as connection:
|
||||
parent = connection.execute(
|
||||
"SELECT title FROM sessions WHERE id = ?", (module.TRIAGE_PARENT,)
|
||||
).fetchone()
|
||||
triage = connection.execute(
|
||||
"SELECT parent_session_id, title FROM sessions WHERE id = 'triage-run'"
|
||||
).fetchone()
|
||||
interactive = connection.execute(
|
||||
"SELECT parent_session_id FROM sessions WHERE id = 'interactive-run'"
|
||||
).fetchone()
|
||||
jenkins = connection.execute(
|
||||
"SELECT parent_session_id, title FROM sessions WHERE id = 'jenkins-run'"
|
||||
).fetchone()
|
||||
assert parent == (module.TRIAGE_PARENT_TITLE,)
|
||||
assert triage == (
|
||||
module.TRIAGE_PARENT,
|
||||
"Sonar · bstein_home · python:S2208 · iage-run",
|
||||
)
|
||||
assert interactive == (None,)
|
||||
assert jenkins == (
|
||||
module.TRIAGE_PARENT,
|
||||
"Sonar · soteria/291 · kins-run",
|
||||
)
|
||||
|
||||
def test_stale_parent_linked_api_workers_close_on_startup(tmp_path: Path):
|
||||
"""A previous gateway lifetime cannot leave phantom active workers."""
|
||||
module_path = HERMES / "scripts" / "migrate_api_session_lineage.py"
|
||||
spec = importlib.util.spec_from_file_location("close_stale_api_workers", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
database = tmp_path / "state.db"
|
||||
with sqlite3.connect(database) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, "
|
||||
"parent_session_id TEXT, title TEXT, started_at REAL, ended_at REAL, "
|
||||
"end_reason TEXT, archived INTEGER DEFAULT 0)"
|
||||
)
|
||||
connection.executemany(
|
||||
"INSERT INTO sessions "
|
||||
"(id, source, parent_session_id, started_at, ended_at, end_reason) "
|
||||
"VALUES (?, ?, ?, 1, ?, ?)",
|
||||
(
|
||||
("stale", "api_server", "parent", None, None),
|
||||
("root", "api_server", None, None, None),
|
||||
("finished", "api_server", "parent", 2.0, "api_run_completed"),
|
||||
("interactive", "tui", "parent", None, None),
|
||||
),
|
||||
)
|
||||
|
||||
assert module.migrate(database) == 1
|
||||
assert module.migrate(database) == 0
|
||||
with sqlite3.connect(database) as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT id, ended_at, end_reason FROM sessions ORDER BY id"
|
||||
).fetchall()
|
||||
by_id = {row[0]: row[1:] for row in rows}
|
||||
assert by_id["stale"][0] is not None
|
||||
assert by_id["stale"][1] == "api_run_recovered_stale"
|
||||
assert by_id["root"] == (None, None)
|
||||
assert by_id["finished"] == (2.0, "api_run_completed")
|
||||
assert by_id["interactive"] == (None, None)
|
||||
@ -2,10 +2,16 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
import time
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from types import ModuleType, SimpleNamespace
|
||||
import pytest
|
||||
|
||||
import yaml
|
||||
|
||||
@ -13,11 +19,9 @@ ROOT = Path(__file__).parents[2]
|
||||
HERMES = ROOT / "services" / "hermes"
|
||||
VAULT = ROOT / "services" / "vault"
|
||||
|
||||
|
||||
def _documents(path: Path) -> list[dict]:
|
||||
return [doc for doc in yaml.safe_load_all(path.read_text()) if doc]
|
||||
|
||||
|
||||
def _load_broker_module(name: str, filename: str, monkeypatch):
|
||||
"""Load one broker with its mounted routing-catalog dependency."""
|
||||
catalog_path = HERMES / "scripts" / "routing_catalog.py"
|
||||
@ -36,3 +40,22 @@ def _load_broker_module(name: str, filename: str, monkeypatch):
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
__all__ = (
|
||||
"HERMES",
|
||||
"ModuleType",
|
||||
"Path",
|
||||
"ROOT",
|
||||
"SimpleNamespace",
|
||||
"VAULT",
|
||||
"_documents",
|
||||
"_load_broker_module",
|
||||
"base64",
|
||||
"importlib",
|
||||
"json",
|
||||
"pytest",
|
||||
"sqlite3",
|
||||
"sys",
|
||||
"time",
|
||||
"tomllib",
|
||||
"yaml",
|
||||
)
|
||||
|
||||
483
testing/tests/test_hermes_chat_switchyard.py
Normal file
483
testing/tests/test_hermes_chat_switchyard.py
Normal file
@ -0,0 +1,483 @@
|
||||
"""Hermes chat switchyard contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
HERMES,
|
||||
Path,
|
||||
SimpleNamespace,
|
||||
_documents,
|
||||
_load_broker_module,
|
||||
base64,
|
||||
importlib,
|
||||
json,
|
||||
pytest,
|
||||
sys,
|
||||
time,
|
||||
tomllib,
|
||||
)
|
||||
|
||||
|
||||
def test_codex_broker_auth_and_request_contract(tmp_path: Path, monkeypatch):
|
||||
"""The relay is bounded, stateless, and rejects unapproved models."""
|
||||
module = _load_broker_module(
|
||||
"hermes_codex_broker", "codex_broker.py", monkeypatch
|
||||
)
|
||||
monkeypatch.setattr(module, "TOKEN", "relay-secret")
|
||||
|
||||
assert module._authorized("Bearer relay-secret") is True
|
||||
assert module._authorized("Bearer wrong") is False
|
||||
assert module._real_model("route/codex/gpt-5.6-sol/xhigh") == "gpt-5.6-sol"
|
||||
payload = module._validate_payload(
|
||||
{
|
||||
"model": "gpt-5.6-terra",
|
||||
"input": "route this chat turn",
|
||||
"store": True,
|
||||
"stream": False,
|
||||
"max_output_tokens": 96,
|
||||
"max_completion_tokens": 96,
|
||||
"max_tokens": 96,
|
||||
"temperature": 0.7,
|
||||
"top_p": 0.9,
|
||||
}
|
||||
)
|
||||
assert payload["store"] is False
|
||||
assert payload["stream"] is True
|
||||
assert "max_output_tokens" not in payload
|
||||
assert "max_completion_tokens" not in payload
|
||||
assert "max_tokens" not in payload
|
||||
assert "temperature" not in payload
|
||||
assert "top_p" not in payload
|
||||
assert payload["input"] == [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "route this chat turn"}],
|
||||
}
|
||||
]
|
||||
response_item = {
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "keep this item"}],
|
||||
}
|
||||
assert module._validate_payload(
|
||||
{"model": "gpt-5.6-terra", "input": response_item}
|
||||
)["input"] == [response_item]
|
||||
response_items = [response_item]
|
||||
assert module._validate_payload(
|
||||
{"model": "gpt-5.6-terra", "input": response_items}
|
||||
)["input"] is response_items
|
||||
image_items = [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "What color is this?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert module._validate_payload(
|
||||
{"model": "gpt-5.6-terra", "input": image_items}
|
||||
)["input"][0]["content"][1] == {
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
"detail": "high",
|
||||
}
|
||||
switchyard_image_items = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "What color is this?"},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {
|
||||
"url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
"detail": "auto",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
assert module._validate_payload(
|
||||
{"model": "gpt-5.6-terra", "input": switchyard_image_items}
|
||||
)["input"][0]["content"][1] == {
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
"detail": "auto",
|
||||
}
|
||||
switchyard_base64_items = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "What color is this?"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "cHJpdmF0ZQ==",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
normalized_base64 = module._validate_payload(
|
||||
{"model": "gpt-5.6-terra", "input": switchyard_base64_items}
|
||||
)["input"][0]["content"][1]
|
||||
assert normalized_base64 == {
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
}
|
||||
switchyard_enum_items = [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": {
|
||||
"type": "url",
|
||||
"data": {
|
||||
"url": "data:image/png;base64,cHJpdmF0ZQ==",
|
||||
"detail": "high",
|
||||
},
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
nested_image = module._validate_payload(
|
||||
{"model": "gpt-5.6-terra", "input": switchyard_enum_items}
|
||||
)["input"][0]["content"][0]
|
||||
assert nested_image["image_url"] == "data:image/png;base64,cHJpdmF0ZQ=="
|
||||
assert nested_image["detail"] == "high"
|
||||
with pytest.raises(ValueError, match=r"non-empty Responses image URL.*str\[4\]"):
|
||||
module._validate_payload(
|
||||
{
|
||||
"model": "gpt-5.6-terra",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": {"detail": "high"},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
routed = module._validate_payload(
|
||||
{
|
||||
"model": "route/codex/gpt-5.6-luna/low",
|
||||
"input": "use the low route",
|
||||
"stream": False,
|
||||
}
|
||||
)
|
||||
assert routed["model"] == "gpt-5.6-luna"
|
||||
with pytest.raises(ValueError, match="unsupported Codex model"):
|
||||
module._validate_payload({"model": "unapproved-model", "input": "hello"})
|
||||
with pytest.raises(ValueError, match="non-empty Responses input"):
|
||||
module._validate_payload({"model": "gpt-5.6-terra", "input": ""})
|
||||
with pytest.raises(ValueError, match="non-empty Responses input list"):
|
||||
module._validate_payload({"model": "gpt-5.6-terra", "input": []})
|
||||
|
||||
completed = {
|
||||
"id": "resp_test",
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"output": [],
|
||||
}
|
||||
completed_item = {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": [{"type": "output_text", "text": "done"}],
|
||||
}
|
||||
assert module._completed_response(
|
||||
[
|
||||
"event: response.created",
|
||||
'data: {"type":"response.created","response":{}}',
|
||||
"event: response.output_item.done",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": completed_item,
|
||||
}
|
||||
),
|
||||
"event: response.completed",
|
||||
"data: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed}),
|
||||
"data: [DONE]",
|
||||
]
|
||||
)["output"] == [completed_item]
|
||||
raw_stream = (
|
||||
"event: response.output_item.done\n"
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": completed_item,
|
||||
}
|
||||
)
|
||||
+ "\n\nevent: response.completed\ndata: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed})
|
||||
+ "\n\n"
|
||||
).encode()
|
||||
normalized = module._normalized_stream(
|
||||
raw_stream, {**completed, "output": [completed_item]}
|
||||
).decode()
|
||||
terminal_data = next(
|
||||
line for line in normalized.splitlines() if '"response.completed"' in line
|
||||
)
|
||||
assert json.loads(terminal_data.removeprefix("data: "))["response"][
|
||||
"output"
|
||||
] == [completed_item]
|
||||
assert normalized.endswith("\n\n")
|
||||
streamed_function_item = {
|
||||
"type": "function_call",
|
||||
"name": "read_file",
|
||||
"status": "completed",
|
||||
"arguments": '{"path":"/tmp"}',
|
||||
}
|
||||
streamed_function_body = (
|
||||
"event: response.function_call_arguments.delta\n"
|
||||
'data: {"type":"response.function_call_arguments.delta",'
|
||||
'"item_id":"call_1","delta":"{\\"path\\":\\"/tmp\\"}"}\n\n'
|
||||
"event: response.function_call_arguments.done\n"
|
||||
'data: {"type":"response.function_call_arguments.done",'
|
||||
'"item_id":"call_1","arguments":"{\\"path\\":\\"/tmp\\"}"}\n\n'
|
||||
"event: response.output_item.done\n"
|
||||
'data: {"type":"response.output_item.done","output_index":0,'
|
||||
'"item":{"type":"function_call","name":"read_file",'
|
||||
'"arguments":"{\\"path\\":\\"/tmp\\"}"}}\n\n'
|
||||
"event: response.completed\n"
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.completed",
|
||||
"response": {**completed, "output": [streamed_function_item]},
|
||||
}
|
||||
)
|
||||
+ "\n\n"
|
||||
).encode()
|
||||
normalized_function_stream = module._normalized_stream(
|
||||
streamed_function_body, {**completed, "output": [streamed_function_item]}
|
||||
).decode()
|
||||
assert "response.function_call_arguments.delta" in normalized_function_stream
|
||||
assert "response.function_call_arguments.done" not in normalized_function_stream
|
||||
assert "response.output_item.done" not in normalized_function_stream
|
||||
normalized_terminal = next(
|
||||
line
|
||||
for line in normalized_function_stream.splitlines()
|
||||
if '"response.completed"' in line
|
||||
)
|
||||
assert json.loads(normalized_terminal.removeprefix("data: "))["response"][
|
||||
"output"
|
||||
] == []
|
||||
with pytest.raises(RuntimeError, match="retryable incomplete response"):
|
||||
module._completed_response(
|
||||
[
|
||||
"event: response.incomplete",
|
||||
'data: {"type":"response.incomplete","response":'
|
||||
'{"status":"incomplete","incomplete_details":'
|
||||
'{"reason":"max_output_tokens"}}}',
|
||||
]
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="provider unavailable"):
|
||||
module._completed_response(
|
||||
[
|
||||
"event: error",
|
||||
'data: {"type":"error","error":{"message":"provider unavailable"}}',
|
||||
]
|
||||
)
|
||||
malformed_tool_item = {
|
||||
"type": "function_call",
|
||||
"name": "search_files",
|
||||
"status": "completed",
|
||||
"arguments": '{"path":"","offset":',
|
||||
}
|
||||
with pytest.raises(RuntimeError, match="malformed function arguments"):
|
||||
module._completed_response(
|
||||
[
|
||||
"event: response.output_item.done",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": malformed_tool_item,
|
||||
}
|
||||
),
|
||||
"event: response.completed",
|
||||
"data: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed}),
|
||||
]
|
||||
)
|
||||
valid_tool_item = {
|
||||
**malformed_tool_item,
|
||||
"arguments": '{"path":"","offset":0}',
|
||||
}
|
||||
assert module._completed_response(
|
||||
[
|
||||
"event: response.output_item.done",
|
||||
"data: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"type": "response.output_item.done",
|
||||
"output_index": 0,
|
||||
"item": valid_tool_item,
|
||||
}
|
||||
),
|
||||
"event: response.completed",
|
||||
"data: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed}),
|
||||
]
|
||||
)["output"] == [valid_tool_item]
|
||||
with pytest.raises(RuntimeError, match="malformed function arguments"):
|
||||
module._completed_response(
|
||||
[
|
||||
"event: response.function_call_arguments.delta",
|
||||
'data: {"type":"response.function_call_arguments.delta",'
|
||||
'"item_id":"call_1","output_index":0,'
|
||||
'"delta":"{\\"path\\":\\"/tmp\\",\\"offset\\":"}',
|
||||
"event: response.completed",
|
||||
"data: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed}),
|
||||
]
|
||||
)
|
||||
streamed_tool = module._completed_response(
|
||||
[
|
||||
"event: response.function_call_arguments.delta",
|
||||
'data: {"type":"response.function_call_arguments.delta",'
|
||||
'"item_id":"call_2","output_index":0,'
|
||||
'"delta":"{\\"path\\":\\"/tmp\\",\\"offset\\":"}',
|
||||
"event: response.function_call_arguments.done",
|
||||
'data: {"type":"response.function_call_arguments.done",'
|
||||
'"item_id":"call_2","output_index":0,'
|
||||
'"arguments":"{\\"path\\":\\"/tmp\\",\\"offset\\":0}"}',
|
||||
"event: response.completed",
|
||||
"data: "
|
||||
+ json.dumps({"type": "response.completed", "response": completed}),
|
||||
]
|
||||
)
|
||||
assert streamed_tool["status"] == "completed"
|
||||
|
||||
auth_dir = tmp_path / ".codex"
|
||||
auth_dir.mkdir()
|
||||
# The token payload need only prove the broker reads CODEX_HOME directly.
|
||||
encoded = base64.urlsafe_b64encode(
|
||||
json.dumps({"exp": time.time() + 3600}).encode()
|
||||
).decode().rstrip("=")
|
||||
(auth_dir / "auth.json").write_text(
|
||||
json.dumps({"tokens": {"access_token": f"header.{encoded}.signature"}})
|
||||
)
|
||||
monkeypatch.setenv("CODEX_HOME", str(auth_dir))
|
||||
assert module._access_token().startswith("header.")
|
||||
|
||||
def test_classifier_broker_bounds_history_without_losing_routing_intent(monkeypatch):
|
||||
"""AUTO classification must fit the Jetson context without losing intent."""
|
||||
broker_path = HERMES / "scripts" / "classifier_broker.py"
|
||||
monkeypatch.setitem(sys.modules, "httpx", SimpleNamespace())
|
||||
spec = importlib.util.spec_from_file_location("hermes_classifier_broker", broker_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
payload = {
|
||||
"model": "qwen2.5:14b-instruct-q4_0",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "routing contract\n" + ("candidate policy " * 1000),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Build and verify the Cassandra release safely.",
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_large",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "large_tool",
|
||||
"arguments": '{"command":"' + ("x" * 5000) + '"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"content": "unbounded test output " * 10000,
|
||||
"tool_call_id": "call_large",
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,private"}},
|
||||
{"type": "text", "text": "Turn this cat into a cute clown."},
|
||||
],
|
||||
},
|
||||
],
|
||||
"tools": [{"type": "function", "function": {"name": "large_tool"}}],
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": True,
|
||||
"response_format": {"type": "json_object"},
|
||||
}
|
||||
|
||||
compacted = module.compact_payload(payload)
|
||||
encoded = json.dumps(compacted)
|
||||
|
||||
assert compacted["model"] == payload["model"]
|
||||
assert compacted["response_format"] == payload["response_format"]
|
||||
assert "tools" not in compacted
|
||||
assert "tool_choice" not in compacted
|
||||
assert "parallel_tool_calls" not in compacted
|
||||
assert all(message.get("role") != "tool" for message in compacted["messages"])
|
||||
assert all("tool_call_id" not in message for message in compacted["messages"])
|
||||
assert all("tool_calls" not in message for message in compacted["messages"])
|
||||
assert "[tool evidence]" in encoded
|
||||
assert "[assistant requested an external tool]" in encoded
|
||||
assert "Build and verify the Cassandra release safely." in encoded
|
||||
assert "Turn this cat into a cute clown." in encoded
|
||||
assert "image attachment available to the selected worker" in encoded
|
||||
assert "data:image/png;base64" not in encoded
|
||||
assert "unbounded test output " * 100 not in encoded
|
||||
assert len(encoded) < 18_000
|
||||
|
||||
def test_switchyard_classifier_is_bounded_and_fails_open_once():
|
||||
"""A sick local judge must not hold chat through repeated long retries."""
|
||||
config = tomllib.loads(
|
||||
_documents(HERMES / "switchyard-configmap.yaml")[0]["data"]["routes.toml"]
|
||||
)
|
||||
classifier = config["llm_clients"]["classifier"]
|
||||
assert classifier["base_url"] == "http://127.0.0.1:9008/v1"
|
||||
assert classifier["max_retries"] == 0
|
||||
for route in ("auto_fast", "auto_balanced"):
|
||||
assert config["routes"][route]["recent_turn_window"] == 4
|
||||
for route in ("auto_deep", "auto_maximum", "worker_auto_maximum"):
|
||||
assert config["routes"][route]["recent_turn_window"] == 6
|
||||
|
||||
deployment = _documents(HERMES / "switchyard-deployment.yaml")[0]
|
||||
containers = {
|
||||
item["name"]: item
|
||||
for item in deployment["spec"]["template"]["spec"]["containers"]
|
||||
}
|
||||
classifier_container = containers["classifier-broker"]
|
||||
env = {item["name"]: item["value"] for item in classifier_container["env"]}
|
||||
assert env["HERMES_CLASSIFIER_BROKER_READ_TIMEOUT"] == "60"
|
||||
assert classifier_container["readinessProbe"]["httpGet"]["port"] == "classifier"
|
||||
265
testing/tests/test_hermes_chat_voice.py
Normal file
265
testing/tests/test_hermes_chat_voice.py
Normal file
@ -0,0 +1,265 @@
|
||||
"""Hermes chat voice contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_chat_support import (
|
||||
HERMES,
|
||||
ROOT,
|
||||
SimpleNamespace,
|
||||
VAULT,
|
||||
_documents,
|
||||
importlib,
|
||||
sys,
|
||||
yaml,
|
||||
)
|
||||
|
||||
|
||||
def test_webui_recovers_auth_and_labels_session_scoped_controls():
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
|
||||
|
||||
assert "res.status===401||res.status===403" in dockerfile
|
||||
assert "window.location.assign('/oauth2/start?rd='" in dockerfile
|
||||
assert "childrenExpanded?'▾ ':'▸ '" in dockerfile
|
||||
assert "'atlas/auto/maximum': 'Automatic · Maximum'" in dockerfile
|
||||
assert "hermes-webui-telegram-project-patch.py" in dockerfile
|
||||
telegram_project_patch = (
|
||||
ROOT / "dockerfiles" / "hermes-webui-telegram-project-patch.py"
|
||||
).read_text()
|
||||
assert "TELEGRAM_PROJECT_NAME = 'Telegram'" in telegram_project_patch
|
||||
assert "session_key.startswith('telegram-topic-')" in telegram_project_patch
|
||||
|
||||
router = (ROOT / "dockerfiles" / "hermes-webui-router.js").read_text()
|
||||
assert "'atlas/auto/fast':'AUTO · Fast'" in router
|
||||
assert "'atlas/manual/codex/sol':'Codex · SOL'" in router
|
||||
assert "'atlas/manual/claude/opus':'Claude · Opus'" in router
|
||||
assert "watchModelOptions('modelSelect')" in router
|
||||
assert "watchModelOptions('settingsModel')" in router
|
||||
|
||||
def test_chat_voice_uses_private_jetson_services_and_shared_auto_route():
|
||||
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
|
||||
containers = statefulset["spec"]["template"]["spec"]["containers"]
|
||||
webui = next(item for item in containers if item["name"] == "webui")
|
||||
env = {item["name"]: item["value"] for item in webui["env"]}
|
||||
|
||||
assert env["HERMES_STT_URL"] == (
|
||||
"http://hermes-stt.hermes.svc.cluster.local:9000/v1/audio/transcriptions"
|
||||
)
|
||||
assert env["HERMES_WEBUI_ATLAS_TTS_URL"] == (
|
||||
"http://hermes-tts.hermes.svc.cluster.local:9001/v1/audio/speech"
|
||||
)
|
||||
assert "hermes_stt_client.py" in env["HERMES_LOCAL_STT_COMMAND"]
|
||||
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
assert config["stt"] == {
|
||||
"enabled": True,
|
||||
"provider": "local_command",
|
||||
"local": {"model": "small", "language": "auto"},
|
||||
}
|
||||
|
||||
dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-webui").read_text()
|
||||
assert "hermes-webui-atlas-patch.py" in dockerfile
|
||||
assert "hermes-webui-atlas-voice.js" in dockerfile
|
||||
voice_script = (ROOT / "dockerfiles" / "hermes-webui-atlas-voice.js").read_text()
|
||||
assert "/api/transcribe/capability" in voice_script
|
||||
assert "/api/transcribe" in voice_script
|
||||
assert "/api/tts" in voice_script
|
||||
assert "speakResponse(generation)" in voice_script
|
||||
assert "window._splitForTTS(text,280)" in voice_script
|
||||
assert "pending=fetchSpeech(chunks[index+1])" in voice_script
|
||||
assert "restartSoon(token,450)" in voice_script
|
||||
assert "constraints.voiceIsolation=true" in voice_script
|
||||
assert "highpass.frequency.value=140" in voice_script
|
||||
assert "Math.max(0.04,noiseFloor*2.4+0.006)" in voice_script
|
||||
assert "while(preRoll.length>3) preRoll.shift()" in voice_script
|
||||
|
||||
stt_server = (ROOT / "dockerfiles" / "hermes-jetson-stt-server.py").read_text()
|
||||
assert "def _repetitive_token" in stt_server
|
||||
assert "compression_ratio_threshold=2.0" in stt_server
|
||||
assert "no_speech_threshold=0.5" in stt_server
|
||||
|
||||
def test_voice_transcript_filter_removes_fan_hallucinations(monkeypatch):
|
||||
server_path = ROOT / "dockerfiles" / "hermes-jetson-stt-server.py"
|
||||
spec = importlib.util.spec_from_file_location("hermes_jetson_stt_server", server_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
monkeypatch.setitem(sys.modules, "cgi", SimpleNamespace())
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"torch",
|
||||
SimpleNamespace(cuda=SimpleNamespace(is_available=lambda: False)),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "whisper", SimpleNamespace())
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
result = {
|
||||
"segments": [
|
||||
{
|
||||
"text": " ththththththththth Testing.",
|
||||
"no_speech_prob": 0.12,
|
||||
"avg_logprob": -0.2,
|
||||
},
|
||||
{
|
||||
"text": " background hum",
|
||||
"no_speech_prob": 0.82,
|
||||
"avg_logprob": -0.9,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
assert module._clean_transcript(result) == "Testing."
|
||||
|
||||
def test_voice_models_are_baked_and_runtime_has_no_public_egress():
|
||||
stt_dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-stt").read_text()
|
||||
tts_dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-jetson-tts").read_text()
|
||||
assert "ADD --checksum=sha256:aff26ae4" in stt_dockerfile
|
||||
assert "ADD --checksum=sha256:9ecf7799" in stt_dockerfile
|
||||
assert "--chmod=0444" in stt_dockerfile
|
||||
assert "chmod 0555 /opt/models /opt/models/whisper" in stt_dockerfile
|
||||
assert "HERMES_STT_CACHE=/opt/models/whisper" in stt_dockerfile
|
||||
assert "ADD --checksum=sha256:4cabf7c3" in tts_dockerfile
|
||||
assert "ADD --checksum=sha256:db42b97d" in tts_dockerfile
|
||||
assert tts_dockerfile.count("--chmod=0444") == 6
|
||||
assert "chmod 0555 /opt/models /opt/models/piper" in tts_dockerfile
|
||||
assert "HERMES_TTS_CACHE=/opt/models/piper" in tts_dockerfile
|
||||
tts_server = (ROOT / "dockerfiles" / "hermes-jetson-tts-server.py").read_text()
|
||||
assert "download_voice" not in tts_server
|
||||
assert "baked Piper voice is missing" in tts_server
|
||||
assert "session_options.intra_op_num_threads = ONNX_THREADS" in tts_server
|
||||
|
||||
policies = _documents(HERMES / "networkpolicy.yaml")
|
||||
voice_policy = next(
|
||||
item for item in policies if item["metadata"]["name"] == "hermes-private-voice"
|
||||
)
|
||||
assert voice_policy["spec"]["policyTypes"] == ["Ingress", "Egress"]
|
||||
assert not any(
|
||||
"ipBlock" in destination
|
||||
for rule in voice_policy["spec"]["egress"]
|
||||
for destination in rule.get("to", [])
|
||||
)
|
||||
|
||||
def test_voice_workloads_have_deliberate_xavier_placement():
|
||||
documents = _documents(HERMES / "voice-deployment.yaml")
|
||||
deployments = {
|
||||
item["metadata"]["name"]: item
|
||||
for item in documents
|
||||
if item["kind"] == "Deployment"
|
||||
}
|
||||
stt = deployments["hermes-stt"]["spec"]["template"]["spec"]
|
||||
tts = deployments["hermes-tts"]["spec"]["template"]["spec"]
|
||||
|
||||
assert "@sha256:" in stt["containers"][0]["image"]
|
||||
assert "@sha256:" in tts["containers"][0]["image"]
|
||||
assert stt["nodeSelector"] == {"kubernetes.io/hostname": "titan-21"}
|
||||
assert tts["nodeSelector"] == {"kubernetes.io/hostname": "titan-21"}
|
||||
assert stt["automountServiceAccountToken"] is False
|
||||
assert tts["automountServiceAccountToken"] is False
|
||||
assert stt["enableServiceLinks"] is False
|
||||
assert tts["enableServiceLinks"] is False
|
||||
assert stt["runtimeClassName"] == "nvidia"
|
||||
assert stt["securityContext"]["supplementalGroups"] == [44]
|
||||
stt_resources = stt["containers"][0]["resources"]
|
||||
stt_env = {
|
||||
item["name"]: item["value"] for item in stt["containers"][0]["env"]
|
||||
}
|
||||
assert stt_env["NVIDIA_DRIVER_CAPABILITIES"] == "compute,utility"
|
||||
assert stt_resources["requests"]["nvidia.com/gpu.shared"] == 1
|
||||
assert stt_resources["limits"]["nvidia.com/gpu.shared"] == 1
|
||||
assert "nvidia.com/gpu.shared" not in tts["containers"][0]["resources"]["requests"]
|
||||
tts_env = {
|
||||
item["name"]: item["value"] for item in tts["containers"][0]["env"]
|
||||
}
|
||||
assert tts_env["HERMES_TTS_VOICE"] == "en_US-lessac-medium"
|
||||
assert tts_env["HERMES_TTS_ONNX_THREADS"] == "2"
|
||||
assert tts["containers"][0]["resources"]["limits"]["cpu"] == "4"
|
||||
assert all("hostPath" not in volume for volume in stt["volumes"])
|
||||
assert all("hostPath" not in volume for volume in tts["volumes"])
|
||||
|
||||
def test_chat_image_generation_uses_private_owner_broker():
|
||||
"""Family pods get image bytes without receiving the owner's OAuth file."""
|
||||
configmap = _documents(HERMES / "chat-configmap.yaml")[0]
|
||||
assert "shared desktop/Wolf lane" in configmap["data"]["SOUL.md"]
|
||||
assert "local FLUX waits" in configmap["data"]["SOUL.md"]
|
||||
assert "Use `image_generate_local`" in configmap["data"]["SOUL.md"]
|
||||
assert "Use `image_generate_hosted`" in configmap["data"]["SOUL.md"]
|
||||
assert "ComfyUI endpoint" in configmap["data"]["SOUL.md"]
|
||||
assert "`MEDIA:` path" in configmap["data"]["SOUL.md"]
|
||||
assert "`image_edit_latest`" in configmap["data"]["SOUL.md"]
|
||||
assert "Preserve the most recently selected image lane" in configmap["data"]["SOUL.md"]
|
||||
config = yaml.safe_load(configmap["data"]["config.yaml"])
|
||||
assert config["image_gen"] == {
|
||||
"provider": "atlas-broker",
|
||||
"model": "atlas-image-auto-high",
|
||||
}
|
||||
assert config["plugins"]["enabled"] == ["atlas-broker", "auto-router"]
|
||||
|
||||
statefulset = _documents(HERMES / "chat-statefulset.yaml")[0]
|
||||
pod = statefulset["spec"]["template"]["spec"]
|
||||
hermes = next(item for item in pod["containers"] if item["name"] == "hermes")
|
||||
mounts = {item["name"]: item for item in hermes["volumeMounts"]}
|
||||
assert mounts["image-plugin"]["mountPath"] == (
|
||||
"/opt/hermes/plugins/image_gen/atlas-broker"
|
||||
)
|
||||
assert mounts["runtime-access"]["mountPath"] == "/runtime-access"
|
||||
assert "provider-auth" not in mounts
|
||||
assert not any(mount["name"] == "home" and "agent" in str(mount) for mount in hermes["volumeMounts"])
|
||||
env = {item["name"]: item["value"] for item in hermes["env"]}
|
||||
assert env["HERMES_IMAGE_BROKER_URL"].startswith("http://hermes-image-broker.")
|
||||
|
||||
plugin = (HERMES / "plugins" / "image-gen-broker" / "__init__.py").read_text()
|
||||
assert '"local": "flux-2-klein-4b-local"' in plugin
|
||||
assert '"hosted": "gpt-image-2-high"' in plugin
|
||||
assert 'name="image_generate_local"' in plugin
|
||||
assert 'name="image_generate_hosted"' in plugin
|
||||
assert '"name": "image_edit_latest"' in plugin
|
||||
assert '"name": "image_edit_latest_local"' in plugin
|
||||
assert '"name": "image_edit_latest_hosted"' in plugin
|
||||
assert "def _latest_generated_image" in plugin
|
||||
assert "newest MEDIA: path from the conversation" in plugin
|
||||
assert 'candidate.upper().startswith("MEDIA:")' in plugin
|
||||
assert "override=True" not in plugin
|
||||
|
||||
agent = _documents(HERMES / "agent-deployment.yaml")[0]
|
||||
containers = agent["spec"]["template"]["spec"]["containers"]
|
||||
broker = next(item for item in containers if item["name"] == "image-broker")
|
||||
assert broker["ports"] == [
|
||||
{"name": "image-broker", "containerPort": 9002, "protocol": "TCP"}
|
||||
]
|
||||
assert broker["securityContext"]["readOnlyRootFilesystem"] is True
|
||||
assert broker["securityContext"]["runAsNonRoot"] is True
|
||||
|
||||
services = _documents(HERMES / "service.yaml")
|
||||
service = next(
|
||||
item for item in services if item["metadata"]["name"] == "hermes-image-broker"
|
||||
)
|
||||
assert service["spec"]["selector"] == {"app": "hermes-agent"}
|
||||
|
||||
oauth_store = _documents(HERMES / "oauth-session-store.yaml")
|
||||
redis = next(item for item in oauth_store if item["kind"] == "Deployment")
|
||||
assert redis["spec"]["strategy"]["type"] == "Recreate"
|
||||
assert "--appendonly" in redis["spec"]["template"]["spec"]["containers"][0]["args"]
|
||||
|
||||
policies = _documents(HERMES / "networkpolicy.yaml")
|
||||
agent_policy = next(
|
||||
item for item in policies if item["metadata"]["name"] == "hermes-agent-isolation"
|
||||
)
|
||||
broker_ingress = next(
|
||||
rule
|
||||
for rule in agent_policy["spec"]["ingress"]
|
||||
if {port["port"] for port in rule["ports"]} == {9002, 9003}
|
||||
)
|
||||
assert broker_ingress["from"][0]["podSelector"]["matchLabels"] == {
|
||||
"app": "hermes-chat-tenant"
|
||||
}
|
||||
vault_policy = (VAULT / "scripts" / "vault_k8s_auth_configure.sh").read_text()
|
||||
assert (
|
||||
'"hermes/agent-oidc hermes/agent-tokens hermes/chat-telegram '
|
||||
'hermes/developer-keycloak hermes/developer-gitea '
|
||||
'hermes/developer-harbor hermes/developer-jenkins '
|
||||
'hermes/developer-ssh"'
|
||||
in vault_policy
|
||||
)
|
||||
assert (
|
||||
'write_policy_and_role "hermes-node-ssh" "hermes" '
|
||||
'"hermes-node-ssh-access"' in vault_policy
|
||||
)
|
||||
272
testing/tests/test_hermes_cli_capabilities.py
Normal file
272
testing/tests/test_hermes_cli_capabilities.py
Normal file
@ -0,0 +1,272 @@
|
||||
"""Mixed-image capability gates for mounted Hermes CLI lane scripts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.tests.test_hermes_cli_support import _completed_result, lanes
|
||||
|
||||
|
||||
def _old_complete(
|
||||
_conn,
|
||||
_task_id,
|
||||
*,
|
||||
result=None,
|
||||
summary=None,
|
||||
metadata=None,
|
||||
expected_run_id=None,
|
||||
):
|
||||
return bool(result or summary or metadata or expected_run_id)
|
||||
|
||||
|
||||
def _new_complete(
|
||||
_conn,
|
||||
_task_id,
|
||||
*,
|
||||
result=None,
|
||||
summary=None,
|
||||
metadata=None,
|
||||
expected_run_id=None,
|
||||
replay_ended_run_id=None,
|
||||
):
|
||||
return bool(result or summary or metadata or expected_run_id or replay_ended_run_id)
|
||||
|
||||
|
||||
def _old_reclaim(_conn, _task_id, *, reason, signal_fn=None):
|
||||
return bool(reason or signal_fn)
|
||||
|
||||
|
||||
def _new_reclaim(
|
||||
_conn,
|
||||
_task_id,
|
||||
*,
|
||||
reason,
|
||||
signal_fn=None,
|
||||
expected_run_id=None,
|
||||
):
|
||||
return bool(reason or signal_fn or expected_run_id)
|
||||
|
||||
|
||||
def _db(*, patched: bool):
|
||||
return SimpleNamespace(
|
||||
complete_task=_new_complete if patched else _old_complete,
|
||||
reclaim_task=_new_reclaim if patched else _old_reclaim,
|
||||
)
|
||||
|
||||
|
||||
def _terminal_document(run_id: int = 7) -> dict:
|
||||
structured = _completed_result("accepted result")
|
||||
return {
|
||||
"board": "cassandra",
|
||||
"task_id": "t_compat",
|
||||
"expected_run_id": run_id,
|
||||
"result": json.dumps(structured, sort_keys=True),
|
||||
"summary": "accepted result",
|
||||
"metadata": {},
|
||||
"kanban_state": "pending",
|
||||
"recorded_at": lanes.utc_now(),
|
||||
}
|
||||
|
||||
|
||||
def test_capability_detection_requires_explicit_safety_keywords():
|
||||
old = lanes.detect_kanban_capabilities(_db(patched=False))
|
||||
new = lanes.detect_kanban_capabilities(_db(patched=True))
|
||||
|
||||
assert old.exact_run_completion is True
|
||||
assert old.ended_run_replay is False
|
||||
assert old.exact_run_reclaim is False
|
||||
assert old.ready is False
|
||||
assert old.deferred_features == ("ended-run-replay", "exact-run-reclaim")
|
||||
assert new.ready is True
|
||||
assert new.deferred_features == ()
|
||||
|
||||
variadic = SimpleNamespace(
|
||||
complete_task=lambda *_args, **_kwargs: True,
|
||||
reclaim_task=lambda *_args, **_kwargs: True,
|
||||
)
|
||||
assert lanes.detect_kanban_capabilities(variadic).ready is False
|
||||
assert lanes._explicit_keyword(object(), "expected_run_id") is False
|
||||
|
||||
|
||||
def test_startup_health_moves_from_bounded_deferred_to_ready(
|
||||
tmp_path: Path,
|
||||
capsys,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
health = tmp_path / "runtime-health.json"
|
||||
old = lanes.initialize_kanban_capabilities(_db(patched=False), health_path=health)
|
||||
old_document = lanes.runtime_health(health)
|
||||
|
||||
assert old.ready is False
|
||||
assert old_document["state"] == "deferred"
|
||||
assert old_document["ready"] is False
|
||||
assert old_document["active_run_completion_safe"] is True
|
||||
assert old_document["deferred_features"] == [
|
||||
"ended-run-replay",
|
||||
"exact-run-reclaim",
|
||||
]
|
||||
assert health.stat().st_size < 2048
|
||||
assert health.stat().st_mode & 0o777 == 0o600
|
||||
assert "compatibility deferred" in capsys.readouterr().err
|
||||
|
||||
new_db = _db(patched=True)
|
||||
new = lanes.kanban_capabilities(new_db)
|
||||
lanes.initialize_kanban_capabilities(new_db, health_path=health)
|
||||
assert new.ready is True
|
||||
assert lanes.runtime_health(health)["state"] == "ready"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "run_id", "expected"),
|
||||
[("running", 7, "committed"), ("blocked", None, "deferred")],
|
||||
)
|
||||
def test_old_image_completes_only_the_exact_active_run(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
status: str,
|
||||
run_id: int | None,
|
||||
expected: str,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
task = SimpleNamespace(
|
||||
id="t_compat",
|
||||
status=status,
|
||||
current_run_id=run_id,
|
||||
completed_run_id=None,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
calls = []
|
||||
|
||||
def complete(
|
||||
_conn,
|
||||
_task_id,
|
||||
*,
|
||||
result=None,
|
||||
summary=None,
|
||||
metadata=None,
|
||||
expected_run_id=None,
|
||||
):
|
||||
calls.append(expected_run_id)
|
||||
task.status = "done"
|
||||
task.current_run_id = None
|
||||
task.completed_run_id = expected_run_id
|
||||
task.result = result
|
||||
return True
|
||||
|
||||
db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: SimpleNamespace(close=lambda: None),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete,
|
||||
reclaim_task=_old_reclaim,
|
||||
)
|
||||
identity = lanes.TerminalIdentity("cassandra", "t_compat", 7, "pending")
|
||||
|
||||
outcome = lanes._finalize_document_db(db, identity, _terminal_document())
|
||||
|
||||
assert outcome == expected
|
||||
assert calls == ([7] if status == "running" else [])
|
||||
if expected == "deferred":
|
||||
assert task.status == "blocked"
|
||||
|
||||
|
||||
def test_old_image_preserves_pending_and_prepared_journals(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
task = SimpleNamespace(
|
||||
id="t_compat",
|
||||
status="blocked",
|
||||
current_run_id=None,
|
||||
completed_run_id=None,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: SimpleNamespace(close=lambda: None),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=_old_complete,
|
||||
reclaim_task=_old_reclaim,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=db))
|
||||
pending, _record = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "t_compat"),
|
||||
board="cassandra",
|
||||
task_id="t_compat",
|
||||
run_id=7,
|
||||
structured=_completed_result("accepted result"),
|
||||
summary="accepted result",
|
||||
metadata={},
|
||||
)
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert pending.exists()
|
||||
assert len(list(pending.parent.glob("*.terminal.prepared-*.json"))) == 1
|
||||
assert task.status == "blocked"
|
||||
|
||||
|
||||
def test_old_image_never_calls_unguarded_reclaim(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
task = SimpleNamespace(
|
||||
id="t_running",
|
||||
status="running",
|
||||
current_run_id=11,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
calls = []
|
||||
|
||||
def reclaim(_conn, _task_id, *, reason, signal_fn=None):
|
||||
calls.append((reason, signal_fn))
|
||||
return True
|
||||
|
||||
db = SimpleNamespace(
|
||||
complete_task=_old_complete,
|
||||
reclaim_task=reclaim,
|
||||
list_boards=lambda include_archived=False: [{"slug": "cassandra"}],
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: SimpleNamespace(close=lambda: None),
|
||||
list_tasks=lambda _conn: [task],
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=db))
|
||||
monkeypatch.setattr(lanes, "recover_pending_finalizations", lambda: 0)
|
||||
|
||||
lanes.recover_orphans()
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_main_detects_capabilities_before_startup_recovery(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
events = []
|
||||
db = _db(patched=False)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=db))
|
||||
monkeypatch.setattr(lanes, "RESULT_SCHEMA_PATH", tmp_path / "result.schema.json")
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"initialize_kanban_capabilities",
|
||||
lambda candidate: events.append(("detect", candidate))
|
||||
or lanes.KanbanCapabilities(True, False, False),
|
||||
)
|
||||
|
||||
def stop_after_detection():
|
||||
events.append(("recover", db))
|
||||
raise RuntimeError("stop after startup boundary")
|
||||
|
||||
monkeypatch.setattr(lanes, "recover_orphans", stop_after_detection)
|
||||
|
||||
with pytest.raises(RuntimeError, match="startup boundary"):
|
||||
lanes.main()
|
||||
|
||||
assert events == [("detect", db), ("recover", db)]
|
||||
420
testing/tests/test_hermes_cli_dispatch.py
Normal file
420
testing/tests/test_hermes_cli_dispatch.py
Normal file
@ -0,0 +1,420 @@
|
||||
"""Board scanning, claiming, orphan recovery, and replay guards."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
SimpleNamespace,
|
||||
_completed_result,
|
||||
json,
|
||||
lanes,
|
||||
nullcontext,
|
||||
pytest,
|
||||
sys,
|
||||
)
|
||||
|
||||
|
||||
def test_unassigned_ready_task_is_persistently_routed_to_auto_lane(monkeypatch):
|
||||
task = SimpleNamespace(id="t_auto", assignee=None, status="ready")
|
||||
assigned = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def assign_task(_conn, task_id, profile):
|
||||
assigned.append((task_id, profile))
|
||||
task.assignee = profile
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
list_boards=lambda include_archived=False: [{"slug": "cassandra"}],
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
recompute_ready=lambda _conn: None,
|
||||
list_tasks=lambda _conn: [task],
|
||||
assign_task=assign_task,
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
claim_task=lambda _conn, _task_id, **_kwargs: task,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.claim_ready(set(), 1) == [("cassandra", "t_auto")]
|
||||
assert assigned == [("t_auto", "cli-auto")]
|
||||
|
||||
|
||||
def test_corrupt_board_is_quarantined_without_stopping_healthy_lanes(monkeypatch, capsys):
|
||||
class CorruptBoardError(Exception):
|
||||
pass
|
||||
|
||||
task = SimpleNamespace(id="t_healthy", assignee="cli-auto", status="ready")
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def connect(*, board):
|
||||
if board == "cassandra":
|
||||
raise CorruptBoardError("integrity_check failed")
|
||||
return Connection()
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
KanbanDbCorruptError=CorruptBoardError,
|
||||
list_boards=lambda include_archived=False: [
|
||||
{"slug": "cassandra"},
|
||||
{"slug": "healthy"},
|
||||
],
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=connect,
|
||||
recompute_ready=lambda _conn: None,
|
||||
list_tasks=lambda _conn: [task],
|
||||
claim_task=lambda _conn, _task_id, **_kwargs: task,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
lanes.BOARD_CORRUPTION_ERRORS.clear()
|
||||
|
||||
assert lanes.claim_ready(set(), 1) == [("healthy", "t_healthy")]
|
||||
assert "temporarily skipping Kanban board 'cassandra'" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_transient_board_scan_failure_does_not_stop_healthy_lanes(monkeypatch, capsys):
|
||||
task = SimpleNamespace(id="t_healthy", assignee="cli-auto", status="ready")
|
||||
|
||||
class Connection:
|
||||
def __init__(self, board):
|
||||
self.board = board
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def recompute_ready(connection):
|
||||
if connection.board == "cassandra":
|
||||
raise lanes.sqlite3.OperationalError("disk I/O error")
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
list_boards=lambda include_archived=False: [
|
||||
{"slug": "cassandra"},
|
||||
{"slug": "healthy"},
|
||||
],
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(board),
|
||||
recompute_ready=recompute_ready,
|
||||
list_tasks=lambda _conn: [task],
|
||||
claim_task=lambda _conn, _task_id, **_kwargs: task,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
lanes.BOARD_CORRUPTION_ERRORS.clear()
|
||||
|
||||
assert lanes.claim_ready(set(), 1) == [("healthy", "t_healthy")]
|
||||
error = capsys.readouterr().err
|
||||
assert "temporarily skipping Kanban board 'cassandra'" in error
|
||||
assert "storage OperationalError: disk I/O error" in error
|
||||
|
||||
|
||||
def test_board_call_retries_storage_faults_on_fresh_connections():
|
||||
connections = []
|
||||
|
||||
class Connection:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def connect(*, board):
|
||||
assert board == "cassandra"
|
||||
connection = Connection()
|
||||
connections.append(connection)
|
||||
return connection
|
||||
|
||||
attempts = []
|
||||
|
||||
def operation(_connection):
|
||||
attempts.append(1)
|
||||
if len(attempts) < 3:
|
||||
raise lanes.sqlite3.OperationalError("disk I/O error")
|
||||
return "healthy"
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=connect,
|
||||
)
|
||||
lanes.BOARD_CORRUPTION_ERRORS.clear()
|
||||
|
||||
assert lanes._board_call(fake_db, "cassandra", operation) == "healthy"
|
||||
assert len(connections) == 3
|
||||
assert all(connection.closed for connection in connections)
|
||||
|
||||
|
||||
def test_accepted_result_survives_failed_finalization_and_replays_exact_run(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
state_root = tmp_path / "cli-lanes"
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", state_root)
|
||||
task = SimpleNamespace(
|
||||
id="t_terminal",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=41,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=60,
|
||||
)
|
||||
completions = []
|
||||
comments = []
|
||||
permit_completion = {"value": False}
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
completions.append(kwargs)
|
||||
if not permit_completion["value"]:
|
||||
return False
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.completed_run_id = kwargs["expected_run_id"]
|
||||
task.current_run_id = None
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
worker_log_path=lambda _task_id, board: tmp_path / "worker.log",
|
||||
_resolve_worktree_workspace=lambda _task, board: (tmp_path, "wt/t_terminal"),
|
||||
set_branch_name=lambda *_args: None,
|
||||
set_workspace_path=lambda *_args: None,
|
||||
build_worker_context=lambda *_args: "Finish and verify the objective.",
|
||||
heartbeat_worker=lambda *_args, **_kwargs: True,
|
||||
add_comment=lambda _conn, _task_id, _author, body: comments.append(body),
|
||||
complete_task=complete_task,
|
||||
block_task=lambda *_args, **_kwargs: pytest.fail(
|
||||
"an accepted journaled result must not be converted into a block"
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"select_route",
|
||||
lambda *_args, **_kwargs: lanes.Route(
|
||||
"codex", "gpt-5.6-sol", "high", "codex-high", "jetson", "vote", 1, ()
|
||||
),
|
||||
)
|
||||
structured = {
|
||||
"status": "completed",
|
||||
"summary": "Verified exact terminal result.",
|
||||
"changed_files": [],
|
||||
"tests_run": ["pytest: passed"],
|
||||
"artifacts": [],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"run_provider",
|
||||
lambda *_args, **_kwargs: lanes.ProcessResult(0, "", structured, False),
|
||||
)
|
||||
|
||||
lanes.execute_claim("cassandra", "t_terminal")
|
||||
|
||||
candidates = list((state_root / "cassandra").glob("*.candidate-*.json"))
|
||||
terminals = list((state_root / "cassandra").glob("*.terminal.pending.json"))
|
||||
assert len(candidates) == 1
|
||||
assert json.loads(candidates[0].read_text())["structured"] == structured
|
||||
assert len(terminals) == 1
|
||||
assert json.loads(terminals[0].read_text())["kanban_state"] == "pending"
|
||||
assert task.status == "running"
|
||||
assert any("durably journaled" in body for body in comments)
|
||||
|
||||
permit_completion["value"] = True
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert task.status == "done"
|
||||
assert len(completions) == 2
|
||||
assert not terminals[0].exists()
|
||||
committed = list(
|
||||
(state_root / "cassandra").glob("*.terminal.committed.json")
|
||||
)
|
||||
assert len(committed) == 1
|
||||
terminal = json.loads(committed[0].read_text())
|
||||
assert terminal["kanban_state"] == "committed"
|
||||
assert completions[-1]["result"] == terminal["result"]
|
||||
|
||||
|
||||
def test_restart_does_not_reclaim_an_exact_run_awaiting_finalization(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
state_root = tmp_path / "cli-lanes"
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", state_root)
|
||||
state_file = lanes.state_path("cassandra", "t_terminal")
|
||||
terminal_path, _record = lanes._write_terminal_record(
|
||||
state_file,
|
||||
board="cassandra",
|
||||
task_id="t_terminal",
|
||||
run_id=8,
|
||||
structured={
|
||||
"status": "completed",
|
||||
"summary": "done",
|
||||
"changed_files": [],
|
||||
"tests_run": [],
|
||||
"artifacts": [],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
},
|
||||
summary="done",
|
||||
metadata={},
|
||||
)
|
||||
assert terminal_path.exists()
|
||||
task = SimpleNamespace(
|
||||
id="t_terminal",
|
||||
status="running",
|
||||
assignee="cli-auto",
|
||||
current_run_id=8,
|
||||
)
|
||||
reclaimed = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
list_boards=lambda include_archived=False: [{"slug": "cassandra"}],
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
list_tasks=lambda _conn: [task],
|
||||
reclaim_task=lambda *_args, **_kwargs: reclaimed.append(True),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(lanes, "recover_pending_finalizations", lambda: 0)
|
||||
|
||||
lanes.recover_orphans()
|
||||
|
||||
assert reclaimed == []
|
||||
|
||||
|
||||
def test_orphan_recovery_passes_the_scanned_run_as_atomic_reclaim_guard(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
tasks = [
|
||||
SimpleNamespace(
|
||||
id="t_guarded",
|
||||
status="running",
|
||||
assignee="cli-auto",
|
||||
current_run_id=91,
|
||||
),
|
||||
SimpleNamespace(
|
||||
id="t_no_run",
|
||||
status="running",
|
||||
assignee="cli-auto",
|
||||
current_run_id=None,
|
||||
),
|
||||
]
|
||||
reclaimed = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
list_boards=lambda include_archived=False: [{"slug": "cassandra"}],
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
list_tasks=lambda _conn: tasks,
|
||||
reclaim_task=lambda _conn, task_id, **kwargs: (
|
||||
reclaimed.append((task_id, kwargs)) or True
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(lanes, "recover_pending_finalizations", lambda: 0)
|
||||
|
||||
lanes.recover_orphans()
|
||||
|
||||
assert reclaimed == [
|
||||
(
|
||||
"t_guarded",
|
||||
{
|
||||
"reason": "direct CLI lane restarted; provider session will resume",
|
||||
"expected_run_id": 91,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_prepared_evidence_without_pending_still_pins_the_exact_run(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
pending, record = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "t_prepared_pin"),
|
||||
board="cassandra",
|
||||
task_id="t_prepared_pin",
|
||||
run_id=14,
|
||||
structured=_completed_result("accepted and prepared"),
|
||||
summary="accepted and prepared",
|
||||
metadata={},
|
||||
)
|
||||
identity = lanes._terminal_identity(pending)
|
||||
assert identity is not None
|
||||
lanes._persist_prepared_evidence(identity, record)
|
||||
pending.unlink()
|
||||
|
||||
assert lanes._has_pending_finalization("cassandra", "t_prepared_pin", 14) is True
|
||||
|
||||
|
||||
def test_terminal_replay_never_crosses_into_a_replacement_run(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
state_root = tmp_path / "cli-lanes"
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", state_root)
|
||||
state_file = lanes.state_path("cassandra", "t_terminal")
|
||||
terminal_path, _record = lanes._write_terminal_record(
|
||||
state_file,
|
||||
board="cassandra",
|
||||
task_id="t_terminal",
|
||||
run_id=8,
|
||||
structured={
|
||||
"status": "completed",
|
||||
"summary": "old run",
|
||||
"changed_files": [],
|
||||
"tests_run": [],
|
||||
"artifacts": [],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
},
|
||||
summary="old run",
|
||||
metadata={},
|
||||
)
|
||||
replacement = SimpleNamespace(
|
||||
id="t_terminal",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=9,
|
||||
)
|
||||
completions = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: replacement,
|
||||
complete_task=lambda *_args, **_kwargs: completions.append(True),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert completions == []
|
||||
assert not terminal_path.exists()
|
||||
conflicts = list((state_root / "cassandra").glob("*.terminal.conflict-*.json"))
|
||||
assert len(conflicts) == 1
|
||||
conflict = json.loads(conflicts[0].read_text(encoding="utf-8"))
|
||||
assert conflict["result"] == _record["result"]
|
||||
assert conflict["kanban_state"] == "conflict"
|
||||
197
testing/tests/test_hermes_cli_dispatch_runtime.py
Normal file
197
testing/tests/test_hermes_cli_dispatch_runtime.py
Normal file
@ -0,0 +1,197 @@
|
||||
"""Dispatcher startup, degraded-board, and worker-loop regressions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.tests.test_hermes_cli_support import lanes
|
||||
|
||||
|
||||
def test_board_slug_and_connection_failure_paths(monkeypatch):
|
||||
assert lanes._board_slug(SimpleNamespace(slug="cassandra")) == "cassandra"
|
||||
assert lanes._board_slug(SimpleNamespace(id="fallback")) == "fallback"
|
||||
failures = []
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_record_board_access_error",
|
||||
lambda board, error: failures.append((board, str(error))),
|
||||
)
|
||||
db = SimpleNamespace(
|
||||
connect=lambda **_kwargs: (_ for _ in ()).throw(OSError("offline"))
|
||||
)
|
||||
assert lanes._connect_healthy_board(db, "broken") is None
|
||||
assert failures == [("broken", "offline")]
|
||||
|
||||
|
||||
def test_orphan_recovery_bounds_registry_and_per_board_failures(
|
||||
monkeypatch,
|
||||
):
|
||||
failures = []
|
||||
registry_db = SimpleNamespace(
|
||||
complete_task=lambda *_args, expected_run_id=None, **_kwargs: True,
|
||||
reclaim_task=lambda *_args, expected_run_id=None, **_kwargs: True,
|
||||
list_boards=lambda **_kwargs: (_ for _ in ()).throw(OSError("registry")),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli",
|
||||
SimpleNamespace(kanban_db=registry_db),
|
||||
)
|
||||
monkeypatch.setattr(lanes, "recover_pending_finalizations", lambda: 0)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_record_board_access_error",
|
||||
lambda board, error: failures.append((board, str(error))),
|
||||
)
|
||||
lanes.recover_orphans()
|
||||
assert failures == [("board-registry", "registry")]
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
failures.append(("close", "yes"))
|
||||
|
||||
board_db = SimpleNamespace(
|
||||
complete_task=registry_db.complete_task,
|
||||
reclaim_task=registry_db.reclaim_task,
|
||||
list_boards=lambda **_kwargs: [{"slug": ""}, {"slug": "broken"}],
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
list_tasks=lambda _conn: (_ for _ in ()).throw(OSError("board read")),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=board_db))
|
||||
lanes.recover_orphans()
|
||||
assert ("broken", "board read") in failures
|
||||
assert ("close", "yes") in failures
|
||||
|
||||
|
||||
def test_claim_ready_handles_zero_limit_empty_boards_and_claim_errors(monkeypatch):
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli",
|
||||
SimpleNamespace(kanban_db=SimpleNamespace()),
|
||||
)
|
||||
assert lanes.claim_ready(set(), 0) == []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
task = SimpleNamespace(
|
||||
id="t_claim",
|
||||
status="ready",
|
||||
assignee="cli-auto",
|
||||
)
|
||||
db = SimpleNamespace(
|
||||
list_boards=lambda **_kwargs: [{"slug": ""}, {"slug": "cassandra"}],
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
recompute_ready=lambda _conn: None,
|
||||
list_tasks=lambda _conn: [task],
|
||||
claim_task=lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
RuntimeError("claim raced")
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=db))
|
||||
assert lanes.claim_ready(set(), 1) == []
|
||||
|
||||
|
||||
class _Future:
|
||||
def __init__(self):
|
||||
self.polls = 0
|
||||
|
||||
def done(self):
|
||||
self.polls += 1
|
||||
return self.polls >= 1
|
||||
|
||||
def result(self):
|
||||
raise RuntimeError("worker failed")
|
||||
|
||||
|
||||
class _Pool:
|
||||
def __init__(self, max_workers):
|
||||
self.max_workers = max_workers
|
||||
self.future = _Future()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def submit(self, function, board, task_id):
|
||||
assert callable(function)
|
||||
assert (board, task_id) == ("cassandra", "t_loop")
|
||||
return self.future
|
||||
|
||||
|
||||
def test_ready_dispatch_loop_submits_and_reaps_failed_workers(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
capsys,
|
||||
):
|
||||
db = SimpleNamespace()
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=db))
|
||||
monkeypatch.setattr(lanes, "RESULT_SCHEMA_PATH", tmp_path / "schema.json")
|
||||
monkeypatch.setattr(lanes, "recover_orphans", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"initialize_kanban_capabilities",
|
||||
lambda _db: lanes.KanbanCapabilities(True, True, True),
|
||||
)
|
||||
monkeypatch.setattr(lanes, "recover_pending_finalizations", lambda: 0)
|
||||
monkeypatch.setattr(lanes, "maybe_gc_lane_artifacts", lambda: 0)
|
||||
claims = [[("cassandra", "t_loop")], []]
|
||||
monkeypatch.setattr(lanes, "claim_ready", lambda *_args: claims.pop(0))
|
||||
monkeypatch.setattr(
|
||||
lanes.concurrent.futures,
|
||||
"ThreadPoolExecutor",
|
||||
_Pool,
|
||||
)
|
||||
sleeps = []
|
||||
|
||||
def stop_second_loop(_seconds):
|
||||
sleeps.append(True)
|
||||
if len(sleeps) == 2:
|
||||
raise RuntimeError("stop loop")
|
||||
|
||||
monkeypatch.setattr(lanes.time, "sleep", stop_second_loop)
|
||||
|
||||
with pytest.raises(RuntimeError, match="stop loop"):
|
||||
lanes.main()
|
||||
|
||||
assert "worker future failed: worker failed" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_deferred_dispatch_health_never_claims_new_work(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
db = SimpleNamespace()
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=db))
|
||||
monkeypatch.setattr(lanes, "RESULT_SCHEMA_PATH", tmp_path / "schema.json")
|
||||
monkeypatch.setattr(lanes, "recover_orphans", lambda: None)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"initialize_kanban_capabilities",
|
||||
lambda _db: lanes.KanbanCapabilities(True, False, False),
|
||||
)
|
||||
monkeypatch.setattr(lanes, "recover_pending_finalizations", lambda: 0)
|
||||
monkeypatch.setattr(lanes, "maybe_gc_lane_artifacts", lambda: 0)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"claim_ready",
|
||||
lambda *_args: pytest.fail("deferred startup must not claim work"),
|
||||
)
|
||||
monkeypatch.setattr(lanes.concurrent.futures, "ThreadPoolExecutor", _Pool)
|
||||
monkeypatch.setattr(
|
||||
lanes.time,
|
||||
"sleep",
|
||||
lambda _seconds: (_ for _ in ()).throw(RuntimeError("stop loop")),
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="stop loop"):
|
||||
lanes.main()
|
||||
361
testing/tests/test_hermes_cli_durability.py
Normal file
361
testing/tests/test_hermes_cli_durability.py
Normal file
@ -0,0 +1,361 @@
|
||||
"""Prepared evidence and filesystem durability boundaries."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
SimpleNamespace,
|
||||
_completed_result,
|
||||
errno,
|
||||
json,
|
||||
lanes,
|
||||
nullcontext,
|
||||
os,
|
||||
pytest,
|
||||
stat,
|
||||
sys,
|
||||
threading,
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_db_completion_outcome_replays_from_prepared_evidence(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path, record = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "t_unknown_db_outcome"),
|
||||
board="cassandra",
|
||||
task_id="t_unknown_db_outcome",
|
||||
run_id=35,
|
||||
structured=_completed_result("DB committed before transport error"),
|
||||
summary="DB committed before transport error",
|
||||
metadata={},
|
||||
)
|
||||
task = SimpleNamespace(
|
||||
id="t_unknown_db_outcome",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=35,
|
||||
completed_run_id=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
fail_once = {"value": True}
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.completed_run_id = kwargs["expected_run_id"]
|
||||
task.current_run_id = None
|
||||
if fail_once["value"]:
|
||||
fail_once["value"] = False
|
||||
raise RuntimeError("transport failed after DB commit")
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="after DB commit"):
|
||||
lanes._finalize_terminal_record(fake_db, path, record)
|
||||
|
||||
assert task.status == "done"
|
||||
assert path.exists()
|
||||
assert len(list(path.parent.glob("*.terminal.prepared-*.json"))) == 1
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert not path.exists()
|
||||
assert list(path.parent.glob("*.terminal.prepared-*.json")) == []
|
||||
committed = list(path.parent.glob("*.terminal.committed.json"))
|
||||
assert len(committed) == 1
|
||||
assert json.loads(committed[0].read_text())["result"] == record["result"]
|
||||
|
||||
|
||||
def test_pending_retirement_fsync_failure_keeps_db_winner_committed(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path, record = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "t_retire_fsync"),
|
||||
board="cassandra",
|
||||
task_id="t_retire_fsync",
|
||||
run_id=33,
|
||||
structured=_completed_result("retirement fsync winner"),
|
||||
summary="retirement fsync winner",
|
||||
metadata={},
|
||||
)
|
||||
task = SimpleNamespace(
|
||||
id="t_retire_fsync",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=33,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.current_run_id = None
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
real_fsync = lanes.os.fsync
|
||||
failed = {"value": False}
|
||||
|
||||
def fail_pending_retirement_fsync(descriptor):
|
||||
if (
|
||||
stat.S_ISDIR(os.fstat(descriptor).st_mode)
|
||||
and not path.exists()
|
||||
and list(path.parent.glob("*.terminal.committed.json"))
|
||||
and not failed["value"]
|
||||
):
|
||||
failed["value"] = True
|
||||
raise OSError(errno.ENOSPC, "pending retirement fsync failed")
|
||||
real_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(lanes.os, "fsync", fail_pending_retirement_fsync)
|
||||
|
||||
assert lanes._finalize_terminal_record(fake_db, path, record) == "committed"
|
||||
assert failed["value"] is True
|
||||
assert task.status == "done"
|
||||
assert task.result == record["result"]
|
||||
assert not path.exists()
|
||||
assert len(list(path.parent.glob("*.terminal.committed.json"))) == 1
|
||||
assert list(path.parent.glob("*.terminal.prepared-*.json")) == []
|
||||
|
||||
|
||||
def test_committed_first_writer_is_never_overwritten_by_a_db_winner(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
state_file = lanes.state_path("cassandra", "t_committed_collision")
|
||||
pending, winner = lanes._write_terminal_record(
|
||||
state_file,
|
||||
board="cassandra",
|
||||
task_id="t_committed_collision",
|
||||
run_id=34,
|
||||
structured=_completed_result("DB winner"),
|
||||
summary="DB winner",
|
||||
metadata={"writer": "db"},
|
||||
)
|
||||
committed_path = lanes._terminal_path(state_file, 34, "committed")
|
||||
first_writer = {
|
||||
"board": "cassandra",
|
||||
"task_id": "t_committed_collision",
|
||||
"expected_run_id": 34,
|
||||
"result": json.dumps(_completed_result("evidence first writer"), sort_keys=True),
|
||||
"summary": "evidence first writer",
|
||||
"metadata": {"writer": "evidence"},
|
||||
"kanban_state": "committed",
|
||||
"recorded_at": lanes.utc_now(),
|
||||
}
|
||||
lanes.atomic_json(committed_path, first_writer)
|
||||
task = SimpleNamespace(
|
||||
id="t_committed_collision",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=34,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.current_run_id = None
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
|
||||
assert lanes._finalize_terminal_record(fake_db, pending, winner) == "committed"
|
||||
assert task.result == winner["result"]
|
||||
assert json.loads(committed_path.read_text())["result"] == first_writer["result"]
|
||||
conflicts = list(pending.parent.glob("*.terminal.conflict-*.json"))
|
||||
assert len(conflicts) == 1
|
||||
assert json.loads(conflicts[0].read_text())["result"] == winner["result"]
|
||||
assert not pending.exists()
|
||||
|
||||
|
||||
def test_concurrent_duplicate_terminal_finalizers_are_idempotent(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path, record = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "t_duplicate"),
|
||||
board="cassandra",
|
||||
task_id="t_duplicate",
|
||||
run_id=29,
|
||||
structured=_completed_result("same accepted result"),
|
||||
summary="same accepted result",
|
||||
metadata={},
|
||||
)
|
||||
task_state = {
|
||||
"status": "running",
|
||||
"result": None,
|
||||
"current_run_id": 29,
|
||||
"completed_run_id": None,
|
||||
}
|
||||
state_lock = threading.Lock()
|
||||
readers = threading.Barrier(2)
|
||||
completions = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def get_task(_conn, _task_id):
|
||||
with state_lock:
|
||||
snapshot = SimpleNamespace(
|
||||
id="t_duplicate",
|
||||
assignee="cli-auto",
|
||||
**task_state,
|
||||
)
|
||||
if snapshot.status == "running":
|
||||
readers.wait(timeout=5)
|
||||
return snapshot
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
with state_lock:
|
||||
if task_state["status"] != "running":
|
||||
return False
|
||||
task_state.update(
|
||||
status="done",
|
||||
result=kwargs["result"],
|
||||
current_run_id=None,
|
||||
completed_run_id=kwargs["expected_run_id"],
|
||||
)
|
||||
completions.append(kwargs["result"])
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=get_task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
outcomes = []
|
||||
errors = []
|
||||
|
||||
def finalize():
|
||||
try:
|
||||
outcomes.append(lanes._finalize_terminal_record(fake_db, path, record))
|
||||
except Exception as error: # pragma: no cover - assertion reports detail
|
||||
errors.append(error)
|
||||
|
||||
workers = [threading.Thread(target=finalize) for _index in range(2)]
|
||||
for worker in workers:
|
||||
worker.start()
|
||||
for worker in workers:
|
||||
worker.join(timeout=10)
|
||||
|
||||
assert all(not worker.is_alive() for worker in workers)
|
||||
assert errors == []
|
||||
assert outcomes == ["committed", "committed"]
|
||||
assert len(completions) == 1
|
||||
assert not path.exists()
|
||||
assert len(list(path.parent.glob("*.terminal.committed.json"))) == 1
|
||||
assert list(path.parent.glob("*.terminal.conflict-*.json")) == []
|
||||
assert list(path.parent.glob("*.terminal.prepared-*.json")) == []
|
||||
|
||||
|
||||
def test_recovery_uses_first_durable_prepared_result_as_db_writer(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
state_file = lanes.state_path("cassandra", "t_prepared_order")
|
||||
pending, first = lanes._write_terminal_record(
|
||||
state_file,
|
||||
board="cassandra",
|
||||
task_id="t_prepared_order",
|
||||
run_id=30,
|
||||
structured=_completed_result("first durable result"),
|
||||
summary="first durable result",
|
||||
metadata={"writer": "first"},
|
||||
)
|
||||
identity = lanes._terminal_identity(pending)
|
||||
assert identity is not None
|
||||
first_prepared = lanes._persist_prepared_evidence(identity, first)
|
||||
os.utime(first_prepared, ns=(100, 100))
|
||||
second_structured = _completed_result("second durable result")
|
||||
second = {
|
||||
"board": "cassandra",
|
||||
"task_id": "t_prepared_order",
|
||||
"expected_run_id": 30,
|
||||
"result": json.dumps(second_structured, sort_keys=True),
|
||||
"summary": "second durable result",
|
||||
"metadata": {"writer": "second"},
|
||||
"kanban_state": "pending",
|
||||
"recorded_at": lanes.utc_now(),
|
||||
}
|
||||
second_prepared = lanes._persist_prepared_evidence(identity, second)
|
||||
os.utime(second_prepared, ns=(200, 200))
|
||||
lanes.atomic_json(pending, second)
|
||||
task = SimpleNamespace(
|
||||
id="t_prepared_order",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=30,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
completions = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
if task.status != "running":
|
||||
return False
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.current_run_id = None
|
||||
completions.append(kwargs["result"])
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert completions == [first["result"]]
|
||||
assert task.result == first["result"]
|
||||
assert not pending.exists()
|
||||
assert list(pending.parent.glob("*.terminal.prepared-*.json")) == []
|
||||
committed = list(pending.parent.glob("*.terminal.committed.json"))
|
||||
conflicts = list(pending.parent.glob("*.terminal.conflict-*.json"))
|
||||
assert len(committed) == 1
|
||||
assert len(conflicts) == 1
|
||||
assert json.loads(committed[0].read_text())["result"] == first["result"]
|
||||
assert json.loads(conflicts[0].read_text())["result"] == second["result"]
|
||||
194
testing/tests/test_hermes_cli_evidence_edges.py
Normal file
194
testing/tests/test_hermes_cli_evidence_edges.py
Normal file
@ -0,0 +1,194 @@
|
||||
"""Failure-boundary coverage for immutable lane evidence and retention."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.tests.test_hermes_cli_support import _pending_terminal_record, lanes
|
||||
|
||||
|
||||
def test_rename_noreplace_rejects_missing_kernel_support(monkeypatch):
|
||||
monkeypatch.setattr(lanes.ctypes, "CDLL", lambda *_args, **_kwargs: object())
|
||||
with pytest.raises(OSError) as raised:
|
||||
lanes._rename_noreplace("a", "b", source_dir=1, destination_dir=1)
|
||||
assert raised.value.errno == errno.ENOSYS
|
||||
|
||||
|
||||
def test_rename_noreplace_surfaces_kernel_errno(monkeypatch):
|
||||
class Rename:
|
||||
argtypes = None
|
||||
restype = None
|
||||
|
||||
def __call__(self, *_args):
|
||||
return -1
|
||||
|
||||
monkeypatch.setattr(
|
||||
lanes.ctypes,
|
||||
"CDLL",
|
||||
lambda *_args, **_kwargs: SimpleNamespace(renameat2=Rename()),
|
||||
)
|
||||
monkeypatch.setattr(lanes.ctypes, "get_errno", lambda: errno.EEXIST)
|
||||
with pytest.raises(FileExistsError):
|
||||
lanes._rename_noreplace("a", "b", source_dir=1, destination_dir=1)
|
||||
|
||||
|
||||
def test_retirement_reports_missing_replacement_collision_and_restoration(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
path = tmp_path / "pending"
|
||||
path.write_text("old", encoding="utf-8")
|
||||
source = path.stat()
|
||||
directory = os.open(tmp_path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
path.unlink()
|
||||
assert lanes._retire_terminal_entry(
|
||||
path,
|
||||
source,
|
||||
board_descriptor=directory,
|
||||
quarantine_descriptor=directory,
|
||||
) == "missing"
|
||||
|
||||
path.write_text("new", encoding="utf-8")
|
||||
assert lanes._retire_terminal_entry(
|
||||
path,
|
||||
source,
|
||||
board_descriptor=directory,
|
||||
quarantine_descriptor=directory,
|
||||
) == "replacement"
|
||||
|
||||
replacement = path.stat()
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_rename_noreplace",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(FileExistsError()),
|
||||
)
|
||||
assert lanes._retire_terminal_entry(
|
||||
path,
|
||||
replacement,
|
||||
board_descriptor=directory,
|
||||
quarantine_descriptor=directory,
|
||||
) == "collision"
|
||||
finally:
|
||||
os.close(directory)
|
||||
|
||||
|
||||
def test_retirement_preserves_a_postcheck_replacement(tmp_path: Path, monkeypatch):
|
||||
path = tmp_path / "pending"
|
||||
path.write_text("old", encoding="utf-8")
|
||||
source = path.stat()
|
||||
directory = os.open(tmp_path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
calls = []
|
||||
|
||||
def race(source_name, destination, **_kwargs):
|
||||
calls.append((source_name, destination))
|
||||
if len(calls) == 1:
|
||||
os.rename(source_name, destination, src_dir_fd=directory, dst_dir_fd=directory)
|
||||
staged = tmp_path / destination
|
||||
staged.unlink()
|
||||
staged.write_text("replacement", encoding="utf-8")
|
||||
else:
|
||||
os.rename(source_name, destination, src_dir_fd=directory, dst_dir_fd=directory)
|
||||
|
||||
monkeypatch.setattr(lanes, "_rename_noreplace", race)
|
||||
try:
|
||||
assert lanes._retire_terminal_entry(
|
||||
path,
|
||||
source,
|
||||
board_descriptor=directory,
|
||||
quarantine_descriptor=directory,
|
||||
) == "replacement"
|
||||
finally:
|
||||
os.close(directory)
|
||||
assert path.read_text(encoding="utf-8") == "replacement"
|
||||
|
||||
|
||||
def test_evidence_writer_bounds_payload_and_cleans_failed_temporary(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "MAX_TERMINAL_RECORD_BYTES", 8)
|
||||
with pytest.raises(ValueError, match="bounded"):
|
||||
lanes._write_json_noreplace(tmp_path / "large.json", {"value": "x" * 5000})
|
||||
|
||||
monkeypatch.setattr(lanes, "MAX_TERMINAL_RECORD_BYTES", 1024 * 1024)
|
||||
real_retire = lanes._retire_terminal_entry
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_rename_noreplace",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("rename failed")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_retire_terminal_entry",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("cleanup failed")),
|
||||
)
|
||||
with pytest.raises(OSError, match="rename failed"):
|
||||
lanes._write_json_noreplace(tmp_path / "failed.json", {"value": "small"})
|
||||
monkeypatch.setattr(lanes, "_retire_terminal_entry", real_retire)
|
||||
|
||||
|
||||
def test_evidence_paths_and_collision_validation_fail_closed(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
identity = lanes.TerminalIdentity("cassandra", "t_evidence", 9, "pending")
|
||||
document = _pending_terminal_record("cassandra", "t_evidence", 9, "accepted")
|
||||
with pytest.raises(ValueError, match="evidence state"):
|
||||
lanes._terminal_evidence_path(identity, "pending", document)
|
||||
|
||||
monkeypatch.setattr(lanes, "_write_json_noreplace", lambda *_args: False)
|
||||
monkeypatch.setattr(lanes, "_terminal_evidence_identity", lambda _path: None)
|
||||
with pytest.raises(OSError, match="identity is invalid"):
|
||||
lanes._persist_prepared_evidence(identity, document)
|
||||
with pytest.raises(OSError, match="conflict evidence identity"):
|
||||
lanes._persist_conflict_evidence(identity, document, "loser")
|
||||
|
||||
|
||||
def test_existing_evidence_with_different_result_is_rejected(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
identity = lanes.TerminalIdentity("cassandra", "t_evidence", 10, "pending")
|
||||
document = _pending_terminal_record("cassandra", "t_evidence", 10, "accepted")
|
||||
prepared_identity = lanes.TerminalIdentity("cassandra", "t_evidence", 10, "prepared")
|
||||
conflict_identity = lanes.TerminalIdentity("cassandra", "t_evidence", 10, "conflict")
|
||||
monkeypatch.setattr(lanes, "_write_json_noreplace", lambda *_args: False)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_terminal_evidence_identity",
|
||||
lambda path: conflict_identity if "conflict" in path.name else prepared_identity,
|
||||
)
|
||||
monkeypatch.setattr(lanes, "_load_small_json", lambda _path: {})
|
||||
with pytest.raises(OSError, match="conflicting result"):
|
||||
lanes._persist_prepared_evidence(identity, document)
|
||||
with pytest.raises(OSError, match="different result"):
|
||||
lanes._persist_conflict_evidence(identity, document, "loser")
|
||||
|
||||
|
||||
def test_retention_failure_paths_are_bounded(tmp_path: Path, monkeypatch, capsys):
|
||||
path = tmp_path / "artifact"
|
||||
path.write_text("data", encoding="utf-8")
|
||||
observed = path.stat()
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_retire_terminal_entry",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("busy")),
|
||||
)
|
||||
assert lanes._unlink_artifact_if_same(path, observed) is False
|
||||
|
||||
monkeypatch.setattr(lanes, "LAST_ARTIFACT_GC", 0.0)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"gc_lane_artifacts",
|
||||
lambda **_kwargs: (_ for _ in ()).throw(OSError("volume")),
|
||||
)
|
||||
assert lanes.maybe_gc_lane_artifacts(now=1000) == 0
|
||||
assert "retention deferred" in capsys.readouterr().err
|
||||
218
testing/tests/test_hermes_cli_execution_edges.py
Normal file
218
testing/tests/test_hermes_cli_execution_edges.py
Normal file
@ -0,0 +1,218 @@
|
||||
"""Execution-loop edge coverage for preparation, callbacks, and recovery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from testing.tests.test_hermes_cli_support import _completed_result, lanes
|
||||
|
||||
|
||||
class _Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
def test_missing_claim_is_a_noop(tmp_path: Path, monkeypatch):
|
||||
db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: _Connection(),
|
||||
get_task=lambda *_args: None,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=db))
|
||||
lanes.execute_claim("cassandra", "missing")
|
||||
|
||||
|
||||
def test_transient_callback_storage_errors_do_not_kill_provider(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
task = SimpleNamespace(
|
||||
id="t_callbacks",
|
||||
status="running",
|
||||
current_run_id=20,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=60,
|
||||
)
|
||||
blocks = []
|
||||
db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: _Connection(),
|
||||
get_task=lambda *_args: task,
|
||||
worker_log_path=lambda *_args, **_kwargs: tmp_path / "worker.log",
|
||||
_resolve_worktree_workspace=lambda *_args, **_kwargs: (tmp_path, "branch"),
|
||||
set_branch_name=lambda *_args: None,
|
||||
set_workspace_path=lambda *_args: None,
|
||||
build_worker_context=lambda *_args: "exercise callbacks",
|
||||
heartbeat_worker=lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
sqlite3.OperationalError("heartbeat volume")
|
||||
),
|
||||
add_comment=lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
sqlite3.OperationalError("comment volume")
|
||||
),
|
||||
block_task=lambda *_args, **kwargs: blocks.append(kwargs),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=db))
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
monkeypatch.setattr(lanes, "KANBAN_STORAGE_ATTEMPTS", 1)
|
||||
monkeypatch.setattr(lanes, "fresh_unavailable_provider", lambda: "claude")
|
||||
route = lanes.Route("codex", "gpt", "high", "p", "c", "r", 1, ())
|
||||
monkeypatch.setattr(lanes, "select_route", lambda *_args, **_kwargs: route)
|
||||
|
||||
def provider(*args, **_kwargs):
|
||||
assert args[6]("still working") is True
|
||||
return lanes.ProcessResult(1, "failed", None, False)
|
||||
|
||||
monkeypatch.setattr(lanes, "run_provider", provider)
|
||||
lanes.execute_claim("cassandra", "t_callbacks")
|
||||
|
||||
assert blocks and blocks[0]["kind"] == "capability"
|
||||
|
||||
|
||||
def test_restart_handoff_survives_missing_prior_log(tmp_path: Path, monkeypatch):
|
||||
task = SimpleNamespace(
|
||||
id="t_restart",
|
||||
status="running",
|
||||
current_run_id=21,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=60,
|
||||
)
|
||||
state_root = tmp_path / "lanes"
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", state_root)
|
||||
state_file = lanes.state_path("cassandra", "t_restart")
|
||||
lanes.atomic_json(state_file, {"current_route": {"provider": "claude"}})
|
||||
blocks = []
|
||||
db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: _Connection(),
|
||||
get_task=lambda *_args: task,
|
||||
worker_log_path=lambda *_args, **_kwargs: tmp_path / "missing.log",
|
||||
_resolve_worktree_workspace=lambda *_args, **_kwargs: (tmp_path, "branch"),
|
||||
set_branch_name=lambda *_args: None,
|
||||
set_workspace_path=lambda *_args: None,
|
||||
build_worker_context=lambda *_args: "resume",
|
||||
add_comment=lambda *_args, **_kwargs: None,
|
||||
heartbeat_worker=lambda *_args, **_kwargs: True,
|
||||
block_task=lambda *_args, **kwargs: blocks.append(kwargs),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=db))
|
||||
route = lanes.Route("codex", "gpt", "high", "p", "c", "r", 1, ())
|
||||
monkeypatch.setattr(lanes, "select_route", lambda *_args, **_kwargs: route)
|
||||
handoffs = []
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"git_handoff",
|
||||
lambda _workspace, output: handoffs.append(output) or "handoff",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"run_provider",
|
||||
lambda *_args, **_kwargs: lanes.ProcessResult(1, "failed", None, False),
|
||||
)
|
||||
|
||||
lanes.execute_claim("cassandra", "t_restart")
|
||||
|
||||
assert handoffs == ["Previous provider log was unavailable after restart."]
|
||||
assert blocks
|
||||
|
||||
|
||||
def test_unreplayable_persistence_error_recovers_exact_run(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
task = SimpleNamespace(
|
||||
id="t_persist",
|
||||
status="running",
|
||||
current_run_id=22,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=60,
|
||||
)
|
||||
comments = []
|
||||
db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: _Connection(),
|
||||
get_task=lambda *_args: task,
|
||||
worker_log_path=lambda *_args, **_kwargs: tmp_path / "worker.log",
|
||||
_resolve_worktree_workspace=lambda *_args, **_kwargs: (tmp_path, "branch"),
|
||||
set_branch_name=lambda *_args: None,
|
||||
set_workspace_path=lambda *_args: None,
|
||||
build_worker_context=lambda *_args: "finish",
|
||||
add_comment=lambda _conn, _task, _author, body: comments.append(body),
|
||||
heartbeat_worker=lambda *_args, **_kwargs: True,
|
||||
block_task=lambda *_args, **_kwargs: None,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=db))
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"select_route",
|
||||
lambda *_args, **_kwargs: lanes.Route(
|
||||
"codex", "gpt", "high", "p", "c", "r", 1, ()
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"run_provider",
|
||||
lambda *_args, **_kwargs: lanes.ProcessResult(
|
||||
0, "", _completed_result("accepted"), False
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_write_terminal_record",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("disk")),
|
||||
)
|
||||
monkeypatch.setattr(lanes, "_has_pending_finalization", lambda *_args: False)
|
||||
recovered = []
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_recover_exact_run",
|
||||
lambda *args: recovered.append(args) or True,
|
||||
)
|
||||
|
||||
lanes.execute_claim("cassandra", "t_persist")
|
||||
|
||||
assert recovered and recovered[0][1].run_id == 22
|
||||
assert any("terminal replayable=False" in body for body in comments)
|
||||
|
||||
|
||||
def test_unexpected_route_exception_blocks_the_exact_run(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
task = SimpleNamespace(
|
||||
id="t_route",
|
||||
status="running",
|
||||
current_run_id=23,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=60,
|
||||
)
|
||||
blocks = []
|
||||
db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: _Connection(),
|
||||
get_task=lambda *_args: task,
|
||||
worker_log_path=lambda *_args, **_kwargs: tmp_path / "worker.log",
|
||||
_resolve_worktree_workspace=lambda *_args, **_kwargs: (tmp_path, "branch"),
|
||||
set_branch_name=lambda *_args: None,
|
||||
set_workspace_path=lambda *_args: None,
|
||||
build_worker_context=lambda *_args: "route",
|
||||
add_comment=lambda *_args, **_kwargs: None,
|
||||
heartbeat_worker=lambda *_args, **_kwargs: True,
|
||||
block_task=lambda *_args, **kwargs: blocks.append(kwargs),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=db))
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"select_route",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("router down")),
|
||||
)
|
||||
|
||||
lanes.execute_claim("cassandra", "t_route")
|
||||
|
||||
assert blocks[0]["expected_run_id"] == 23
|
||||
assert "router down" in blocks[0]["reason"]
|
||||
381
testing/tests/test_hermes_cli_fallback.py
Normal file
381
testing/tests/test_hermes_cli_fallback.py
Normal file
@ -0,0 +1,381 @@
|
||||
"""Provider fallback, workspace, and command contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
SimpleNamespace,
|
||||
_completed_result,
|
||||
json,
|
||||
lanes,
|
||||
nullcontext,
|
||||
pytest,
|
||||
sys,
|
||||
)
|
||||
|
||||
|
||||
def test_goal_card_continues_after_local_judge_rejects_progress(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
task = SimpleNamespace(
|
||||
id="t_goal",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=12,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=300,
|
||||
goal_mode=True,
|
||||
goal_max_turns=3,
|
||||
)
|
||||
calls = []
|
||||
comments = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
worker_log_path=lambda _task_id, board: tmp_path / "worker.log",
|
||||
_resolve_worktree_workspace=lambda _task, board: (tmp_path, "wt/t_goal"),
|
||||
set_branch_name=lambda *_args: None,
|
||||
set_workspace_path=lambda *_args: None,
|
||||
build_worker_context=lambda *_args: "Run tests, commit, push, and verify remote HEAD.",
|
||||
heartbeat_worker=lambda *_args, **_kwargs: True,
|
||||
add_comment=lambda _conn, _task_id, _author, body: comments.append(body),
|
||||
complete_task=lambda *_args, **kwargs: (
|
||||
calls.append(("complete", kwargs)) or True
|
||||
),
|
||||
block_task=lambda *_args, **kwargs: calls.append(("block", kwargs)),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
claude_low = lanes.Route(
|
||||
"claude", "claude-fable-5", "low", "claude-low", "jetson", "vote", 1, ()
|
||||
)
|
||||
codex_low = lanes.Route(
|
||||
"codex", "gpt-5.6-luna", "low", "codex-low", "manual", "fallback", 1, ()
|
||||
)
|
||||
codex_xhigh = lanes.Route(
|
||||
"codex", "gpt-5.6-sol", "xhigh", "codex-xhigh", "jetson", "escalated", 1, ()
|
||||
)
|
||||
route_calls = []
|
||||
|
||||
def select_route(_prompt, assignee, **kwargs):
|
||||
route_calls.append((assignee, kwargs))
|
||||
if assignee == "cli-codex-low":
|
||||
return codex_low
|
||||
if len(route_calls) == 1:
|
||||
return claude_low
|
||||
return codex_xhigh
|
||||
|
||||
monkeypatch.setattr(lanes, "select_route", select_route)
|
||||
monkeypatch.setattr(lanes, "fresh_unavailable_provider", lambda: None)
|
||||
reports = [
|
||||
lanes.ProcessResult(1, "authentication expired", None, True),
|
||||
lanes.ProcessResult(
|
||||
0,
|
||||
"first turn",
|
||||
{
|
||||
"status": "completed",
|
||||
"summary": "Focused tests passed.",
|
||||
"changed_files": ["src/a.py"],
|
||||
"tests_run": ["pytest focused: passed"],
|
||||
"artifacts": [],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
},
|
||||
False,
|
||||
),
|
||||
lanes.ProcessResult(
|
||||
0,
|
||||
"second turn",
|
||||
{
|
||||
"status": "completed",
|
||||
"summary": "Full tests passed; commit pushed and remote HEAD verified.",
|
||||
"changed_files": ["src/a.py"],
|
||||
"tests_run": ["pytest full: passed"],
|
||||
"artifacts": [],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
},
|
||||
False,
|
||||
),
|
||||
]
|
||||
monkeypatch.setattr(lanes, "run_provider", lambda *_args, **_kwargs: reports.pop(0))
|
||||
verdicts = iter(
|
||||
[
|
||||
(False, "commit, push, and remote verification are missing"),
|
||||
(True, "all explicit acceptance criteria have evidence"),
|
||||
]
|
||||
)
|
||||
judge_contexts = []
|
||||
|
||||
def judge_goal_completion(objective, *_args, **_kwargs):
|
||||
judge_contexts.append(objective)
|
||||
return next(verdicts)
|
||||
|
||||
monkeypatch.setattr(
|
||||
lanes.cli_lane_goal,
|
||||
"judge_goal_completion",
|
||||
judge_goal_completion,
|
||||
)
|
||||
|
||||
lanes.execute_claim("cassandra", "t_goal")
|
||||
|
||||
assert calls[0][0] == "complete"
|
||||
assert calls[0][1]["metadata"]["goal_turn"] == 2
|
||||
assert any("Goal completion rejected; continuing turn 2/3" in item for item in comments)
|
||||
assert any("Goal route 2/3: codex/gpt-5.6-sol at xhigh" in item for item in comments)
|
||||
assert route_calls[2][1]["exclude_provider"] == "claude"
|
||||
assert "prior rejected reports" in judge_contexts[1]
|
||||
assert "commit, push, and remote verification are missing" in judge_contexts[1]
|
||||
candidates = sorted(
|
||||
(lanes.STATE_ROOT / "cassandra").glob("t_goal.run-12.candidate-*.json")
|
||||
)
|
||||
assert len(candidates) == 2
|
||||
assert json.loads(candidates[0].read_text())["structured"]["summary"] == (
|
||||
"Focused tests passed."
|
||||
)
|
||||
assert json.loads(candidates[1].read_text())["structured"]["summary"].startswith(
|
||||
"Full tests passed"
|
||||
)
|
||||
assert reports == []
|
||||
|
||||
|
||||
def test_capacity_fallback_preserves_first_claude_structured_response(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
task = SimpleNamespace(
|
||||
id="t_fallback",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=14,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=60,
|
||||
)
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
worker_log_path=lambda _task_id, board: tmp_path / "worker.log",
|
||||
_resolve_worktree_workspace=lambda _task, board: (tmp_path, "wt/t_fallback"),
|
||||
set_branch_name=lambda *_args: None,
|
||||
set_workspace_path=lambda *_args: None,
|
||||
build_worker_context=lambda *_args: "Complete with a fallback if needed.",
|
||||
heartbeat_worker=lambda *_args, **_kwargs: True,
|
||||
add_comment=lambda *_args: None,
|
||||
complete_task=lambda _conn, _task_id, **kwargs: (
|
||||
setattr(task, "status", "done") or setattr(task, "result", kwargs["result"]) or True
|
||||
),
|
||||
block_task=lambda *_args, **_kwargs: pytest.fail("fallback should complete"),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
claude = lanes.Route(
|
||||
"claude", "claude-fable-5", "high", "claude-high", "test", "test", 1, ()
|
||||
)
|
||||
codex = lanes.Route(
|
||||
"codex", "gpt-5.6-sol", "high", "codex-high", "test", "test", 1, ()
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"select_route",
|
||||
lambda _prompt, assignee, **_kwargs: codex
|
||||
if assignee == "cli-codex-high"
|
||||
else claude,
|
||||
)
|
||||
reports = [
|
||||
lanes.ProcessResult(
|
||||
1,
|
||||
"subscription capacity exhausted",
|
||||
{
|
||||
**_completed_result("Claude preserved evidence"),
|
||||
"status": "incomplete",
|
||||
"blockers": ["subscription capacity exhausted"],
|
||||
},
|
||||
True,
|
||||
),
|
||||
lanes.ProcessResult(0, "", _completed_result("Codex completed"), False),
|
||||
]
|
||||
monkeypatch.setattr(lanes, "run_provider", lambda *_args, **_kwargs: reports.pop(0))
|
||||
|
||||
lanes.execute_claim("cassandra", "t_fallback")
|
||||
|
||||
candidates = sorted(
|
||||
(lanes.STATE_ROOT / "cassandra").glob("t_fallback.run-14.candidate-*.json")
|
||||
)
|
||||
assert len(candidates) == 2
|
||||
first, second = [json.loads(path.read_text()) for path in candidates]
|
||||
assert (first["provider"], first["structured"]["summary"]) == (
|
||||
"claude",
|
||||
"Claude preserved evidence",
|
||||
)
|
||||
assert (second["provider"], second["structured"]["summary"]) == (
|
||||
"codex",
|
||||
"Codex completed",
|
||||
)
|
||||
assert task.status == "done"
|
||||
|
||||
|
||||
def test_workspace_preparation_failure_durably_blocks_the_claim(tmp_path: Path, monkeypatch):
|
||||
task = SimpleNamespace(id="t_bad_worktree", current_run_id=7, assignee="cli-auto")
|
||||
calls = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
worker_log_path=lambda _task_id, board: tmp_path / "worker.log",
|
||||
_resolve_worktree_workspace=lambda _task, board: (_ for _ in ()).throw(
|
||||
ValueError("not a Git repository")
|
||||
),
|
||||
block_task=lambda *_args, **kwargs: calls.append(kwargs),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(lanes, "state_path", lambda _board, _task_id: tmp_path / "state.json")
|
||||
|
||||
lanes.execute_claim("cassandra", "t_bad_worktree")
|
||||
|
||||
assert calls[0]["kind"] == "capability"
|
||||
assert calls[0]["expected_run_id"] == 7
|
||||
assert "not a Git repository" in calls[0]["reason"]
|
||||
|
||||
|
||||
def test_artifacts_cannot_escape_the_task_worktree(tmp_path: Path):
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
inside = workspace / "report.json"
|
||||
outside = tmp_path / "auth.json"
|
||||
inside.write_text("{}\n", encoding="utf-8")
|
||||
outside.write_text("secret\n", encoding="utf-8")
|
||||
|
||||
assert lanes.workspace_artifacts(
|
||||
workspace,
|
||||
["report.json", str(outside), "missing.json"],
|
||||
) == [str(inside)]
|
||||
|
||||
|
||||
def test_restart_provider_change_includes_explicit_workspace_handoff(tmp_path: Path, monkeypatch):
|
||||
task = SimpleNamespace(
|
||||
id="t_resume",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=9,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=60,
|
||||
)
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
state_file = lanes.state_path("cassandra", "t_resume")
|
||||
state_file.parent.mkdir(parents=True)
|
||||
state_file.write_text(
|
||||
json.dumps({"current_route": {"provider": "claude"}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / "worker.log").write_text("prior provider evidence", encoding="utf-8")
|
||||
prompts = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
worker_log_path=lambda _task_id, board: tmp_path / "worker.log",
|
||||
_resolve_worktree_workspace=lambda _task, board: (tmp_path, "wt/t_resume"),
|
||||
set_branch_name=lambda *_args: None,
|
||||
set_workspace_path=lambda *_args: None,
|
||||
build_worker_context=lambda *_args: "resume objective",
|
||||
add_comment=lambda *_args: None,
|
||||
complete_task=lambda *_args, **_kwargs: True,
|
||||
block_task=lambda *_args, **_kwargs: None,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"select_route",
|
||||
lambda *_args, **_kwargs: lanes.Route(
|
||||
"codex", "gpt-5.6-sol", "high", "codex-high", "jetson", "vote", 1, ()
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"git_handoff",
|
||||
lambda _workspace, output: f"HANDOFF:{output}",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"run_provider",
|
||||
lambda _route, prompt, *_args, **_kwargs: (
|
||||
prompts.append(prompt)
|
||||
or lanes.ProcessResult(
|
||||
0,
|
||||
"",
|
||||
{
|
||||
"status": "completed",
|
||||
"summary": "done",
|
||||
"changed_files": [],
|
||||
"tests_run": [],
|
||||
"artifacts": [],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
},
|
||||
False,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
lanes.execute_claim("cassandra", "t_resume")
|
||||
|
||||
assert "HANDOFF:prior provider evidence" in prompts[0]
|
||||
|
||||
|
||||
def test_provider_commands_are_structured_unattended_and_capped(tmp_path: Path):
|
||||
route = lanes.Route("codex", "gpt-5.6-sol", "xhigh", "codex-xhigh", "jetson", "vote", 1, ())
|
||||
command = lanes._codex_command(route, "Work.", tmp_path, {}, tmp_path / "result.json")
|
||||
assert "--dangerously-bypass-approvals-and-sandbox" in command
|
||||
assert "--json" in command
|
||||
assert "--output-schema" in command
|
||||
assert 'model_reasoning_effort="xhigh"' in command
|
||||
|
||||
claude_state = {"claude_session_id": "13864642-2985-4f91-bef5-53f145f878e8"}
|
||||
claude = lanes._claude_command(
|
||||
lanes.Route("claude", "claude-opus-5", "xhigh", "claude-xhigh", "jetson", "vote", 1, ()),
|
||||
"Review.",
|
||||
claude_state,
|
||||
False,
|
||||
)
|
||||
assert "--dangerously-skip-permissions" in claude
|
||||
assert "--output-format" in claude and "stream-json" in claude
|
||||
assert "--json-schema" in claude
|
||||
assert "--disallowedTools" in claude
|
||||
assert "Bash(kubectl apply *)" not in claude
|
||||
assert "Bash(flux reconcile *)" not in claude
|
||||
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 set(lanes.RESULT_SCHEMA["required"]) == set(
|
||||
lanes.RESULT_SCHEMA["properties"]
|
||||
)
|
||||
assert "assigned task itself" in lanes.RESULT_SCHEMA["properties"]["blockers"]["description"]
|
||||
411
testing/tests/test_hermes_cli_finalization.py
Normal file
411
testing/tests/test_hermes_cli_finalization.py
Normal file
@ -0,0 +1,411 @@
|
||||
"""Terminal-result validation and exact-run finalization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
SimpleNamespace,
|
||||
_completed_result,
|
||||
errno,
|
||||
json,
|
||||
lanes,
|
||||
nullcontext,
|
||||
pytest,
|
||||
sys,
|
||||
)
|
||||
|
||||
|
||||
def test_terminal_recovery_can_complete_the_exact_latest_ended_run(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
state_file = lanes.state_path("cassandra", "t_ended")
|
||||
pending, _record = lanes._write_terminal_record(
|
||||
state_file,
|
||||
board="cassandra",
|
||||
task_id="t_ended",
|
||||
run_id=17,
|
||||
structured=_completed_result("accepted before legacy block"),
|
||||
summary="accepted before legacy block",
|
||||
metadata={},
|
||||
)
|
||||
task = SimpleNamespace(
|
||||
id="t_ended",
|
||||
status="blocked",
|
||||
result=None,
|
||||
current_run_id=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
guards = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
guards.append(kwargs)
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert guards[0]["replay_ended_run_id"] == 17
|
||||
assert "expected_run_id" not in guards[0]
|
||||
assert task.status == "done"
|
||||
assert not pending.exists()
|
||||
|
||||
|
||||
def test_terminal_recovery_completes_an_unchanged_triage_exact_run(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
pending, record = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "t_triage_ended"),
|
||||
board="cassandra",
|
||||
task_id="t_triage_ended",
|
||||
run_id=18,
|
||||
structured=_completed_result("accepted before loop-breaker triage"),
|
||||
summary="accepted before loop-breaker triage",
|
||||
metadata={},
|
||||
)
|
||||
task = SimpleNamespace(
|
||||
id="t_triage_ended",
|
||||
status="triage",
|
||||
result=None,
|
||||
current_run_id=None,
|
||||
completed_run_id=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
guards = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
guards.append(kwargs)
|
||||
assert kwargs == {
|
||||
"result": record["result"],
|
||||
"summary": record["summary"],
|
||||
"metadata": {},
|
||||
"replay_ended_run_id": 18,
|
||||
}
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.completed_run_id = 18
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert len(guards) == 1
|
||||
assert task.status == "done"
|
||||
assert task.completed_run_id == 18
|
||||
assert not pending.exists()
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert len(guards) == 1
|
||||
|
||||
|
||||
def test_complete_exception_after_journal_is_replayable_and_never_blocks(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
state_root = tmp_path / "cli-lanes"
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", state_root)
|
||||
task = SimpleNamespace(
|
||||
id="t_crash",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=51,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=60,
|
||||
)
|
||||
completion_raises = {"value": True}
|
||||
blocks = []
|
||||
comments = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
if completion_raises["value"]:
|
||||
raise RuntimeError("crash between journal and commit")
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.current_run_id = None
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
worker_log_path=lambda _task_id, board: tmp_path / "worker.log",
|
||||
_resolve_worktree_workspace=lambda _task, board: (tmp_path, "wt/t_crash"),
|
||||
set_branch_name=lambda *_args: None,
|
||||
set_workspace_path=lambda *_args: None,
|
||||
build_worker_context=lambda *_args: "Finish safely.",
|
||||
heartbeat_worker=lambda *_args, **_kwargs: True,
|
||||
add_comment=lambda _conn, _task_id, _author, body: comments.append(body),
|
||||
complete_task=complete_task,
|
||||
block_task=lambda *_args, **kwargs: blocks.append(kwargs),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"select_route",
|
||||
lambda *_args, **_kwargs: lanes.Route(
|
||||
"codex", "gpt-5.6-sol", "high", "codex-high", "test", "test", 1, ()
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"run_provider",
|
||||
lambda *_args, **_kwargs: lanes.ProcessResult(
|
||||
0, "", _completed_result("crash-safe result"), False
|
||||
),
|
||||
)
|
||||
|
||||
lanes.execute_claim("cassandra", "t_crash")
|
||||
|
||||
pending = list((state_root / "cassandra").glob("*.terminal.pending.json"))
|
||||
assert len(pending) == 1
|
||||
assert blocks == []
|
||||
assert any("RuntimeError" in body for body in comments)
|
||||
|
||||
completion_raises["value"] = False
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert task.status == "done"
|
||||
assert not pending[0].exists()
|
||||
|
||||
|
||||
def test_terminal_replace_then_directory_fsync_enospc_replays_after_restart(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
state_root = tmp_path / "cli-lanes"
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", state_root)
|
||||
task = SimpleNamespace(
|
||||
id="t_enospc",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=52,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=60,
|
||||
)
|
||||
blocks = []
|
||||
comments = []
|
||||
completions = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
completions.append(kwargs)
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.current_run_id = None
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
worker_log_path=lambda _task_id, board: tmp_path / "worker.log",
|
||||
_resolve_worktree_workspace=lambda _task, board: (tmp_path, "wt/t_enospc"),
|
||||
set_branch_name=lambda *_args: None,
|
||||
set_workspace_path=lambda *_args: None,
|
||||
build_worker_context=lambda *_args: "Finish without losing the result.",
|
||||
heartbeat_worker=lambda *_args, **_kwargs: True,
|
||||
add_comment=lambda _conn, _task_id, _author, body: comments.append(body),
|
||||
complete_task=complete_task,
|
||||
block_task=lambda *_args, **kwargs: blocks.append(kwargs),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"select_route",
|
||||
lambda *_args, **_kwargs: lanes.Route(
|
||||
"codex", "gpt-5.6-sol", "high", "codex-high", "test", "test", 1, ()
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"run_provider",
|
||||
lambda *_args, **_kwargs: lanes.ProcessResult(
|
||||
0, "", _completed_result("persisted before ENOSPC"), False
|
||||
),
|
||||
)
|
||||
real_replace = lanes.os.replace
|
||||
real_fsync_directory = lanes._fsync_directory
|
||||
terminal_replaced = {"value": False}
|
||||
fail_once = {"value": True}
|
||||
|
||||
def replace_then_mark(source, destination):
|
||||
real_replace(source, destination)
|
||||
if str(destination).endswith(".terminal.pending.json"):
|
||||
terminal_replaced["value"] = True
|
||||
|
||||
def fail_after_terminal_replace(directory):
|
||||
if terminal_replaced["value"] and fail_once["value"]:
|
||||
fail_once["value"] = False
|
||||
raise OSError(errno.ENOSPC, "no space after terminal rename")
|
||||
real_fsync_directory(directory)
|
||||
|
||||
monkeypatch.setattr(lanes.os, "replace", replace_then_mark)
|
||||
monkeypatch.setattr(lanes, "_fsync_directory", fail_after_terminal_replace)
|
||||
|
||||
lanes.execute_claim("cassandra", "t_enospc")
|
||||
|
||||
pending = list((state_root / "cassandra").glob("*.terminal.pending.json"))
|
||||
assert terminal_replaced["value"] is True
|
||||
assert fail_once["value"] is False
|
||||
assert len(pending) == 1
|
||||
assert blocks == []
|
||||
assert completions == []
|
||||
assert task.status == "running"
|
||||
assert any("terminal replayable=True" in body for body in comments)
|
||||
|
||||
# A restarted runner sees the exact pending journal and completes the run.
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert len(completions) == 1
|
||||
assert task.status == "done"
|
||||
assert not pending[0].exists()
|
||||
committed = list((state_root / "cassandra").glob("*.terminal.committed.json"))
|
||||
assert len(committed) == 1
|
||||
|
||||
|
||||
def test_terminal_payload_cannot_select_a_different_board(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
state_root = tmp_path / "cli-lanes"
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", state_root)
|
||||
path = lanes._terminal_path(lanes.state_path("alpha", "t_alpha"), 3)
|
||||
lanes.atomic_json(
|
||||
path,
|
||||
{
|
||||
"board": "beta",
|
||||
"task_id": "t_beta",
|
||||
"expected_run_id": 9,
|
||||
"result": json.dumps(_completed_result()),
|
||||
"summary": "forged",
|
||||
"metadata": {},
|
||||
"kanban_state": "pending",
|
||||
},
|
||||
)
|
||||
alpha_task = SimpleNamespace(
|
||||
id="t_alpha", status="running", current_run_id=3, assignee="cli-auto"
|
||||
)
|
||||
opened = []
|
||||
reclaimed = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: (opened.append(board) or Connection()),
|
||||
get_task=lambda _conn, task_id: alpha_task if task_id == "t_alpha" else None,
|
||||
reclaim_task=lambda _conn, task_id, **_kwargs: (reclaimed.append(task_id) or True),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
|
||||
assert opened == ["alpha"]
|
||||
assert reclaimed == ["t_alpha"]
|
||||
assert not path.exists()
|
||||
quarantined = list((state_root / "alpha/quarantine").glob("*.quarantine"))
|
||||
assert len(quarantined) == 1
|
||||
assert quarantined[0].stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_terminal_finalize_rereads_journal_after_a_path_swap(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
state_file = lanes.state_path("alpha", "t_alpha")
|
||||
path, safe_record = lanes._write_terminal_record(
|
||||
state_file,
|
||||
board="alpha",
|
||||
task_id="t_alpha",
|
||||
run_id=4,
|
||||
structured=_completed_result("safe"),
|
||||
summary="safe",
|
||||
metadata={},
|
||||
)
|
||||
forged = dict(safe_record)
|
||||
forged.update({"board": "beta", "task_id": "t_beta", "expected_run_id": 7})
|
||||
lanes.atomic_json(path, forged)
|
||||
opened = []
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: (opened.append(board) or pytest.fail("must not open DB")),
|
||||
)
|
||||
|
||||
assert lanes._finalize_terminal_record(fake_db, path, safe_record) == "foreign"
|
||||
assert opened == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("payload", [b"", b'{"board":"cassandra"'])
|
||||
def test_malformed_exact_run_journal_is_quarantined_and_reclaimed(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
payload: bytes,
|
||||
):
|
||||
state_root = tmp_path / "cli-lanes"
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", state_root)
|
||||
path = lanes._terminal_path(lanes.state_path("cassandra", "t_partial"), 12)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(payload)
|
||||
task = SimpleNamespace(
|
||||
id="t_partial", status="running", current_run_id=12, assignee="cli-auto"
|
||||
)
|
||||
reclaimed = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
reclaim_task=lambda *_args, **kwargs: (reclaimed.append(kwargs) or True),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
|
||||
assert reclaimed == [{
|
||||
"reason": (
|
||||
"terminal journal recovery failed (malformed-payload); "
|
||||
"exact run may retry"
|
||||
),
|
||||
"expected_run_id": 12,
|
||||
}]
|
||||
assert not path.exists()
|
||||
assert lanes._has_pending_finalization("cassandra", "t_partial", 12) is False
|
||||
quarantined = list((state_root / "cassandra/quarantine").glob("*.quarantine"))
|
||||
assert len(quarantined) == 1
|
||||
assert quarantined[0].stat().st_mode & 0o777 == 0o600
|
||||
53
testing/tests/test_hermes_cli_finalization_authority.py
Normal file
53
testing/tests/test_hermes_cli_finalization_authority.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Post-transaction authority classification for exact terminal results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
SimpleNamespace,
|
||||
lanes,
|
||||
nullcontext,
|
||||
pytest,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("status", "current_run_id", "expected"),
|
||||
[
|
||||
("running", 17, "pending"),
|
||||
("running", 18, "stale"),
|
||||
("ready", None, "stale"),
|
||||
("blocked", None, "stale"),
|
||||
("triage", None, "stale"),
|
||||
],
|
||||
)
|
||||
def test_failed_completion_is_pending_only_for_the_exact_live_run(
|
||||
status: str,
|
||||
current_run_id: int | None,
|
||||
expected: str,
|
||||
):
|
||||
task = SimpleNamespace(
|
||||
id="t_authority",
|
||||
status=status,
|
||||
current_run_id=current_run_id,
|
||||
completed_run_id=None,
|
||||
result=None,
|
||||
)
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
database = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=lambda *_args, **_kwargs: False,
|
||||
)
|
||||
|
||||
outcome = lanes._finalize_document_db(
|
||||
database,
|
||||
lanes.TerminalIdentity("cassandra", "t_authority", 17, "pending"),
|
||||
{"result": "accepted", "summary": "accepted", "metadata": {}},
|
||||
)
|
||||
|
||||
assert outcome == expected
|
||||
164
testing/tests/test_hermes_cli_finalization_edges.py
Normal file
164
testing/tests/test_hermes_cli_finalization_edges.py
Normal file
@ -0,0 +1,164 @@
|
||||
"""Exact-run finalization guard and convergence edge coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
_pending_terminal_record,
|
||||
lanes,
|
||||
)
|
||||
|
||||
|
||||
class _Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
|
||||
def _db(task):
|
||||
return SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: _Connection(),
|
||||
get_task=lambda *_args: task,
|
||||
reclaim_task=lambda *_args, expected_run_id=None, **_kwargs: True,
|
||||
complete_task=lambda *_args, expected_run_id=None,
|
||||
replay_ended_run_id=None, **_kwargs: False,
|
||||
)
|
||||
|
||||
|
||||
def test_exact_recovery_rejects_missing_capability_and_nonauthoritative_tasks(
|
||||
monkeypatch,
|
||||
):
|
||||
identity = lanes.TerminalIdentity("cassandra", "t_recover", 3, "pending")
|
||||
assert lanes._recover_exact_run(_db(None), None, "missing") is False
|
||||
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"kanban_capabilities",
|
||||
lambda _db: lanes.KanbanCapabilities(True, True, False),
|
||||
)
|
||||
assert lanes._recover_exact_run(_db(None), identity, "old image") is False
|
||||
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"kanban_capabilities",
|
||||
lambda _db: lanes.KanbanCapabilities(True, True, True),
|
||||
)
|
||||
assert lanes._recover_exact_run(_db(None), identity, "missing task") is False
|
||||
internal = SimpleNamespace(
|
||||
status="running",
|
||||
current_run_id=3,
|
||||
assignee="internal",
|
||||
)
|
||||
assert lanes._recover_exact_run(_db(internal), identity, "internal") is False
|
||||
|
||||
|
||||
def test_exact_recovery_bounds_board_access_failure(monkeypatch):
|
||||
identity = lanes.TerminalIdentity("cassandra", "t_recover", 4, "pending")
|
||||
db = _db(SimpleNamespace(status="running", current_run_id=4, assignee="cli-auto"))
|
||||
db.connect = lambda **_kwargs: (_ for _ in ()).throw(OSError("volume"))
|
||||
errors = []
|
||||
monkeypatch.setattr(lanes, "KANBAN_STORAGE_ATTEMPTS", 1)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_record_board_access_error",
|
||||
lambda board, error: errors.append((board, str(error))),
|
||||
)
|
||||
assert lanes._recover_exact_run(db, identity, "storage") is False
|
||||
assert errors[-1] == ("cassandra", "volume")
|
||||
|
||||
|
||||
def test_finalizer_guards_invalid_runtime_identity_and_missing_task(monkeypatch):
|
||||
identity = object.__new__(lanes.TerminalIdentity)
|
||||
object.__setattr__(identity, "board", "cassandra")
|
||||
object.__setattr__(identity, "task_id", "t_invalid")
|
||||
object.__setattr__(identity, "run_id", 0)
|
||||
object.__setattr__(identity, "state", "pending")
|
||||
assert lanes._finalize_document_db(_db(None), identity, {}) == "stale"
|
||||
|
||||
valid = lanes.TerminalIdentity("cassandra", "missing", 5, "pending")
|
||||
assert lanes._finalize_document_db(_db(None), valid, {}) == "stale"
|
||||
|
||||
running = SimpleNamespace(
|
||||
status="running",
|
||||
current_run_id=5,
|
||||
completed_run_id=None,
|
||||
result=None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"kanban_capabilities",
|
||||
lambda _db: lanes.KanbanCapabilities(False, False, False),
|
||||
)
|
||||
assert lanes._finalize_document_db(_db(running), valid, {}) == "deferred"
|
||||
|
||||
|
||||
class _Snapshot:
|
||||
def __init__(self, document):
|
||||
self.document = document
|
||||
self.closed = 0
|
||||
|
||||
def close(self):
|
||||
self.closed += 1
|
||||
|
||||
|
||||
def test_resolve_pending_rejects_invalid_document_and_unknown_retirement(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
identity = lanes.TerminalIdentity("cassandra", "t_pending", 6, "pending")
|
||||
invalid = _Snapshot({})
|
||||
monkeypatch.setattr(lanes, "_open_terminal_snapshot", lambda *_args: invalid)
|
||||
assert lanes._resolve_pending_after_winner(tmp_path / "pending", identity, {}) is False
|
||||
assert invalid.closed == 1
|
||||
|
||||
valid_document = _pending_terminal_record("cassandra", "t_pending", 6, "winner")
|
||||
unknown = _Snapshot(valid_document)
|
||||
monkeypatch.setattr(lanes, "_open_terminal_snapshot", lambda *_args: unknown)
|
||||
monkeypatch.setattr(lanes, "_retire_snapshot_after_db", lambda *_args: "deferred")
|
||||
assert (
|
||||
lanes._resolve_pending_after_winner(
|
||||
tmp_path / "pending",
|
||||
identity,
|
||||
valid_document,
|
||||
)
|
||||
is False
|
||||
)
|
||||
assert unknown.closed == 1
|
||||
|
||||
|
||||
def test_resolve_pending_stops_after_bounded_replacement_churn(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
identity = lanes.TerminalIdentity("cassandra", "t_churn", 7, "pending")
|
||||
document = _pending_terminal_record("cassandra", "t_churn", 7, "winner")
|
||||
snapshots = []
|
||||
|
||||
def snapshot(*_args):
|
||||
value = _Snapshot(document)
|
||||
snapshots.append(value)
|
||||
return value
|
||||
|
||||
monkeypatch.setattr(lanes, "_open_terminal_snapshot", snapshot)
|
||||
monkeypatch.setattr(lanes, "_retire_snapshot_after_db", lambda *_args: "replacement")
|
||||
assert lanes._resolve_pending_after_winner(tmp_path / "pending", identity, document) is False
|
||||
assert len(snapshots) == 8
|
||||
assert all(item.closed == 1 for item in snapshots)
|
||||
|
||||
|
||||
def test_terminal_finalizer_closes_supplied_invalid_snapshots(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
snapshot = _Snapshot({})
|
||||
assert lanes._finalize_terminal_record(_db(None), tmp_path / "bad", snapshot=snapshot) == "invalid"
|
||||
assert snapshot.closed == 1
|
||||
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("board", "task"), 8)
|
||||
empty = _Snapshot(None)
|
||||
assert lanes._finalize_terminal_record(_db(None), path, snapshot=empty) == "invalid"
|
||||
assert empty.closed == 1
|
||||
207
testing/tests/test_hermes_cli_foundation_coverage.py
Normal file
207
testing/tests/test_hermes_cli_foundation_coverage.py
Normal file
@ -0,0 +1,207 @@
|
||||
"""Adversarial branch coverage for CLI lane foundation helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from urllib.error import URLError
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.tests.test_hermes_cli_support import _completed_result, lanes
|
||||
|
||||
|
||||
def test_board_context_serializes_objects_and_storage_failure_is_bounded(
|
||||
monkeypatch,
|
||||
):
|
||||
db = SimpleNamespace(build_worker_context=lambda _conn, _task: {"priority": 3})
|
||||
assert lanes._task_context(db, object(), "t_ctx") == '{\n "priority": 3\n}'
|
||||
|
||||
connections = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
connections.append("closed")
|
||||
|
||||
failing = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
)
|
||||
monkeypatch.setattr(lanes, "KANBAN_STORAGE_ATTEMPTS", 2)
|
||||
monkeypatch.setattr(lanes.time, "sleep", lambda _seconds: None)
|
||||
with pytest.raises(sqlite3.OperationalError, match="volume unavailable"):
|
||||
lanes._board_call(
|
||||
failing,
|
||||
"cassandra",
|
||||
lambda _conn: (_ for _ in ()).throw(
|
||||
sqlite3.OperationalError("volume unavailable")
|
||||
),
|
||||
)
|
||||
assert connections == ["closed", "closed"]
|
||||
|
||||
|
||||
def test_atomic_json_closes_an_unwrapped_descriptor_on_open_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
destination = tmp_path / "state.json"
|
||||
real_fdopen = lanes.os.fdopen
|
||||
descriptors = []
|
||||
|
||||
def fail_fdopen(descriptor, *_args, **_kwargs):
|
||||
descriptors.append(descriptor)
|
||||
raise OSError("cannot wrap descriptor")
|
||||
|
||||
monkeypatch.setattr(lanes.os, "fdopen", fail_fdopen)
|
||||
with pytest.raises(OSError, match="cannot wrap"):
|
||||
lanes.atomic_json(destination, {"safe": True})
|
||||
monkeypatch.setattr(lanes.os, "fdopen", real_fdopen)
|
||||
|
||||
assert descriptors
|
||||
with pytest.raises(OSError):
|
||||
lanes.os.fstat(descriptors[0])
|
||||
assert list(tmp_path.glob("*.tmp")) == []
|
||||
|
||||
|
||||
def test_json_and_terminal_path_helpers_fail_closed(tmp_path: Path, monkeypatch):
|
||||
invalid = tmp_path / "invalid.json"
|
||||
invalid.write_bytes(b"\xff")
|
||||
assert lanes.load_json(invalid) == {}
|
||||
array = tmp_path / "array.json"
|
||||
array.write_text("[]", encoding="utf-8")
|
||||
assert lanes.load_json(array) == {}
|
||||
|
||||
with pytest.raises(ValueError, match="journal state"):
|
||||
lanes._terminal_path(tmp_path / "task.json", 1, "prepared")
|
||||
with pytest.raises(ValueError, match="SQLite"):
|
||||
lanes._terminal_path(tmp_path / "task.json", True)
|
||||
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
outside = tmp_path / "outside.run-1.terminal.pending.json"
|
||||
assert lanes._terminal_identity(outside) is None
|
||||
nested = lanes.STATE_ROOT / "board" / "nested" / "x.json"
|
||||
assert lanes._terminal_identity(nested) is None
|
||||
missing = lanes.STATE_ROOT / "board" / "t.run-1.terminal.pending.json"
|
||||
assert lanes._terminal_identity(missing) is None
|
||||
assert lanes._terminal_evidence_identity(outside) is None
|
||||
assert lanes._terminal_evidence_identity(nested) is None
|
||||
missing_evidence = lanes.STATE_ROOT / "board" / (
|
||||
"t.run-1.terminal.prepared-" + "a" * 32 + ".json"
|
||||
)
|
||||
assert lanes._terminal_evidence_identity(missing_evidence) is None
|
||||
|
||||
|
||||
def test_goal_helpers_cover_compaction_and_deterministic_failures():
|
||||
compacted = lanes.cli_lane_goal._bounded("a" * 100, 40)
|
||||
assert len(compacted) == 40
|
||||
assert "compacted" in compacted
|
||||
assert lanes.cli_lane_goal.unfinished_result_reason(
|
||||
{"status": "incomplete", "summary": ""}
|
||||
) == "worker explicitly reported incomplete work"
|
||||
assert lanes.cli_lane_goal.unfinished_result_reason(
|
||||
{"status": "blocked", "summary": "waiting"}
|
||||
) is None
|
||||
assert "blockers" in lanes.cli_lane_goal.unfinished_result_reason(
|
||||
{"status": "completed", "summary": "done", "blockers": ["remote"]}
|
||||
)
|
||||
assert "unfinished" in lanes.cli_lane_goal.unfinished_result_reason(
|
||||
{
|
||||
"status": "completed",
|
||||
"summary": "verification is pending",
|
||||
"blockers": [],
|
||||
"tests_run": [],
|
||||
}
|
||||
)
|
||||
accepted, reason = lanes.cli_lane_goal.judge_goal_completion(
|
||||
"finish",
|
||||
{"status": "incomplete", "summary": "still running"},
|
||||
open_request=lambda *_args, **_kwargs: pytest.fail("network must not run"),
|
||||
)
|
||||
assert accepted is False
|
||||
assert reason
|
||||
|
||||
|
||||
def test_prompt_artifact_and_json_helpers_reject_unsafe_inputs(
|
||||
tmp_path: Path,
|
||||
):
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
artifact = workspace / "evidence.txt"
|
||||
artifact.write_text("evidence", encoding="utf-8")
|
||||
outside = tmp_path / "outside.txt"
|
||||
outside.write_text("outside", encoding="utf-8")
|
||||
directory = workspace / "directory"
|
||||
directory.mkdir()
|
||||
|
||||
assert lanes.workspace_artifacts(workspace, "not-a-list") == []
|
||||
assert lanes.workspace_artifacts(
|
||||
workspace,
|
||||
[None, "", "missing", outside, directory, artifact, str(artifact)],
|
||||
) == [str(artifact.resolve())]
|
||||
assert lanes._extract_json({"status": "completed"}) == {"status": "completed"}
|
||||
assert lanes._extract_json(42) is None
|
||||
assert lanes._extract_json("not json") is None
|
||||
assert lanes._extract_json('prefix {"status":"blocked"} suffix') == {
|
||||
"status": "blocked"
|
||||
}
|
||||
|
||||
|
||||
def test_provider_event_parsing_persists_sessions_and_nested_results(
|
||||
tmp_path: Path,
|
||||
):
|
||||
state_file = tmp_path / "state.json"
|
||||
state = {}
|
||||
assert lanes._event_payload("codex", "not-json", state, state_file) is None
|
||||
assert lanes._event_payload("codex", "[]", state, state_file) is None
|
||||
assert (
|
||||
lanes._event_payload(
|
||||
"codex",
|
||||
json.dumps({"type": "thread.started", "thread": {"id": "thread-1"}}),
|
||||
state,
|
||||
state_file,
|
||||
)
|
||||
is None
|
||||
)
|
||||
assert state["codex_thread_id"] == "thread-1"
|
||||
result = lanes._event_payload(
|
||||
"claude",
|
||||
json.dumps(
|
||||
{
|
||||
"session_id": "session-1",
|
||||
"item": {"content": json.dumps(_completed_result("nested"))},
|
||||
}
|
||||
),
|
||||
state,
|
||||
state_file,
|
||||
)
|
||||
assert result and result["summary"] == "nested"
|
||||
assert state["claude_session_id"] == "session-1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("assignee", ["cli-codex-max", "worker-auto", ""])
|
||||
def test_routing_rejects_invalid_assignees(assignee: str):
|
||||
with pytest.raises(ValueError, match="unsupported"):
|
||||
lanes.parse_assignee(assignee)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
["codex/model/high", "worker/local/model/high", "worker/codex/model/max"],
|
||||
)
|
||||
def test_worker_target_decoder_rejects_invalid_targets(target: str):
|
||||
with pytest.raises(RuntimeError):
|
||||
lanes._decode_worker_target(target)
|
||||
|
||||
|
||||
def test_switchyard_network_failure_is_explicit():
|
||||
with pytest.raises(RuntimeError, match="routing failed"):
|
||||
lanes.select_route(
|
||||
"objective",
|
||||
"cli-auto",
|
||||
open_request=lambda *_args, **_kwargs: (_ for _ in ()).throw(
|
||||
URLError("offline")
|
||||
),
|
||||
)
|
||||
151
testing/tests/test_hermes_cli_goal_execution.py
Normal file
151
testing/tests/test_hermes_cli_goal_execution.py
Normal file
@ -0,0 +1,151 @@
|
||||
"""Structured goal execution and local completion judging."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
SimpleNamespace,
|
||||
lanes,
|
||||
nullcontext,
|
||||
pytest,
|
||||
sys,
|
||||
)
|
||||
|
||||
|
||||
def test_artifact_gc_is_interval_gated(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
monkeypatch.setattr(lanes, "LAST_ARTIFACT_GC", 0.0)
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
lanes, "gc_lane_artifacts", lambda **kwargs: (calls.append(kwargs) or 0)
|
||||
)
|
||||
|
||||
assert lanes.maybe_gc_lane_artifacts(now=1000.0) == 0
|
||||
assert lanes.maybe_gc_lane_artifacts(now=1001.0) == 0
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("result", "expected_action"),
|
||||
[
|
||||
(lanes.ProcessResult(0, "plain text only", None, False), "block"),
|
||||
(
|
||||
lanes.ProcessResult(
|
||||
0,
|
||||
"",
|
||||
{
|
||||
"status": "completed",
|
||||
"summary": "done",
|
||||
"changed_files": ["src/a.py"],
|
||||
"tests_run": ["pytest -q"],
|
||||
"artifacts": ["reports/result.json"],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
},
|
||||
False,
|
||||
),
|
||||
"complete",
|
||||
),
|
||||
(
|
||||
lanes.ProcessResult(
|
||||
0,
|
||||
"",
|
||||
{
|
||||
"status": "completed",
|
||||
"summary": "The full test suite is still running.",
|
||||
"changed_files": ["src/a.py"],
|
||||
"tests_run": ["pytest -q — in progress"],
|
||||
"artifacts": [],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
},
|
||||
False,
|
||||
),
|
||||
"block",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_claim_requires_structured_evidence_and_surfaces_artifacts(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
result,
|
||||
expected_action,
|
||||
):
|
||||
task = SimpleNamespace(
|
||||
id="t_worker",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=4,
|
||||
assignee="cli-auto",
|
||||
max_runtime_seconds=60,
|
||||
)
|
||||
calls = []
|
||||
heartbeats = []
|
||||
connections = []
|
||||
artifact = tmp_path / "reports/result.json"
|
||||
artifact.parent.mkdir()
|
||||
artifact.write_text("{}\n", encoding="utf-8")
|
||||
|
||||
class Connection:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
def connect(*, board):
|
||||
assert board == "cassandra"
|
||||
connection = Connection()
|
||||
connections.append(connection)
|
||||
return connection
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=connect,
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
worker_log_path=lambda _task_id, board: tmp_path / "worker.log",
|
||||
_resolve_worktree_workspace=lambda _task, board: (tmp_path, "wt/t_worker"),
|
||||
set_branch_name=lambda *_args: None,
|
||||
set_workspace_path=lambda *_args: None,
|
||||
build_worker_context=lambda *_args: "bounded objective",
|
||||
heartbeat_worker=lambda _conn, _task_id, *, note, expected_run_id: (
|
||||
heartbeats.append((note, expected_run_id)) or True
|
||||
),
|
||||
add_comment=lambda *_args: None,
|
||||
complete_task=lambda *_args, **kwargs: (
|
||||
calls.append(("complete", kwargs)) or True
|
||||
),
|
||||
block_task=lambda *_args, **kwargs: calls.append(("block", kwargs)),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"select_route",
|
||||
lambda *_args, **_kwargs: lanes.Route(
|
||||
"codex", "gpt-5.6-sol", "high", "codex-high", "jetson", "vote", 1, ()
|
||||
),
|
||||
)
|
||||
def run_provider(*args, **_kwargs):
|
||||
assert connections[0].closed
|
||||
before_heartbeat = len(connections)
|
||||
assert args[6]("working") is True
|
||||
assert len(connections) == before_heartbeat + 1
|
||||
assert connections[-1].closed
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(lanes, "run_provider", run_provider)
|
||||
|
||||
lanes.execute_claim("cassandra", "t_worker")
|
||||
|
||||
assert calls[0][0] == expected_action
|
||||
assert heartbeats == [("working", 4)]
|
||||
if expected_action == "complete":
|
||||
assert calls[0][1]["metadata"]["artifacts"] == [str(artifact)]
|
||||
assert calls[0][1]["metadata"]["tests_run"] == ["pytest -q"]
|
||||
else:
|
||||
assert calls[0][1]["kind"] == "capability"
|
||||
assert all(connection.closed for connection in connections)
|
||||
@ -11,7 +11,7 @@ from pathlib import Path
|
||||
SCRIPTS = Path(__file__).parents[2] / "services/hermes/scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
SCRIPT = SCRIPTS / "cli_lane_runner.py"
|
||||
SPEC = importlib.util.spec_from_file_location("cli_lane_routing", SCRIPT)
|
||||
SPEC = importlib.util.spec_from_file_location("cli_lane_runner_routing_test", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
lanes = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = lanes
|
||||
|
||||
313
testing/tests/test_hermes_cli_provenance.py
Normal file
313
testing/tests/test_hermes_cli_provenance.py
Normal file
@ -0,0 +1,313 @@
|
||||
"""Run provenance and first-writer conflict behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
SimpleNamespace,
|
||||
_completed_result,
|
||||
_install_terminal_recovery_db,
|
||||
_pending_terminal_record,
|
||||
errno,
|
||||
json,
|
||||
lanes,
|
||||
nullcontext,
|
||||
os,
|
||||
pytest,
|
||||
stat,
|
||||
sys,
|
||||
)
|
||||
|
||||
|
||||
def test_legacy_valid_retirement_staging_is_promoted_without_reexecution(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
board = lanes.STATE_ROOT / "cassandra"
|
||||
board.mkdir(parents=True)
|
||||
staged = board / f".retire.{'d' * 32}.0"
|
||||
record = _pending_terminal_record(
|
||||
"cassandra", "t_legacy_staged", 56, "legacy staged result"
|
||||
)
|
||||
lanes.atomic_json(staged, record)
|
||||
task = SimpleNamespace(
|
||||
id="t_legacy_staged",
|
||||
status="running",
|
||||
current_run_id=56,
|
||||
completed_run_id=None,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
completions = []
|
||||
reclaims = []
|
||||
_install_terminal_recovery_db(monkeypatch, task, completions, reclaims)
|
||||
|
||||
assert lanes._has_pending_finalization(
|
||||
"cassandra", "t_legacy_staged", 56
|
||||
) is True
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert completions == [record["result"]]
|
||||
assert reclaims == []
|
||||
assert not staged.exists()
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert completions == [record["result"]]
|
||||
|
||||
|
||||
def test_done_result_bytes_do_not_authorize_a_different_run_journal(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path, stale = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "t_provenance"),
|
||||
board="cassandra",
|
||||
task_id="t_provenance",
|
||||
run_id=1,
|
||||
structured=_completed_result("same bytes"),
|
||||
summary="same bytes",
|
||||
metadata={},
|
||||
)
|
||||
task = SimpleNamespace(
|
||||
id="t_provenance",
|
||||
status="done",
|
||||
current_run_id=None,
|
||||
completed_run_id=2,
|
||||
result=stale["result"],
|
||||
assignee="cli-auto",
|
||||
)
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=lambda *_args, **_kwargs: pytest.fail(
|
||||
"a done task with different run provenance must not be completed"
|
||||
),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert not path.exists()
|
||||
assert not list(path.parent.glob("*.terminal.committed.json"))
|
||||
conflicts = list(path.parent.glob("*.terminal.conflict-*.json"))
|
||||
assert len(conflicts) == 1
|
||||
conflict = json.loads(conflicts[0].read_text())
|
||||
assert conflict["expected_run_id"] == 1
|
||||
assert conflict["result"] == task.result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"swap_point",
|
||||
[
|
||||
"during-complete",
|
||||
"after-db-before-promote",
|
||||
"before-committed-create",
|
||||
"before-pending-retire",
|
||||
],
|
||||
)
|
||||
def test_terminal_first_writer_preserves_valid_replacement_conflicts(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
swap_point: str,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path, old_record = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "t_first_writer"),
|
||||
board="cassandra",
|
||||
task_id="t_first_writer",
|
||||
run_id=27,
|
||||
structured=_completed_result("first result"),
|
||||
summary="first result",
|
||||
metadata={"writer": "first"},
|
||||
)
|
||||
replacement_structured = _completed_result("replacement result")
|
||||
replacement_record = {
|
||||
"board": "cassandra",
|
||||
"task_id": "t_first_writer",
|
||||
"expected_run_id": 27,
|
||||
"result": json.dumps(replacement_structured, sort_keys=True),
|
||||
"summary": "replacement result",
|
||||
"metadata": {"writer": "replacement"},
|
||||
"kanban_state": "pending",
|
||||
"recorded_at": lanes.utc_now(),
|
||||
}
|
||||
replacement = path.with_name(f"replacement-{swap_point}.tmp")
|
||||
lanes.atomic_json(replacement, replacement_record)
|
||||
swapped = {"value": False}
|
||||
|
||||
def swap_pending():
|
||||
if not swapped["value"]:
|
||||
os.replace(replacement, path)
|
||||
swapped["value"] = True
|
||||
|
||||
task = SimpleNamespace(
|
||||
id="t_first_writer",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=27,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.current_run_id = None
|
||||
if swap_point == "during-complete":
|
||||
swap_pending()
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
if swap_point == "after-db-before-promote":
|
||||
real_promote = lanes._promote_prepared_evidence
|
||||
|
||||
def swap_then_promote(*args, **kwargs):
|
||||
swap_pending()
|
||||
return real_promote(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(lanes, "_promote_prepared_evidence", swap_then_promote)
|
||||
elif swap_point == "before-committed-create":
|
||||
real_create = lanes._write_json_noreplace
|
||||
|
||||
def swap_before_committed(path_arg, value):
|
||||
if path_arg.name.endswith(".terminal.committed.json"):
|
||||
swap_pending()
|
||||
return real_create(path_arg, value)
|
||||
|
||||
monkeypatch.setattr(lanes, "_write_json_noreplace", swap_before_committed)
|
||||
elif swap_point == "before-pending-retire":
|
||||
real_retire = lanes._retire_snapshot
|
||||
|
||||
def swap_before_retire(path_arg, snapshot):
|
||||
if path_arg == path:
|
||||
swap_pending()
|
||||
return real_retire(path_arg, snapshot)
|
||||
|
||||
monkeypatch.setattr(lanes, "_retire_snapshot", swap_before_retire)
|
||||
|
||||
assert lanes._finalize_terminal_record(fake_db, path, old_record) == "committed"
|
||||
|
||||
assert swapped["value"] is True
|
||||
assert task.result == old_record["result"]
|
||||
assert not path.exists()
|
||||
committed = list(path.parent.glob("*.terminal.committed.json"))
|
||||
conflicts = list(path.parent.glob("*.terminal.conflict-*.json"))
|
||||
prepared = list(path.parent.glob("*.terminal.prepared-*.json"))
|
||||
assert len(committed) == 1
|
||||
assert len(conflicts) == 1
|
||||
assert prepared == []
|
||||
assert committed[0].stat().st_mode & 0o777 == 0o600
|
||||
assert conflicts[0].stat().st_mode & 0o777 == 0o600
|
||||
committed_document = json.loads(committed[0].read_text(encoding="utf-8"))
|
||||
conflict_document = json.loads(conflicts[0].read_text(encoding="utf-8"))
|
||||
assert committed_document["result"] == old_record["result"]
|
||||
assert committed_document["kanban_state"] == "committed"
|
||||
assert conflict_document["result"] == replacement_record["result"]
|
||||
assert conflict_document["metadata"] == {"writer": "replacement"}
|
||||
assert conflict_document["kanban_state"] == "conflict"
|
||||
|
||||
|
||||
def test_terminal_commit_directory_fsync_failure_recovers_from_prepared_evidence(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path, record = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "t_post_db_fsync"),
|
||||
board="cassandra",
|
||||
task_id="t_post_db_fsync",
|
||||
run_id=28,
|
||||
structured=_completed_result("durable DB winner"),
|
||||
summary="durable DB winner",
|
||||
metadata={},
|
||||
)
|
||||
task = SimpleNamespace(
|
||||
id="t_post_db_fsync",
|
||||
status="running",
|
||||
result=None,
|
||||
current_run_id=28,
|
||||
completed_run_id=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
replacement_structured = _completed_result("replacement after DB commit")
|
||||
replacement_record = {
|
||||
"board": "cassandra",
|
||||
"task_id": "t_post_db_fsync",
|
||||
"expected_run_id": 28,
|
||||
"result": json.dumps(replacement_structured, sort_keys=True),
|
||||
"summary": "replacement after DB commit",
|
||||
"metadata": {"writer": "replacement"},
|
||||
"kanban_state": "pending",
|
||||
"recorded_at": lanes.utc_now(),
|
||||
}
|
||||
replacement = path.with_name("post-db-fsync-replacement.tmp")
|
||||
lanes.atomic_json(replacement, replacement_record)
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.completed_run_id = kwargs["expected_run_id"]
|
||||
task.current_run_id = None
|
||||
os.replace(replacement, path)
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
real_fsync = lanes.os.fsync
|
||||
failed = {"value": False}
|
||||
|
||||
def fail_committed_directory_fsync(descriptor):
|
||||
if (
|
||||
stat.S_ISDIR(os.fstat(descriptor).st_mode)
|
||||
and list(path.parent.glob("*.terminal.committed.json"))
|
||||
and not failed["value"]
|
||||
):
|
||||
failed["value"] = True
|
||||
raise OSError(errno.ENOSPC, "post-DB committed directory fsync failed")
|
||||
real_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(lanes.os, "fsync", fail_committed_directory_fsync)
|
||||
|
||||
with pytest.raises(OSError, match="post-DB committed"):
|
||||
lanes._finalize_terminal_record(fake_db, path, record)
|
||||
|
||||
assert task.status == "done"
|
||||
assert task.result == record["result"]
|
||||
assert path.exists()
|
||||
assert json.loads(path.read_text(encoding="utf-8"))["result"] == (
|
||||
replacement_record["result"]
|
||||
)
|
||||
assert len(list(path.parent.glob("*.terminal.prepared-*.json"))) == 1
|
||||
|
||||
monkeypatch.setattr(lanes.os, "fsync", real_fsync)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert not path.exists()
|
||||
assert list(path.parent.glob("*.terminal.prepared-*.json")) == []
|
||||
committed = list(path.parent.glob("*.terminal.committed.json"))
|
||||
conflicts = list(path.parent.glob("*.terminal.conflict-*.json"))
|
||||
assert len(committed) == 1
|
||||
assert len(conflicts) == 1
|
||||
assert json.loads(committed[0].read_text())["result"] == record["result"]
|
||||
assert json.loads(conflicts[0].read_text())["result"] == replacement_record["result"]
|
||||
173
testing/tests/test_hermes_cli_provider_edges.py
Normal file
173
testing/tests/test_hermes_cli_provider_edges.py
Normal file
@ -0,0 +1,173 @@
|
||||
"""Process lifecycle and provider artifact edge coverage for CLI workers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from testing.tests.test_hermes_cli_support import _completed_result, lanes
|
||||
|
||||
|
||||
def test_stream_process_enforces_runtime_and_lease_boundaries(
|
||||
tmp_path: Path,
|
||||
):
|
||||
timed_out = lanes.stream_process(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"],
|
||||
provider="codex",
|
||||
cwd=tmp_path,
|
||||
env=dict(os.environ),
|
||||
log_path=tmp_path / "timeout.log",
|
||||
state={},
|
||||
state_file=tmp_path / "timeout-state.json",
|
||||
heartbeat=lambda _note: True,
|
||||
max_runtime=-1,
|
||||
)
|
||||
assert "maximum runtime" in timed_out.output
|
||||
assert timed_out.returncode != 0
|
||||
|
||||
lease_lost = lanes.stream_process(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"],
|
||||
provider="claude",
|
||||
cwd=tmp_path,
|
||||
env=dict(os.environ),
|
||||
log_path=tmp_path / "lease.log",
|
||||
state={},
|
||||
state_file=tmp_path / "lease-state.json",
|
||||
heartbeat=lambda _note: False,
|
||||
max_runtime=60,
|
||||
)
|
||||
assert "lease was lost" in lease_lost.output
|
||||
assert lease_lost.returncode != 0
|
||||
|
||||
|
||||
def test_process_identity_and_signal_failures_are_bounded(monkeypatch):
|
||||
assert lanes._process_record(999999999) is None
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_process_identity_matches",
|
||||
lambda _pid, _start: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes.os,
|
||||
"killpg",
|
||||
lambda *_args: (_ for _ in ()).throw(ProcessLookupError()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes.os,
|
||||
"kill",
|
||||
lambda *_args: (_ for _ in ()).throw(ProcessLookupError()),
|
||||
)
|
||||
lanes._signal_worker_tree(10, {11: (12, 13)}, signal.SIGTERM)
|
||||
|
||||
|
||||
def test_descendant_snapshot_follows_multiple_generations(monkeypatch):
|
||||
entries = [Path("/proc/self"), Path("/proc/10"), Path("/proc/11"), Path("/proc/12")]
|
||||
monkeypatch.setattr(lanes.Path, "iterdir", lambda _path: iter(entries))
|
||||
records = {
|
||||
10: (1, 10, 100),
|
||||
11: (10, 11, 101),
|
||||
12: (11, 12, 102),
|
||||
}
|
||||
monkeypatch.setattr(lanes, "_process_record", lambda pid: records.get(pid))
|
||||
assert lanes._descendant_processes(10) == {11: (11, 101), 12: (12, 102)}
|
||||
|
||||
|
||||
def test_terminate_escalates_after_term_timeout(monkeypatch):
|
||||
class Process:
|
||||
pid = 321
|
||||
waits = 0
|
||||
|
||||
@classmethod
|
||||
def poll(cls):
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def wait(cls, timeout):
|
||||
cls.waits += 1
|
||||
if cls.waits == 1:
|
||||
raise subprocess.TimeoutExpired("worker", timeout)
|
||||
return 0
|
||||
|
||||
signals = []
|
||||
monkeypatch.setattr(lanes, "_descendant_processes", lambda _pid: {})
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_signal_worker_tree",
|
||||
lambda _pid, _descendants, sig: signals.append(sig),
|
||||
)
|
||||
lanes._terminate_worker_process(Process())
|
||||
assert signals == [signal.SIGTERM, signal.SIGKILL]
|
||||
assert Process.waits == 2
|
||||
|
||||
|
||||
def test_codex_result_collision_and_chmod_failure_preserve_result(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
state = {"run_id": 7, "result_sequence": 0}
|
||||
state_file = tmp_path / "task.json"
|
||||
first = lanes._result_path(state_file, 7, 1)
|
||||
first.write_text("prior", encoding="utf-8")
|
||||
result_paths = []
|
||||
|
||||
def stream(command, **_kwargs):
|
||||
result_path = Path(command[command.index("-o") + 1])
|
||||
result_path.write_text(json.dumps(_completed_result("new result")), encoding="utf-8")
|
||||
result_paths.append(result_path)
|
||||
return lanes.ProcessResult(0, "", None, False)
|
||||
|
||||
monkeypatch.setattr(lanes, "stream_process", stream)
|
||||
monkeypatch.setattr(
|
||||
lanes.Path,
|
||||
"chmod",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("readonly")),
|
||||
)
|
||||
route = lanes.Route("codex", "gpt", "high", "p", "c", "r", 1, ())
|
||||
result = lanes.run_provider(
|
||||
route,
|
||||
"work",
|
||||
tmp_path,
|
||||
state,
|
||||
state_file,
|
||||
tmp_path / "log",
|
||||
lambda _note: True,
|
||||
60,
|
||||
)
|
||||
assert result.structured["summary"] == "new result"
|
||||
assert result_paths[0].name.endswith("provider-2.result.json")
|
||||
|
||||
|
||||
def test_codex_missing_thread_skips_colliding_restart_result(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
state = {"run_id": 8, "codex_thread_id": "missing"}
|
||||
state_file = tmp_path / "task.json"
|
||||
second = lanes._result_path(state_file, 8, 2)
|
||||
second.write_text("collision", encoding="utf-8")
|
||||
calls = []
|
||||
|
||||
def stream(command, **_kwargs):
|
||||
calls.append(command)
|
||||
if len(calls) == 1:
|
||||
return lanes.ProcessResult(1, lanes.NO_CODEX_THREAD, None, False)
|
||||
return lanes.ProcessResult(0, "", None, False)
|
||||
|
||||
monkeypatch.setattr(lanes, "stream_process", stream)
|
||||
route = lanes.Route("codex", "gpt", "high", "p", "c", "r", 1, ())
|
||||
lanes.run_provider(
|
||||
route,
|
||||
"work",
|
||||
tmp_path,
|
||||
state,
|
||||
state_file,
|
||||
tmp_path / "log",
|
||||
lambda _note: True,
|
||||
60,
|
||||
)
|
||||
restart = Path(calls[1][calls[1].index("-o") + 1])
|
||||
assert restart.name.endswith("provider-3.result.json")
|
||||
@ -1,163 +1,16 @@
|
||||
"""Provider routing and process-isolation contracts for Hermes CLI lanes."""
|
||||
"""Codex, Claude, and worker-process session behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from testing.tests.test_hermes_cli_lanes_support import (
|
||||
_SwitchyardResponse,
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
json,
|
||||
lanes,
|
||||
os,
|
||||
signal,
|
||||
sys,
|
||||
)
|
||||
|
||||
def test_auto_lane_uses_switchyard_worker_decision():
|
||||
observed = {}
|
||||
|
||||
def route(request, timeout):
|
||||
observed["payload"] = json.loads(request.data)
|
||||
observed["timeout"] = timeout
|
||||
return _SwitchyardResponse("worker/claude/claude-opus-5/xhigh")
|
||||
|
||||
route = lanes.select_route(
|
||||
"Perform the security review.",
|
||||
"cli-auto",
|
||||
open_request=route,
|
||||
)
|
||||
|
||||
assert route.provider == "claude"
|
||||
assert route.model == "claude-opus-5"
|
||||
assert route.effort == "xhigh"
|
||||
assert route.classifier == "switchyard-classifier"
|
||||
assert observed["payload"]["model"] == "atlas/worker/auto/maximum"
|
||||
assert observed["timeout"] == 60
|
||||
|
||||
|
||||
def test_manual_lane_is_still_enforced_by_switchyard():
|
||||
observed = {}
|
||||
|
||||
def route(request, timeout):
|
||||
observed["payload"] = json.loads(request.data)
|
||||
return _SwitchyardResponse(
|
||||
"worker/claude/claude-sonnet-5/high", "manual route"
|
||||
)
|
||||
|
||||
route = lanes.select_route(
|
||||
"Implement it.",
|
||||
"cli-claude-high",
|
||||
open_request=route,
|
||||
)
|
||||
|
||||
assert route.provider == "claude"
|
||||
assert route.effort == "high"
|
||||
assert route.classifier == "switchyard-manual"
|
||||
assert route.reason == "manual route"
|
||||
assert observed["payload"]["model"] == "atlas/worker/manual/claude/high"
|
||||
|
||||
|
||||
def test_cross_provider_retry_passes_failed_provider_to_switchyard():
|
||||
observed = {}
|
||||
|
||||
def route(request, timeout):
|
||||
observed["payload"] = json.loads(request.data)
|
||||
return _SwitchyardResponse("worker/claude/claude-sonnet-5/high")
|
||||
|
||||
route = lanes.select_route(
|
||||
"Retry after capacity exhaustion.",
|
||||
"cli-auto",
|
||||
exclude_provider="codex",
|
||||
open_request=route,
|
||||
)
|
||||
|
||||
assert route.provider == "claude"
|
||||
assert route.classifier == "switchyard-classifier"
|
||||
content = observed["payload"]["messages"][0]["content"]
|
||||
assert "codex provider failed or exhausted capacity" in content
|
||||
|
||||
|
||||
def test_classifier_cannot_select_a_freshly_excluded_provider():
|
||||
payloads = []
|
||||
|
||||
def route(request, timeout):
|
||||
payload = json.loads(request.data)
|
||||
payloads.append(payload)
|
||||
if len(payloads) == 1:
|
||||
return _SwitchyardResponse("worker/claude/opus/xhigh")
|
||||
return _SwitchyardResponse("worker/codex/sol/xhigh", "healthy route")
|
||||
|
||||
selected = lanes.select_route(
|
||||
"Perform a consequential review.",
|
||||
"cli-auto",
|
||||
exclude_provider="claude",
|
||||
exclude_reason="is unavailable according to fresh native health",
|
||||
open_request=route,
|
||||
)
|
||||
|
||||
assert selected.provider == "codex"
|
||||
assert selected.effort == "xhigh"
|
||||
assert selected.classifier == "switchyard-classifier-health-guard"
|
||||
assert payloads[0]["model"] == "atlas/worker/auto/maximum"
|
||||
assert payloads[1]["model"] == "atlas/worker/manual/codex/xhigh"
|
||||
assert "claude provider is unavailable" in payloads[0]["messages"][0]["content"]
|
||||
|
||||
|
||||
def test_fresh_native_health_excludes_only_one_proven_down_provider(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
paths = {
|
||||
"codex": tmp_path / "codex.json",
|
||||
"claude": tmp_path / "claude.json",
|
||||
}
|
||||
paths["codex"].write_text('{"state":"available"}\n', encoding="utf-8")
|
||||
paths["claude"].write_text('{"state":"unavailable"}\n', encoding="utf-8")
|
||||
monkeypatch.setattr(lanes, "PROVIDER_HEALTH_PATHS", paths)
|
||||
|
||||
now = max(path.stat().st_mtime for path in paths.values())
|
||||
assert lanes.fresh_unavailable_provider(now=now) == "claude"
|
||||
|
||||
paths["codex"].write_text('{"state":"unavailable"}\n', encoding="utf-8")
|
||||
now = max(path.stat().st_mtime for path in paths.values())
|
||||
assert lanes.fresh_unavailable_provider(now=now) is None
|
||||
|
||||
|
||||
def test_explicit_auth_failure_survives_restart_health_gap(tmp_path: Path, monkeypatch):
|
||||
paths = {
|
||||
"codex": tmp_path / "codex.json",
|
||||
"claude": tmp_path / "claude.json",
|
||||
}
|
||||
paths["codex"].write_text(
|
||||
'{"state":"available","authenticated":true}\n', encoding="utf-8"
|
||||
)
|
||||
paths["claude"].write_text(
|
||||
'{"state":"unavailable","authenticated":false}\n', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(lanes, "PROVIDER_HEALTH_PATHS", paths)
|
||||
|
||||
stale_during_rollout = paths["claude"].stat().st_mtime + 10 * 60
|
||||
assert lanes.fresh_unavailable_provider(now=stale_during_rollout) == "claude"
|
||||
|
||||
paths["claude"].write_text(
|
||||
'{"state":"unavailable","authenticated":true}\n', encoding="utf-8"
|
||||
)
|
||||
transient_stale = paths["claude"].stat().st_mtime + 10 * 60
|
||||
assert lanes.fresh_unavailable_provider(now=transient_stale) is None
|
||||
|
||||
|
||||
def test_worker_environment_preserves_vault_backed_cli_homes(monkeypatch):
|
||||
"""Kanban workers must not fall back to credential-free persistent homes."""
|
||||
monkeypatch.setenv("CODEX_HOME", "/runtime-access/codex")
|
||||
monkeypatch.setenv("CLAUDE_CONFIG_DIR", "/runtime-access/claude")
|
||||
|
||||
env = lanes._base_env()
|
||||
|
||||
assert env["HOME"] == str(lanes.DATA_ROOT / "home")
|
||||
assert env["CODEX_HOME"] == "/runtime-access/codex"
|
||||
assert env["CLAUDE_CONFIG_DIR"] == "/runtime-access/claude"
|
||||
assert env["GIT_TERMINAL_PROMPT"] == "0"
|
||||
|
||||
|
||||
def test_claude_session_is_reserved_before_first_process(tmp_path: Path, monkeypatch):
|
||||
state: dict = {}
|
||||
@ -285,6 +138,66 @@ def test_codex_missing_server_thread_restarts_fresh(tmp_path: Path, monkeypatch)
|
||||
assert "codex_thread_id" not in json.loads(state_file.read_text())
|
||||
|
||||
|
||||
def test_codex_result_files_are_unique_and_prior_turns_are_retained(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
state = {"run_id": 17}
|
||||
state_file = tmp_path / "task.json"
|
||||
result_paths = []
|
||||
|
||||
def fake_stream(command, **_kwargs):
|
||||
result_path = Path(command[command.index("-o") + 1])
|
||||
result_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "completed",
|
||||
"summary": f"turn {len(result_paths) + 1}",
|
||||
"changed_files": [],
|
||||
"tests_run": [],
|
||||
"artifacts": [],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
result_paths.append(result_path)
|
||||
return lanes.ProcessResult(0, "", None, False)
|
||||
|
||||
monkeypatch.setattr(lanes, "stream_process", fake_stream)
|
||||
route = lanes.Route(
|
||||
"codex", "gpt-5.6-sol", "high", "codex-high", "jetson", "vote", 1, ()
|
||||
)
|
||||
|
||||
first = lanes.run_provider(
|
||||
route,
|
||||
"First turn.",
|
||||
tmp_path,
|
||||
state,
|
||||
state_file,
|
||||
tmp_path / "worker.log",
|
||||
lambda _note: True,
|
||||
60,
|
||||
)
|
||||
second = lanes.run_provider(
|
||||
route,
|
||||
"Second turn.",
|
||||
tmp_path,
|
||||
state,
|
||||
state_file,
|
||||
tmp_path / "worker.log",
|
||||
lambda _note: True,
|
||||
60,
|
||||
)
|
||||
|
||||
assert first.structured["summary"] == "turn 1"
|
||||
assert second.structured["summary"] == "turn 2"
|
||||
assert len(set(result_paths)) == 2
|
||||
assert all(path.exists() for path in result_paths)
|
||||
assert all(path.stat().st_mode & 0o777 == 0o600 for path in result_paths)
|
||||
|
||||
|
||||
def test_successful_process_text_cannot_masquerade_as_capacity_failure(tmp_path: Path):
|
||||
result = lanes.stream_process(
|
||||
[sys.executable, "-c", "print('authentication work completed')"],
|
||||
375
testing/tests/test_hermes_cli_quarantine.py
Normal file
375
testing/tests/test_hermes_cli_quarantine.py
Normal file
@ -0,0 +1,375 @@
|
||||
"""Identity-safe quarantine and replacement races."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
SimpleNamespace,
|
||||
_completed_result,
|
||||
_install_terminal_recovery_db,
|
||||
_pending_terminal_record,
|
||||
hashlib,
|
||||
json,
|
||||
lanes,
|
||||
nullcontext,
|
||||
os,
|
||||
pytest,
|
||||
stat,
|
||||
sys,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"structured",
|
||||
[
|
||||
{"status": "completed", "blockers": {}},
|
||||
{**_completed_result(), "changed_files": "src/a.py"},
|
||||
{**_completed_result(), "unexpected": True},
|
||||
{**_completed_result(), "blockers": ["work remains"]},
|
||||
{**_completed_result("tests are still running")},
|
||||
],
|
||||
)
|
||||
def test_terminal_record_requires_exact_completed_result_contract(structured):
|
||||
record = {
|
||||
"board": "cassandra",
|
||||
"task_id": "t_schema",
|
||||
"expected_run_id": 1,
|
||||
"result": json.dumps(structured),
|
||||
"summary": str(structured.get("summary") or "done"),
|
||||
"metadata": {},
|
||||
"kanban_state": "pending",
|
||||
}
|
||||
|
||||
assert lanes._terminal_record_valid(record) is False
|
||||
|
||||
|
||||
def test_minimal_completed_result_with_mapping_blockers_quarantines_without_completion(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("cassandra", "t_schema"), 6)
|
||||
lanes.atomic_json(
|
||||
path,
|
||||
{
|
||||
"board": "cassandra",
|
||||
"task_id": "t_schema",
|
||||
"expected_run_id": 6,
|
||||
"result": json.dumps({"status": "completed", "blockers": {}}),
|
||||
"summary": "done",
|
||||
"metadata": {},
|
||||
"kanban_state": "pending",
|
||||
},
|
||||
)
|
||||
task = SimpleNamespace(
|
||||
id="t_schema", status="running", current_run_id=6, assignee="cli-auto"
|
||||
)
|
||||
completed = []
|
||||
reclaimed = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=lambda *_args, **_kwargs: completed.append(True),
|
||||
reclaim_task=lambda *_args, **_kwargs: (reclaimed.append(True) or True),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert completed == []
|
||||
assert reclaimed == [True]
|
||||
assert not path.exists()
|
||||
quarantined = list((path.parent / "quarantine").glob("*.quarantine"))
|
||||
assert len(quarantined) == 1
|
||||
assert quarantined[0].stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_quarantine_avoids_symlink_and_mode_collision_destinations(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("cassandra", "t_collision"), 2)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"invalid")
|
||||
quarantine = path.parent / "quarantine"
|
||||
quarantine.mkdir()
|
||||
fingerprint = hashlib.sha256(b"invalid").hexdigest()[:16]
|
||||
base = f"{path.name}.malformed-payload.{fingerprint}"
|
||||
victim = tmp_path / "victim"
|
||||
victim.write_text("unchanged", encoding="utf-8")
|
||||
(quarantine / f"{base}.0.quarantine").symlink_to(victim)
|
||||
collision = quarantine / f"{base}.1.quarantine"
|
||||
collision.write_text("attacker collision", encoding="utf-8")
|
||||
collision.chmod(0o644)
|
||||
monkeypatch.setattr(
|
||||
lanes.os,
|
||||
"chmod",
|
||||
lambda *_args, **_kwargs: pytest.fail("quarantine must not chmod foreign inodes"),
|
||||
)
|
||||
|
||||
destination = lanes._quarantine_terminal(
|
||||
path,
|
||||
lanes._terminal_identity(path),
|
||||
"malformed-payload",
|
||||
)
|
||||
|
||||
assert not path.exists()
|
||||
assert destination.name == f"{base}.2.quarantine"
|
||||
assert destination.stat().st_mode & 0o777 == 0o600
|
||||
diagnostic = json.loads(destination.read_text(encoding="utf-8"))
|
||||
assert diagnostic["size"] == len(b"invalid")
|
||||
assert diagnostic["sha256"] == hashlib.sha256(b"invalid").hexdigest()
|
||||
assert diagnostic["source_kind"] == "regular"
|
||||
assert victim.read_text(encoding="utf-8") == "unchanged"
|
||||
assert collision.read_text(encoding="utf-8") == "attacker collision"
|
||||
assert collision.stat().st_mode & 0o777 == 0o644
|
||||
|
||||
|
||||
def test_hardlinked_terminal_source_is_never_chmodded_or_copied_as_data(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("cassandra", "t_hardlink"), 5)
|
||||
path.parent.mkdir(parents=True)
|
||||
foreign = tmp_path / "foreign"
|
||||
foreign.write_text("foreign inode contents", encoding="utf-8")
|
||||
foreign.chmod(0o644)
|
||||
os.link(foreign, path)
|
||||
monkeypatch.setattr(
|
||||
lanes.os,
|
||||
"chmod",
|
||||
lambda *_args, **_kwargs: pytest.fail("hardlinked source must not be chmodded"),
|
||||
)
|
||||
|
||||
destination = lanes._quarantine_terminal(
|
||||
path,
|
||||
lanes._terminal_identity(path),
|
||||
"malformed-payload",
|
||||
)
|
||||
|
||||
assert not path.exists()
|
||||
assert foreign.read_text(encoding="utf-8") == "foreign inode contents"
|
||||
assert foreign.stat().st_mode & 0o777 == 0o644
|
||||
assert destination.stat().st_mode & 0o777 == 0o600
|
||||
diagnostic = json.loads(destination.read_text(encoding="utf-8"))
|
||||
assert diagnostic["source_kind"] == "hardlink"
|
||||
assert "foreign inode contents" not in destination.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_quarantine_preserves_and_replays_an_atomic_replacement(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("cassandra", "t_swap"), 21)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"malformed")
|
||||
identity = lanes._terminal_identity(path)
|
||||
assert identity is not None
|
||||
structured = _completed_result("replacement result")
|
||||
replacement_record = {
|
||||
"board": "cassandra",
|
||||
"task_id": "t_swap",
|
||||
"expected_run_id": 21,
|
||||
"result": json.dumps(structured, sort_keys=True),
|
||||
"summary": structured["summary"],
|
||||
"metadata": {},
|
||||
"kanban_state": "pending",
|
||||
"recorded_at": lanes.utc_now(),
|
||||
}
|
||||
replacement = path.with_name("replacement.tmp")
|
||||
replacement.write_text(
|
||||
json.dumps(replacement_record, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
)
|
||||
replacement.chmod(0o600)
|
||||
real_fsync = lanes.os.fsync
|
||||
swapped = {"value": False}
|
||||
|
||||
def swap_on_quarantine_directory_fsync(descriptor):
|
||||
descriptor_stat = os.fstat(descriptor)
|
||||
if stat.S_ISDIR(descriptor_stat.st_mode) and not swapped["value"]:
|
||||
os.replace(replacement, path)
|
||||
swapped["value"] = True
|
||||
real_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(lanes.os, "fsync", swap_on_quarantine_directory_fsync)
|
||||
|
||||
lanes._quarantine_terminal(path, identity, "malformed-payload")
|
||||
|
||||
assert swapped["value"] is True
|
||||
assert path.exists()
|
||||
assert lanes._terminal_record_valid(
|
||||
lanes._load_terminal_json(path, identity), identity
|
||||
)
|
||||
|
||||
task = SimpleNamespace(
|
||||
id="t_swap", status="running", current_run_id=21, result=None, assignee="cli-auto"
|
||||
)
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.current_run_id = None
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert task.status == "done"
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_recovery_quarantines_the_loaded_inode_not_a_before_open_replacement(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("cassandra", "t_gap"), 51)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"malformed-original")
|
||||
replacement = path.with_name("replacement.valid")
|
||||
lanes.atomic_json(
|
||||
replacement,
|
||||
_pending_terminal_record("cassandra", "t_gap", 51, "preserved replacement"),
|
||||
)
|
||||
task = SimpleNamespace(
|
||||
id="t_gap",
|
||||
status="running",
|
||||
current_run_id=51,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
completions = []
|
||||
reclaims = []
|
||||
_install_terminal_recovery_db(monkeypatch, task, completions, reclaims)
|
||||
real_quarantine = lanes._quarantine_terminal
|
||||
swapped = {"value": False}
|
||||
|
||||
def swap_before_quarantine_reopens(*args, **kwargs):
|
||||
assert kwargs["snapshot"].prefix == b"malformed-original"
|
||||
if not swapped["value"]:
|
||||
os.replace(replacement, path)
|
||||
swapped["value"] = True
|
||||
return real_quarantine(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(lanes, "_quarantine_terminal", swap_before_quarantine_reopens)
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert swapped["value"] is True
|
||||
assert task.status == "done"
|
||||
assert len(completions) == 1
|
||||
assert reclaims == []
|
||||
assert not path.exists()
|
||||
committed = next(path.parent.glob("*.terminal.committed.json"))
|
||||
assert json.loads(committed.read_text())["result"] == completions[0]
|
||||
|
||||
|
||||
def test_recovery_replays_replacement_installed_after_quarantine_pins_source(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("cassandra", "t_after_open"), 52)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"malformed-original")
|
||||
replacement = path.with_name("replacement.valid")
|
||||
lanes.atomic_json(
|
||||
replacement,
|
||||
_pending_terminal_record(
|
||||
"cassandra", "t_after_open", 52, "replacement after open"
|
||||
),
|
||||
)
|
||||
task = SimpleNamespace(
|
||||
id="t_after_open",
|
||||
status="running",
|
||||
current_run_id=52,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
completions = []
|
||||
reclaims = []
|
||||
_install_terminal_recovery_db(monkeypatch, task, completions, reclaims)
|
||||
real_fsync = lanes.os.fsync
|
||||
swapped = {"value": False}
|
||||
|
||||
def swap_after_quarantine_open(descriptor):
|
||||
descriptor_stat = os.fstat(descriptor)
|
||||
if stat.S_ISDIR(descriptor_stat.st_mode) and not swapped["value"]:
|
||||
os.replace(replacement, path)
|
||||
swapped["value"] = True
|
||||
real_fsync(descriptor)
|
||||
|
||||
monkeypatch.setattr(lanes.os, "fsync", swap_after_quarantine_open)
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert swapped["value"] is True
|
||||
assert task.status == "done"
|
||||
assert len(completions) == 1
|
||||
assert reclaims == []
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_recovery_survives_repeated_invalid_then_valid_replacements(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("cassandra", "t_churn"), 53)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"malformed-zero")
|
||||
second = path.with_name("replacement.invalid")
|
||||
second.write_bytes(b"malformed-one")
|
||||
valid = path.with_name("replacement.valid")
|
||||
lanes.atomic_json(
|
||||
valid,
|
||||
_pending_terminal_record("cassandra", "t_churn", 53, "valid after churn"),
|
||||
)
|
||||
task = SimpleNamespace(
|
||||
id="t_churn",
|
||||
status="running",
|
||||
current_run_id=53,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
completions = []
|
||||
reclaims = []
|
||||
_install_terminal_recovery_db(monkeypatch, task, completions, reclaims)
|
||||
real_quarantine = lanes._quarantine_terminal
|
||||
replacements = [second, valid]
|
||||
pinned = []
|
||||
|
||||
def churn_before_quarantine(*args, **kwargs):
|
||||
pinned.append(kwargs["snapshot"].prefix)
|
||||
if replacements:
|
||||
os.replace(replacements.pop(0), path)
|
||||
return real_quarantine(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(lanes, "_quarantine_terminal", churn_before_quarantine)
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert pinned == [b"malformed-zero", b"malformed-one"]
|
||||
assert replacements == []
|
||||
assert task.status == "done"
|
||||
assert len(completions) == 1
|
||||
assert reclaims == []
|
||||
assert not path.exists()
|
||||
diagnostics = list((path.parent / "quarantine").glob("*.quarantine"))
|
||||
assert len(diagnostics) == 2
|
||||
185
testing/tests/test_hermes_cli_quarantine_edges.py
Normal file
185
testing/tests/test_hermes_cli_quarantine_edges.py
Normal file
@ -0,0 +1,185 @@
|
||||
"""Degraded quarantine-path coverage for terminal journal safety."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from testing.tests.test_hermes_cli_support import lanes
|
||||
|
||||
|
||||
def test_quarantine_refuses_missing_or_unsafe_board_directory(
|
||||
tmp_path: Path,
|
||||
capsys,
|
||||
):
|
||||
path = tmp_path / "missing" / "journal"
|
||||
assert lanes._quarantine_terminal(path, None, "invalid") == path
|
||||
assert "unsafe board directory" in capsys.readouterr().err
|
||||
|
||||
regular_parent = tmp_path / "regular"
|
||||
regular_parent.write_text("not a directory", encoding="utf-8")
|
||||
unsafe = regular_parent / "journal"
|
||||
assert lanes._quarantine_terminal(unsafe, None, "invalid") == unsafe
|
||||
|
||||
|
||||
def test_quarantine_accepts_a_lexically_outside_but_pinned_path(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "other-root")
|
||||
board = tmp_path / "board"
|
||||
board.mkdir()
|
||||
path = board / "journal"
|
||||
path.write_bytes(b"invalid")
|
||||
|
||||
destination = lanes._quarantine_terminal(path, None, "bad reason / outside")
|
||||
|
||||
assert destination.parent == board / "quarantine"
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_quarantine_rejects_changed_pinned_board_identity(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
capsys,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path)
|
||||
board = tmp_path / "board"
|
||||
board.mkdir()
|
||||
path = board / "journal"
|
||||
path.write_bytes(b"invalid")
|
||||
snapshot = lanes._open_terminal_recovery_snapshot(path)
|
||||
assert snapshot is not None
|
||||
real_fstat = lanes.os.fstat
|
||||
|
||||
def changed_board(descriptor):
|
||||
observed = real_fstat(descriptor)
|
||||
if descriptor == snapshot.directory_descriptor:
|
||||
return os.stat_result(
|
||||
(*observed[:1], observed.st_ino + 1, *observed[2:])
|
||||
)
|
||||
return observed
|
||||
|
||||
monkeypatch.setattr(lanes.os, "fstat", changed_board)
|
||||
try:
|
||||
lanes._quarantine_terminal(path, None, "changed-board", snapshot=snapshot)
|
||||
finally:
|
||||
snapshot.close()
|
||||
assert not path.exists()
|
||||
assert "degraded safely" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_quarantine_rejects_source_replaced_during_open(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
capsys,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path)
|
||||
board = tmp_path / "board"
|
||||
board.mkdir()
|
||||
path = board / "journal"
|
||||
path.write_bytes(b"original")
|
||||
replacement = board / "replacement"
|
||||
replacement.write_bytes(b"replacement")
|
||||
real_open = lanes.os.open
|
||||
swapped = {"value": False}
|
||||
|
||||
def swap_before_source_open(target, flags, *args, **kwargs):
|
||||
if target == path.name and kwargs.get("dir_fd") is not None and not swapped["value"]:
|
||||
os.replace(replacement, path)
|
||||
swapped["value"] = True
|
||||
return real_open(target, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(lanes.os, "open", swap_before_source_open)
|
||||
lanes._quarantine_terminal(path, None, "source-race")
|
||||
assert path.exists()
|
||||
assert path.read_bytes() == b"replacement"
|
||||
assert "degraded safely" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_quarantine_unlinks_an_invalid_destination_before_degrading(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
capsys,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path)
|
||||
board = tmp_path / "board"
|
||||
board.mkdir()
|
||||
path = board / "journal"
|
||||
path.write_bytes(b"invalid")
|
||||
real_fstat = lanes.os.fstat
|
||||
target_descriptors = set()
|
||||
real_fdopen = lanes.os.fdopen
|
||||
|
||||
def remember_target(descriptor, *args, **kwargs):
|
||||
target_descriptors.add(descriptor)
|
||||
return real_fdopen(descriptor, *args, **kwargs)
|
||||
|
||||
def unsafe_target(descriptor):
|
||||
observed = real_fstat(descriptor)
|
||||
if descriptor in target_descriptors:
|
||||
return os.stat_result(
|
||||
(*observed[:3], 0o100644, *observed[4:])
|
||||
)
|
||||
return observed
|
||||
|
||||
monkeypatch.setattr(lanes.os, "fdopen", remember_target)
|
||||
monkeypatch.setattr(lanes.os, "fstat", unsafe_target)
|
||||
lanes._quarantine_terminal(path, None, "unsafe-target")
|
||||
|
||||
assert not list((board / "quarantine").glob("*.quarantine"))
|
||||
assert "degraded safely" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_quarantine_destination_collision_is_bounded(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
capsys,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path)
|
||||
board = tmp_path / "board"
|
||||
board.mkdir()
|
||||
path = board / "journal"
|
||||
path.write_bytes(b"invalid")
|
||||
real_open = lanes.os.open
|
||||
|
||||
def collide_on_destination(target, flags, *args, **kwargs):
|
||||
if (
|
||||
isinstance(target, str)
|
||||
and target.endswith(".quarantine")
|
||||
and flags & os.O_EXCL
|
||||
):
|
||||
raise FileExistsError(target)
|
||||
return real_open(target, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(lanes.os, "open", collide_on_destination)
|
||||
destination = lanes._quarantine_terminal(path, None, "collision")
|
||||
|
||||
assert destination == path
|
||||
assert not path.exists()
|
||||
assert "could not reserve" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_quarantine_deferred_retirement_does_not_raise(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
capsys,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path)
|
||||
board = tmp_path / "board"
|
||||
board.mkdir()
|
||||
path = board / "journal"
|
||||
path.write_bytes(b"invalid")
|
||||
calls = {"value": 0}
|
||||
|
||||
def fail_retirement(*_args, **_kwargs):
|
||||
calls["value"] += 1
|
||||
raise OSError("retirement unavailable")
|
||||
|
||||
monkeypatch.setattr(lanes, "_retire_terminal_entry", fail_retirement)
|
||||
destination = lanes._quarantine_terminal(path, None, "retire-failure")
|
||||
|
||||
assert destination.exists()
|
||||
assert path.exists()
|
||||
assert calls["value"] == 2
|
||||
assert "retirement=deferred" in capsys.readouterr().err
|
||||
230
testing/tests/test_hermes_cli_records_edges.py
Normal file
230
testing/tests/test_hermes_cli_records_edges.py
Normal file
@ -0,0 +1,230 @@
|
||||
"""Bounded-read and terminal-schema edge coverage for CLI lane records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
_completed_result,
|
||||
_pending_terminal_record,
|
||||
lanes,
|
||||
)
|
||||
|
||||
|
||||
def test_bounded_reader_stops_at_eof_and_zero_limit(tmp_path: Path):
|
||||
path = tmp_path / "payload"
|
||||
path.write_bytes(b"abc")
|
||||
descriptor = os.open(path, os.O_RDONLY)
|
||||
try:
|
||||
assert lanes._read_bounded(descriptor, 10) == b"abc"
|
||||
os.lseek(descriptor, 0, os.SEEK_SET)
|
||||
assert lanes._read_bounded(descriptor, 0) == b""
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def test_small_snapshot_rejects_nonregular_hardlinked_oversized_and_nonobject(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
directory = tmp_path / "directory"
|
||||
directory.mkdir()
|
||||
assert lanes._open_small_json_snapshot(directory) is None
|
||||
|
||||
original = tmp_path / "original"
|
||||
original.write_text("{}", encoding="utf-8")
|
||||
hardlink = tmp_path / "hardlink"
|
||||
os.link(original, hardlink)
|
||||
assert lanes._open_small_json_snapshot(hardlink) is None
|
||||
|
||||
oversized = tmp_path / "oversized"
|
||||
oversized.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(lanes, "MAX_TERMINAL_RECORD_BYTES", 1)
|
||||
assert lanes._open_small_json_snapshot(oversized) is None
|
||||
monkeypatch.setattr(lanes, "MAX_TERMINAL_RECORD_BYTES", 1024 * 1024)
|
||||
|
||||
array = tmp_path / "array"
|
||||
array.write_text("[]", encoding="utf-8")
|
||||
assert lanes._open_small_json_snapshot(array) is None
|
||||
malformed = tmp_path / "malformed"
|
||||
malformed.write_bytes(b"\xff")
|
||||
assert lanes._open_small_json_snapshot(malformed) is None
|
||||
|
||||
|
||||
def test_small_snapshot_rejects_path_replaced_after_read(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
path = tmp_path / "record"
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
real_stat = lanes.os.stat
|
||||
calls = {"value": 0}
|
||||
|
||||
def changed_stat(target, *args, **kwargs):
|
||||
result = real_stat(target, *args, **kwargs)
|
||||
if target == path.name and kwargs.get("dir_fd") is not None:
|
||||
calls["value"] += 1
|
||||
if calls["value"] == 1:
|
||||
return os.stat_result(
|
||||
(*result[:6], result.st_size + 1, *result[7:])
|
||||
)
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(lanes.os, "stat", changed_stat)
|
||||
assert lanes._open_small_json_snapshot(path) is None
|
||||
|
||||
|
||||
def test_recovery_snapshot_classifies_nonregular_and_open_failure(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
fifo = tmp_path / "fifo"
|
||||
os.mkfifo(fifo)
|
||||
snapshot = lanes._open_terminal_recovery_snapshot(fifo)
|
||||
assert snapshot is not None
|
||||
assert snapshot.invalid_reason == "non-regular"
|
||||
snapshot.close()
|
||||
|
||||
path = tmp_path / "record"
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
real_open = lanes.os.open
|
||||
|
||||
def fail_file_open(target, flags, *args, **kwargs):
|
||||
if target == path.name:
|
||||
raise OSError("denied")
|
||||
return real_open(target, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(lanes.os, "open", fail_file_open)
|
||||
snapshot = lanes._open_terminal_recovery_snapshot(path)
|
||||
assert snapshot is not None
|
||||
assert snapshot.invalid_reason == "open-failed"
|
||||
snapshot.close()
|
||||
|
||||
|
||||
def test_recovery_snapshot_detects_identity_change_during_open(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
path = tmp_path / "record"
|
||||
path.write_text("{}", encoding="utf-8")
|
||||
replacement = tmp_path / "replacement"
|
||||
replacement.write_text('{"replacement":true}', encoding="utf-8")
|
||||
real_open = lanes.os.open
|
||||
swapped = {"value": False}
|
||||
|
||||
def swap_before_file_open(target, flags, *args, **kwargs):
|
||||
if target == path.name and not swapped["value"]:
|
||||
os.replace(replacement, path)
|
||||
swapped["value"] = True
|
||||
return real_open(target, flags, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(lanes.os, "open", swap_before_file_open)
|
||||
snapshot = lanes._open_terminal_recovery_snapshot(path)
|
||||
assert snapshot is not None
|
||||
assert snapshot.invalid_reason == "identity-changed-during-open"
|
||||
snapshot.close()
|
||||
|
||||
|
||||
def test_recovery_snapshot_bounds_hardlinks_oversize_and_bad_payloads(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
original = tmp_path / "original"
|
||||
original.write_text("{}", encoding="utf-8")
|
||||
hardlink = tmp_path / "hardlink"
|
||||
os.link(original, hardlink)
|
||||
snapshot = lanes._open_terminal_recovery_snapshot(hardlink)
|
||||
assert snapshot is not None and snapshot.invalid_reason == "hardlinked"
|
||||
snapshot.close()
|
||||
|
||||
oversized = tmp_path / "oversized"
|
||||
oversized.write_bytes(b"x" * 128)
|
||||
monkeypatch.setattr(lanes, "MAX_TERMINAL_RECORD_BYTES", 64)
|
||||
monkeypatch.setattr(lanes, "QUARANTINE_HASH_BYTES", 8)
|
||||
snapshot = lanes._open_terminal_recovery_snapshot(oversized)
|
||||
assert snapshot is not None and snapshot.invalid_reason == "oversized"
|
||||
assert snapshot.prefix == b"x" * 8
|
||||
snapshot.close()
|
||||
|
||||
monkeypatch.setattr(lanes, "MAX_TERMINAL_RECORD_BYTES", 1024 * 1024)
|
||||
array = tmp_path / "array"
|
||||
array.write_text("[]", encoding="utf-8")
|
||||
snapshot = lanes._open_terminal_recovery_snapshot(array)
|
||||
assert snapshot is not None and snapshot.invalid_reason == "non-object-payload"
|
||||
snapshot.close()
|
||||
malformed = tmp_path / "malformed"
|
||||
malformed.write_bytes(b"\xff")
|
||||
snapshot = lanes._open_terminal_recovery_snapshot(malformed)
|
||||
assert snapshot is not None and snapshot.invalid_reason == "malformed-payload"
|
||||
snapshot.close()
|
||||
|
||||
|
||||
def test_snapshot_loaders_return_empty_for_missing_or_wrong_identity(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
identity = lanes.TerminalIdentity("board", "task", 1, "pending")
|
||||
missing = lanes.STATE_ROOT / "board" / "task.run-1.terminal.pending.json"
|
||||
assert lanes._open_terminal_snapshot(missing, identity) is None
|
||||
assert lanes._load_terminal_json(missing, identity) == {}
|
||||
assert lanes._load_small_json(missing) == {}
|
||||
|
||||
|
||||
def test_candidate_persistence_skips_existing_sequence(tmp_path: Path):
|
||||
state_file = tmp_path / "task.json"
|
||||
state = {"board": "board", "task_id": "task", "run_id": 4}
|
||||
route = lanes.Route("codex", "gpt", "high", "profile", "test", "test", 1, ())
|
||||
first = lanes._candidate_path(state_file, 4, 1)
|
||||
first.write_text("collision", encoding="utf-8")
|
||||
path = lanes._persist_candidate(
|
||||
state,
|
||||
state_file,
|
||||
_completed_result(),
|
||||
route=route,
|
||||
returncode=0,
|
||||
goal_turn=2,
|
||||
)
|
||||
assert path.name.endswith("candidate-2.json")
|
||||
assert state["candidate_sequence"] == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mutation",
|
||||
[
|
||||
lambda record: [],
|
||||
lambda record: {**record, "board": ""},
|
||||
lambda record: {**record, "expected_run_id": True},
|
||||
lambda record: {**record, "metadata": []},
|
||||
lambda record: {**record, "result": "not-json"},
|
||||
lambda record: {**record, "result": "[]"},
|
||||
lambda record: {
|
||||
**record,
|
||||
"result": json.dumps({**_completed_result(), "extra": True}),
|
||||
},
|
||||
lambda record: {
|
||||
**record,
|
||||
"result": json.dumps({**_completed_result(), "summary": ""}),
|
||||
},
|
||||
lambda record: {
|
||||
**record,
|
||||
"result": json.dumps({**_completed_result(), "tests_run": [1]}),
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_terminal_schema_rejects_each_malformed_boundary(mutation):
|
||||
record = _pending_terminal_record("board", "task", 1, "done")
|
||||
assert lanes._terminal_record_valid(mutation(record)) is False
|
||||
|
||||
|
||||
def test_terminal_schema_validates_optional_identity():
|
||||
record = _pending_terminal_record("board", "task", 1, "done")
|
||||
assert lanes._terminal_record_valid(record) is True
|
||||
assert lanes._terminal_record_valid(
|
||||
record,
|
||||
lanes.TerminalIdentity("other", "task", 1, "pending"),
|
||||
) is False
|
||||
298
testing/tests/test_hermes_cli_recovery_edges.py
Normal file
298
testing/tests/test_hermes_cli_recovery_edges.py
Normal file
@ -0,0 +1,298 @@
|
||||
"""Recovery convergence and hidden-authority edge coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
_pending_terminal_record,
|
||||
lanes,
|
||||
)
|
||||
|
||||
|
||||
class _Snapshot:
|
||||
def __init__(self, document=None):
|
||||
self.document = document
|
||||
self.closed = 0
|
||||
|
||||
def close(self):
|
||||
self.closed += 1
|
||||
|
||||
|
||||
def test_terminal_absence_distinguishes_present_missing_and_inaccessible(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
path = tmp_path / "journal"
|
||||
path.write_text("data", encoding="utf-8")
|
||||
assert lanes._terminal_entry_absent(path) is False
|
||||
path.unlink()
|
||||
assert lanes._terminal_entry_absent(path) is True
|
||||
monkeypatch.setattr(
|
||||
lanes.os,
|
||||
"open",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(PermissionError("denied")),
|
||||
)
|
||||
assert lanes._terminal_entry_absent(path) is False
|
||||
|
||||
|
||||
def _staged_path(root: Path, identity, document: dict) -> tuple[Path, Path]:
|
||||
if identity.state in {"pending", "committed"}:
|
||||
canonical = lanes._terminal_path(
|
||||
lanes.state_path(identity.board, identity.task_id),
|
||||
identity.run_id,
|
||||
identity.state,
|
||||
)
|
||||
else:
|
||||
canonical = lanes._terminal_evidence_path(identity, identity.state, document)
|
||||
digest = hashlib.sha256(canonical.name.encode("utf-8")).hexdigest()[:16]
|
||||
return root / identity.board / f".retire.{digest}.{'a' * 16}.0", canonical
|
||||
|
||||
|
||||
@pytest.mark.parametrize("state", ["prepared", "conflict"])
|
||||
def test_staged_authority_accepts_valid_evidence_states(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
state: str,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
identity = lanes.TerminalIdentity("board", "task", 5, state)
|
||||
document = _pending_terminal_record("board", "task", 5, "accepted")
|
||||
document["kanban_state"] = state
|
||||
staged, canonical = _staged_path(lanes.STATE_ROOT, identity, document)
|
||||
staged.parent.mkdir(parents=True)
|
||||
authority = lanes._staged_terminal_authority(staged, _Snapshot(document))
|
||||
assert authority == (identity, canonical)
|
||||
|
||||
|
||||
def test_staged_authority_rejects_outside_nested_and_unknown_state(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
document = _pending_terminal_record("board", "task", 5, "accepted")
|
||||
outside = tmp_path / ".retire.x"
|
||||
assert lanes._staged_terminal_authority(outside, _Snapshot(document)) is None
|
||||
nested = lanes.STATE_ROOT / "board" / "nested" / ".retire.name"
|
||||
assert lanes._staged_terminal_authority(nested, _Snapshot(document)) is None
|
||||
board = lanes.STATE_ROOT / "board"
|
||||
board.mkdir(parents=True)
|
||||
staged = board / f".retire.{'a' * 16}.{'b' * 16}.0"
|
||||
document["kanban_state"] = "unknown"
|
||||
assert lanes._staged_terminal_authority(staged, _Snapshot(document)) is None
|
||||
|
||||
|
||||
def test_restore_staged_authority_handles_every_first_writer_state(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
document = _pending_terminal_record("board", "task", 6, "accepted")
|
||||
canonical = tmp_path / "canonical"
|
||||
pending = lanes.TerminalIdentity("board", "task", 6, "pending")
|
||||
calls = []
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_persist_prepared_evidence",
|
||||
lambda identity, value: calls.append(("prepared", identity, value)),
|
||||
)
|
||||
lanes._restore_staged_terminal_authority(pending, canonical, document)
|
||||
assert calls[0][0] == "prepared"
|
||||
|
||||
monkeypatch.setattr(lanes, "_write_json_noreplace", lambda *_args: True)
|
||||
committed = lanes.TerminalIdentity("board", "task", 6, "committed")
|
||||
lanes._restore_staged_terminal_authority(committed, canonical, document)
|
||||
|
||||
monkeypatch.setattr(lanes, "_write_json_noreplace", lambda *_args: False)
|
||||
monkeypatch.setattr(lanes, "_load_small_json", lambda _path: {})
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_persist_conflict_evidence",
|
||||
lambda *args: calls.append(("conflict", *args)),
|
||||
)
|
||||
lanes._restore_staged_terminal_authority(committed, canonical, document)
|
||||
assert calls[-1][0] == "conflict"
|
||||
|
||||
prepared = lanes.TerminalIdentity("board", "task", 6, "prepared")
|
||||
monkeypatch.setattr(lanes, "_terminal_evidence_valid", lambda *_args: False)
|
||||
with pytest.raises(OSError, match="conflicting result"):
|
||||
lanes._restore_staged_terminal_authority(prepared, canonical, document)
|
||||
|
||||
|
||||
def test_restore_staged_authority_accepts_identical_existing_evidence(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
document = _pending_terminal_record("board", "task", 7, "accepted")
|
||||
document["kanban_state"] = "prepared"
|
||||
identity = lanes.TerminalIdentity("board", "task", 7, "prepared")
|
||||
monkeypatch.setattr(lanes, "_write_json_noreplace", lambda *_args: False)
|
||||
monkeypatch.setattr(lanes, "_load_small_json", lambda _path: document)
|
||||
monkeypatch.setattr(lanes, "_terminal_evidence_valid", lambda *_args: True)
|
||||
lanes._restore_staged_terminal_authority(identity, tmp_path / "existing", document)
|
||||
|
||||
|
||||
def test_staging_recovery_bounds_missing_snapshots_and_restore_errors(
|
||||
monkeypatch,
|
||||
):
|
||||
path = Path("/tmp/staged")
|
||||
monkeypatch.setattr(lanes, "_retirement_staging_paths", lambda: [path])
|
||||
monkeypatch.setattr(lanes, "_open_terminal_recovery_snapshot", lambda _path: None)
|
||||
assert lanes._recover_retirement_staging() == 0
|
||||
|
||||
identity = lanes.TerminalIdentity("board", "task", 8, "pending")
|
||||
snapshot = _Snapshot(_pending_terminal_record("board", "task", 8, "accepted"))
|
||||
monkeypatch.setattr(lanes, "_open_terminal_recovery_snapshot", lambda _path: snapshot)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_staged_terminal_authority",
|
||||
lambda *_args: (identity, Path("/tmp/canonical")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_restore_staged_terminal_authority",
|
||||
lambda *_args: (_ for _ in ()).throw(OSError("restore")),
|
||||
)
|
||||
errors = []
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_record_board_access_error",
|
||||
lambda board, error: errors.append((board, str(error))),
|
||||
)
|
||||
assert lanes._recover_retirement_staging() == 0
|
||||
assert errors == [("board", "restore")]
|
||||
assert snapshot.closed == 1
|
||||
|
||||
|
||||
def test_drain_staging_ignores_disappearing_entries(monkeypatch):
|
||||
class Vanished:
|
||||
def stat(self, **_kwargs):
|
||||
raise FileNotFoundError
|
||||
|
||||
monkeypatch.setattr(lanes, "_retirement_staging_paths", lambda: [Vanished()])
|
||||
assert lanes._drain_retirement_staging() == 0
|
||||
|
||||
|
||||
def _prepared_path(tmp_path: Path, monkeypatch) -> tuple[Path, dict]:
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
pending = _pending_terminal_record("board", "task", 9, "accepted")
|
||||
identity = lanes.TerminalIdentity("board", "task", 9, "pending")
|
||||
path = lanes._persist_prepared_evidence(identity, pending)
|
||||
return path, pending
|
||||
|
||||
|
||||
def test_prepared_recovery_defers_without_discarding_evidence(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
path, _document = _prepared_path(tmp_path, monkeypatch)
|
||||
monkeypatch.setattr(lanes, "_finalize_document_db", lambda *_args: "deferred")
|
||||
assert lanes._recover_prepared_finalizations(object()) == 0
|
||||
assert path.exists()
|
||||
|
||||
|
||||
def test_prepared_recovery_bounds_db_and_publication_exceptions(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
path, _document = _prepared_path(tmp_path, monkeypatch)
|
||||
errors = []
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_record_board_access_error",
|
||||
lambda board, error: errors.append((board, str(error))),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_finalize_document_db",
|
||||
lambda *_args: (_ for _ in ()).throw(OSError("database")),
|
||||
)
|
||||
assert lanes._recover_prepared_finalizations(object()) == 0
|
||||
assert errors[-1] == ("board", "database")
|
||||
assert path.exists()
|
||||
|
||||
monkeypatch.setattr(lanes, "_finalize_document_db", lambda *_args: "committed")
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_promote_prepared_evidence",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("publish")),
|
||||
)
|
||||
assert lanes._recover_prepared_finalizations(object()) == 0
|
||||
assert errors[-1] == ("board", "publish")
|
||||
|
||||
|
||||
def test_pending_recovery_bounds_finalizer_errors_and_replacement_churn(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
capsys,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("board", "task"), 10)
|
||||
lanes.atomic_json(path, _pending_terminal_record("board", "task", 10, "accepted"))
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=object()))
|
||||
errors = []
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_record_board_access_error",
|
||||
lambda board, error: errors.append((board, str(error))),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_finalize_terminal_record",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("finalize")),
|
||||
)
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert errors[-1] == ("board", "finalize")
|
||||
|
||||
def invalid(*_args, snapshot=None, **_kwargs):
|
||||
snapshot.close()
|
||||
return "invalid"
|
||||
|
||||
monkeypatch.setattr(lanes, "_finalize_terminal_record", invalid)
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert "replacement churn deferred" in capsys.readouterr().err
|
||||
|
||||
|
||||
def test_pending_recovery_classifies_foreign_identity(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("board", "task"), 11)
|
||||
lanes.atomic_json(path, _pending_terminal_record("other", "task", 11, "accepted"))
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=object()))
|
||||
reasons = []
|
||||
|
||||
def quarantine(_path, _identity, reason, *, snapshot):
|
||||
reasons.append(reason)
|
||||
os.unlink(_path)
|
||||
|
||||
monkeypatch.setattr(lanes, "_quarantine_terminal", quarantine)
|
||||
monkeypatch.setattr(lanes, "_recover_exact_run", lambda *_args: False)
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert reasons == ["foreign-identity"]
|
||||
|
||||
|
||||
def test_pending_guard_rejects_invalid_run_and_inaccessible_or_bad_artifacts(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
assert lanes._has_pending_finalization("board", "task", True) is False
|
||||
path = lanes._terminal_path(lanes.state_path("board", "task"), 12)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("bad", encoding="utf-8")
|
||||
real_stat = Path.stat
|
||||
|
||||
def denied(candidate, *args, **kwargs):
|
||||
if candidate == path:
|
||||
raise PermissionError("denied")
|
||||
return real_stat(candidate, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "stat", denied)
|
||||
assert lanes._has_pending_finalization("board", "task", 12) is False
|
||||
325
testing/tests/test_hermes_cli_retention.py
Normal file
325
testing/tests/test_hermes_cli_retention.py
Normal file
@ -0,0 +1,325 @@
|
||||
"""Bounded reads, artifact retention, and recovery cadence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
SimpleNamespace,
|
||||
_completed_result,
|
||||
json,
|
||||
lanes,
|
||||
nullcontext,
|
||||
os,
|
||||
pytest,
|
||||
sys,
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_utf8_journal_quarantines_and_does_not_stop_later_replay(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
bad = lanes._terminal_path(lanes.state_path("cassandra", "a_bad_utf8"), 31)
|
||||
bad.parent.mkdir(parents=True)
|
||||
bad.write_bytes(b"\xff\xfe\x80not-json")
|
||||
good, _record = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "z_good"),
|
||||
board="cassandra",
|
||||
task_id="z_good",
|
||||
run_id=32,
|
||||
structured=_completed_result("valid after invalid UTF-8"),
|
||||
summary="valid after invalid UTF-8",
|
||||
metadata={},
|
||||
)
|
||||
tasks = {
|
||||
"a_bad_utf8": SimpleNamespace(
|
||||
id="a_bad_utf8",
|
||||
status="running",
|
||||
current_run_id=31,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
),
|
||||
"z_good": SimpleNamespace(
|
||||
id="z_good",
|
||||
status="running",
|
||||
current_run_id=32,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
),
|
||||
}
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def reclaim_task(_conn, task_id, **_kwargs):
|
||||
tasks[task_id].status = "ready"
|
||||
tasks[task_id].current_run_id = None
|
||||
return True
|
||||
|
||||
def complete_task(_conn, task_id, **kwargs):
|
||||
tasks[task_id].status = "done"
|
||||
tasks[task_id].result = kwargs["result"]
|
||||
tasks[task_id].current_run_id = None
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, task_id: tasks[task_id],
|
||||
reclaim_task=reclaim_task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert not bad.exists()
|
||||
assert not good.exists()
|
||||
assert tasks["a_bad_utf8"].status == "ready"
|
||||
assert tasks["z_good"].status == "done"
|
||||
diagnostic_path = next((bad.parent / "quarantine").glob("*.quarantine"))
|
||||
diagnostic = json.loads(diagnostic_path.read_text(encoding="utf-8"))
|
||||
assert diagnostic["size"] == len(b"\xff\xfe\x80not-json")
|
||||
assert diagnostic_path.stat().st_size < 4096
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"size",
|
||||
[lanes.MAX_TERMINAL_RECORD_BYTES + 1, 16 * 1024 * 1024],
|
||||
)
|
||||
def test_oversized_sparse_journal_has_bounded_read_and_quarantine(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
size: int,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
huge = lanes._terminal_path(lanes.state_path("cassandra", "a_huge"), 41)
|
||||
huge.parent.mkdir(parents=True)
|
||||
with huge.open("wb") as stream:
|
||||
stream.seek(size - 1)
|
||||
stream.write(b"\0")
|
||||
good, _record = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "z_after_huge"),
|
||||
board="cassandra",
|
||||
task_id="z_after_huge",
|
||||
run_id=42,
|
||||
structured=_completed_result("valid after oversized journal"),
|
||||
summary="valid after oversized journal",
|
||||
metadata={},
|
||||
)
|
||||
tasks = {
|
||||
"a_huge": SimpleNamespace(
|
||||
id="a_huge",
|
||||
status="running",
|
||||
current_run_id=41,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
),
|
||||
"z_after_huge": SimpleNamespace(
|
||||
id="z_after_huge",
|
||||
status="running",
|
||||
current_run_id=42,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
),
|
||||
}
|
||||
reads = []
|
||||
real_read_bounded = lanes._read_bounded
|
||||
|
||||
def observe_read_limit(descriptor, limit):
|
||||
reads.append((os.fstat(descriptor).st_size, limit))
|
||||
return real_read_bounded(descriptor, limit)
|
||||
|
||||
monkeypatch.setattr(lanes, "_read_bounded", observe_read_limit)
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def reclaim_task(_conn, task_id, **_kwargs):
|
||||
tasks[task_id].status = "ready"
|
||||
tasks[task_id].current_run_id = None
|
||||
return True
|
||||
|
||||
def complete_task(_conn, task_id, **kwargs):
|
||||
tasks[task_id].status = "done"
|
||||
tasks[task_id].result = kwargs["result"]
|
||||
tasks[task_id].current_run_id = None
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, task_id: tasks[task_id],
|
||||
reclaim_task=reclaim_task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert not huge.exists()
|
||||
assert not good.exists()
|
||||
assert tasks["a_huge"].status == "ready"
|
||||
assert tasks["z_after_huge"].status == "done"
|
||||
diagnostics = list((huge.parent / "quarantine").glob("*.quarantine"))
|
||||
assert len(diagnostics) == 1
|
||||
assert diagnostics[0].stat().st_size < 4096
|
||||
diagnostic = json.loads(diagnostics[0].read_text(encoding="utf-8"))
|
||||
assert diagnostic["size"] == size
|
||||
assert diagnostic["hashed_bytes"] == lanes.QUARANTINE_HASH_BYTES
|
||||
assert diagnostic["hash_complete"] is False
|
||||
assert (size, lanes.QUARANTINE_HASH_BYTES) in reads
|
||||
assert (size, lanes.MAX_TERMINAL_RECORD_BYTES + 1) not in reads
|
||||
assert max(limit for _file_size, limit in reads) <= (
|
||||
lanes.MAX_TERMINAL_RECORD_BYTES + 1
|
||||
)
|
||||
|
||||
|
||||
def test_recovery_does_not_parse_committed_journals_every_tick(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
state_root = tmp_path / "cli-lanes"
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", state_root)
|
||||
committed = lanes._terminal_path(
|
||||
lanes.state_path("cassandra", "t_done"), 1, "committed"
|
||||
)
|
||||
lanes.atomic_json(committed, {"kanban_state": "committed"})
|
||||
reads = []
|
||||
monkeypatch.setattr(lanes, "load_json", lambda path: (reads.append(path) or {}))
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli",
|
||||
SimpleNamespace(kanban_db=SimpleNamespace()),
|
||||
)
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert reads == []
|
||||
|
||||
|
||||
def test_atomic_json_fsyncs_file_and_directory_and_uses_private_mode(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
calls = []
|
||||
real_fsync = lanes.os.fsync
|
||||
monkeypatch.setattr(
|
||||
lanes.os,
|
||||
"fsync",
|
||||
lambda descriptor: (calls.append(descriptor), real_fsync(descriptor))[1],
|
||||
)
|
||||
path = tmp_path / "state.json"
|
||||
|
||||
lanes.atomic_json(path, {"safe": True})
|
||||
|
||||
assert json.loads(path.read_text()) == {"safe": True}
|
||||
assert path.stat().st_mode & 0o777 == 0o600
|
||||
assert len(calls) >= 2
|
||||
|
||||
|
||||
def test_atomic_json_refuses_a_precreated_temp_symlink(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
path = tmp_path / "state.json"
|
||||
victim = tmp_path / "credential"
|
||||
victim.write_text("do not overwrite", encoding="utf-8")
|
||||
temporary = tmp_path / ".state.json.fixed.tmp"
|
||||
temporary.symlink_to(victim)
|
||||
monkeypatch.setattr(lanes.uuid, "uuid4", lambda: SimpleNamespace(hex="fixed"))
|
||||
|
||||
with pytest.raises(FileExistsError):
|
||||
lanes.atomic_json(path, {"unsafe": True})
|
||||
|
||||
assert victim.read_text(encoding="utf-8") == "do not overwrite"
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_artifact_gc_prunes_by_age_without_touching_pending_journals(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
board = lanes.STATE_ROOT / "cassandra"
|
||||
board.mkdir(parents=True)
|
||||
quarantine = board / "quarantine"
|
||||
quarantine.mkdir()
|
||||
old_artifacts = [
|
||||
board / "t.run-1.candidate-1.json",
|
||||
board / "t.run-1.provider-1.result.json",
|
||||
board / "t.run-1.terminal.committed.json",
|
||||
board / f"t.run-1.terminal.conflict-{'a' * 32}.json",
|
||||
quarantine / "t.invalid.1234.quarantine",
|
||||
]
|
||||
for artifact in old_artifacts:
|
||||
artifact.write_text("{}", encoding="utf-8")
|
||||
quarantine_symlink = quarantine / "attacker.quarantine"
|
||||
quarantine_symlink.symlink_to(tmp_path / "missing-target")
|
||||
pending = board / "t.run-1.terminal.pending.json"
|
||||
pending.write_text("{}", encoding="utf-8")
|
||||
prepared = board / f"t.run-1.terminal.prepared-{'b' * 32}.json"
|
||||
prepared.write_text("{}", encoding="utf-8")
|
||||
old_time = 100.0
|
||||
for artifact in old_artifacts:
|
||||
os.utime(artifact, (old_time, old_time))
|
||||
os.utime(pending, (old_time, old_time))
|
||||
os.utime(prepared, (old_time, old_time))
|
||||
|
||||
assert lanes.gc_lane_artifacts(
|
||||
now=1000.0, max_age_seconds=10, max_count=100, max_bytes=10000
|
||||
) == len(old_artifacts) + 1
|
||||
assert not any(artifact.exists() for artifact in old_artifacts)
|
||||
assert not quarantine_symlink.is_symlink()
|
||||
assert pending.exists()
|
||||
assert prepared.exists()
|
||||
|
||||
|
||||
def test_artifact_gc_prunes_oldest_by_count_and_total_bytes(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
board = lanes.STATE_ROOT / "cassandra"
|
||||
board.mkdir(parents=True)
|
||||
files = []
|
||||
for sequence in range(3):
|
||||
path = board / f"t.run-1.provider-{sequence}.result.json"
|
||||
path.write_text("x" * 40, encoding="utf-8")
|
||||
os.utime(path, (100 + sequence, 100 + sequence))
|
||||
files.append(path)
|
||||
|
||||
assert lanes.gc_lane_artifacts(
|
||||
now=200.0, max_age_seconds=1000, max_count=2, max_bytes=45
|
||||
) == 2
|
||||
assert [path.exists() for path in files] == [False, False, True]
|
||||
|
||||
|
||||
def test_artifact_gc_preserves_a_concurrent_replacement(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
board = lanes.STATE_ROOT / "cassandra"
|
||||
board.mkdir(parents=True)
|
||||
path = board / "t.run-1.provider-1.result.json"
|
||||
path.write_text("old", encoding="utf-8")
|
||||
os.utime(path, (100, 100))
|
||||
replacement = board / "replacement.tmp"
|
||||
replacement.write_text("new", encoding="utf-8")
|
||||
real_unlink = lanes._unlink_artifact_if_same
|
||||
swapped = {"value": False}
|
||||
|
||||
def swap_before_identity_checked(candidate, observed):
|
||||
if not swapped["value"]:
|
||||
os.replace(replacement, candidate)
|
||||
swapped["value"] = True
|
||||
return real_unlink(candidate, observed)
|
||||
|
||||
monkeypatch.setattr(lanes, "_unlink_artifact_if_same", swap_before_identity_checked)
|
||||
|
||||
assert lanes.gc_lane_artifacts(
|
||||
now=1000.0, max_age_seconds=10, max_count=100, max_bytes=10000
|
||||
) == 0
|
||||
assert swapped["value"] is True
|
||||
assert path.read_text(encoding="utf-8") == "new"
|
||||
89
testing/tests/test_hermes_cli_retention_edges.py
Normal file
89
testing/tests/test_hermes_cli_retention_edges.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""Retained terminal classification and filesystem-failure edge coverage."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
_pending_terminal_record,
|
||||
lanes,
|
||||
)
|
||||
|
||||
|
||||
class _Snapshot:
|
||||
document = None
|
||||
invalid_reason = "malformed"
|
||||
|
||||
def __init__(self):
|
||||
self.closed = 0
|
||||
|
||||
def close(self):
|
||||
self.closed += 1
|
||||
|
||||
|
||||
def test_retained_classifier_handles_missing_snapshot(tmp_path: Path, monkeypatch):
|
||||
path = tmp_path / "task.run-1.terminal.committed.json"
|
||||
monkeypatch.setattr(lanes, "_open_terminal_recovery_snapshot", lambda _path: None)
|
||||
assert lanes._quarantine_invalid_retained_terminal(path) == (True, False)
|
||||
|
||||
|
||||
def test_retained_classifier_accepts_valid_committed_evidence(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "lanes")
|
||||
state_file = lanes.state_path("board", "task")
|
||||
path = lanes._terminal_path(state_file, 2, "committed")
|
||||
record = _pending_terminal_record("board", "task", 2, "accepted")
|
||||
record["kanban_state"] = "committed"
|
||||
lanes.atomic_json(path, record)
|
||||
assert lanes._quarantine_invalid_retained_terminal(path) == (False, False)
|
||||
|
||||
|
||||
def test_retained_classifier_bounds_post_quarantine_stat_error(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
path = tmp_path / "task.run-3.terminal.committed.json"
|
||||
snapshot = _Snapshot()
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_open_terminal_recovery_snapshot",
|
||||
lambda _path: snapshot,
|
||||
)
|
||||
monkeypatch.setattr(lanes, "_quarantine_terminal", lambda *_args, **_kwargs: path)
|
||||
real_stat = Path.stat
|
||||
|
||||
def fail_selected(candidate, *args, **kwargs):
|
||||
if candidate == path:
|
||||
raise PermissionError("denied")
|
||||
return real_stat(candidate, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "stat", fail_selected)
|
||||
assert lanes._quarantine_invalid_retained_terminal(path) == (True, False)
|
||||
assert snapshot.closed == 1
|
||||
|
||||
|
||||
def test_gc_skips_symlink_board_and_disappearing_candidate(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
root = tmp_path / "lanes"
|
||||
root.mkdir()
|
||||
target = tmp_path / "target"
|
||||
target.mkdir()
|
||||
(root / "linked-board").symlink_to(target, target_is_directory=True)
|
||||
board = root / "board"
|
||||
board.mkdir()
|
||||
candidate = board / "task.candidate-1.json"
|
||||
candidate.write_text("{}", encoding="utf-8")
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", root)
|
||||
real_stat = Path.stat
|
||||
|
||||
def disappear(selected, *args, **kwargs):
|
||||
if selected == candidate:
|
||||
raise FileNotFoundError
|
||||
return real_stat(selected, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "stat", disappear)
|
||||
assert lanes.gc_lane_artifacts() == 0
|
||||
155
testing/tests/test_hermes_cli_routing.py
Normal file
155
testing/tests/test_hermes_cli_routing.py
Normal file
@ -0,0 +1,155 @@
|
||||
"""Switchyard routing and provider-health behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
_SwitchyardResponse,
|
||||
json,
|
||||
lanes,
|
||||
)
|
||||
|
||||
|
||||
def test_auto_lane_uses_switchyard_worker_decision():
|
||||
observed = {}
|
||||
|
||||
def route(request, timeout):
|
||||
observed["payload"] = json.loads(request.data)
|
||||
observed["timeout"] = timeout
|
||||
return _SwitchyardResponse("worker/claude/claude-opus-5/xhigh")
|
||||
|
||||
route = lanes.select_route(
|
||||
"Perform the security review.",
|
||||
"cli-auto",
|
||||
open_request=route,
|
||||
)
|
||||
|
||||
assert route.provider == "claude"
|
||||
assert route.model == "claude-opus-5"
|
||||
assert route.effort == "xhigh"
|
||||
assert route.classifier == "switchyard-classifier"
|
||||
assert observed["payload"]["model"] == "atlas/worker/auto/maximum"
|
||||
assert observed["timeout"] == 60
|
||||
|
||||
|
||||
def test_manual_lane_is_still_enforced_by_switchyard():
|
||||
observed = {}
|
||||
|
||||
def route(request, timeout):
|
||||
observed["payload"] = json.loads(request.data)
|
||||
return _SwitchyardResponse(
|
||||
"worker/claude/claude-sonnet-5/high", "manual route"
|
||||
)
|
||||
|
||||
route = lanes.select_route(
|
||||
"Implement it.",
|
||||
"cli-claude-high",
|
||||
open_request=route,
|
||||
)
|
||||
|
||||
assert route.provider == "claude"
|
||||
assert route.effort == "high"
|
||||
assert route.classifier == "switchyard-manual"
|
||||
assert route.reason == "manual route"
|
||||
assert observed["payload"]["model"] == "atlas/worker/manual/claude/high"
|
||||
|
||||
|
||||
def test_cross_provider_retry_passes_failed_provider_to_switchyard():
|
||||
observed = {}
|
||||
|
||||
def route(request, timeout):
|
||||
observed["payload"] = json.loads(request.data)
|
||||
return _SwitchyardResponse("worker/claude/claude-sonnet-5/high")
|
||||
|
||||
route = lanes.select_route(
|
||||
"Retry after capacity exhaustion.",
|
||||
"cli-auto",
|
||||
exclude_provider="codex",
|
||||
open_request=route,
|
||||
)
|
||||
|
||||
assert route.provider == "claude"
|
||||
assert route.classifier == "switchyard-classifier"
|
||||
content = observed["payload"]["messages"][0]["content"]
|
||||
assert "codex provider failed or exhausted capacity" in content
|
||||
|
||||
|
||||
def test_classifier_cannot_select_a_freshly_excluded_provider():
|
||||
payloads = []
|
||||
|
||||
def route(request, timeout):
|
||||
payload = json.loads(request.data)
|
||||
payloads.append(payload)
|
||||
if len(payloads) == 1:
|
||||
return _SwitchyardResponse("worker/claude/opus/xhigh")
|
||||
return _SwitchyardResponse("worker/codex/sol/xhigh", "healthy route")
|
||||
|
||||
selected = lanes.select_route(
|
||||
"Perform a consequential review.",
|
||||
"cli-auto",
|
||||
exclude_provider="claude",
|
||||
exclude_reason="is unavailable according to fresh native health",
|
||||
open_request=route,
|
||||
)
|
||||
|
||||
assert selected.provider == "codex"
|
||||
assert selected.effort == "xhigh"
|
||||
assert selected.classifier == "switchyard-classifier-health-guard"
|
||||
assert payloads[0]["model"] == "atlas/worker/auto/maximum"
|
||||
assert payloads[1]["model"] == "atlas/worker/manual/codex/xhigh"
|
||||
assert "claude provider is unavailable" in payloads[0]["messages"][0]["content"]
|
||||
|
||||
|
||||
def test_fresh_native_health_excludes_only_one_proven_down_provider(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
paths = {
|
||||
"codex": tmp_path / "codex.json",
|
||||
"claude": tmp_path / "claude.json",
|
||||
}
|
||||
paths["codex"].write_text('{"state":"available"}\n', encoding="utf-8")
|
||||
paths["claude"].write_text('{"state":"unavailable"}\n', encoding="utf-8")
|
||||
monkeypatch.setattr(lanes, "PROVIDER_HEALTH_PATHS", paths)
|
||||
|
||||
now = max(path.stat().st_mtime for path in paths.values())
|
||||
assert lanes.fresh_unavailable_provider(now=now) == "claude"
|
||||
|
||||
paths["codex"].write_text('{"state":"unavailable"}\n', encoding="utf-8")
|
||||
now = max(path.stat().st_mtime for path in paths.values())
|
||||
assert lanes.fresh_unavailable_provider(now=now) is None
|
||||
|
||||
|
||||
def test_explicit_auth_failure_survives_restart_health_gap(tmp_path: Path, monkeypatch):
|
||||
paths = {
|
||||
"codex": tmp_path / "codex.json",
|
||||
"claude": tmp_path / "claude.json",
|
||||
}
|
||||
paths["codex"].write_text(
|
||||
'{"state":"available","authenticated":true}\n', encoding="utf-8"
|
||||
)
|
||||
paths["claude"].write_text(
|
||||
'{"state":"unavailable","authenticated":false}\n', encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(lanes, "PROVIDER_HEALTH_PATHS", paths)
|
||||
|
||||
stale_during_rollout = paths["claude"].stat().st_mtime + 10 * 60
|
||||
assert lanes.fresh_unavailable_provider(now=stale_during_rollout) == "claude"
|
||||
|
||||
paths["claude"].write_text(
|
||||
'{"state":"unavailable","authenticated":true}\n', encoding="utf-8"
|
||||
)
|
||||
transient_stale = paths["claude"].stat().st_mtime + 10 * 60
|
||||
assert lanes.fresh_unavailable_provider(now=transient_stale) is None
|
||||
|
||||
|
||||
def test_worker_environment_preserves_vault_backed_cli_homes(monkeypatch):
|
||||
"""Kanban workers must not fall back to credential-free persistent homes."""
|
||||
monkeypatch.setenv("CODEX_HOME", "/runtime-access/codex")
|
||||
monkeypatch.setenv("CLAUDE_CONFIG_DIR", "/runtime-access/claude")
|
||||
|
||||
env = lanes._base_env()
|
||||
|
||||
assert env["HOME"] == str(lanes.DATA_ROOT / "home")
|
||||
assert env["CODEX_HOME"] == "/runtime-access/codex"
|
||||
assert env["CLAUDE_CONFIG_DIR"] == "/runtime-access/claude"
|
||||
assert env["GIT_TERMINAL_PROMPT"] == "0"
|
||||
217
testing/tests/test_hermes_cli_run_identity.py
Normal file
217
testing/tests/test_hermes_cli_run_identity.py
Normal file
@ -0,0 +1,217 @@
|
||||
"""Canonical SQLite run-identity contracts for every terminal artifact class."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
_completed_result,
|
||||
_pending_terminal_record,
|
||||
lanes,
|
||||
pytest,
|
||||
sys,
|
||||
)
|
||||
|
||||
|
||||
class _NoDatabase:
|
||||
"""Fail if malformed run authority reaches a Kanban transaction."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.accesses = []
|
||||
|
||||
def scoped_current_board(self, *_args, **_kwargs):
|
||||
self.accesses.append("scope")
|
||||
raise AssertionError("noncanonical authority reached the database")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
False,
|
||||
True,
|
||||
0,
|
||||
-1,
|
||||
2**63,
|
||||
"0",
|
||||
"01",
|
||||
"9223372036854775808",
|
||||
"9" * 150,
|
||||
"+1",
|
||||
" 1",
|
||||
],
|
||||
)
|
||||
def test_run_identity_rejects_noncanonical_or_non_sqlite_values(value):
|
||||
assert lanes.canonical_run_id(value) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [1, 2**63 - 1, "1", "9223372036854775807"])
|
||||
def test_run_identity_accepts_the_positive_sqlite_int64_domain(value):
|
||||
assert lanes.canonical_run_id(value) == int(value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("run_id", [False, True, 0, -1, 2**63, 10**149])
|
||||
def test_terminal_writer_refuses_invalid_runtime_run_ids(tmp_path: Path, run_id):
|
||||
with pytest.raises(ValueError, match="SQLite int64"):
|
||||
lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "t_invalid_writer"),
|
||||
board="cassandra",
|
||||
task_id="t_invalid_writer",
|
||||
run_id=run_id,
|
||||
structured=_completed_result(),
|
||||
summary="completed",
|
||||
metadata={},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "suffix", "parser"),
|
||||
[
|
||||
("pending", "terminal.pending.json", "_terminal_identity"),
|
||||
("committed", "terminal.committed.json", "_terminal_identity"),
|
||||
(
|
||||
"prepared",
|
||||
f"terminal.prepared-{'a' * 32}.json",
|
||||
"_terminal_evidence_identity",
|
||||
),
|
||||
(
|
||||
"conflict",
|
||||
f"terminal.conflict-{'a' * 32}.json",
|
||||
"_terminal_evidence_identity",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("encoded", ["0", "01", "9223372036854775808", "9" * 150])
|
||||
def test_every_terminal_filename_class_rejects_noncanonical_run_ids(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
state: str,
|
||||
suffix: str,
|
||||
parser: str,
|
||||
encoded: str,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
board = lanes.STATE_ROOT / "cassandra"
|
||||
board.mkdir(parents=True)
|
||||
path = board / f"t_filename.run-{encoded}.{suffix}"
|
||||
|
||||
assert getattr(lanes, parser)(path) is None, state
|
||||
|
||||
|
||||
@pytest.mark.parametrize("run_id", [False, True, 0, -1, 2**63, 10**149])
|
||||
@pytest.mark.parametrize("state", ["pending", "prepared", "committed", "conflict"])
|
||||
def test_every_terminal_record_class_rejects_invalid_run_ids(run_id, state):
|
||||
record = _pending_terminal_record(
|
||||
"cassandra", "t_record", run_id, "invalid identity"
|
||||
)
|
||||
record["kanban_state"] = state
|
||||
|
||||
assert lanes._terminal_record_valid(record) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("run_id", [False, 0, 2**63, 10**149])
|
||||
def test_retirement_staging_rejects_invalid_document_run_ids(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
run_id,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
board = lanes.STATE_ROOT / "cassandra"
|
||||
board.mkdir(parents=True)
|
||||
staged = board / f".retire.{'a' * 16}.{'b' * 16}.0"
|
||||
lanes.atomic_json(
|
||||
staged,
|
||||
_pending_terminal_record(
|
||||
"cassandra", "t_staged", run_id, "invalid staged identity"
|
||||
),
|
||||
)
|
||||
snapshot = lanes._open_terminal_recovery_snapshot(staged)
|
||||
assert snapshot is not None
|
||||
try:
|
||||
assert lanes._staged_terminal_authority(staged, snapshot) is None
|
||||
finally:
|
||||
snapshot.close()
|
||||
assert lanes._recover_retirement_staging() == 0
|
||||
assert not staged.exists()
|
||||
assert len(list((board / "quarantine").glob("*.quarantine"))) == 1
|
||||
|
||||
|
||||
def test_noncanonical_pending_filename_is_quarantined_before_db_access(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
board = lanes.STATE_ROOT / "cassandra"
|
||||
board.mkdir(parents=True)
|
||||
pending = board / "t_noncanonical.run-01.terminal.pending.json"
|
||||
lanes.atomic_json(
|
||||
pending,
|
||||
_pending_terminal_record(
|
||||
"cassandra", "t_noncanonical", 1, "noncanonical filename"
|
||||
),
|
||||
)
|
||||
database = _NoDatabase()
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli",
|
||||
type("HermesCli", (), {"kanban_db": database})(),
|
||||
)
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert database.accesses == []
|
||||
assert not pending.exists()
|
||||
assert len(list((board / "quarantine").glob("*.quarantine"))) == 1
|
||||
|
||||
|
||||
def test_noncanonical_prepared_filename_is_quarantined_before_db_access(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
board = lanes.STATE_ROOT / "cassandra"
|
||||
board.mkdir(parents=True)
|
||||
prepared = (
|
||||
board / f"t_prepared.run-01.terminal.prepared-{'a' * 32}.json"
|
||||
)
|
||||
record = _pending_terminal_record(
|
||||
"cassandra", "t_prepared", 1, "noncanonical prepared filename"
|
||||
)
|
||||
record["kanban_state"] = "prepared"
|
||||
lanes.atomic_json(prepared, record)
|
||||
database = _NoDatabase()
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"hermes_cli",
|
||||
type("HermesCli", (), {"kanban_db": database})(),
|
||||
)
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert database.accesses == []
|
||||
assert not prepared.exists()
|
||||
assert len(list((board / "quarantine").glob("*.quarantine"))) == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("state", "suffix"),
|
||||
[
|
||||
("committed", "terminal.committed.json"),
|
||||
("conflict", f"terminal.conflict-{'a' * 32}.json"),
|
||||
],
|
||||
)
|
||||
def test_gc_quarantines_noncanonical_retained_evidence(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
state: str,
|
||||
suffix: str,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
board = lanes.STATE_ROOT / "cassandra"
|
||||
board.mkdir(parents=True)
|
||||
artifact = board / f"t_retained.run-01.{suffix}"
|
||||
record = _pending_terminal_record(
|
||||
"cassandra", "t_retained", 1, "noncanonical retained filename"
|
||||
)
|
||||
record["kanban_state"] = state
|
||||
lanes.atomic_json(artifact, record)
|
||||
|
||||
assert lanes.gc_lane_artifacts() == 1
|
||||
assert not artifact.exists()
|
||||
assert len(list((board / "quarantine").glob("*.quarantine"))) == 1
|
||||
441
testing/tests/test_hermes_cli_staging.py
Normal file
441
testing/tests/test_hermes_cli_staging.py
Normal file
@ -0,0 +1,441 @@
|
||||
"""Retirement staging, recovery, and bounded quarantine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from testing.tests.test_hermes_cli_support import (
|
||||
Path,
|
||||
SimpleNamespace,
|
||||
_completed_result,
|
||||
_install_terminal_recovery_db,
|
||||
_pending_terminal_record,
|
||||
hashlib,
|
||||
json,
|
||||
lanes,
|
||||
nullcontext,
|
||||
os,
|
||||
sys,
|
||||
time,
|
||||
)
|
||||
|
||||
|
||||
def test_recovery_finalizes_the_valid_snapshot_it_classified_before_replacement(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path, first = lanes._write_terminal_record(
|
||||
lanes.state_path("cassandra", "t_valid_gap"),
|
||||
board="cassandra",
|
||||
task_id="t_valid_gap",
|
||||
run_id=54,
|
||||
structured=_completed_result("first valid snapshot"),
|
||||
summary="first valid snapshot",
|
||||
metadata={},
|
||||
)
|
||||
replacement = path.with_name("replacement.valid")
|
||||
lanes.atomic_json(
|
||||
replacement,
|
||||
_pending_terminal_record(
|
||||
"cassandra", "t_valid_gap", 54, "second valid replacement"
|
||||
),
|
||||
)
|
||||
task = SimpleNamespace(
|
||||
id="t_valid_gap",
|
||||
status="running",
|
||||
current_run_id=54,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
completions = []
|
||||
reclaims = []
|
||||
_install_terminal_recovery_db(monkeypatch, task, completions, reclaims)
|
||||
real_finalize = lanes._finalize_terminal_record
|
||||
swapped = {"value": False}
|
||||
|
||||
def replace_before_finalize(*args, **kwargs):
|
||||
assert kwargs["snapshot"].document == first
|
||||
if not swapped["value"]:
|
||||
os.replace(replacement, path)
|
||||
swapped["value"] = True
|
||||
return real_finalize(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(lanes, "_finalize_terminal_record", replace_before_finalize)
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert task.result == first["result"]
|
||||
assert completions == [first["result"]]
|
||||
assert reclaims == []
|
||||
assert not path.exists()
|
||||
conflicts = list(path.parent.glob("*.terminal.conflict-*.json"))
|
||||
assert len(conflicts) == 1
|
||||
conflict = json.loads(conflicts[0].read_text())
|
||||
assert conflict["summary"] == "second valid replacement"
|
||||
|
||||
|
||||
def test_replacement_staged_journal_promotes_replays_and_never_reexecutes(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("cassandra", "t_staged"), 55)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"malformed source")
|
||||
source_stat = path.stat(follow_symlinks=False)
|
||||
staged_winner = path.with_name("staged-winner.tmp")
|
||||
canonical_replacement = path.with_name("canonical-replacement.tmp")
|
||||
first = _pending_terminal_record(
|
||||
"cassandra", "t_staged", 55, "staged accepted result"
|
||||
)
|
||||
second = _pending_terminal_record(
|
||||
"cassandra", "t_staged", 55, "later canonical replacement"
|
||||
)
|
||||
lanes.atomic_json(staged_winner, first)
|
||||
lanes.atomic_json(canonical_replacement, second)
|
||||
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
||||
descriptor = os.open(path.parent, directory_flags)
|
||||
real_rename = lanes._rename_noreplace
|
||||
rename_steps = []
|
||||
|
||||
def force_replacement_staging(source, destination, **kwargs):
|
||||
rename_steps.append((source, destination))
|
||||
if source == path.name:
|
||||
os.replace(staged_winner, path)
|
||||
elif source.startswith(".retire."):
|
||||
os.replace(canonical_replacement, path)
|
||||
return real_rename(source, destination, **kwargs)
|
||||
|
||||
try:
|
||||
with monkeypatch.context() as race:
|
||||
race.setattr(lanes, "_rename_noreplace", force_replacement_staging)
|
||||
outcome = lanes._retire_terminal_entry(
|
||||
path,
|
||||
source_stat,
|
||||
board_descriptor=descriptor,
|
||||
quarantine_descriptor=descriptor,
|
||||
)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
assert outcome == "replacement-staged"
|
||||
assert len(rename_steps) == 2
|
||||
staged = list(path.parent.glob(".retire.*"))
|
||||
assert len(staged) == 1
|
||||
staged_snapshot = lanes._open_terminal_recovery_snapshot(staged[0])
|
||||
assert staged_snapshot is not None
|
||||
try:
|
||||
authority = lanes._staged_terminal_authority(staged[0], staged_snapshot)
|
||||
assert authority is not None
|
||||
assert authority[0].run_id == 55
|
||||
assert authority[1] == path
|
||||
finally:
|
||||
staged_snapshot.close()
|
||||
assert lanes._has_pending_finalization("cassandra", "t_staged", 55) is True
|
||||
assert lanes.gc_lane_artifacts(
|
||||
now=staged[0].stat().st_mtime + 3600,
|
||||
max_age_seconds=0,
|
||||
max_count=0,
|
||||
max_bytes=0,
|
||||
) == 0
|
||||
assert staged[0].exists()
|
||||
|
||||
task = SimpleNamespace(
|
||||
id="t_staged",
|
||||
status="running",
|
||||
current_run_id=55,
|
||||
completed_run_id=None,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
completions = []
|
||||
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.completed_run_id = kwargs["expected_run_id"]
|
||||
task.current_run_id = None
|
||||
completions.append(kwargs["result"])
|
||||
return True
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete_task,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert completions == [first["result"]]
|
||||
assert not list(path.parent.glob(".retire.*"))
|
||||
assert not path.exists()
|
||||
conflicts = list(path.parent.glob("*.terminal.conflict-*.json"))
|
||||
assert len(conflicts) == 1
|
||||
assert json.loads(conflicts[0].read_text())["result"] == second["result"]
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert completions == [first["result"]]
|
||||
|
||||
|
||||
def test_quarantine_staged_replacement_is_recovered_bounded_and_preserved(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
path = lanes._terminal_path(lanes.state_path("cassandra", "t_qstage"), 58)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"malformed original")
|
||||
identity = lanes._terminal_identity(path)
|
||||
assert identity is not None
|
||||
records = [
|
||||
_pending_terminal_record(
|
||||
"cassandra", "t_qstage", 58, f"quarantine replacement {number}"
|
||||
)
|
||||
for number in range(2)
|
||||
]
|
||||
replacements = [
|
||||
path.with_name(f"quarantine-replacement-{number}.tmp")
|
||||
for number in range(2)
|
||||
]
|
||||
for replacement, record in zip(replacements, records, strict=True):
|
||||
lanes.atomic_json(replacement, record)
|
||||
real_rename = lanes._rename_noreplace
|
||||
retirement_steps = []
|
||||
|
||||
def replace_during_cross_directory_retirement(source, destination, **kwargs):
|
||||
if source == path.name and destination.startswith(".retire."):
|
||||
retirement_steps.append("move")
|
||||
os.replace(replacements[0], path)
|
||||
elif source.startswith(".retire.") and destination == path.name:
|
||||
retirement_steps.append("restore")
|
||||
os.replace(replacements[1], path)
|
||||
return real_rename(source, destination, **kwargs)
|
||||
|
||||
with monkeypatch.context() as race:
|
||||
race.setattr(
|
||||
lanes,
|
||||
"_rename_noreplace",
|
||||
replace_during_cross_directory_retirement,
|
||||
)
|
||||
lanes._quarantine_terminal(path, identity, "malformed-payload")
|
||||
|
||||
assert retirement_steps == ["move", "restore"]
|
||||
quarantine_stages = list((path.parent / "quarantine").glob(".retire.*"))
|
||||
assert len(quarantine_stages) == 1
|
||||
snapshot = lanes._open_terminal_recovery_snapshot(quarantine_stages[0])
|
||||
assert snapshot is not None
|
||||
try:
|
||||
authority = lanes._staged_terminal_authority(quarantine_stages[0], snapshot)
|
||||
assert authority is not None
|
||||
assert authority[0].run_id == 58
|
||||
assert authority[1] == path
|
||||
finally:
|
||||
snapshot.close()
|
||||
assert lanes._has_pending_finalization("cassandra", "t_qstage", 58) is True
|
||||
assert lanes.gc_lane_artifacts(
|
||||
now=quarantine_stages[0].stat().st_mtime + 3600,
|
||||
max_age_seconds=0,
|
||||
max_count=0,
|
||||
max_bytes=0,
|
||||
) == 1
|
||||
assert quarantine_stages[0].exists()
|
||||
|
||||
task = SimpleNamespace(
|
||||
id="t_qstage",
|
||||
status="running",
|
||||
current_run_id=58,
|
||||
completed_run_id=None,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
completions = []
|
||||
reclaims = []
|
||||
_install_terminal_recovery_db(monkeypatch, task, completions, reclaims)
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert len(completions) == 1
|
||||
assert reclaims == []
|
||||
assert not list(path.parent.glob(".retire.*"))
|
||||
assert not list((path.parent / "quarantine").glob(".retire.*"))
|
||||
conflicts = list(path.parent.glob("*.terminal.conflict-*.json"))
|
||||
assert len(conflicts) == 1
|
||||
preserved = {task.result, json.loads(conflicts[0].read_text())["result"]}
|
||||
assert preserved == {record["result"] for record in records}
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert len(completions) == 1
|
||||
|
||||
|
||||
def test_repeated_retirement_staging_preserves_every_valid_result_once(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
canonical = lanes._terminal_path(
|
||||
lanes.state_path("cassandra", "t_staged_repeat"), 57
|
||||
)
|
||||
canonical.parent.mkdir(parents=True)
|
||||
path_digest = hashlib.sha256(canonical.name.encode("utf-8")).hexdigest()[:16]
|
||||
staged = canonical.parent / f".retire.{path_digest}.{'a' * 16}.0"
|
||||
records = [
|
||||
_pending_terminal_record(
|
||||
"cassandra", "t_staged_repeat", 57, f"accepted result {number}"
|
||||
)
|
||||
for number in range(3)
|
||||
]
|
||||
lanes.atomic_json(staged, records[0])
|
||||
second = canonical.with_name("second-staged-replacement.tmp")
|
||||
third = canonical.with_name("third-staged-replacement.tmp")
|
||||
lanes.atomic_json(second, records[1])
|
||||
lanes.atomic_json(third, records[2])
|
||||
real_rename = lanes._rename_noreplace
|
||||
retirement_steps = []
|
||||
|
||||
def replace_each_retirement_generation(source, destination, **kwargs):
|
||||
if source == staged.name and destination.startswith(".retire."):
|
||||
retirement_steps.append("move")
|
||||
os.replace(second, staged)
|
||||
elif source.startswith(".retire.") and destination == staged.name:
|
||||
retirement_steps.append("restore")
|
||||
os.replace(third, staged)
|
||||
return real_rename(source, destination, **kwargs)
|
||||
|
||||
with monkeypatch.context() as race:
|
||||
race.setattr(lanes, "_rename_noreplace", replace_each_retirement_generation)
|
||||
assert lanes._recover_retirement_staging() == 0
|
||||
|
||||
assert retirement_steps == ["move", "restore"]
|
||||
staged_generations = list(canonical.parent.glob(".retire.*"))
|
||||
assert len(staged_generations) == 2
|
||||
for generation in staged_generations:
|
||||
snapshot = lanes._open_terminal_recovery_snapshot(generation)
|
||||
assert snapshot is not None
|
||||
try:
|
||||
authority = lanes._staged_terminal_authority(generation, snapshot)
|
||||
assert authority is not None
|
||||
assert authority[1] == canonical
|
||||
finally:
|
||||
snapshot.close()
|
||||
|
||||
task = SimpleNamespace(
|
||||
id="t_staged_repeat",
|
||||
status="running",
|
||||
current_run_id=57,
|
||||
completed_run_id=None,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
completions = []
|
||||
reclaims = []
|
||||
_install_terminal_recovery_db(monkeypatch, task, completions, reclaims)
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert len(completions) == 1
|
||||
assert reclaims == []
|
||||
assert not list(canonical.parent.glob(".retire.*"))
|
||||
conflicts = list(canonical.parent.glob("*.terminal.conflict-*.json"))
|
||||
assert len(conflicts) == 2
|
||||
preserved_results = {task.result}
|
||||
preserved_results.update(json.loads(path.read_text())["result"] for path in conflicts)
|
||||
assert preserved_results == {record["result"] for record in records}
|
||||
assert lanes.recover_pending_finalizations() == 0
|
||||
assert len(completions) == 1
|
||||
|
||||
|
||||
def test_prepared_recovery_quarantines_pinned_inode_then_replays_replacement(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
pending_identity = lanes.TerminalIdentity(
|
||||
"cassandra", "t_prepared_swap", 59, "pending"
|
||||
)
|
||||
pending = _pending_terminal_record(
|
||||
"cassandra", "t_prepared_swap", 59, "valid prepared replacement"
|
||||
)
|
||||
prepared_path = lanes._persist_prepared_evidence(pending_identity, pending)
|
||||
valid_prepared = json.loads(prepared_path.read_text())
|
||||
replacement = prepared_path.with_name("prepared-valid-replacement.tmp")
|
||||
lanes.atomic_json(replacement, valid_prepared)
|
||||
invalid_prepared = dict(valid_prepared)
|
||||
invalid_prepared["summary"] = "does not match the structured result"
|
||||
lanes.atomic_json(prepared_path, invalid_prepared)
|
||||
task = SimpleNamespace(
|
||||
id="t_prepared_swap",
|
||||
status="running",
|
||||
current_run_id=59,
|
||||
completed_run_id=None,
|
||||
result=None,
|
||||
assignee="cli-auto",
|
||||
)
|
||||
completions = []
|
||||
reclaims = []
|
||||
_install_terminal_recovery_db(monkeypatch, task, completions, reclaims)
|
||||
real_quarantine = lanes._quarantine_terminal
|
||||
swapped = {"value": False}
|
||||
|
||||
def swap_before_prepared_quarantine(*args, **kwargs):
|
||||
assert kwargs["snapshot"].document == invalid_prepared
|
||||
if not swapped["value"]:
|
||||
os.replace(replacement, prepared_path)
|
||||
swapped["value"] = True
|
||||
return real_quarantine(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(
|
||||
lanes,
|
||||
"_quarantine_terminal",
|
||||
swap_before_prepared_quarantine,
|
||||
)
|
||||
|
||||
assert lanes.recover_pending_finalizations() == 1
|
||||
assert swapped["value"] is True
|
||||
assert completions == [valid_prepared["result"]]
|
||||
assert reclaims == []
|
||||
assert not prepared_path.exists()
|
||||
committed = list(prepared_path.parent.glob("*.terminal.committed.json"))
|
||||
assert len(committed) == 1
|
||||
assert json.loads(committed[0].read_text())["result"] == valid_prepared["result"]
|
||||
|
||||
|
||||
def test_untrusted_retirement_staging_is_bounded_by_artifact_gc(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
board = lanes.STATE_ROOT / "cassandra"
|
||||
board.mkdir(parents=True)
|
||||
for sequence in range(10):
|
||||
(board / f".retire.{'a' * 16}.{'b' * 16}.{sequence}").write_bytes(
|
||||
b"not terminal authority"
|
||||
)
|
||||
(board / f".retire.{'c' * 32}.0").write_bytes(b"legacy hidden artifact")
|
||||
|
||||
removed = lanes.gc_lane_artifacts(
|
||||
now=time.time(),
|
||||
max_age_seconds=3600,
|
||||
max_count=2,
|
||||
max_bytes=1024 * 1024,
|
||||
)
|
||||
|
||||
assert removed == 9
|
||||
assert len(list(board.glob(".retire.*"))) == 2
|
||||
|
||||
|
||||
def test_untrusted_quarantine_stage_never_creates_recursive_quarantine(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setattr(lanes, "STATE_ROOT", tmp_path / "cli-lanes")
|
||||
quarantine = lanes.STATE_ROOT / "cassandra" / "quarantine"
|
||||
quarantine.mkdir(parents=True)
|
||||
staged = quarantine / f".retire.{'a' * 16}.{'b' * 16}.0"
|
||||
staged.write_bytes(b"untrusted staged bytes")
|
||||
|
||||
assert lanes._recover_retirement_staging() == 0
|
||||
|
||||
assert not staged.exists()
|
||||
assert not (quarantine / "quarantine").exists()
|
||||
diagnostics = list(quarantine.glob("*.quarantine"))
|
||||
assert len(diagnostics) == 1
|
||||
assert diagnostics[0].stat().st_mode & 0o777 == 0o600
|
||||
236
testing/tests/test_hermes_cli_support.py
Normal file
236
testing/tests/test_hermes_cli_support.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""Focused tests for Agent Hermes' direct Codex and Claude Kanban lanes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
|
||||
import importlib.util
|
||||
|
||||
import hashlib
|
||||
|
||||
import json
|
||||
|
||||
import os
|
||||
|
||||
import signal
|
||||
|
||||
import stat
|
||||
|
||||
import sys
|
||||
|
||||
import threading
|
||||
|
||||
import time
|
||||
|
||||
from contextlib import nullcontext
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
import yaml
|
||||
|
||||
SCRIPTS = Path(__file__).parents[2] / "services/hermes/scripts"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
HERMES = Path(__file__).parents[2] / "services/hermes"
|
||||
|
||||
KEYCLOAK = Path(__file__).parents[2] / "services/keycloak"
|
||||
|
||||
FLUX_HERMES = (
|
||||
Path(__file__).parents[2]
|
||||
/ "clusters/atlas/flux-system/applications/hermes/kustomization.yaml"
|
||||
)
|
||||
|
||||
def _load(name: str):
|
||||
spec = importlib.util.spec_from_file_location(name, SCRIPTS / f"{name}.py")
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
_lane_facade = _load("cli_lane_runner")
|
||||
|
||||
|
||||
class _LaneSurface:
|
||||
"""Patch every module-local import behind the compatibility facade."""
|
||||
|
||||
def __init__(self, facade):
|
||||
object.__setattr__(self, "_facade", facade)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"_modules",
|
||||
tuple(
|
||||
module
|
||||
for name, module in sys.modules.items()
|
||||
if name == "cli_lane_runner" or name.startswith("cli_lane_")
|
||||
),
|
||||
)
|
||||
|
||||
def __getattr__(self, name):
|
||||
facade = object.__getattribute__(self, "_facade")
|
||||
if hasattr(facade, name):
|
||||
return getattr(facade, name)
|
||||
for module in object.__getattribute__(self, "_modules"):
|
||||
if hasattr(module, name):
|
||||
return getattr(module, name)
|
||||
raise AttributeError(name)
|
||||
|
||||
def __setattr__(self, name, value):
|
||||
matched = False
|
||||
for module in object.__getattribute__(self, "_modules"):
|
||||
if hasattr(module, name):
|
||||
setattr(module, name, value)
|
||||
matched = True
|
||||
if not matched:
|
||||
setattr(object.__getattribute__(self, "_facade"), name, value)
|
||||
|
||||
|
||||
lanes = _LaneSurface(_lane_facade)
|
||||
|
||||
policy = _load("claude_command_policy")
|
||||
|
||||
migration = _load("migrate_herdr_state")
|
||||
|
||||
auth_patch = _load("patch_hermes_auth")
|
||||
|
||||
tui_gateway_patch = _load("patch_tui_gateway")
|
||||
|
||||
codex_runtime_patch = _load("patch_codex_runtime")
|
||||
|
||||
ttyd_patch = _load("patch_ttyd_index")
|
||||
|
||||
client_config = _load("configure_agent_clients")
|
||||
|
||||
def _agent_deployment() -> dict:
|
||||
return yaml.safe_load((HERMES / "agent-deployment.yaml").read_text())
|
||||
|
||||
def _services() -> dict[str, dict]:
|
||||
return {
|
||||
item["metadata"]["name"]: item
|
||||
for item in yaml.safe_load_all((HERMES / "service.yaml").read_text())
|
||||
if item
|
||||
}
|
||||
|
||||
def _oauth_deployment(name: str) -> dict:
|
||||
documents = [
|
||||
item
|
||||
for item in yaml.safe_load_all((HERMES / "oauth2-proxy.yaml").read_text())
|
||||
if item
|
||||
]
|
||||
return next(
|
||||
item
|
||||
for item in documents
|
||||
if item["kind"] == "Deployment" and item["metadata"]["name"] == name
|
||||
)
|
||||
|
||||
class _SwitchyardResponse:
|
||||
"""Minimal context-managed response used by routing contract tests."""
|
||||
|
||||
def __init__(self, selected: str, rationale: str = "local classifier vote"):
|
||||
self.headers = {
|
||||
"x-model-router-selected-model": selected,
|
||||
"x-model-router-rationale": rationale,
|
||||
}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return b"{}"
|
||||
|
||||
def _completed_result(summary: str = "done") -> dict:
|
||||
return {
|
||||
"status": "completed",
|
||||
"summary": summary,
|
||||
"changed_files": [],
|
||||
"tests_run": [],
|
||||
"artifacts": [],
|
||||
"findings": [],
|
||||
"blockers": [],
|
||||
}
|
||||
|
||||
def _pending_terminal_record(board: str, task_id: str, run_id: int, summary: str) -> dict:
|
||||
structured = _completed_result(summary)
|
||||
return {
|
||||
"board": board,
|
||||
"task_id": task_id,
|
||||
"expected_run_id": run_id,
|
||||
"result": json.dumps(structured, sort_keys=True),
|
||||
"summary": summary,
|
||||
"metadata": {},
|
||||
"kanban_state": "pending",
|
||||
"recorded_at": lanes.utc_now(),
|
||||
}
|
||||
|
||||
def _install_terminal_recovery_db(monkeypatch, task, completions, reclaims) -> None:
|
||||
class Connection:
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
def complete_task(_conn, _task_id, **kwargs):
|
||||
if task.status != "running" or task.current_run_id != kwargs["expected_run_id"]:
|
||||
return False
|
||||
task.status = "done"
|
||||
task.result = kwargs["result"]
|
||||
task.current_run_id = None
|
||||
completions.append(kwargs["result"])
|
||||
return True
|
||||
|
||||
def reclaim_task(_conn, _task_id, **kwargs):
|
||||
reclaims.append(kwargs)
|
||||
return False
|
||||
|
||||
fake_db = SimpleNamespace(
|
||||
scoped_current_board=lambda _board: nullcontext(),
|
||||
connect=lambda board: Connection(),
|
||||
get_task=lambda _conn, _task_id: task,
|
||||
complete_task=complete_task,
|
||||
reclaim_task=reclaim_task,
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "hermes_cli", SimpleNamespace(kanban_db=fake_db))
|
||||
|
||||
__all__ = [
|
||||
'FLUX_HERMES',
|
||||
'HERMES',
|
||||
'KEYCLOAK',
|
||||
'Path',
|
||||
'SCRIPTS',
|
||||
'SimpleNamespace',
|
||||
'_SwitchyardResponse',
|
||||
'_agent_deployment',
|
||||
'_completed_result',
|
||||
'_install_terminal_recovery_db',
|
||||
'_load',
|
||||
'_oauth_deployment',
|
||||
'_pending_terminal_record',
|
||||
'_services',
|
||||
'auth_patch',
|
||||
'client_config',
|
||||
'codex_runtime_patch',
|
||||
'errno',
|
||||
'hashlib',
|
||||
'importlib',
|
||||
'json',
|
||||
'lanes',
|
||||
'migration',
|
||||
'nullcontext',
|
||||
'os',
|
||||
'policy',
|
||||
'pytest',
|
||||
'signal',
|
||||
'stat',
|
||||
'sys',
|
||||
'threading',
|
||||
'time',
|
||||
'ttyd_patch',
|
||||
'tui_gateway_patch',
|
||||
'yaml',
|
||||
]
|
||||
204
testing/tests/test_hermes_coordinator_boards.py
Normal file
204
testing/tests/test_hermes_coordinator_boards.py
Normal file
@ -0,0 +1,204 @@
|
||||
"""Hermes coordinator board recovery contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from test_hermes_coordinator_support import (
|
||||
Path,
|
||||
_base_config,
|
||||
coordinator,
|
||||
pytest,
|
||||
routing,
|
||||
subprocess,
|
||||
yaml,
|
||||
)
|
||||
|
||||
|
||||
def test_refresh_writes_non_secret_routing_status(tmp_path: Path, monkeypatch):
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump(_base_config()), encoding="utf-8"
|
||||
)
|
||||
(tmp_path / ".env").write_text("GITEA_TOKEN=do-not-report\n", encoding="utf-8")
|
||||
codex = routing.Catalog("openai-codex", ["gpt-5.6-terra"], True, True, "connected")
|
||||
claude = routing.Catalog("anthropic", ["claude-opus-5"], True, True, "connected")
|
||||
monkeypatch.setattr(coordinator, "discover_codex_models", lambda: codex)
|
||||
monkeypatch.setattr(coordinator, "discover_claude_models", lambda: claude)
|
||||
monkeypatch.setattr(
|
||||
coordinator, "bootstrap_cassandra_state", lambda root: {"state": "ready"}
|
||||
)
|
||||
monkeypatch.setattr(coordinator, "sync_cassandra_repo", lambda env: "ready")
|
||||
|
||||
status = coordinator.refresh_once(tmp_path)
|
||||
|
||||
status_path = tmp_path / "workspace/coordinator/model-routing.json"
|
||||
assert status_path.is_file()
|
||||
assert status["projects"]["cassandra"]["state"] == "ready"
|
||||
assert status["projects"]["cassandra"]["board_state"] == {"state": "ready"}
|
||||
assert "do-not-report" not in status_path.read_text(encoding="utf-8")
|
||||
|
||||
def test_corrupt_cassandra_board_is_preserved_without_blocking_refresh(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""A damaged project board degrades Kanban instead of stopping all services."""
|
||||
|
||||
class FakeKanbanDbCorruptError(Exception):
|
||||
def __init__(self):
|
||||
super().__init__("integrity check failed")
|
||||
self.db_path = tmp_path / "kanban.db"
|
||||
self.backup_path = tmp_path / "kanban.db.corrupt.backup"
|
||||
self.reason = "row out of order"
|
||||
|
||||
def raise_corruption(_root: Path) -> None:
|
||||
raise FakeKanbanDbCorruptError()
|
||||
|
||||
monkeypatch.setattr(coordinator, "bootstrap_cassandra", raise_corruption)
|
||||
monkeypatch.setattr(
|
||||
coordinator, "_kanban_corruption_type", lambda: FakeKanbanDbCorruptError
|
||||
)
|
||||
|
||||
status = coordinator.bootstrap_cassandra_state(tmp_path)
|
||||
|
||||
assert status == {
|
||||
"state": "corrupt-preserved",
|
||||
"database": str(tmp_path / "kanban.db"),
|
||||
"backup": str(tmp_path / "kanban.db.corrupt.backup"),
|
||||
"reason": "row out of order",
|
||||
}
|
||||
|
||||
def test_unrelated_cassandra_bootstrap_failure_remains_fatal(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""Only the known fail-closed corruption state may be degraded."""
|
||||
|
||||
class FakeKanbanDbCorruptError(Exception):
|
||||
pass
|
||||
|
||||
def raise_permission_error(_root: Path) -> None:
|
||||
raise PermissionError("cannot access board")
|
||||
|
||||
monkeypatch.setattr(coordinator, "bootstrap_cassandra", raise_permission_error)
|
||||
monkeypatch.setattr(
|
||||
coordinator, "_kanban_corruption_type", lambda: FakeKanbanDbCorruptError
|
||||
)
|
||||
|
||||
try:
|
||||
coordinator.bootstrap_cassandra_state(tmp_path)
|
||||
except PermissionError as error:
|
||||
assert str(error) == "cannot access board"
|
||||
else:
|
||||
raise AssertionError("unrelated board errors must fail the coordinator refresh")
|
||||
|
||||
def test_cassandra_workspace_uses_valid_configured_worktree(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
"""The coordinator selects an explicit Git worktree but rejects stale paths."""
|
||||
active = tmp_path / "cassandra-v69"
|
||||
active.mkdir()
|
||||
(active / ".git").write_text("gitdir: elsewhere\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_CASSANDRA_ACTIVE_WORKTREE", str(active))
|
||||
|
||||
assert coordinator.cassandra_workspace() == active
|
||||
|
||||
(active / ".git").unlink()
|
||||
assert coordinator.cassandra_workspace() == coordinator.CASSANDRA_BASE_PATH
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_remote", "repair_action"),
|
||||
[
|
||||
("https://scm.bstein.dev/bstein/cassandra.git", "set-url"),
|
||||
(None, "add"),
|
||||
(coordinator.CASSANDRA_REMOTE, None),
|
||||
],
|
||||
ids=["old-origin", "missing-origin", "canonical-origin"],
|
||||
)
|
||||
def test_cassandra_sync_repairs_existing_worktree(
|
||||
tmp_path: Path, monkeypatch, current_remote: str | None, repair_action: str | None
|
||||
):
|
||||
"""Existing worktrees use the canonical Atlas origin before fetching."""
|
||||
workspace = tmp_path / "cassandra"
|
||||
(workspace / ".git").mkdir(parents=True)
|
||||
monkeypatch.setattr(coordinator, "CASSANDRA_BASE_PATH", workspace)
|
||||
monkeypatch.setattr(coordinator.shutil, "which", lambda _name: "/usr/bin/git")
|
||||
token_path = tmp_path / "gitea-token"
|
||||
token_path.write_text("configured\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_GITEA_TOKEN_FILE", str(token_path))
|
||||
commands: list[list[str]] = []
|
||||
environments: list[dict[str, str]] = []
|
||||
|
||||
def run(command, **kwargs):
|
||||
commands.append(command)
|
||||
environments.append(kwargs.get("env", {}))
|
||||
if command[-2:] == ["get-url", "origin"]:
|
||||
return subprocess.CompletedProcess(
|
||||
command, 0 if current_remote else 2, stdout=f"{current_remote or ''}\n"
|
||||
)
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
|
||||
monkeypatch.setattr(coordinator.subprocess, "run", run)
|
||||
assert coordinator.sync_cassandra_repo({"GITEA_TOKEN": "must-not-pass"}) == "ready"
|
||||
repair_commands = [command for command in commands if repair_action in command]
|
||||
assert bool(repair_commands) is bool(repair_action)
|
||||
assert commands[-1][-4:] == ["fetch", "--quiet", "--prune", "origin"]
|
||||
assert all("GITEA_TOKEN" not in environment for environment in environments)
|
||||
|
||||
def test_cassandra_sync_repairs_origin_without_token(tmp_path: Path, monkeypatch):
|
||||
"""Missing credentials skip only the fetch, not the local origin repair."""
|
||||
workspace = tmp_path / "cassandra"
|
||||
(workspace / ".git").mkdir(parents=True)
|
||||
monkeypatch.setattr(coordinator, "CASSANDRA_BASE_PATH", workspace)
|
||||
monkeypatch.setattr(coordinator.shutil, "which", lambda _name: "/usr/bin/git")
|
||||
commands: list[list[str]] = []
|
||||
|
||||
def run(command, **_kwargs):
|
||||
commands.append(command)
|
||||
stdout = "old-origin\n" if command[-2:] == ["get-url", "origin"] else None
|
||||
return subprocess.CompletedProcess(command, 0, stdout=stdout)
|
||||
|
||||
monkeypatch.setattr(coordinator.subprocess, "run", run)
|
||||
state = coordinator.sync_cassandra_repo({})
|
||||
|
||||
assert state == "ready; fetch skipped until Gitea token is configured"
|
||||
assert [command[-3] for command in commands] == ["remote", "set-url"]
|
||||
|
||||
def test_migrate_open_cassandra_tasks_preserves_running_and_done_tasks():
|
||||
"""A project switch moves queued work without relocating active evidence."""
|
||||
|
||||
class Task:
|
||||
def __init__(self, task_id: str, status: str):
|
||||
self.id = task_id
|
||||
self.status = status
|
||||
self.workspace_kind = "dir"
|
||||
self.workspace_path = str(coordinator.CASSANDRA_BASE_PATH)
|
||||
|
||||
class Closing:
|
||||
def __enter__(self):
|
||||
return object()
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return None
|
||||
|
||||
class FakeKanban:
|
||||
tasks = [
|
||||
Task("queued", "todo"),
|
||||
Task("active", "running"),
|
||||
Task("done", "done"),
|
||||
]
|
||||
moved: list[tuple[str, str]] = []
|
||||
|
||||
@staticmethod
|
||||
def connect_closing(*, board: str):
|
||||
assert board == "cassandra"
|
||||
return Closing()
|
||||
|
||||
@classmethod
|
||||
def list_tasks(cls, _connection, *, include_archived: bool):
|
||||
assert include_archived is False
|
||||
return cls.tasks
|
||||
|
||||
@classmethod
|
||||
def set_workspace_path(cls, _connection, task_id: str, path: Path):
|
||||
cls.moved.append((task_id, str(path)))
|
||||
|
||||
active = Path("/opt/data/workspace/projects/cassandra-hermes-v69")
|
||||
coordinator._migrate_open_cassandra_tasks(FakeKanban, active)
|
||||
|
||||
assert FakeKanban.moved == [("queued", str(active))]
|
||||
@ -1,4 +1,4 @@
|
||||
"""Unit tests for Hermes coordinator model routing and profile generation."""
|
||||
"""Hermes coordinator model routing contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -36,12 +36,10 @@ def test_model_version_and_quality_selection_handle_new_and_small_models():
|
||||
assert routing.choose_claude_for_effort(claude, "medium") == "claude-sonnet-5"
|
||||
assert routing.choose_claude_for_effort(claude, "xhigh") == "claude-opus-4.8"
|
||||
|
||||
|
||||
def test_empty_catalog_retains_current_models():
|
||||
assert routing.choose_codex_model([], "gpt-5.6-terra") == "gpt-5.6-terra"
|
||||
assert routing.choose_claude_model([], "claude-opus-5") == "claude-opus-5"
|
||||
|
||||
|
||||
def test_dynamic_catalog_resolves_new_models_and_preserves_last_known_good():
|
||||
"""Stable Switchyard aliases follow live releases and survive catalog outages."""
|
||||
codex = routing.Catalog(
|
||||
@ -88,7 +86,6 @@ def test_dynamic_catalog_resolves_new_models_and_preserves_last_known_good():
|
||||
== current["providers"]["claude"]["tiers"]
|
||||
)
|
||||
|
||||
|
||||
def test_live_catalog_never_routes_to_a_removed_model_tier():
|
||||
"""A live provider catalog must replace a retired tier with a live model."""
|
||||
previous = {
|
||||
@ -120,7 +117,6 @@ def test_live_catalog_never_routes_to_a_removed_model_tier():
|
||||
assert current["providers"]["codex"]["tiers"]["luna"] in codex.models
|
||||
assert current["providers"]["claude"]["tiers"]["opus"] == "claude-sonnet-6"
|
||||
|
||||
|
||||
def test_switchyard_targets_have_unique_upstream_identities():
|
||||
"""Switchyard drops duplicate client/model pairs, so reject them in Git."""
|
||||
manifest = yaml.safe_load(
|
||||
@ -139,7 +135,6 @@ def test_switchyard_targets_have_unique_upstream_identities():
|
||||
if route.get("target"):
|
||||
assert route["target"] in target_names
|
||||
|
||||
|
||||
def test_worker_alias_resolves_before_cli_launch():
|
||||
document = {
|
||||
"providers": {
|
||||
@ -154,7 +149,6 @@ def test_worker_alias_resolves_before_cli_launch():
|
||||
== "worker/codex/gpt-5.8-sol/high"
|
||||
)
|
||||
|
||||
|
||||
def test_codex_cli_login_counts_as_connected_runtime(monkeypatch):
|
||||
"""AUTO routing must recognize the authenticated app-server CLI lane."""
|
||||
monkeypatch.setattr(routing.shutil, "which", lambda name: "/usr/bin/codex")
|
||||
@ -171,7 +165,6 @@ def test_codex_cli_login_counts_as_connected_runtime(monkeypatch):
|
||||
|
||||
assert routing.codex_cli_authenticated() is True
|
||||
|
||||
|
||||
def test_configure_routes_keeps_every_profile_on_switchyard(tmp_path: Path):
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump(_base_config()), encoding="utf-8"
|
||||
@ -246,7 +239,6 @@ def test_configure_routes_keeps_every_profile_on_switchyard(tmp_path: Path):
|
||||
assert "claude-secret" not in json.dumps(routes)
|
||||
assert (tmp_path / "profiles/codex-high/.env").stat().st_mode & 0o777 == 0o600
|
||||
|
||||
|
||||
def test_degraded_catalog_does_not_replace_switchyard_authority(tmp_path: Path):
|
||||
base = _base_config()
|
||||
base["model"]["model"] = base["model"]["default"] = "gpt-5.6-sol"
|
||||
@ -262,7 +254,6 @@ def test_degraded_catalog_does_not_replace_switchyard_authority(tmp_path: Path):
|
||||
assert current["model"]["provider"] == "atlas-switchyard"
|
||||
assert current["fallback_providers"] == []
|
||||
|
||||
|
||||
def test_degraded_refresh_preserves_switchyard_worker_preference(tmp_path: Path):
|
||||
"""A catalog outage must not bypass a managed worker's Switchyard route."""
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
@ -287,8 +278,6 @@ def test_degraded_refresh_preserves_switchyard_worker_preference(tmp_path: Path)
|
||||
)
|
||||
assert worker["model"]["model"] == "atlas/manual/codex/sol"
|
||||
assert worker["model"]["provider"] == "atlas-switchyard"
|
||||
|
||||
|
||||
def test_refresh_writes_non_secret_routing_status(tmp_path: Path, monkeypatch):
|
||||
(tmp_path / "config.yaml").write_text(
|
||||
yaml.safe_dump(_base_config()), encoding="utf-8"
|
||||
@ -4,8 +4,13 @@ from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
SCRIPT = Path(__file__).parents[2] / "services/hermes/scripts/hermes_coordinator.py"
|
||||
sys.path.insert(0, str(SCRIPT.parent))
|
||||
@ -17,7 +22,6 @@ coordinator = importlib.util.module_from_spec(SPEC)
|
||||
sys.modules[SPEC.name] = coordinator
|
||||
SPEC.loader.exec_module(coordinator)
|
||||
|
||||
|
||||
def _base_config() -> dict:
|
||||
"""Return the minimal coordinator configuration used by routing tests."""
|
||||
return {
|
||||
@ -33,3 +37,19 @@ def _base_config() -> dict:
|
||||
"toolsets": ["kanban"],
|
||||
"terminal": {"cwd": "/opt/data/workspace"},
|
||||
}
|
||||
__all__ = (
|
||||
"Path",
|
||||
"SCRIPT",
|
||||
"SPEC",
|
||||
"_base_config",
|
||||
"catalog_resolver",
|
||||
"coordinator",
|
||||
"importlib",
|
||||
"json",
|
||||
"pytest",
|
||||
"routing",
|
||||
"subprocess",
|
||||
"sys",
|
||||
"tomllib",
|
||||
"yaml",
|
||||
)
|
||||
|
||||
@ -42,7 +42,7 @@ class FakeKanban:
|
||||
self.tasks = tasks
|
||||
self.has_board = board_exists
|
||||
self.connection = FakeConnection()
|
||||
self.reclaimed: list[tuple[str, str]] = []
|
||||
self.reclaimed: list[tuple[str, str, int]] = []
|
||||
self.comments: list[tuple[str, str, str]] = []
|
||||
|
||||
def board_exists(self, board: str) -> bool:
|
||||
@ -61,9 +61,16 @@ class FakeKanban:
|
||||
assert connection is self.connection
|
||||
return self.tasks
|
||||
|
||||
def reclaim_task(self, connection, task_id: str, *, reason: str) -> bool:
|
||||
def reclaim_task(
|
||||
self,
|
||||
connection,
|
||||
task_id: str,
|
||||
*,
|
||||
reason: str,
|
||||
expected_run_id: int,
|
||||
) -> bool:
|
||||
assert connection is self.connection
|
||||
self.reclaimed.append((task_id, reason))
|
||||
self.reclaimed.append((task_id, reason, expected_run_id))
|
||||
return task_id != "t_race"
|
||||
|
||||
def add_comment(self, connection, task_id: str, author: str, body: str) -> None:
|
||||
@ -75,16 +82,17 @@ def test_recover_running_tasks_requeues_only_claimed_workers() -> None:
|
||||
module = _load_module()
|
||||
kanban = FakeKanban(
|
||||
[
|
||||
SimpleNamespace(id="t_running", status="running"),
|
||||
SimpleNamespace(id="t_running", status="running", current_run_id=10),
|
||||
SimpleNamespace(id="t_done", status="done"),
|
||||
SimpleNamespace(id="t_race", status="running"),
|
||||
SimpleNamespace(id="t_race", status="running", current_run_id=11),
|
||||
SimpleNamespace(id="t_missing_run", status="running", current_run_id=None),
|
||||
]
|
||||
)
|
||||
|
||||
assert module.recover_running_tasks(kanban) == ["t_running"]
|
||||
assert kanban.reclaimed == [
|
||||
("t_running", module.REASON),
|
||||
("t_race", module.REASON),
|
||||
("t_running", module.REASON, 10),
|
||||
("t_race", module.REASON, 11),
|
||||
]
|
||||
assert kanban.comments == [
|
||||
(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user