"""Unit tests for agent_team.ledger — pending-questions ledger ops (§3.3.1).""" from __future__ import annotations import sqlite3 import threading from pathlib import Path import pytest from agent_team.db.schema import SCHEMA_VERSION, connect, init_db, migrate from agent_team.ledger import ( QUESTION_STATES, PendingQuestion, answer_question, answered_questions, count_by_status, expire_question, get_question, list_questions, open_questions_needing_ref, overdue_open_questions, post_question, set_channel_ref, supersede_question, ) @pytest.fixture() def conn(tmp_path: Path) -> sqlite3.Connection: """A connection to an initialized agent-team DB.""" db = tmp_path / "ledger.sqlite" init_db(db) connection = connect(db) yield connection connection.close() # -------------------------------------------------------------------------- # Re-export contract: the ledger exposes the foundation primitives verbatim. # -------------------------------------------------------------------------- def test_reexports_are_the_foundation_objects() -> None: from agent_team.db import schema assert answer_question is schema.answer_question assert expire_question is schema.expire_question assert supersede_question is schema.supersede_question assert QUESTION_STATES is schema.QUESTION_STATES # -------------------------------------------------------------------------- # post_question — write the row `open` first, no channel_ref (delivery step 1). # -------------------------------------------------------------------------- def test_post_question_writes_open_row_without_ref(conn: sqlite3.Connection) -> None: post_question(conn, question_id="q1", thread_id="t1", turn=0, transport="slack") q = get_question(conn, "q1") assert q is not None assert q.status == "open" assert q.channel_ref is None assert q.transport == "slack" assert q.posted_at # defaulted to now assert q.deadline_at is None def test_post_question_records_deadline_and_posted_at( conn: sqlite3.Connection, ) -> None: post_question( conn, question_id="q1", thread_id="t1", turn=2, transport="github", deadline_at="2026-06-17T12:00:00+00:00", posted_at="2026-06-17T11:00:00+00:00", ) q = get_question(conn, "q1") assert q is not None assert q.turn == 2 assert q.deadline_at == "2026-06-17T12:00:00+00:00" assert q.posted_at == "2026-06-17T11:00:00+00:00" def test_post_question_duplicate_id_raises(conn: sqlite3.Connection) -> None: post_question(conn, question_id="dup", thread_id="t", turn=0, transport="slack") with pytest.raises(sqlite3.IntegrityError): post_question(conn, question_id="dup", thread_id="t", turn=1, transport="slack") def test_post_question_defaults_kind_clarify(conn: sqlite3.Connection) -> None: """Existing call sites (no kind arg) keep writing clarifier rows.""" post_question(conn, question_id="q1", thread_id="t", turn=0, transport="slack") q = get_question(conn, "q1") assert q is not None assert q.kind == "clarify" def test_post_question_accepts_plan_decision_kind(conn: sqlite3.Connection) -> None: """A plan-review row round-trips with kind='plan_decision' on the read path.""" post_question( conn, question_id="q-pd", thread_id="t", turn=0, transport="slack", kind="plan_decision", ) q = get_question(conn, "q-pd") assert q is not None assert q.kind == "plan_decision" # -------------------------------------------------------------------------- # set_channel_ref — delivery step 2, guarded on status='open'. # -------------------------------------------------------------------------- def test_set_channel_ref_on_open_row(conn: sqlite3.Connection) -> None: post_question(conn, question_id="q1", thread_id="t", turn=0, transport="slack") assert set_channel_ref(conn, question_id="q1", channel_ref="1700.0001") is True assert get_question(conn, "q1").channel_ref == "1700.0001" def test_set_channel_ref_unknown_id_returns_false(conn: sqlite3.Connection) -> None: assert set_channel_ref(conn, question_id="nope", channel_ref="x") is False def test_set_channel_ref_refuses_non_open(conn: sqlite3.Connection) -> None: post_question(conn, question_id="q1", thread_id="t", turn=0, transport="slack") answer_question(conn, question_id="q1", answer_json="{}", answered_via="slack") # Late post-confirm must not resurrect a ref on an answered question. assert set_channel_ref(conn, question_id="q1", channel_ref="late") is False assert get_question(conn, "q1").channel_ref is None # -------------------------------------------------------------------------- # Compare-and-set ops integrate with ledger-inserted rows (first-answer-wins). # -------------------------------------------------------------------------- def test_answer_first_wins_on_posted_row(conn: sqlite3.Connection) -> None: post_question(conn, question_id="q1", thread_id="t", turn=0, transport="slack") first = answer_question( conn, question_id="q1", answer_json='{"a":1}', answered_via="slack" ) second = answer_question( conn, question_id="q1", answer_json='{"a":2}', answered_via="github" ) assert (first, second) == (True, False) q = get_question(conn, "q1") assert q.status == "answered" assert q.answer_json == '{"a":1}' assert q.answered_via == "slack" assert q.answered_at # -------------------------------------------------------------------------- # open_questions_needing_ref — lost-post reconcile feed. # -------------------------------------------------------------------------- def test_open_questions_needing_ref(conn: sqlite3.Connection) -> None: post_question( conn, question_id="no-ref", thread_id="t", turn=0, transport="slack", posted_at="2026-06-17T01:00:00+00:00", ) post_question( conn, question_id="with-ref", thread_id="t", turn=1, transport="slack", posted_at="2026-06-17T02:00:00+00:00", ) set_channel_ref(conn, question_id="with-ref", channel_ref="ts") # answered rows (even without a ref) are not delivery-reconcile candidates post_question( conn, question_id="answered", thread_id="t", turn=2, transport="slack", posted_at="2026-06-17T03:00:00+00:00", ) answer_question(conn, question_id="answered", answer_json="{}", answered_via="x") ids = [q.question_id for q in open_questions_needing_ref(conn)] assert ids == ["no-ref"] # -------------------------------------------------------------------------- # overdue_open_questions + deadline-vs-answer race (§3.3.1). # -------------------------------------------------------------------------- def test_overdue_open_questions_filters_by_deadline( conn: sqlite3.Connection, ) -> None: post_question( conn, question_id="past", thread_id="t", turn=0, transport="slack", deadline_at="2026-06-17T10:00:00+00:00", ) post_question( conn, question_id="future", thread_id="t", turn=1, transport="slack", deadline_at="2026-06-17T20:00:00+00:00", ) post_question( conn, question_id="no-deadline", thread_id="t", turn=2, transport="slack", ) overdue = overdue_open_questions(conn, now="2026-06-17T12:00:00+00:00") assert [q.question_id for q in overdue] == ["past"] def test_overdue_excludes_already_closed(conn: sqlite3.Connection) -> None: post_question( conn, question_id="q", thread_id="t", turn=0, transport="slack", deadline_at="2026-06-17T10:00:00+00:00", ) answer_question(conn, question_id="q", answer_json="{}", answered_via="slack") assert overdue_open_questions(conn, now="2026-06-17T12:00:00+00:00") == [] def test_answer_after_expire_loses(conn: sqlite3.Connection) -> None: """Deterministic deadline-vs-answer race: expiry first, then answer loses.""" post_question( conn, question_id="q", thread_id="t", turn=0, transport="slack", deadline_at="2026-06-17T10:00:00+00:00", ) (overdue,) = overdue_open_questions(conn, now="2026-06-17T12:00:00+00:00") assert expire_question(conn, question_id=overdue.question_id) is True assert ( answer_question(conn, question_id="q", answer_json="{}", answered_via="slack") is False ) assert get_question(conn, "q").status == "expired" # -------------------------------------------------------------------------- # answered_questions — resume-worker / restart-recovery feed. # -------------------------------------------------------------------------- def test_answered_questions_feed(conn: sqlite3.Connection) -> None: for qid, tid in (("a", "t1"), ("b", "t2")): post_question(conn, question_id=qid, thread_id=tid, turn=0, transport="slack") answer_question(conn, question_id="a", answer_json="{}", answered_via="slack") # b stays open answered = answered_questions(conn) assert [q.question_id for q in answered] == ["a"] # thread scoping assert answered_questions(conn, thread_id="t2") == [] assert [q.question_id for q in answered_questions(conn, thread_id="t1")] == ["a"] # -------------------------------------------------------------------------- # list_questions — manual CLI feed. # -------------------------------------------------------------------------- def test_list_questions_orders_oldest_first(conn: sqlite3.Connection) -> None: post_question( conn, question_id="newer", thread_id="t", turn=1, transport="slack", posted_at="2026-06-17T05:00:00+00:00", ) post_question( conn, question_id="older", thread_id="t", turn=0, transport="slack", posted_at="2026-06-17T01:00:00+00:00", ) assert [q.question_id for q in list_questions(conn)] == ["older", "newer"] def test_list_questions_status_filter(conn: sqlite3.Connection) -> None: post_question(conn, question_id="open1", thread_id="t", turn=0, transport="slack") post_question(conn, question_id="ans1", thread_id="t", turn=1, transport="slack") answer_question(conn, question_id="ans1", answer_json="{}", answered_via="slack") assert [q.question_id for q in list_questions(conn, status="open")] == ["open1"] assert [q.question_id for q in list_questions(conn, status="answered")] == ["ans1"] def test_list_questions_thread_filter(conn: sqlite3.Connection) -> None: post_question(conn, question_id="a", thread_id="t1", turn=0, transport="slack") post_question(conn, question_id="b", thread_id="t2", turn=0, transport="slack") assert [q.question_id for q in list_questions(conn, thread_id="t1")] == ["a"] def test_list_questions_rejects_unknown_status(conn: sqlite3.Connection) -> None: with pytest.raises(ValueError): list_questions(conn, status="bogus") # -------------------------------------------------------------------------- # count_by_status — stable shape over all states. # -------------------------------------------------------------------------- def test_count_by_status_stable_shape(conn: sqlite3.Connection) -> None: post_question(conn, question_id="o1", thread_id="t", turn=0, transport="slack") post_question(conn, question_id="o2", thread_id="t", turn=1, transport="slack") post_question(conn, question_id="a1", thread_id="t", turn=2, transport="slack") answer_question(conn, question_id="a1", answer_json="{}", answered_via="slack") counts = count_by_status(conn) assert set(counts) == set(QUESTION_STATES) assert counts["open"] == 2 assert counts["answered"] == 1 assert counts["expired"] == 0 assert counts["superseded"] == 0 # -------------------------------------------------------------------------- # get_question + PendingQuestion view. # -------------------------------------------------------------------------- def test_get_question_missing_returns_none(conn: sqlite3.Connection) -> None: assert get_question(conn, "ghost") is None def test_pending_question_from_row(conn: sqlite3.Connection) -> None: post_question( conn, question_id="q", thread_id="t", turn=3, transport="github", deadline_at="2026-06-17T12:00:00+00:00", ) set_channel_ref(conn, question_id="q", channel_ref="cref") q = get_question(conn, "q") assert isinstance(q, PendingQuestion) assert (q.question_id, q.thread_id, q.turn, q.transport) == ( "q", "t", 3, "github", ) assert q.channel_ref == "cref" # frozen dataclass — read snapshot, not mutable. with pytest.raises(Exception): q.status = "answered" # type: ignore[misc] # -------------------------------------------------------------------------- # Concurrency: two threads racing to set the channel_ref via the open guard. # -------------------------------------------------------------------------- def test_concurrent_answer_single_winner_via_ledger(tmp_path: Path) -> None: db = tmp_path / "race.sqlite" init_db(db) seed = connect(db) try: post_question( seed, question_id="race", thread_id="t", turn=0, transport="slack" ) finally: seed.close() results: list[bool] = [] barrier = threading.Barrier(2) lock = threading.Lock() def worker(via: str) -> None: c = connect(db) try: barrier.wait() won = answer_question( c, question_id="race", answer_json='{"v":1}', answered_via=via ) with lock: results.append(won) finally: c.close() threads = [threading.Thread(target=worker, args=(f"c{i}",)) for i in range(2)] for t in threads: t.start() for t in threads: t.join() assert sorted(results) == [False, True] # -------------------------------------------------------------------------- # v5 schema migration: WIDEN pending_questions.kind CHECK to add # 'confluence_approval' via the 12-step table rebuild (db/schema.py). # -------------------------------------------------------------------------- # The OLD (pre-v5) pending_questions DDL, whose kind CHECK accepts only # ('clarify', 'plan_decision'). Used to build a v4-stamped DB so the v4 -> v5 # upgrade can be exercised against a realistic starting point. _PENDING_QUESTIONS_DDL_V4 = """ CREATE TABLE pending_questions ( question_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, turn INTEGER NOT NULL, status TEXT NOT NULL CHECK (status IN ('open', 'answered', 'expired', 'superseded')), transport TEXT NOT NULL, channel_ref TEXT, posted_at TEXT, deadline_at TEXT, answer_json TEXT, answered_at TEXT, answered_via TEXT, kind TEXT NOT NULL DEFAULT 'clarify' CHECK (kind IN ('clarify', 'plan_decision')) ) """.strip() _PENDING_QUESTIONS_INDEXES_V4 = [ "CREATE INDEX idx_pending_questions_thread ON pending_questions (thread_id, turn)", "CREATE INDEX idx_pending_questions_status ON pending_questions (status)", "CREATE UNIQUE INDEX uq_pending_questions_open_channel_ref " "ON pending_questions (channel_ref) " "WHERE channel_ref IS NOT NULL AND status = 'open'", ] def _build_v4_db(db: Path) -> None: """Create a DB stamped at schema_version=4 with the OLD kind CHECK. Mirrors the production-shaped v4 state: pending_questions carrying only ('clarify', 'plan_decision') in its kind CHECK, all three pending_questions indexes present, and the schema_meta version row pinned at 4. """ conn = connect(db) try: conn.execute( "CREATE TABLE IF NOT EXISTS schema_meta (" "id INTEGER PRIMARY KEY CHECK (id = 1), " "schema_version INTEGER NOT NULL)" ) conn.execute(_PENDING_QUESTIONS_DDL_V4) for stmt in _PENDING_QUESTIONS_INDEXES_V4: conn.execute(stmt) conn.execute("INSERT INTO schema_meta (id, schema_version) VALUES (1, 4)") finally: conn.close() def _kind_check_accepts(conn: sqlite3.Connection, kind: str) -> bool: """True if inserting a pending_questions row with ``kind`` is permitted. Probes the live CHECK constraint by attempting an insert and rolling it back; an IntegrityError means the CHECK rejected the value. """ try: conn.execute( "INSERT INTO pending_questions " "(question_id, thread_id, turn, status, transport, kind) " "VALUES (?, 't', 0, 'open', 'slack', ?)", (f"probe-{kind}", kind), ) except sqlite3.IntegrityError: return False conn.execute( "DELETE FROM pending_questions WHERE question_id = ?", (f"probe-{kind}",) ) return True def _schema_version(conn: sqlite3.Connection) -> int: row = conn.execute("SELECT schema_version FROM schema_meta WHERE id = 1").fetchone() return int(row["schema_version"]) def _index_names(conn: sqlite3.Connection) -> set[str]: rows = conn.execute( "SELECT name FROM sqlite_master " "WHERE type = 'index' AND tbl_name = 'pending_questions'" ).fetchall() return {row["name"] for row in rows} def test_fresh_init_db_stamps_v5_and_accepts_confluence_approval( tmp_path: Path, ) -> None: """A fresh init_db lands at SCHEMA_VERSION and accepts 'confluence_approval'.""" db = tmp_path / "fresh.sqlite" init_db(db) conn = connect(db) try: assert SCHEMA_VERSION == 5 assert _schema_version(conn) == 5 assert _kind_check_accepts(conn, "confluence_approval") is True assert _kind_check_accepts(conn, "clarify") is True assert _kind_check_accepts(conn, "plan_decision") is True assert _kind_check_accepts(conn, "nope") is False finally: conn.close() def test_v4_to_v5_preserves_rows_and_widens_kind_check(tmp_path: Path) -> None: """v4 -> v5: existing rows survive verbatim; widened CHECK takes effect.""" db = tmp_path / "upgrade.sqlite" _build_v4_db(db) # Seed pre-existing v4 rows: a clarify and a plan_decision, with full # column coverage so the rebuild's `INSERT ... SELECT *` copy is exercised. seed = connect(db) try: assert _schema_version(seed) == 4 seed.execute( "INSERT INTO pending_questions " "(question_id, thread_id, turn, status, transport, channel_ref, " "posted_at, deadline_at, kind) " "VALUES ('q-clar', 't1', 0, 'open', 'slack', 'ts-1', " "'2026-06-17T01:00:00+00:00', '2026-06-17T02:00:00+00:00', 'clarify')" ) seed.execute( "INSERT INTO pending_questions " "(question_id, thread_id, turn, status, transport, answer_json, " "answered_at, answered_via, kind) " "VALUES ('q-plan', 't2', 1, 'answered', 'github', '{\"d\":\"go\"}', " "'2026-06-17T03:00:00+00:00', 'github', 'plan_decision')" ) # Pre-rebuild the OLD CHECK must reject confluence_approval. assert _kind_check_accepts(seed, "confluence_approval") is False finally: seed.close() # Run the migration. mig = connect(db) try: migrate(mig) finally: mig.close() conn = connect(db) try: assert _schema_version(conn) == 5 # Existing rows preserved intact (every column round-trips). clar = conn.execute( "SELECT * FROM pending_questions WHERE question_id = 'q-clar'" ).fetchone() assert clar is not None assert clar["thread_id"] == "t1" assert clar["turn"] == 0 assert clar["status"] == "open" assert clar["transport"] == "slack" assert clar["channel_ref"] == "ts-1" assert clar["posted_at"] == "2026-06-17T01:00:00+00:00" assert clar["deadline_at"] == "2026-06-17T02:00:00+00:00" assert clar["kind"] == "clarify" plan = conn.execute( "SELECT * FROM pending_questions WHERE question_id = 'q-plan'" ).fetchone() assert plan is not None assert plan["status"] == "answered" assert plan["transport"] == "github" assert plan["answer_json"] == '{"d":"go"}' assert plan["answered_at"] == "2026-06-17T03:00:00+00:00" assert plan["answered_via"] == "github" assert plan["kind"] == "plan_decision" # Widened CHECK: confluence_approval now accepted, garbage still rejected. conn.execute( "INSERT INTO pending_questions " "(question_id, thread_id, turn, status, transport, kind) " "VALUES ('q-conf', 't3', 0, 'open', 'slack', 'confluence_approval')" ) got = conn.execute( "SELECT kind FROM pending_questions WHERE question_id = 'q-conf'" ).fetchone() assert got["kind"] == "confluence_approval" with pytest.raises(sqlite3.IntegrityError): conn.execute( "INSERT INTO pending_questions " "(question_id, thread_id, turn, status, transport, kind) " "VALUES ('q-bad', 't4', 0, 'open', 'slack', 'nope')" ) finally: conn.close() def test_v4_to_v5_rebuild_preserves_partial_unique_channel_ref_index( tmp_path: Path, ) -> None: """The partial unique index survives the rebuild and still enforces.""" db = tmp_path / "uq.sqlite" _build_v4_db(db) mig = connect(db) try: migrate(mig) finally: mig.close() conn = connect(db) try: # Index exists after the rebuild. assert "uq_pending_questions_open_channel_ref" in _index_names(conn) # It still enforces: two OPEN rows cannot share a non-null channel_ref. conn.execute( "INSERT INTO pending_questions " "(question_id, thread_id, turn, status, transport, channel_ref) " "VALUES ('a', 't', 0, 'open', 'slack', 'dup-ts')" ) with pytest.raises(sqlite3.IntegrityError): conn.execute( "INSERT INTO pending_questions " "(question_id, thread_id, turn, status, transport, channel_ref) " "VALUES ('b', 't', 1, 'open', 'slack', 'dup-ts')" ) finally: conn.close() def test_v5_rebuild_is_atomic_failure_rolls_back_to_intact_table( tmp_path: Path, ) -> None: """A failure mid-rebuild must roll back: the ORIGINAL table and rows survive, version stays 4, and a subsequent migrate() recovers cleanly (no crash-loop). Without the explicit BEGIN IMMEDIATE wrapping the swap, connect()'s autocommit mode commits the DROP TABLE pending_questions on its own; a failure after that point destroys the live ledger and leaves an orphan pending_questions_new that crash-loops the next migrate(). We inject a failure at the index-creation step (which runs AFTER the DROP/RENAME inside the txn) and assert the rebuild was atomic. """ db = tmp_path / "atomic.sqlite" _build_v4_db(db) seed = connect(db) try: seed.execute( "INSERT INTO pending_questions " "(question_id, thread_id, turn, status, transport, channel_ref, kind) " "VALUES ('survivor', 't1', 0, 'open', 'slack', 'ts-keep', 'clarify')" ) finally: seed.close() # Patch the index splitter the v5 block calls AFTER the DROP/RENAME so the # rebuild fails mid-flight, exercising the rollback path. import agent_team.db.schema as schema_mod original_split = schema_mod._split_statements def _boom(ddl: str) -> list[str]: if "idx_pending_questions" in ddl: raise RuntimeError("injected mid-rebuild crash") return original_split(ddl) mig = connect(db) try: schema_mod._split_statements = _boom try: with pytest.raises(RuntimeError, match="injected mid-rebuild crash"): migrate(mig) finally: schema_mod._split_statements = original_split finally: mig.close() # Rollback held: original table intact, the row survives, version still 4, # and NO orphan pending_questions_new remains to crash the retry. conn = connect(db) try: assert _schema_version(conn) == 4 tables = { row["name"] for row in conn.execute( "SELECT name FROM sqlite_master WHERE type = 'table'" ).fetchall() } assert "pending_questions" in tables assert "pending_questions_new" not in tables row = conn.execute( "SELECT thread_id, channel_ref, kind FROM pending_questions " "WHERE question_id = 'survivor'" ).fetchone() assert row is not None assert row["thread_id"] == "t1" assert row["channel_ref"] == "ts-keep" assert row["kind"] == "clarify" # Pre-rebuild CHECK still in force: the widening did not partially apply. assert _kind_check_accepts(conn, "confluence_approval") is False finally: conn.close() # Retry recovers cleanly to v5 with the row still present. retry = connect(db) try: migrate(retry) assert _schema_version(retry) == 5 assert _kind_check_accepts(retry, "confluence_approval") is True row = retry.execute( "SELECT kind FROM pending_questions WHERE question_id = 'survivor'" ).fetchone() assert row["kind"] == "clarify" finally: retry.close() def test_v5_migrate_is_idempotent(tmp_path: Path) -> None: """Re-running migrate on a v5 DB is a no-op that keeps rows and version.""" db = tmp_path / "idem.sqlite" _build_v4_db(db) first = connect(db) try: migrate(first) first.execute( "INSERT INTO pending_questions " "(question_id, thread_id, turn, status, transport, kind) " "VALUES ('keep', 't', 0, 'open', 'slack', 'confluence_approval')" ) finally: first.close() again = connect(db) try: migrate(again) assert _schema_version(again) == 5 row = again.execute( "SELECT kind FROM pending_questions WHERE question_id = 'keep'" ).fetchone() assert row["kind"] == "confluence_approval" finally: again.close()