319 lines
12 KiB
Python
319 lines
12 KiB
Python
"""DAO tests for clarify state on MetaRunWriter (PR2)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from yoyo import get_backend, read_migrations
|
|
|
|
from opensquilla.persistence.meta_run_writer import (
|
|
AwaitingPeek,
|
|
MetaRunWriter,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def writer(tmp_path: Path) -> MetaRunWriter:
|
|
db = tmp_path / "test.sqlite"
|
|
backend = get_backend(f"sqlite:///{db}")
|
|
backend.apply_migrations(read_migrations("migrations"))
|
|
conn = sqlite3.connect(db, check_same_thread=False)
|
|
conn.row_factory = sqlite3.Row
|
|
conn.execute("PRAGMA journal_mode=WAL")
|
|
conn.execute("PRAGMA synchronous=NORMAL")
|
|
return MetaRunWriter(conn)
|
|
|
|
|
|
def _seed_awaiting(writer: MetaRunWriter, *, run_id: str, session_key: str,
|
|
step_id: str = "collect", since: float = 1700000000.0) -> None:
|
|
schema_json = json.dumps({"mode": "form", "fields": []})
|
|
with writer._lock:
|
|
writer._conn.execute(
|
|
"INSERT INTO meta_skill_runs "
|
|
"(run_id, meta_skill_name, meta_skill_digest, plan_snapshot_json, "
|
|
" triggered_by, session_key, status, started_at_ms, inputs_json, "
|
|
" awaiting_step_id, awaiting_schema_json, awaiting_since, "
|
|
" awaiting_filled_json, step_outputs_json) "
|
|
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
|
(run_id, "tskill", "d", "{}", "soft_meta_invoke", session_key,
|
|
"awaiting_user", 0, "{}", step_id, schema_json, since,
|
|
"{}", "{}"),
|
|
)
|
|
writer._conn.commit()
|
|
|
|
|
|
def test_peek_awaiting_returns_none_for_unknown_session(writer):
|
|
assert writer.peek_awaiting(session_id="nope") is None
|
|
|
|
|
|
def test_peek_awaiting_returns_record_for_matching_session(writer):
|
|
_seed_awaiting(writer, run_id="r1", session_key="S1")
|
|
peek = writer.peek_awaiting(session_id="S1")
|
|
assert peek is not None
|
|
assert isinstance(peek, AwaitingPeek)
|
|
assert peek.run_id == "r1"
|
|
assert peek.step_id == "collect"
|
|
assert peek.awaiting_session_id == "S1"
|
|
|
|
|
|
def test_peek_awaiting_ignores_non_awaiting_status(writer):
|
|
with writer._lock:
|
|
writer._conn.execute(
|
|
"INSERT INTO meta_skill_runs "
|
|
"(run_id, meta_skill_name, meta_skill_digest, plan_snapshot_json, "
|
|
" triggered_by, session_key, status, started_at_ms, inputs_json) "
|
|
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
("r2", "tskill", "d", "{}", "soft_meta_invoke", "S2",
|
|
"ok", 0, "{}"),
|
|
)
|
|
writer._conn.commit()
|
|
assert writer.peek_awaiting(session_id="S2") is None
|
|
|
|
|
|
def test_try_claim_awaiting_succeeds_for_running_run(writer):
|
|
with writer._lock:
|
|
writer._conn.execute(
|
|
"INSERT INTO meta_skill_runs "
|
|
"(run_id, meta_skill_name, meta_skill_digest, plan_snapshot_json, "
|
|
" triggered_by, session_key, status, started_at_ms, inputs_json) "
|
|
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
("r1", "t", "d", "{}", "soft_meta_invoke", "S1", "running", 0, "{}"),
|
|
)
|
|
writer._conn.commit()
|
|
|
|
ok = writer.try_claim_awaiting(
|
|
run_id="r1",
|
|
step_id="collect",
|
|
schema_json='{"mode":"form","fields":[]}',
|
|
session_id="S1",
|
|
inputs_json='{"user_message":"hi"}',
|
|
step_outputs_json='{}',
|
|
awaiting_since=1700000000.0,
|
|
)
|
|
assert ok is True
|
|
|
|
peek = writer.peek_awaiting(session_id="S1")
|
|
assert peek is not None
|
|
assert peek.run_id == "r1"
|
|
assert peek.step_id == "collect"
|
|
|
|
|
|
def test_try_claim_awaiting_fails_when_run_already_finished(writer):
|
|
with writer._lock:
|
|
writer._conn.execute(
|
|
"INSERT INTO meta_skill_runs "
|
|
"(run_id, meta_skill_name, meta_skill_digest, plan_snapshot_json, "
|
|
" triggered_by, session_key, status, started_at_ms, inputs_json) "
|
|
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
("r1", "t", "d", "{}", "soft_meta_invoke", "S1", "ok", 0, "{}"),
|
|
)
|
|
writer._conn.commit()
|
|
|
|
ok = writer.try_claim_awaiting(
|
|
run_id="r1",
|
|
step_id="collect",
|
|
schema_json="{}",
|
|
session_id="S1",
|
|
inputs_json="{}",
|
|
step_outputs_json="{}",
|
|
awaiting_since=0.0,
|
|
)
|
|
assert ok is False
|
|
assert writer.peek_awaiting(session_id="S1") is None
|
|
|
|
|
|
def test_try_claim_awaiting_partial_unique_index_blocks_double_awaiting(writer):
|
|
with writer._lock:
|
|
for run_id in ("r1", "r2"):
|
|
writer._conn.execute(
|
|
"INSERT INTO meta_skill_runs "
|
|
"(run_id, meta_skill_name, meta_skill_digest, plan_snapshot_json, "
|
|
" triggered_by, session_key, status, started_at_ms, inputs_json) "
|
|
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
(run_id, "t", "d", "{}", "soft_meta_invoke", "S1",
|
|
"running", 0, "{}"),
|
|
)
|
|
writer._conn.commit()
|
|
|
|
assert writer.try_claim_awaiting(
|
|
run_id="r1", step_id="collect", schema_json="{}",
|
|
session_id="S1", inputs_json="{}", step_outputs_json="{}",
|
|
awaiting_since=0.0,
|
|
) is True
|
|
assert writer.try_claim_awaiting(
|
|
run_id="r2", step_id="collect", schema_json="{}",
|
|
session_id="S1", inputs_json="{}", step_outputs_json="{}",
|
|
awaiting_since=0.0,
|
|
) is False
|
|
|
|
|
|
def test_try_claim_resume_wins_on_first_call(writer):
|
|
_seed_awaiting(writer, run_id="r1", session_key="S1")
|
|
payload = writer.try_claim_resume(run_id="r1", session_id="S1")
|
|
assert payload is not None
|
|
assert payload.run_id == "r1"
|
|
assert payload.awaiting_step_id == "collect"
|
|
assert payload.inputs_json == "{}"
|
|
peek_after = writer.peek_awaiting(session_id="S1")
|
|
assert peek_after is None
|
|
|
|
|
|
def test_try_claim_resume_loses_when_already_consumed(writer):
|
|
_seed_awaiting(writer, run_id="r1", session_key="S1")
|
|
first = writer.try_claim_resume(run_id="r1", session_id="S1")
|
|
assert first is not None
|
|
second = writer.try_claim_resume(run_id="r1", session_id="S1")
|
|
assert second is None
|
|
|
|
|
|
def test_try_claim_resume_rejects_session_mismatch(writer):
|
|
_seed_awaiting(writer, run_id="r1", session_key="S1")
|
|
payload = writer.try_claim_resume(run_id="r1", session_id="DIFFERENT")
|
|
assert payload is None
|
|
assert writer.peek_awaiting(session_id="S1") is not None
|
|
|
|
|
|
def test_mark_expired_moves_awaiting_to_expired(writer):
|
|
_seed_awaiting(writer, run_id="r1", session_key="S1")
|
|
writer.mark_expired(run_id="r1")
|
|
assert writer.peek_awaiting(session_id="S1") is None
|
|
with writer._lock:
|
|
row = writer._conn.execute(
|
|
"SELECT status FROM meta_skill_runs WHERE run_id=?",
|
|
("r1",),
|
|
).fetchone()
|
|
assert row["status"] == "expired"
|
|
|
|
|
|
def test_mark_cancelled_records_reason_in_error_column(writer):
|
|
_seed_awaiting(writer, run_id="r1", session_key="S1")
|
|
writer.mark_cancelled(run_id="r1", reason="user_cancel")
|
|
with writer._lock:
|
|
row = writer._conn.execute(
|
|
"SELECT status, error FROM meta_skill_runs WHERE run_id=?",
|
|
("r1",),
|
|
).fetchone()
|
|
assert row["status"] == "cancelled"
|
|
assert "user_cancel" in (row["error"] or "")
|
|
|
|
|
|
def test_increment_parse_failures_returns_new_count(writer):
|
|
_seed_awaiting(writer, run_id="r1", session_key="S1")
|
|
assert writer.increment_parse_failures(run_id="r1") == 1
|
|
assert writer.increment_parse_failures(run_id="r1") == 2
|
|
assert writer.increment_parse_failures(run_id="r1") == 3
|
|
|
|
|
|
def test_increment_parse_failures_atomic_across_connections(tmp_path):
|
|
db = tmp_path / "test.sqlite"
|
|
backend = get_backend(f"sqlite:///{db}")
|
|
backend.apply_migrations(read_migrations("migrations"))
|
|
|
|
def _open():
|
|
c = sqlite3.connect(db, check_same_thread=False, timeout=30.0)
|
|
c.row_factory = sqlite3.Row
|
|
c.execute("PRAGMA journal_mode=WAL")
|
|
c.execute("PRAGMA synchronous=NORMAL")
|
|
return MetaRunWriter(c)
|
|
|
|
w1 = _open()
|
|
w2 = _open()
|
|
_seed_awaiting(w1, run_id="r1", session_key="S1")
|
|
|
|
seen = []
|
|
for w in (w1, w2, w1, w2, w1):
|
|
seen.append(w.increment_parse_failures(run_id="r1"))
|
|
|
|
assert seen == [1, 2, 3, 4, 5], (
|
|
f"increment_parse_failures must be atomic per connection; got {seen}"
|
|
)
|
|
|
|
|
|
def test_increment_parse_failures_zero_for_non_awaiting(writer):
|
|
with writer._lock:
|
|
writer._conn.execute(
|
|
"INSERT INTO meta_skill_runs "
|
|
"(run_id, meta_skill_name, meta_skill_digest, plan_snapshot_json, "
|
|
" triggered_by, session_key, status, started_at_ms, inputs_json) "
|
|
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
("r9", "t", "d", "{}", "soft_meta_invoke", "S9", "ok", 0, "{}"),
|
|
)
|
|
writer._conn.commit()
|
|
assert writer.increment_parse_failures(run_id="r9") == 0
|
|
|
|
|
|
def test_update_awaiting_partial_merges_filled_json(writer):
|
|
_seed_awaiting(writer, run_id="r1", session_key="S1")
|
|
ok = writer.update_awaiting_partial(
|
|
run_id="r1",
|
|
filled_json='{"destination":"Tokyo"}',
|
|
awaiting_since=1700001000.0,
|
|
)
|
|
assert ok is True
|
|
with writer._lock:
|
|
row = writer._conn.execute(
|
|
"SELECT awaiting_filled_json, awaiting_since "
|
|
"FROM meta_skill_runs WHERE run_id=?",
|
|
("r1",),
|
|
).fetchone()
|
|
assert row["awaiting_filled_json"] == '{"destination":"Tokyo"}'
|
|
assert float(row["awaiting_since"]) == pytest.approx(1700001000.0)
|
|
|
|
|
|
def test_update_awaiting_partial_rejects_non_awaiting(writer):
|
|
with writer._lock:
|
|
writer._conn.execute(
|
|
"INSERT INTO meta_skill_runs "
|
|
"(run_id, meta_skill_name, meta_skill_digest, plan_snapshot_json, "
|
|
" triggered_by, session_key, status, started_at_ms, inputs_json) "
|
|
"VALUES (?,?,?,?,?,?,?,?,?)",
|
|
("r9", "t", "d", "{}", "soft_meta_invoke", "S9", "ok", 0, "{}"),
|
|
)
|
|
writer._conn.commit()
|
|
ok = writer.update_awaiting_partial(
|
|
run_id="r9", filled_json="{}", awaiting_since=0.0,
|
|
)
|
|
assert ok is False
|
|
|
|
|
|
def test_increment_parse_failures_is_portable_no_returning_clause():
|
|
"""SQLite < 3.35 (RHEL 8 / Ubuntu 20.04 system builds) has no
|
|
``UPDATE ... RETURNING``; the fail-open handler used to convert the
|
|
syntax error into a permanent 0, so the parse-failure limit never
|
|
triggered. Pin the portable UPDATE-then-SELECT form by asserting no
|
|
SQL string literal in the method body uses the clause."""
|
|
import ast
|
|
import inspect
|
|
import textwrap
|
|
|
|
src = textwrap.dedent(inspect.getsource(MetaRunWriter.increment_parse_failures))
|
|
fn = ast.parse(src).body[0]
|
|
body = list(fn.body)
|
|
if (
|
|
body
|
|
and isinstance(body[0], ast.Expr)
|
|
and isinstance(body[0].value, ast.Constant)
|
|
):
|
|
body = body[1:] # drop the docstring — SQL literals only
|
|
literals = [
|
|
node.value
|
|
for stmt in body
|
|
for node in ast.walk(stmt)
|
|
if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
|
]
|
|
assert literals, "expected SQL literals in increment_parse_failures"
|
|
assert not any("returning" in literal.lower() for literal in literals)
|
|
|
|
|
|
def test_increment_parse_failures_survives_and_counts_after_partial_update(writer):
|
|
"""Increment keeps returning the fresh value across repeated calls and
|
|
drops back to 0 once the run leaves awaiting_user."""
|
|
_seed_awaiting(writer, run_id="r1", session_key="S1")
|
|
assert writer.increment_parse_failures(run_id="r1") == 1
|
|
assert writer.increment_parse_failures(run_id="r1") == 2
|
|
writer.mark_cancelled(run_id="r1", reason="parse_failure_limit")
|
|
assert writer.increment_parse_failures(run_id="r1") == 0
|