237 lines
10 KiB
Python
237 lines
10 KiB
Python
"""Native-shaped regression tests for coordinator-owned PR continuations."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import nullcontext
|
|
import json
|
|
from pathlib import Path
|
|
import sqlite3
|
|
import sys
|
|
|
|
import pytest
|
|
|
|
from testing.tests.test_hermes_cli_support import HERMES, _load
|
|
|
|
|
|
sys.path.insert(0, str(HERMES / "scm-common/scripts"))
|
|
state = _load("supervisor_state")
|
|
continuation = _load("kanban_continue_pr")
|
|
|
|
|
|
class NativeKanban:
|
|
"""Match live ``kanban_db``'s keyword-only create API without metadata."""
|
|
|
|
def __init__(self) -> None:
|
|
self.created: list[dict] = []
|
|
self.by_key: dict[str, str] = {}
|
|
self.comments: list[tuple] = []
|
|
|
|
def scoped_current_board(self, _board: str):
|
|
return nullcontext()
|
|
|
|
def connect(self, *, board: str):
|
|
class Connection:
|
|
def close(self):
|
|
return None
|
|
return Connection()
|
|
|
|
def get_task(self, _conn, task_id: str):
|
|
return {"id": task_id} if task_id == "root" else None
|
|
|
|
def create_task(self, _conn, *, title, body=None, assignee=None, created_by=None,
|
|
workspace_kind="scratch", workspace_path=None, branch_name=None,
|
|
tenant=None, priority=0, parents=(), triage=False,
|
|
idempotency_key=None, max_runtime_seconds=None, skills=None,
|
|
max_retries=None, goal_mode=False, goal_max_turns=None,
|
|
initial_status="running", session_id=None, board=None,
|
|
project_id=None):
|
|
assert branch_name is None
|
|
assert workspace_kind == "scratch"
|
|
assert idempotency_key
|
|
if idempotency_key in self.by_key:
|
|
return self.by_key[idempotency_key]
|
|
task_id = f"child-{len(self.created) + 1}"
|
|
self.by_key[idempotency_key] = task_id
|
|
self.created.append({
|
|
"id": task_id, "title": title, "body": body, "assignee": assignee,
|
|
"created_by": created_by, "parents": tuple(parents),
|
|
"idempotency_key": idempotency_key, "initial_status": initial_status,
|
|
})
|
|
return task_id
|
|
|
|
def add_comment(self, _conn, *args):
|
|
self.comments.append(args)
|
|
|
|
|
|
@pytest.fixture
|
|
def trusted_root(tmp_path: Path, monkeypatch):
|
|
"""Make a real SQLite board DB holding a signed root lineage record."""
|
|
board = "titan-iac"
|
|
monkeypatch.setattr(state, "KANBAN_ROOT", tmp_path / "boards")
|
|
path = state.KANBAN_ROOT / board / "kanban.db"
|
|
lineage = state.Lineage(
|
|
root_task_id="root", project="atlas-iac", branch="hermes/root",
|
|
pull_request="https://scm.bstein.dev/titan/atlas-iac/pulls/7", base_branch="main",
|
|
)
|
|
state.record_submission(board, "root", lineage, "a" * 40, path=path)
|
|
state.set_ready(board, "root", "a" * 40, path=path)
|
|
assert state.get_root(board, "root") == lineage
|
|
return board, lineage, state.KANBAN_ROOT / board / state.STATE_FILE
|
|
|
|
|
|
def _pr(head: str) -> bytes:
|
|
return json.dumps({
|
|
"state": "open",
|
|
"head": {"ref": "hermes/root", "sha": head,
|
|
"repo": {"full_name": "titan/atlas-iac"}},
|
|
"base": {"ref": "main", "repo": {"full_name": "titan/atlas-iac"}},
|
|
}).encode()
|
|
|
|
|
|
def test_queue_uses_real_sqlite_lineage_and_native_create_signature(trusted_root, monkeypatch):
|
|
board, _lineage, path = trusted_root
|
|
native = NativeKanban()
|
|
monkeypatch.setattr(continuation.scm_broker_client, "read", lambda _path: _pr("b" * 40))
|
|
|
|
child, created = continuation.queue(native, board=board, root_task="root", objective="fix review")
|
|
|
|
assert (child, created) == ("child-1", True)
|
|
assert native.created[0]["parents"] == ("root",)
|
|
assert native.created[0]["initial_status"] == "running"
|
|
assert "metadata" not in native.created[0]
|
|
assert state.get_live_head(board, "root", path=path) == "b" * 40
|
|
assert state.get_child(board, child, path=path)["parent_task_id"] == "root"
|
|
with state._connect(board, path) as connection:
|
|
ready = connection.execute(
|
|
"SELECT ready_for_human_merge,ready_commit FROM supervisor_roots"
|
|
).fetchone()
|
|
assert tuple(ready) == (0, "")
|
|
|
|
same, created = continuation.queue(native, board=board, root_task="root", objective="fix review")
|
|
assert (same, created) == (child, False)
|
|
assert len(native.created) == 1
|
|
|
|
|
|
def test_interrupted_recording_recovers_through_native_idempotency(trusted_root, monkeypatch):
|
|
board, _lineage, path = trusted_root
|
|
native = NativeKanban()
|
|
monkeypatch.setattr(continuation.scm_broker_client, "read", lambda _path: _pr("a" * 40))
|
|
original = continuation.supervisor_state.record_child
|
|
monkeypatch.setattr(
|
|
continuation.supervisor_state, "record_child",
|
|
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("simulated restart")),
|
|
)
|
|
with pytest.raises(OSError, match="simulated restart"):
|
|
continuation.queue(native, board=board, root_task="root", objective="repair flaky test")
|
|
assert len(native.created) == 1
|
|
monkeypatch.setattr(continuation.supervisor_state, "record_child", original)
|
|
|
|
child, created = continuation.queue(native, board=board, root_task="root", objective="repair flaky test")
|
|
assert (child, created) == ("child-1", True)
|
|
assert len(native.created) == 1
|
|
assert state.get_child(board, child, path=path) is not None
|
|
|
|
|
|
def test_submission_cannot_rewrite_root_and_new_head_clears_ready(trusted_root):
|
|
board, lineage, path = trusted_root
|
|
replacement = state.Lineage(
|
|
root_task_id="root", project="atlas-iac", branch="attacker/ref",
|
|
pull_request=lineage.pull_request, base_branch="main",
|
|
)
|
|
with pytest.raises(ValueError, match="immutable"):
|
|
state.record_submission(board, "root", replacement, "c" * 40, path=path)
|
|
state.record_submission(board, "repair-child", lineage, "c" * 40, path=path)
|
|
with state._connect(board, path) as connection:
|
|
row = connection.execute(
|
|
"SELECT live_pr_head,ready_for_human_merge,ready_commit FROM supervisor_roots"
|
|
).fetchone()
|
|
assert tuple(row) == ("c" * 40, 0, "")
|
|
|
|
|
|
def test_present_malformed_continuation_state_fails_closed(trusted_root):
|
|
board, _lineage, path = trusted_root
|
|
state.record_child(board, "child", "root", "root", "repair", "a" * 40, "fix", path=path)
|
|
with state._connect(board, path) as connection:
|
|
connection.execute(
|
|
"UPDATE supervisor_children SET cycle=? WHERE board=? AND child_task_id=?",
|
|
("not-a-cycle", board, "child"),
|
|
)
|
|
with pytest.raises(state.SupervisorStateError, match="malformed"):
|
|
state.get_child(board, "child", path=path)
|
|
|
|
|
|
def test_default_state_sidecar_imports_healthy_legacy_rows_once(tmp_path, monkeypatch):
|
|
"""The first isolated state DB preserves native rows without sharing its file."""
|
|
board = "titan-iac"
|
|
monkeypatch.setattr(state, "KANBAN_ROOT", tmp_path / "boards")
|
|
legacy = state.KANBAN_ROOT / board / "kanban.db"
|
|
lineage = state.Lineage("root", "feature/root", "https://scm.bstein.dev/titan/atlas-iac/pulls/9", "atlas-iac", "main")
|
|
state.record_submission(board, "root", lineage, "a" * 40, path=legacy)
|
|
state.set_ready(board, "root", "a" * 40, path=legacy)
|
|
state.record_child(board, "repair", "root", "root", "repair", "a" * 40, "repair", path=legacy)
|
|
|
|
assert state.get_root(board, "root") == lineage
|
|
assert state.get_child(board, "repair")["parent_task_id"] == "root"
|
|
sidecar = state.KANBAN_ROOT / board / state.STATE_FILE
|
|
assert sidecar.is_file() and sidecar != legacy
|
|
state.record_submission(board, "root", lineage, "b" * 40)
|
|
assert state.get_live_head(board, "root") == "b" * 40
|
|
assert state.get_live_head(board, "root", path=legacy) == "a" * 40
|
|
|
|
|
|
def test_default_state_refuses_corrupt_legacy_without_empty_sidecar(tmp_path, monkeypatch):
|
|
"""A damaged native DB cannot silently erase supervisor authority."""
|
|
board = "soteria"
|
|
monkeypatch.setattr(state, "KANBAN_ROOT", tmp_path / "boards")
|
|
legacy = state.KANBAN_ROOT / board / "kanban.db"
|
|
legacy.parent.mkdir(parents=True)
|
|
legacy.write_bytes(b"not a sqlite database")
|
|
|
|
with pytest.raises(state.SupervisorStateError, match="unreadable"):
|
|
state.get_root(board, "root")
|
|
assert not (state.KANBAN_ROOT / board / state.STATE_FILE).exists()
|
|
|
|
|
|
def test_default_state_imports_additive_legacy_schema_and_rejects_orphans(tmp_path, monkeypatch):
|
|
"""Older valid rows keep their safe defaults; orphan children stop migration."""
|
|
board = "atlas-iac"
|
|
monkeypatch.setattr(state, "KANBAN_ROOT", tmp_path / "boards")
|
|
legacy = state.KANBAN_ROOT / board / "kanban.db"
|
|
legacy.parent.mkdir(parents=True)
|
|
schema = """
|
|
CREATE TABLE supervisor_roots (
|
|
board TEXT, root_task_id TEXT, project TEXT, branch TEXT, pull_request TEXT,
|
|
base_branch TEXT, live_pr_head TEXT
|
|
);
|
|
CREATE TABLE supervisor_children (
|
|
board TEXT, child_task_id TEXT, root_task_id TEXT, parent_task_id TEXT,
|
|
kind TEXT, head_commit TEXT, objective_digest TEXT
|
|
);
|
|
"""
|
|
with sqlite3.connect(legacy) as connection:
|
|
connection.executescript(schema)
|
|
connection.execute(
|
|
"INSERT INTO supervisor_roots VALUES(?,?,?,?,?,?,?)",
|
|
(board, "root", "atlas-iac", "feature/root", "pull/1", "main", "a" * 40),
|
|
)
|
|
connection.execute(
|
|
"INSERT INTO supervisor_children VALUES(?,?,?,?,?,?,?)",
|
|
(board, "child", "root", "root", "repair", "a" * 40, "digest"),
|
|
)
|
|
assert state.get_child(board, "child")["cycle"] == 1
|
|
with state._connect(board) as connection:
|
|
assert connection.execute("SELECT ready_for_human_merge,ready_commit FROM supervisor_roots").fetchone() == (0, "")
|
|
|
|
orphan_board = "soteria"
|
|
orphan = state.KANBAN_ROOT / orphan_board / "kanban.db"
|
|
orphan.parent.mkdir()
|
|
with sqlite3.connect(orphan) as connection:
|
|
connection.executescript(schema)
|
|
connection.execute(
|
|
"INSERT INTO supervisor_children VALUES(?,?,?,?,?,?,?)",
|
|
(orphan_board, "child", "missing", "missing", "repair", "a" * 40, "digest"),
|
|
)
|
|
with pytest.raises(state.SupervisorStateError, match="malformed"):
|
|
state.get_root(orphan_board, "missing")
|
|
assert not (state.KANBAN_ROOT / orphan_board / state.STATE_FILE).exists()
|