Add a Confluence documentation lane to the Plane-2 pipeline, flag-gated behind AGENT_TEAM_CONFLUENCE_ENABLED (default off; daemon behavior unchanged when off). - confluence/client.py: OAuth 2LO + Basic REST client, dry-run-default writes - confluence/mermaid.py: vendored ADF-only Mermaid editor (macro-count + revert-diff guards, dry-run default) - nodes/confluence_writer.py(+_llm): conf_draft -> conf_gate -> conf_write, both direct (task_kind=confluence) and post-build documentation flows - task_model/graph/coordinator: new phases, state channels, route_after_intake, CONFLUENCE_APPROVAL_KIND gate delivery, task_kind forwarding - db schema v5: widen pending_questions kind CHECK (atomic rebuild) - tests for client, mermaid, writer node, ledger v5, coordinator gate, e2e
764 lines
26 KiB
Python
764 lines
26 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 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()
|