This repository has been archived on 2026-08-04. You can view files and clone it, but cannot push or open issues or pull requests.
orchestrator/agent-team/tests/test_ledger.py
Adam Moussa 1b4d30e47f feat(agent-team): add pending_questions.kind discriminator + migration (Phase B1)
The plan-review gate (coming next) needs to tell its decision questions apart
from clarifier questions in the durable ledger. Add a `kind` column to
pending_questions (values 'clarify' | 'plan_decision').

- Fresh DBs: `kind TEXT NOT NULL DEFAULT 'clarify'` (+ CHECK) in the DDL.
- Live ledger: idempotent additive migration (SCHEMA_VERSION 3→4) — a guarded
  ALTER (PRAGMA table_info) run from both migrate() and init_db; legacy rows
  take the 'clarify' default, never null. (SQLite can't add a CHECK via ALTER,
  so the migrated column is NOT NULL DEFAULT only; value constraint is enforced
  on fresh DBs by the CHECK and on all writes by the typed helper.)
- ledger.post_question gains a keyword-only `kind="clarify"` (backward
  compatible — existing callers unchanged); PendingQuestion.from_row reads it.

Tests: fresh-DB column+default, idempotent init_db, legacy-DB backfill to
'clarify', plan_decision round-trip. 1396 passed.
2026-06-23 21:03:53 -04:00

417 lines
14 KiB
Python

"""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")
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]