CI Pipeline / best-practices (push) Successful in 8s
CI Pipeline / documentation (push) Successful in 8s
CI Pipeline / lint (push) Failing after 29s
CI Pipeline / gitea-offline (push) Successful in 18s
CI Pipeline / type-check (push) Successful in 33s
CI Pipeline / lint (pull_request) Failing after 31s
CI Pipeline / best-practices (pull_request) Successful in 7s
CI Pipeline / unit-tests (pull_request) Failing after 37s
CI Pipeline / documentation (pull_request) Successful in 8s
CI Pipeline / canary (pull_request) Successful in 33s
CI Pipeline / gitea-offline (pull_request) Successful in 18s
CI Pipeline / type-check (pull_request) Successful in 34s
Spec gate (headless) / spec-gate (pull_request) Successful in 17s
deploy / deploy (push) Skipped
CI Pipeline / required-checks (pull_request) Failing after 2s
CI Pipeline / CI Pipeline / notify-event-bridge (pull_request) Successful in 8s
CI Pipeline / canary (push) Successful in 32s
CI Pipeline / unit-tests (push) Successful in 4m46s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Successful in 6s
CI lint's format-check on added files (base-range dependent) failed on files that predate the format gate's effective coverage. Formatting-only: no semantic changes; integration tests 15/15, ruff check clean, ratchet PASS, daemon convergence re-verified.
240 lines
8.3 KiB
Python
240 lines
8.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Frozen tests for REAL_ASYNC_BRIDGE final integration (§2-§5, §9).
|
|
|
|
State ownership (§2/§3):
|
|
- self-resuming waiters register NO resume_cmd (no daemon delivery role)
|
|
- a terminal operation state is never overwritten by session delivery
|
|
Orphan recovery (§4):
|
|
- RESUMING waits past the orphan grace are reconciled truthfully and
|
|
their leases released (failure detection, deterministic)
|
|
CLI contract (§5):
|
|
- every flag is supported or loudly rejected (exit 2) — no silent loss
|
|
Task binding (§9):
|
|
- task-bound resolution scopes to the bound operation; stored state
|
|
wins over model hints
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest import mock
|
|
|
|
REPO = Path(__file__).resolve().parent.parent
|
|
SCRIPTS = REPO / "scripts"
|
|
DAEMON_SRC = REPO / "bridge" / "daemon"
|
|
for p in (str(SCRIPTS), str(DAEMON_SRC), str(REPO / "src")):
|
|
if p not in sys.path:
|
|
sys.path.insert(0, p)
|
|
|
|
|
|
class _IsolatedRoot(unittest.TestCase):
|
|
"""Each test gets an isolated WR_ROOT + reloaded modules."""
|
|
|
|
def setUp(self):
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
root = Path(self._tmp.name) / "wr"
|
|
for sub in ("waits", "events", "leases", "resumes"):
|
|
(root / sub).mkdir(parents=True)
|
|
self.root = root
|
|
self._env = {"WR_ROOT": str(root), "RUNE_WR_ROOT": str(root)}
|
|
self._patcher = mock.patch.dict(os.environ, self._env, clear=False)
|
|
self._patcher.start()
|
|
|
|
def tearDown(self):
|
|
self._patcher.stop()
|
|
self._tmp.cleanup()
|
|
|
|
def _waitstore(self):
|
|
import waitstore
|
|
|
|
importlib.reload(waitstore)
|
|
return waitstore
|
|
|
|
def _daemon(self):
|
|
# daemon caches waitstore at import; reload AFTER waitstore so its
|
|
# binding follows this test's isolated root
|
|
import daemon as wrd
|
|
|
|
importlib.reload(wrd)
|
|
return wrd
|
|
|
|
def _new_wait(self, ws, *, session_id, status, task_id="", resume_cmd=None, stale_secs=0, sha="ab" * 20, **kw):
|
|
rec = ws.new_wait(
|
|
session_id=session_id,
|
|
provider="gitea",
|
|
event_type="ci_completed",
|
|
repo="conductor/rune",
|
|
sha=sha,
|
|
run_ref=f"ci-{sha}",
|
|
target_kind="headless",
|
|
**kw,
|
|
)
|
|
rec["status"] = status
|
|
rec["ext"] = {"operation_id": rec["run_ref"], "task_id": task_id}
|
|
if resume_cmd is not None:
|
|
rec["resume_cmd"] = resume_cmd
|
|
if stale_secs:
|
|
rec["last_updated"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - stale_secs))
|
|
ws.save(rec)
|
|
return rec
|
|
|
|
|
|
class TestResumeModeSelf(_IsolatedRoot):
|
|
"""§3: self-resuming waiters own their continuation."""
|
|
|
|
def test_self_mode_registers_no_resume_cmd(self):
|
|
import importlib as il
|
|
|
|
os.environ["RUNE_WR_CODE"] = str(DAEMON_SRC)
|
|
import rune.event_wait as ew
|
|
|
|
il.reload(ew)
|
|
rec = ew.register_process_wait("ci-selftest0123", project="rune", resume_mode="self")
|
|
self.assertIsNone(rec.get("resume_cmd"), "self-mode must not register a delivery worker")
|
|
|
|
def test_default_mode_preserves_fifo_semantics(self):
|
|
import importlib as il
|
|
|
|
os.environ["RUNE_WR_CODE"] = str(DAEMON_SRC)
|
|
import rune.event_wait as ew
|
|
|
|
il.reload(ew)
|
|
rec = ew.register_process_wait("ci-fifotest01234")
|
|
self.assertIsNotNone(rec.get("resume_cmd"), "T15 builder semantics must be preserved")
|
|
|
|
|
|
class TestTerminalGuard(_IsolatedRoot):
|
|
"""§2: session delivery never overwrites terminal operation truth."""
|
|
|
|
def test_resume_skips_terminal_wait(self):
|
|
ws = self._waitstore()
|
|
wrd = self._daemon()
|
|
rec = self._new_wait(ws, session_id="s-term", status="COMPLETED", resume_cmd=["/bin/true"])
|
|
rec["verdict"] = "success"
|
|
ws.save(rec)
|
|
wrd.resume(rec)
|
|
fresh = ws.load(rec["wait_id"])
|
|
self.assertEqual(fresh["status"], "COMPLETED", "terminal state was downgraded by delivery path")
|
|
leases = list((self.root / "leases").glob("*.json"))
|
|
self.assertEqual(leases, [], "lease created for terminal wait")
|
|
|
|
|
|
class TestOrphanSweep(_IsolatedRoot):
|
|
"""§4: bounded mid-run reconciliation of orphaned resume flows."""
|
|
|
|
def test_stale_resuming_with_dead_owner_recovered(self):
|
|
ws = self._waitstore()
|
|
wrd = self._daemon()
|
|
wrd.ORPHAN_GRACE_SECS = 5 # deterministic, small
|
|
rec = self._new_wait(ws, session_id="s-orphan", status="RESUMING", resume_cmd=["/bin/true"])
|
|
rec["resume_attempts"] = 1
|
|
ws.save(rec)
|
|
# backdate AFTER save: save() stamps last_updated=now
|
|
stale = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(time.time() - 60))
|
|
rec["last_updated"] = stale
|
|
(ws.WAITS / f"{rec['wait_id']}.json").write_text(json.dumps(rec))
|
|
# dead-owner lease
|
|
lease = {
|
|
"session_id": "s-orphan",
|
|
"wait_id": rec["wait_id"],
|
|
"pid": 2**22,
|
|
"pgid": 2**22,
|
|
"pid_starttime": "",
|
|
"acquired_at": "2020-01-01T00:00:00Z",
|
|
}
|
|
ws.lease_path("s-orphan").write_text(json.dumps(lease))
|
|
healed = wrd.orphan_sweep()
|
|
self.assertEqual(healed, 1)
|
|
fresh = ws.load(rec["wait_id"])
|
|
self.assertIn(fresh["status"], ("READY_TO_RETRY", "RESUME_UNVERIFIED"))
|
|
self.assertIn("orphaned mid-run", fresh.get("resume_note", ""))
|
|
self.assertFalse(ws.lease_path("s-orphan").exists())
|
|
|
|
def test_fresh_resuming_untouched(self):
|
|
ws = self._waitstore()
|
|
wrd = self._daemon()
|
|
wrd.ORPHAN_GRACE_SECS = 3600
|
|
rec = self._new_wait(ws, session_id="s-live", status="RESUMING", resume_cmd=["/bin/true"])
|
|
self.assertEqual(wrd.orphan_sweep(), 0)
|
|
self.assertEqual(ws.load(rec["wait_id"])["status"], "RESUMING")
|
|
|
|
def _waitstore(self):
|
|
import waitstore
|
|
|
|
importlib.reload(waitstore)
|
|
return waitstore
|
|
|
|
|
|
class TestCliContract(unittest.TestCase):
|
|
"""§5: no silent argument loss."""
|
|
|
|
def test_unknown_flag_rejected_loudly(self):
|
|
import ci_event_wait as cew
|
|
|
|
with mock.patch.object(sys, "argv", ["ci_event_wait.py", "--sha", "a" * 40, "--watchdoog", "5"]):
|
|
rc = cew.main()
|
|
self.assertEqual(rc, 2, "unknown flag must exit 2, not be ignored")
|
|
|
|
def test_positional_rejected_loudly(self):
|
|
import ci_event_wait as cew
|
|
|
|
with mock.patch.object(sys, "argv", ["ci_event_wait.py", "extra"]):
|
|
rc = cew.main()
|
|
self.assertEqual(rc, 2)
|
|
|
|
def test_watchdog_flag_reaches_run(self):
|
|
import ci_event_wait as cew
|
|
|
|
with (
|
|
mock.patch.object(cew, "run", return_value=0) as run_mock,
|
|
mock.patch.object(sys, "argv", ["ci_event_wait.py", "--sha", "a" * 40, "--watchdog", "123"]),
|
|
):
|
|
rc = cew.main()
|
|
self.assertEqual(rc, 0)
|
|
self.assertEqual(run_mock.call_args.kwargs.get("watchdog_secs"), 123)
|
|
|
|
|
|
class TestTaskBinding(_IsolatedRoot):
|
|
"""§9: task-bound resolution; stored operation wins."""
|
|
|
|
def _bind_env(self):
|
|
import await_intent as ai
|
|
|
|
importlib.reload(ai)
|
|
return ai
|
|
|
|
def test_task_bound_operation_resolved(self):
|
|
ws = self._waitstore()
|
|
self._new_wait(ws, session_id="s-stale", status="WAITING_EXTERNAL")
|
|
bound = self._new_wait(ws, session_id="s-bound", status="WAITING_EXTERNAL", task_id="T9", sha="cd" * 20)
|
|
ai = self._bind_env()
|
|
norm = ai.normalize_action({"kind": "await_operation"}, task_id="T9")
|
|
self.assertTrue(norm.get("normalized"), norm)
|
|
self.assertEqual(norm.get("operation_id"), bound["run_ref"])
|
|
self.assertEqual(norm.get("matched_by", norm.get("evidence")), norm.get("matched_by", norm.get("evidence")))
|
|
|
|
def test_unbound_task_reports_missing(self):
|
|
ws = self._waitstore()
|
|
self._new_wait(ws, session_id="s-stale2", status="WAITING_EXTERNAL")
|
|
ai = self._bind_env()
|
|
norm = ai.normalize_action({"kind": "await_operation"}, task_id="T404")
|
|
self.assertEqual(norm.get("normalized"), False)
|
|
self.assertIn("T404", norm.get("reason", ""))
|
|
|
|
def _waitstore(self):
|
|
import waitstore
|
|
|
|
importlib.reload(waitstore)
|
|
return waitstore
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|