84 lines
2.9 KiB
Python
84 lines
2.9 KiB
Python
"""V019 - durable per-turn error records.
|
|
|
|
Creates ``turn_errors``: one row per failed turn, written best-effort by
|
|
``persistence/turn_error_writer.py`` from the turn-loop catch-all. Each row
|
|
carries the short ``error_id`` shown to users as ``(ref: <error_id>)`` in
|
|
error messages, so a bug report quoting a ref joins directly to the sanitized
|
|
message and redacted traceback here.
|
|
|
|
Privacy contract: unlike V017's no-free-text bar, ``message`` and
|
|
``traceback`` are deliberately free-text — that is the diagnostic point of
|
|
the table. Both are sanitized before insert: ``message`` is the
|
|
``sanitize_agent_error`` output and ``traceback`` passes
|
|
``observability.redact.scrub_text`` (secret-shaped values masked, home
|
|
directory normalized to ``~``). ``tests/test_persistence/test_turn_error_writer.py``
|
|
enforces the scrub bar. Retention is write-time pruning at 30 days.
|
|
|
|
Downgrade story (SchemaAheadError, refusal-by-design): boot only migrates
|
|
forward. An older build refuses to boot against a database recording V019
|
|
(``assert_schema_not_ahead``); operators roll back with yoyo, which invokes
|
|
``rollback_step`` (drops the index and the table) — error records are
|
|
observability, not conversation state, so dropping them is safe.
|
|
|
|
The ``session_key`` column is intentionally not FK-protected: the ``sessions``
|
|
table is created lazily by ``SessionStorage.connect()`` rather than by yoyo
|
|
(V001/V017 precedent). Cleanup is the explicit purge in
|
|
``SessionStorage.delete_session`` plus write-time retention pruning.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from yoyo import step
|
|
|
|
__depends__: set[str] = {"V018__router_decisions_ts_index"}
|
|
|
|
|
|
CREATE_TURN_ERRORS = """
|
|
CREATE TABLE turn_errors (
|
|
error_id TEXT PRIMARY KEY,
|
|
turn_id TEXT,
|
|
session_key TEXT NOT NULL,
|
|
session_id TEXT,
|
|
ts_ms INTEGER NOT NULL,
|
|
surface TEXT,
|
|
error_class TEXT,
|
|
message TEXT,
|
|
traceback TEXT,
|
|
provider TEXT,
|
|
model TEXT,
|
|
fallback_hops INTEGER NOT NULL DEFAULT 0
|
|
)
|
|
"""
|
|
|
|
CREATE_INDEX = (
|
|
"CREATE INDEX IF NOT EXISTS idx_turn_errors_session_ts"
|
|
" ON turn_errors(session_key, ts_ms)"
|
|
)
|
|
|
|
|
|
def _table_exists(conn, table: str) -> bool:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
|
(table,),
|
|
)
|
|
return cur.fetchone() is not None
|
|
|
|
|
|
def apply_step(conn) -> None:
|
|
cur = conn.cursor()
|
|
if not _table_exists(conn, "turn_errors"):
|
|
cur.execute(CREATE_TURN_ERRORS)
|
|
# Outside the table guard so a pre-existing table (created out-of-band)
|
|
# still gains the index; IF NOT EXISTS keeps the step idempotent.
|
|
cur.execute(CREATE_INDEX)
|
|
|
|
|
|
def rollback_step(conn) -> None:
|
|
cur = conn.cursor()
|
|
cur.execute("DROP INDEX IF EXISTS idx_turn_errors_session_ts")
|
|
cur.execute("DROP TABLE IF EXISTS turn_errors")
|
|
|
|
|
|
steps = [step(apply_step, rollback_step)]
|