"""M6: recording whether background derived work succeeded, and why not. M2 shipped with the entire memory bank dead and the full test suite green. The summariser and the embedder raised `AttributeError` inside a fire-and-forget task: no user-visible error, no log a player would look at, and no failing test, because every memory test stubbed the provider factories out (`BUILD-MILESTONES.md`, note from M2; `M2-IMPLEMENTATION-REPORT.md` §A.1). Two rules follow, and they pull in opposite directions: * **Derived work must fail softly.** A memory that could not be written, a summary that could not be generated, an embedding the endpoint refused — none of these may roll back the accepted narration, the accepted state events, the authoritative document, the head, or the transcript. The story turn already happened; the derived work is a commentary on it. * **It must fail visibly.** Soft failure without a record is what M2 shipped. So each attempt writes its outcome to one row per (campaign, kind), and that row is readable through the API. This is deliberately not a job framework: it holds what happened last, not a queue. Retrying is just running the pass again, which the ordinary post-turn path already does on the next accepted turn. """ from __future__ import annotations import logging from sqlalchemy import select from sqlalchemy.orm import Session from . import models log = logging.getLogger(__name__) # The kinds of derived work. Each is independent: embeddings can be failing # while summaries succeed, and a reader should be able to see exactly that. MEMORY = "memory" SUMMARY = "summary" EMBEDDING = "embedding" # M7: building vectors for the imported knowledge library. Separate from # `EMBEDDING`, which is the memory bank's, because the two fail independently # and are repaired by different actions — a reader whose knowledge embeddings # are failing needs to know that their story memory is fine, and one status for # both would be the same untruth M6-F5 was about. KNOWLEDGE = "knowledge" KINDS = (MEMORY, SUMMARY, EMBEDDING, KNOWLEDGE) def _row(db: Session, adventure_id: int, kind: str) -> models.DerivedStatus: row = db.execute( select(models.DerivedStatus).where( models.DerivedStatus.adventure_id == adventure_id, models.DerivedStatus.kind == kind, ) ).scalars().first() if row is None: row = models.DerivedStatus(adventure_id=adventure_id, kind=kind) db.add(row) return row def succeeded(db: Session, adventure_id: int, kind: str, *, did_work: bool = True) -> None: """Records a clean run, clearing any standing failure. `did_work` separates a pass that produced something from one that found nothing to do (M6 review finding M6-F5). Both are healthy, and neither is a failure, but reporting "ok" for a pass that has never actually run reads as "embeddings are working" when nothing has been embedded. `idle` says the true thing: it ran, and there was nothing pending. """ row = _row(db, adventure_id, kind) row.status = "ok" if did_work else "idle" row.detail = "" row.failures = 0 row.last_attempt_at = models.utcnow() if did_work: row.last_success_at = row.last_attempt_at def failed(db: Session, adventure_id: int, kind: str, exc: BaseException) -> None: """Records a failed run, keeping the reason where someone can find it. The detail is the exception's type and message rather than a traceback: it is shown to a reader in the Insights panel, and `ProviderError: connection refused` is the part that tells them what to do. The traceback goes to the log for a maintainer. """ row = _row(db, adventure_id, kind) row.status = "failed" row.detail = f"{type(exc).__name__}: {exc}"[:2000] row.failures = (row.failures or 0) + 1 row.last_attempt_at = models.utcnow() log.exception("derived %s work failed for adventure %s", kind, adventure_id) def report(db: Session, adventure_id: int) -> list[dict]: """Every kind's last outcome, for the API and the prompt inspector.""" rows = db.execute( select(models.DerivedStatus) .where(models.DerivedStatus.adventure_id == adventure_id) .order_by(models.DerivedStatus.kind) ).scalars().all() return [ { "kind": row.kind, "status": row.status, "detail": row.detail, "failures": row.failures, "last_attempt_at": row.last_attempt_at.isoformat() if row.last_attempt_at else None, "last_success_at": row.last_success_at.isoformat() if row.last_success_at else None, } for row in rows ] def failing(db: Session, adventure_id: int) -> list[str]: """The kinds currently in a failed state, for a compact UI badge.""" return [entry["kind"] for entry in report(db, adventure_id) if entry["status"] == "failed"]