"""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 connect, init_db 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") # -------------------------------------------------------------------------- # 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]