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.
417 lines
14 KiB
Python
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]
|