WS Slack-UX Feature 1. A /new-task task now maps to ONE Slack thread instead of
several top-level messages.
- /new-task posts an immediate root "📥 Task received: …" ack and captures its
ts (root_ts); this is the instant acknowledgement.
- root_ts is plumbed into start: new PipelineState/TaskRecord channel
slack_thread_ts, seeded by graph.start_task and threaded through
Coordinator.start_task. The NewTaskCallback is now (task_text, via, root_ts).
- All clarifier questions for the task post as THREADED REPLIES under root_ts
(chat.postMessage thread_ts=root_ts), and each question's ledger channel_ref
is set to root_ts (NOT the reply's own ts). Because answer-mapping resolves a
reply via find_open_question_by_channel_ref(thread_ts), a reply in the root
thread (thread_ts==root_ts) maps to the task's currently-open question with NO
change to the mapping logic or the first-answer-wins CAS. The open-only
partial-unique index still holds (one open question per task at a time).
- Lifecycle milestones (parked / plan-ready / needs-input) and follow-up
questions thread under root_ts too; the notify sink gained an optional
thread_ts kwarg (degrades to top-level on a sink that doesn't accept it).
notify failures still never break tick.
- SlackTransport.post_question + the live poster accept/forward thread_ts.
- No root_ts (non-/new-task origin) ⇒ top-level posts exactly as before.
AUTHZ-01 (owner-allowlist-first, fail-closed) and the atomic open→answered
compare-and-set are unchanged.
Adds plumbing for the inbound-ack reactor seam used by Feature 2 (dormant until
a reactor is injected). Tests cover thread_ts forwarding, channel_ref=root_ts,
graph seeding, and coordinator threading.
625 lines
20 KiB
Python
625 lines
20 KiB
Python
"""Unit tests for agent_team.responder (§3.3, §3.3.1).
|
|
|
|
Covers the notify+resume seam end to end against the real ``pending_questions``
|
|
ledger (an on-disk SQLite DB via the foundation ``init_db``/``connect``):
|
|
|
|
* notify: ledger-row-first ordering, channel_ref persisted, lost-post leaves an
|
|
open row with no ref;
|
|
* submit_answer: first-answer-wins accept + enqueue, duplicate/late no-op,
|
|
answer-after-expiry loses the compare-and-set;
|
|
* ResumeWorker: turn-guard skip → superseded, happy-path resume, single-flight
|
|
per-thread serialization, concurrent different threads;
|
|
* deadline_sweep: overdue open → expired, race vs answer;
|
|
* recover_open_questions: answered rows re-enqueued.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from agent_team.db.schema import (
|
|
answer_question,
|
|
connect,
|
|
expire_question,
|
|
init_db,
|
|
)
|
|
from agent_team.responder import (
|
|
AnswerOutcome,
|
|
GraphHandle,
|
|
ResumeJob,
|
|
ResumeWorker,
|
|
deadline_sweep,
|
|
notify_question,
|
|
recover_open_questions,
|
|
submit_answer,
|
|
)
|
|
from agent_team.transport.base import NormalizedAnswer, QuestionSet, Transport
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures + fakes.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def conn(tmp_path: Path) -> sqlite3.Connection:
|
|
"""A real ledger-backed connection (foundation schema)."""
|
|
db_path = tmp_path / "agent-team.db"
|
|
init_db(db_path)
|
|
connection = connect(db_path)
|
|
try:
|
|
yield connection
|
|
finally:
|
|
connection.close()
|
|
|
|
|
|
class FakeTransport(Transport):
|
|
"""In-memory transport recording posts and parsing dict answers.
|
|
|
|
``post_question`` embeds the ``question_id`` in the returned ref (Slack
|
|
``ts`` analogue). ``post_fails`` toggles the lost-post path.
|
|
"""
|
|
|
|
def __init__(self, *, post_fails: bool = False) -> None:
|
|
self.posts: list[dict[str, Any]] = []
|
|
self.post_fails = post_fails
|
|
|
|
def post_question(
|
|
self, *, thread_id, question_id, turn, question_set, deadline, thread_ts=None
|
|
) -> str:
|
|
if self.post_fails:
|
|
raise RuntimeError("transport unreachable")
|
|
ref = f"slack-ts-{question_id}"
|
|
self.posts.append(
|
|
{
|
|
"thread_id": thread_id,
|
|
"question_id": question_id,
|
|
"turn": turn,
|
|
"deadline": deadline,
|
|
"thread_ts": thread_ts,
|
|
"ref": ref,
|
|
}
|
|
)
|
|
return ref
|
|
|
|
def parse_answer(self, raw) -> tuple[str, Any, str]:
|
|
na = NormalizedAnswer(
|
|
question_id=raw["callback_id"], answer=raw["value"], via="slack"
|
|
)
|
|
return na.question_id, na.answer, na.via
|
|
|
|
|
|
class FakeGraph:
|
|
"""Structural ``GraphHandle``: configurable interrupt turn + resume recorder."""
|
|
|
|
def __init__(self, turns: dict[str, int | None] | None = None) -> None:
|
|
# thread_id -> turn it is interrupted on (None = not interrupted).
|
|
self.turns: dict[str, int | None] = turns or {}
|
|
self.resumed: list[tuple[str, Any]] = []
|
|
self._resume_hook = None
|
|
|
|
def interrupted_turn(self, thread_id: str) -> int | None:
|
|
return self.turns.get(thread_id)
|
|
|
|
def resume(self, thread_id: str, answer: Any) -> Any:
|
|
if self._resume_hook is not None:
|
|
self._resume_hook(thread_id)
|
|
self.resumed.append((thread_id, answer))
|
|
return {"resumed": thread_id}
|
|
|
|
|
|
def _question_set(
|
|
*, thread_id: str = "t1", question_id: str = "q1", turn: int = 0
|
|
) -> QuestionSet:
|
|
return QuestionSet(
|
|
thread_id=thread_id,
|
|
question_id=question_id,
|
|
turn=turn,
|
|
questions=["proceed?"],
|
|
context={"repo": "x"},
|
|
)
|
|
|
|
|
|
def _row(conn: sqlite3.Connection, qid: str) -> sqlite3.Row:
|
|
return conn.execute(
|
|
"SELECT * FROM pending_questions WHERE question_id=?", (qid,)
|
|
).fetchone()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Protocol conformance.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_fake_graph_satisfies_protocol() -> None:
|
|
assert isinstance(FakeGraph(), GraphHandle)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# notify_question — delivery + lost-post (§3.3.1).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_notify_writes_open_row_then_stores_ref(conn: sqlite3.Connection) -> None:
|
|
transport = FakeTransport()
|
|
qs = _question_set()
|
|
|
|
ref = notify_question(conn, transport, qs, deadline="2026-06-18T00:00:00+00:00")
|
|
|
|
assert ref == "slack-ts-q1"
|
|
row = _row(conn, "q1")
|
|
assert row["status"] == "open"
|
|
assert row["channel_ref"] == "slack-ts-q1"
|
|
assert row["thread_id"] == "t1"
|
|
assert row["turn"] == 0
|
|
assert row["transport"] == "FakeTransport"
|
|
assert row["deadline_at"] == "2026-06-18T00:00:00+00:00"
|
|
assert row["posted_at"] is not None
|
|
# The post carried the question_id so an answer can map back.
|
|
assert transport.posts[0]["question_id"] == "q1"
|
|
|
|
|
|
def test_notify_row_is_written_before_post(conn: sqlite3.Connection) -> None:
|
|
"""The durable row must exist even while the post is in flight."""
|
|
seen: dict[str, Any] = {}
|
|
|
|
class CheckingTransport(FakeTransport):
|
|
def post_question(self, *, question_id, **kw): # type: ignore[override]
|
|
# At post time the ledger row must already be persisted as open.
|
|
seen["row"] = _row(conn, question_id)
|
|
return super().post_question(question_id=question_id, **kw)
|
|
|
|
notify_question(
|
|
conn, CheckingTransport(), _question_set(), deadline="2026-06-18T00:00:00+00:00"
|
|
)
|
|
assert seen["row"] is not None
|
|
assert seen["row"]["status"] == "open"
|
|
|
|
|
|
def test_notify_lost_post_leaves_open_row_without_ref(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
transport = FakeTransport(post_fails=True)
|
|
|
|
ref = notify_question(
|
|
conn, transport, _question_set(), deadline="2026-06-18T00:00:00+00:00"
|
|
)
|
|
|
|
# Post failed: no ref returned, row stays open with no ref for reconcile.
|
|
assert ref is None
|
|
row = _row(conn, "q1")
|
|
assert row["status"] == "open"
|
|
assert row["channel_ref"] is None
|
|
|
|
|
|
def test_notify_threads_under_root_and_sets_channel_ref_to_root(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""One-thread-per-task: thread_ts is forwarded to post AND becomes channel_ref.
|
|
|
|
When ``thread_ts`` (the task's root "📥 Task received" ts) is given, the
|
|
question posts as a threaded reply under it, and the durable ``channel_ref``
|
|
is set to that ROOT ts (NOT the posted reply's own ts) — so an inbound reply
|
|
whose ``thread_ts == root_ts`` maps back via
|
|
``find_open_question_by_channel_ref``.
|
|
"""
|
|
transport = FakeTransport()
|
|
qs = _question_set()
|
|
root_ts = "1700000000.ROOT"
|
|
|
|
ref = notify_question(
|
|
conn,
|
|
transport,
|
|
qs,
|
|
deadline="2026-06-18T00:00:00+00:00",
|
|
thread_ts=root_ts,
|
|
)
|
|
|
|
# The post threaded under the root.
|
|
assert transport.posts[0]["thread_ts"] == root_ts
|
|
# channel_ref is the ROOT ts, not the posted reply ref ("slack-ts-q1").
|
|
assert ref == root_ts
|
|
row = _row(conn, "q1")
|
|
assert row["channel_ref"] == root_ts
|
|
|
|
|
|
def test_notify_without_thread_ts_uses_posted_ref_as_channel_ref(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""No thread_ts (the default) preserves the prior behavior exactly.
|
|
|
|
The post is top-level (thread_ts None) and the channel_ref is the posted
|
|
message's own ts (the FakeTransport ref).
|
|
"""
|
|
transport = FakeTransport()
|
|
|
|
ref = notify_question(
|
|
conn, transport, _question_set(), deadline="2026-06-18T00:00:00+00:00"
|
|
)
|
|
|
|
assert transport.posts[0]["thread_ts"] is None
|
|
assert ref == "slack-ts-q1"
|
|
assert _row(conn, "q1")["channel_ref"] == "slack-ts-q1"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# submit_answer — first-answer-wins (§3.3.1).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_submit_answer_first_wins_enqueues_resume(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
transport = FakeTransport()
|
|
notify_question(
|
|
conn, transport, _question_set(), deadline="2026-06-18T00:00:00+00:00"
|
|
)
|
|
|
|
enqueued: list[ResumeJob] = []
|
|
outcome = submit_answer(
|
|
conn,
|
|
transport,
|
|
{"callback_id": "q1", "value": "yes"},
|
|
enqueue_resume=enqueued.append,
|
|
)
|
|
|
|
assert isinstance(outcome, AnswerOutcome)
|
|
assert outcome.accepted is True
|
|
assert outcome.question_id == "q1"
|
|
assert outcome.via == "slack"
|
|
assert outcome.job is not None
|
|
assert outcome.job == ResumeJob(
|
|
thread_id="t1", question_id="q1", turn=0, answer="yes"
|
|
)
|
|
assert enqueued == [outcome.job]
|
|
|
|
row = _row(conn, "q1")
|
|
assert row["status"] == "answered"
|
|
assert row["answer_json"] == '"yes"'
|
|
assert row["answered_via"] == "slack"
|
|
assert row["answered_at"] is not None
|
|
|
|
|
|
def test_submit_answer_duplicate_is_noop(conn: sqlite3.Connection) -> None:
|
|
transport = FakeTransport()
|
|
notify_question(
|
|
conn, transport, _question_set(), deadline="2026-06-18T00:00:00+00:00"
|
|
)
|
|
|
|
enqueued: list[ResumeJob] = []
|
|
first = submit_answer(
|
|
conn,
|
|
transport,
|
|
{"callback_id": "q1", "value": "yes"},
|
|
enqueue_resume=enqueued.append,
|
|
)
|
|
second = submit_answer(
|
|
conn,
|
|
transport,
|
|
{"callback_id": "q1", "value": "no"},
|
|
enqueue_resume=enqueued.append,
|
|
)
|
|
|
|
assert first.accepted is True
|
|
assert second.accepted is False
|
|
assert second.job is None
|
|
# Only the first answer enqueued a resume; the duplicate is ignored.
|
|
assert len(enqueued) == 1
|
|
# The stored answer is the first one, never overwritten by the duplicate.
|
|
assert _row(conn, "q1")["answer_json"] == '"yes"'
|
|
|
|
|
|
def test_submit_answer_after_expiry_loses_race(conn: sqlite3.Connection) -> None:
|
|
transport = FakeTransport()
|
|
notify_question(
|
|
conn, transport, _question_set(), deadline="2000-01-01T00:00:00+00:00"
|
|
)
|
|
# Question times out first.
|
|
assert expire_question(conn, question_id="q1") is True
|
|
|
|
enqueued: list[ResumeJob] = []
|
|
outcome = submit_answer(
|
|
conn,
|
|
transport,
|
|
{"callback_id": "q1", "value": "yes"},
|
|
enqueue_resume=enqueued.append,
|
|
)
|
|
|
|
assert outcome.accepted is False
|
|
assert enqueued == []
|
|
assert _row(conn, "q1")["status"] == "expired"
|
|
|
|
|
|
def test_submit_answer_unknown_question_is_noop(conn: sqlite3.Connection) -> None:
|
|
transport = FakeTransport()
|
|
enqueued: list[ResumeJob] = []
|
|
outcome = submit_answer(
|
|
conn,
|
|
transport,
|
|
{"callback_id": "nope", "value": "x"},
|
|
enqueue_resume=enqueued.append,
|
|
)
|
|
assert outcome.accepted is False
|
|
assert enqueued == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ResumeWorker — single-flight, turn-guarded (§3.3.1).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_resume_happy_path(conn: sqlite3.Connection) -> None:
|
|
notify_question(
|
|
conn, FakeTransport(), _question_set(), deadline="2026-06-18T00:00:00+00:00"
|
|
)
|
|
answer_question(conn, question_id="q1", answer_json='"yes"', answered_via="slack")
|
|
|
|
graph = FakeGraph(turns={"t1": 0})
|
|
worker = ResumeWorker(conn, graph)
|
|
job = ResumeJob(thread_id="t1", question_id="q1", turn=0, answer="yes")
|
|
|
|
assert worker.run(job) is True
|
|
assert graph.resumed == [("t1", "yes")]
|
|
# Still 'answered' — the worker does not mutate the ledger on success.
|
|
assert _row(conn, "q1")["status"] == "answered"
|
|
|
|
|
|
def test_resume_stale_turn_supersedes_and_skips(conn: sqlite3.Connection) -> None:
|
|
notify_question(
|
|
conn,
|
|
FakeTransport(),
|
|
_question_set(turn=2),
|
|
deadline="2026-06-18T00:00:00+00:00",
|
|
)
|
|
answer_question(conn, question_id="q1", answer_json='"yes"', answered_via="slack")
|
|
|
|
# Graph already advanced to turn 3 (or is not on turn 2 any more).
|
|
graph = FakeGraph(turns={"t1": 3})
|
|
worker = ResumeWorker(conn, graph)
|
|
job = ResumeJob(thread_id="t1", question_id="q1", turn=2, answer="yes")
|
|
|
|
assert worker.run(job) is False
|
|
assert graph.resumed == []
|
|
assert _row(conn, "q1")["status"] == "superseded"
|
|
|
|
|
|
def test_resume_not_interrupted_supersedes_and_skips(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
notify_question(
|
|
conn, FakeTransport(), _question_set(), deadline="2026-06-18T00:00:00+00:00"
|
|
)
|
|
answer_question(conn, question_id="q1", answer_json='"yes"', answered_via="slack")
|
|
|
|
# Thread not currently interrupted (None).
|
|
graph = FakeGraph(turns={"t1": None})
|
|
worker = ResumeWorker(conn, graph)
|
|
job = ResumeJob(thread_id="t1", question_id="q1", turn=0, answer="yes")
|
|
|
|
assert worker.run(job) is False
|
|
assert graph.resumed == []
|
|
assert _row(conn, "q1")["status"] == "superseded"
|
|
|
|
|
|
def test_resume_redelivered_job_does_not_double_apply(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""A second (redelivered) job for the same answered turn supersedes-skips."""
|
|
notify_question(
|
|
conn, FakeTransport(), _question_set(), deadline="2026-06-18T00:00:00+00:00"
|
|
)
|
|
answer_question(conn, question_id="q1", answer_json='"yes"', answered_via="slack")
|
|
|
|
graph = FakeGraph(turns={"t1": 0})
|
|
worker = ResumeWorker(conn, graph)
|
|
job = ResumeJob(thread_id="t1", question_id="q1", turn=0, answer="yes")
|
|
|
|
assert worker.run(job) is True
|
|
|
|
# The real graph advances after a successful resume; model that.
|
|
graph.turns["t1"] = 1
|
|
assert worker.run(job) is False
|
|
# Resume applied exactly once.
|
|
assert graph.resumed == [("t1", "yes")]
|
|
|
|
|
|
def test_resume_single_flight_serializes_same_thread(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""Two jobs for one thread never resume concurrently (per-thread lock)."""
|
|
notify_question(
|
|
conn, FakeTransport(), _question_set(), deadline="2026-06-18T00:00:00+00:00"
|
|
)
|
|
answer_question(conn, question_id="q1", answer_json='"yes"', answered_via="slack")
|
|
|
|
graph = FakeGraph(turns={"t1": 0})
|
|
concurrency = {"current": 0, "max": 0}
|
|
lock = threading.Lock()
|
|
|
|
def hook(_thread_id: str) -> None:
|
|
with lock:
|
|
concurrency["current"] += 1
|
|
concurrency["max"] = max(concurrency["max"], concurrency["current"])
|
|
time.sleep(0.02)
|
|
with lock:
|
|
concurrency["current"] -= 1
|
|
|
|
graph._resume_hook = hook
|
|
worker = ResumeWorker(conn, graph)
|
|
job = ResumeJob(thread_id="t1", question_id="q1", turn=0, answer="yes")
|
|
|
|
threads = [threading.Thread(target=worker.run, args=(job,)) for _ in range(5)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
# Same thread_id => never more than one in-flight resume at a time.
|
|
assert concurrency["max"] == 1
|
|
|
|
|
|
def test_resume_different_threads_run_concurrently(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
"""Different thread_ids are NOT serialized against each other."""
|
|
graph = FakeGraph(turns={f"t{i}": 0 for i in range(4)})
|
|
barrier = threading.Barrier(4, timeout=2.0)
|
|
reached = {"ok": True}
|
|
|
|
def hook(_thread_id: str) -> None:
|
|
try:
|
|
barrier.wait()
|
|
except threading.BrokenBarrierError:
|
|
reached["ok"] = False
|
|
|
|
graph._resume_hook = hook
|
|
worker = ResumeWorker(conn, graph)
|
|
jobs = [
|
|
ResumeJob(thread_id=f"t{i}", question_id=f"q{i}", turn=0, answer="y")
|
|
for i in range(4)
|
|
]
|
|
|
|
threads = [threading.Thread(target=worker.run, args=(j,)) for j in jobs]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
|
|
# All four reached the barrier together => they ran concurrently.
|
|
assert reached["ok"] is True
|
|
assert len(graph.resumed) == 4
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# deadline_sweep — overdue open -> expired (§3.3.1).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_deadline_sweep_expires_only_overdue_open(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
transport = FakeTransport()
|
|
notify_question(
|
|
conn,
|
|
transport,
|
|
_question_set(question_id="overdue"),
|
|
deadline="2000-01-01T00:00:00+00:00",
|
|
)
|
|
notify_question(
|
|
conn,
|
|
transport,
|
|
_question_set(question_id="future"),
|
|
deadline="2099-01-01T00:00:00+00:00",
|
|
)
|
|
|
|
expired = deadline_sweep(conn, now="2026-06-17T00:00:00+00:00")
|
|
|
|
assert expired == ["overdue"]
|
|
assert _row(conn, "overdue")["status"] == "expired"
|
|
assert _row(conn, "future")["status"] == "open"
|
|
|
|
|
|
def test_deadline_sweep_skips_already_answered(
|
|
conn: sqlite3.Connection,
|
|
) -> None:
|
|
transport = FakeTransport()
|
|
notify_question(
|
|
conn,
|
|
transport,
|
|
_question_set(question_id="ans"),
|
|
deadline="2000-01-01T00:00:00+00:00",
|
|
)
|
|
answer_question(conn, question_id="ans", answer_json='"yes"', answered_via="slack")
|
|
|
|
expired = deadline_sweep(conn, now="2026-06-17T00:00:00+00:00")
|
|
|
|
# Already answered => the deadline race was already lost; not expired.
|
|
assert expired == []
|
|
assert _row(conn, "ans")["status"] == "answered"
|
|
|
|
|
|
def test_deadline_sweep_ignores_null_deadline(conn: sqlite3.Connection) -> None:
|
|
conn.execute(
|
|
"INSERT INTO pending_questions "
|
|
"(question_id, thread_id, turn, status, transport) "
|
|
"VALUES ('q', 't', 0, 'open', 'slack')"
|
|
)
|
|
assert deadline_sweep(conn, now="2099-01-01T00:00:00+00:00") == []
|
|
assert _row(conn, "q")["status"] == "open"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# recover_open_questions — startup answered->resume replay (§3.3.1).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_recover_reenqueues_answered_rows(conn: sqlite3.Connection) -> None:
|
|
transport = FakeTransport()
|
|
notify_question(
|
|
conn,
|
|
transport,
|
|
_question_set(thread_id="ta", question_id="qa"),
|
|
deadline="2026-06-18T00:00:00+00:00",
|
|
)
|
|
notify_question(
|
|
conn,
|
|
transport,
|
|
_question_set(thread_id="tb", question_id="qb", turn=1),
|
|
deadline="2026-06-18T00:00:00+00:00",
|
|
)
|
|
notify_question(
|
|
conn,
|
|
transport,
|
|
_question_set(thread_id="tc", question_id="qc"),
|
|
deadline="2026-06-18T00:00:00+00:00",
|
|
)
|
|
|
|
# qa, qb answered before a crash; qc still open.
|
|
answer_question(conn, question_id="qa", answer_json='"yes"', answered_via="slack")
|
|
answer_question(
|
|
conn, question_id="qb", answer_json='{"k": 1}', answered_via="github"
|
|
)
|
|
|
|
enqueued: list[ResumeJob] = []
|
|
jobs = recover_open_questions(conn, enqueue_resume=enqueued.append)
|
|
|
|
assert jobs == enqueued
|
|
by_thread = {j.thread_id: j for j in jobs}
|
|
assert set(by_thread) == {"ta", "tb"}
|
|
assert by_thread["ta"] == ResumeJob(
|
|
thread_id="ta", question_id="qa", turn=0, answer="yes"
|
|
)
|
|
assert by_thread["tb"] == ResumeJob(
|
|
thread_id="tb", question_id="qb", turn=1, answer={"k": 1}
|
|
)
|
|
|
|
|
|
def test_recover_is_idempotent_via_turn_guard(conn: sqlite3.Connection) -> None:
|
|
"""Re-enqueued recover jobs no-op when the graph already advanced."""
|
|
notify_question(
|
|
conn, FakeTransport(), _question_set(), deadline="2026-06-18T00:00:00+00:00"
|
|
)
|
|
answer_question(conn, question_id="q1", answer_json='"yes"', answered_via="slack")
|
|
|
|
enqueued: list[ResumeJob] = []
|
|
recover_open_questions(conn, enqueue_resume=enqueued.append)
|
|
assert len(enqueued) == 1
|
|
|
|
# Graph already past turn 0 (resume happened before the crash record cleared).
|
|
graph = FakeGraph(turns={"t1": 1})
|
|
worker = ResumeWorker(conn, graph)
|
|
assert worker.run(enqueued[0]) is False
|
|
assert graph.resumed == []
|
|
assert _row(conn, "q1")["status"] == "superseded"
|
|
|
|
|
|
def test_recover_empty_ledger(conn: sqlite3.Connection) -> None:
|
|
enqueued: list[ResumeJob] = []
|
|
assert recover_open_questions(conn, enqueue_resume=enqueued.append) == []
|
|
assert enqueued == []
|