129 lines
9.0 KiB
Python
129 lines
9.0 KiB
Python
from __future__ import annotations
|
|
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
from agent_pull_runner import AgentPullRunner, JsonStateStore, RunnerConfig, RunnerError
|
|
from autonomy_notification_plane import DurableOutbox, NotificationPlane
|
|
from governed_merge_gate import MergeGateDenied, validate_protected_merge
|
|
from reconciliation_controller import ReconciliationController
|
|
from recurring_task_scheduler import RecurringTaskError, RecurringTaskScheduler
|
|
from release_state_machine import InvalidTransition, ReleaseState, ReleaseStateMachine
|
|
|
|
|
|
class Transport:
|
|
def __init__(self, task=None): self.task = task; self.calls = []
|
|
def register_worker(self, worker_id, capabilities): self.calls.append("register"); return {"status": "registered"}
|
|
def heartbeat(self, worker_id): self.calls.append("heartbeat"); return {"status": "alive"}
|
|
def long_poll(self, worker_id, timeout_seconds): self.calls.append(("long_poll", timeout_seconds)); return self.task
|
|
def poll(self, worker_id): self.calls.append("poll"); return self.task
|
|
def claim(self, worker_id, task): self.calls.append("claim"); return {**task, "lease_id": "lease-1"}
|
|
def renew(self, worker_id, task_id): self.calls.append("renew"); return {"status": "renewed", "task_id": task_id}
|
|
def report_progress(self, worker_id, task_id, payload): self.calls.append("progress"); return {"status": "ok"}
|
|
def report_result(self, worker_id, task_id, payload): self.calls.append("result"); return {"status": "ok", "task_id": task_id}
|
|
def report_evidence(self, worker_id, task_id, payload): self.calls.append("evidence"); return {"status": "ok"}
|
|
|
|
|
|
class RunnerTests(unittest.TestCase):
|
|
def test_registration_heartbeat_and_long_poll(self):
|
|
t = Transport({"task_id": "t1"}); r = AgentPullRunner(RunnerConfig("w1", fallback_seconds=60), t)
|
|
self.assertEqual(r.register()["status"], "registered"); self.assertEqual(r.heartbeat()["status"], "alive")
|
|
self.assertEqual(r.discover_once()["lease_id"], "lease-1"); self.assertIn(("long_poll", 30), t.calls)
|
|
|
|
def test_fallback_poll_after_long_poll_failure(self):
|
|
class Broken(Transport):
|
|
def long_poll(self, worker_id, timeout_seconds): raise TimeoutError
|
|
t = Broken({"task_id": "t1"}); r = AgentPullRunner(RunnerConfig("w1"), t); r.discover_once(); self.assertIn("poll", t.calls)
|
|
|
|
def test_jitter_and_bounded_backoff(self):
|
|
r = AgentPullRunner(RunnerConfig("w1", fallback_seconds=60, jitter_seconds=5, max_backoff_seconds=100), Transport(), random_fn=lambda: 1.0)
|
|
self.assertEqual(r.discovery_delay(0), 65); self.assertEqual(r.discovery_delay(8), 100)
|
|
|
|
def test_claim_lease_renew_progress_result_and_evidence(self):
|
|
t = Transport({"task_id": "t1"}); r = AgentPullRunner(RunnerConfig("w1"), t); r.discover_once(); r.renew("t1"); r.report_progress("t1", {"percent": 50}); r.report_evidence("t1", {"sha256": "a"}); r.report_result("t1", {"ok": True}); self.assertNotIn("t1", r.state.inflight)
|
|
|
|
def test_duplicate_terminal_is_suppressed(self):
|
|
t = Transport({"task_id": "t1"}); r = AgentPullRunner(RunnerConfig("w1"), t); r.discover_once(); r.report_result("t1", {"ok": True}); before = list(t.calls); self.assertEqual(r.report_result("t1", {"ok": True})["status"], "duplicate_terminal_suppressed"); self.assertEqual(before, t.calls)
|
|
|
|
def test_restart_recovery_renews_inflight(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
store = JsonStateStore(Path(d) / "runner.json"); t = Transport({"task_id": "t1"}); r = AgentPullRunner(RunnerConfig("w1"), t, store); r.discover_once(); r2 = AgentPullRunner(RunnerConfig("w1"), t, store); r2.recover_after_restart(); self.assertIn("renew", t.calls)
|
|
|
|
def test_executor_is_argv_only_and_allowlisted(self):
|
|
r = AgentPullRunner(RunnerConfig("w1", executable_allowlist=("/usr/local/bin",), working_directory_allowlist=("/tmp",)), Transport())
|
|
with self.assertRaises(RunnerError): r.validate_execution(["echo", "x"], "/tmp", {})
|
|
with self.assertRaises(RunnerError): r.validate_execution(["/bin/sh", "-c", "echo x"], "/tmp", {})
|
|
|
|
|
|
class NotificationTests(unittest.TestCase):
|
|
def test_durable_owner_and_gpt_outbox(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
outbox = DurableOutbox(Path(d) / "outbox.sqlite3"); plane = NotificationPlane(outbox); ids = plane.emit("task_started", {"task_id": "t1"}, event_id="e1"); self.assertEqual(len(ids), 2); self.assertEqual({e.recipient for e in outbox.due()}, {"owner", "gpt"}); outbox.close()
|
|
|
|
def test_delivery_retry_and_ack(self):
|
|
with tempfile.TemporaryDirectory() as d:
|
|
outbox = DurableOutbox(Path(d) / "outbox.sqlite3")
|
|
try:
|
|
plane = NotificationPlane(outbox); plane.emit("failed", {}, event_id="e2"); attempts = [0]
|
|
def send(event):
|
|
attempts[0] += 1
|
|
if attempts[0] == 1: raise RuntimeError("temporary")
|
|
now = time.time(); plane.deliver_once(send, now=now); self.assertEqual(outbox.due(now=now), []); self.assertEqual(outbox.due(now=now + 3)[0].attempts, 1); plane.deliver_once(send, now=now + 3); self.assertEqual(outbox.due(now=now + 3), [])
|
|
finally:
|
|
outbox.close()
|
|
|
|
|
|
class RecurringTests(unittest.TestCase):
|
|
def test_next_run_count_and_max_runs(self):
|
|
now = datetime(2026, 1, 1, tzinfo=timezone.utc); s = RecurringTaskScheduler(); task = s.schedule("t", 60, now=now, max_runs=2); self.assertEqual(len(s.due(now=now)), 1); s.mark_run("t", now=now); self.assertEqual(task.run_count, 1); self.assertEqual(s.due(now=now), []); s.mark_run("t", now=now + timedelta(seconds=60)); self.assertEqual(s.due(now=now + timedelta(seconds=120)), [])
|
|
|
|
def test_cancellation_and_idempotent_successor(self):
|
|
s = RecurringTaskScheduler(); s.schedule("t", 10); s.cancel("t"); self.assertEqual(s.due(), []); a = s.create_successor_once("t", "next", interval_seconds=10); b = s.create_successor_once("t", "next", interval_seconds=10); self.assertIs(a, b)
|
|
|
|
def test_bounds(self):
|
|
with self.assertRaises(RecurringTaskError): RecurringTaskScheduler().schedule("t", 0)
|
|
|
|
|
|
class StateAndReconciliationTests(unittest.TestCase):
|
|
def test_valid_state_sequence(self):
|
|
m = ReleaseStateMachine();
|
|
for state in (ReleaseState.TESTED, ReleaseState.PR_OPEN, ReleaseState.PR_APPROVED, ReleaseState.MERGED, ReleaseState.ARTIFACT_VERIFIED, ReleaseState.RELEASE_APPROVED, ReleaseState.TAG_VERIFIED): m.transition(state)
|
|
self.assertEqual(m.state, ReleaseState.TAG_VERIFIED)
|
|
|
|
def test_invalid_shortcut_and_production_fail_closed(self):
|
|
with self.assertRaises(InvalidTransition): ReleaseStateMachine().transition(ReleaseState.PRODUCTION_ACCEPTED)
|
|
m = ReleaseStateMachine(ReleaseState.DEPLOYED)
|
|
with self.assertRaises(InvalidTransition): m.transition(ReleaseState.PRODUCTION_ACCEPTED, {"tag_verified": True, "remote_tag_readback": True, "service_health": False})
|
|
|
|
def test_reconciliation_reports_all_drift_actions(self):
|
|
report = ReconciliationController().reconcile({"authority_main": "a", "gitea_main": "b", "local_tag_oid": "t", "remote_tag_oid": "t", "remote_peeled_target": "x", "approved_target_oid": "y", "registry_version": "V2", "baseline_version": "V1", "approved_artifact_digest": "d", "deployed_artifact_digest": "d", "declared_production_state": True, "service_healthy": False})
|
|
self.assertFalse(report.healthy); self.assertIn("main_drift", report.drift); self.assertIn("notify_owner", report.actions)
|
|
|
|
|
|
class MergeGateTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.pr = {"state": "open", "mergeable": True, "user": {"login": "codex"}}
|
|
self.reviews = [{"id": 9, "state": "APPROVED", "user": {"login": "owner", "id": 2}}]
|
|
self.checks = {name: "success" for name in ("autonomy-unit-tests", "secret-scan")}
|
|
|
|
def test_owner_review_and_checks_allow(self):
|
|
decision = validate_protected_merge(self.pr, self.reviews, self.checks, required_contexts=tuple(self.checks), unresolved_conversations=0); self.assertTrue(decision.allowed)
|
|
|
|
def test_owner_review_required(self):
|
|
with self.assertRaises(MergeGateDenied): validate_protected_merge(self.pr, [], self.checks, required_contexts=tuple(self.checks), unresolved_conversations=0)
|
|
|
|
def test_unresolved_conversations_and_missing_checks_deny(self):
|
|
with self.assertRaises(MergeGateDenied): validate_protected_merge(self.pr, self.reviews, self.checks, required_contexts=tuple(self.checks), unresolved_conversations=1)
|
|
with self.assertRaises(MergeGateDenied): validate_protected_merge(self.pr, self.reviews, {}, required_contexts=tuple(self.checks), unresolved_conversations=0)
|
|
|
|
def test_self_approval_denied(self):
|
|
pr = {**self.pr, "user": {"login": "owner"}}
|
|
with self.assertRaises(MergeGateDenied): validate_protected_merge(pr, self.reviews, self.checks, required_contexts=tuple(self.checks), unresolved_conversations=0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|