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

395 lines
13 KiB
Python
Raw Normal View History

Add Plane-2 leaf scaffold (pipeline graph, nodes, HITL, transports, CI) Consolidates the 18 leaf modules from the r720-plane2-scaffold workflow onto the foundation commit. Full suite: 535 passed, 1 skipped; ruff + format clean. Built (pre-deployment scaffold only — nothing provisioned/enabled): - LangGraph pipeline graph.py (INTAKE->CLARIFY->PLAN, interrupt()/resume, checkpointer-injectable) - nodes: clarifier (98% gate), planner, review_loop (GPT-4.1), builders->candidate diff, verifier - §3.3.1 HITL: ledger ops, resume_worker, deadline_timer, recovery sweep, responder - transports: slack / github / claude_code adapters - ci_gate (pure-code pass/fail), operator_cli, run-team.py entry, P1 sim harness - ci/agent-team-apply-verify.yml (split untrusted/privileged jobs) — authored, disabled KNOWN OPEN FINDINGS (verifier/cross-review, not yet fixed — see follow-up): - builders denylist: 4 execution-proven bypasses (delete, mode-change, copy-to, out-of-scope delete) - §3.3.1 CAS: BEGIN IMMEDIATE outside try/except; shared-connection txn nesting unsafe under concurrency - operator_cli: missing re-deliver/force-resume; audit-after-mutate ordering gap - ci yaml: GPT-4.1 cross-review PASS w/ 4 FIX items (symlink path escape, etc.) - P1 sim harness models the ledger layer, not real LangGraph interrupt/resume; P1 exit criteria not yet truly proven Deploy-gated (NOT done): IAM/step-ca/Roles Anywhere/confluence-bot provisioning, /sh-security-review sign-off, live Slack/CI, rsync, live dry-runs, Adam approval.
2026-06-17 14:17:44 -04:00
"""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]