The first M01 trial with the memory bank on was 26 turns on a GPU host. It
accepted every turn and reported "complete". It also wrote two memories and
no summary, and logged 180 `database is locked` errors, while derived status
still read `idle`.
The cause was a single uncommitted UPDATE. Retrieval bumped each used
memory's counter before the model call, and the turn commits only after the
reply has streamed. SQLite has one writer, so the turn held the write lock for
the whole reply. Every post-turn memory, summary and status write in that
window waited out the five-second timeout and failed. Recording the failure
needed a write as well, and without a rollback first it raised
PendingRollbackError. The loss therefore reached the log and never reached
the status the Insights panel reads, which F08 forbids. The draco run never
hit this because the bank was off there.
- `retrieve_memories` now only reads. `record_use` writes the counters in the
turn's single commit, so a turn that never lands counts nothing.
- The post-turn task's outer handler rolls back before it records a failure.
The harness could not have caught any of this. It read three prompt sections
under names the builder does not use: `memories` (really `used_memories`),
`story_history` (really `history`/`recent_history`), and a `knowledge` prefix
that matched the fixed instruction section instead of the imported passages.
Memory tokens read 0 whatever the prompt held, and the in-history and
in-memories recall checks could never come out true. The labels are now
constants, pinned by a test against a prompt the real builder assembled.
The harness also stops at the first sign of failed post-turn work. It checks
/derived and new server.log lines after every turn, keeps its log position
across --resume, and waits for background work to settle before its final
checks. A run with no memories or no summaries now ends "failed", not
"complete".
Both new application tests fail on fec46f6: the lock probe sees
`database is locked`, and memory status stays `idle`. The full backend suite
passes (1392 passed, 17 skipped). A 26-turn re-run against the same host had
0 lock errors, wrote 7 memories and 2 summaries, and used them in the prompt
from turn 8.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0136VBTMUKWYeU6G9HgbDbND
102 lines
4.2 KiB
Python
102 lines
4.2 KiB
Python
"""Read-only views of the context an adventure would send, or did send.
|
|
|
|
The dry run assembles a prompt without calling the model. The per-action endpoint
|
|
returns the prompt a turn was actually generated from. Neither writes anything.
|
|
"""
|
|
|
|
from fastapi import Depends, HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ... import derived, memorybank, models, summaries
|
|
from ... import contextwindow
|
|
from ...context import ContextOverflow, build_context
|
|
from ...database import get_db
|
|
from ...knowledge import retrieval as knowledge_retrieval
|
|
from ..settings import get_settings
|
|
|
|
from .deps import CurrentUser, current_adventure, router
|
|
|
|
|
|
@router.get("/{adventure_id}/context")
|
|
async def dry_run_context(
|
|
db: Session = Depends(get_db),
|
|
user: models.User = CurrentUser,
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""Returns what the app would send to the AI if the player continued now."""
|
|
settings = get_settings(db, user)
|
|
memories = await memorybank.retrieve_memories(adventure, settings)
|
|
# M7: retrieved here too, and by the same call the turn makes. A dry run
|
|
# that skipped the library would show a prompt the next turn will not send,
|
|
# which is the one thing this panel must never do.
|
|
knowledge = await knowledge_retrieval.retrieve(adventure, settings)
|
|
# M11: and by the same probe the turn makes, for the same reason — a panel
|
|
# that showed a 16,384-token budget while the next turn will be capped to
|
|
# 4,096 would be showing a prompt that is not the one about to be sent.
|
|
window = await contextwindow.probe(settings.endpoint_url, settings.model,
|
|
declared=settings.context_window_override)
|
|
try:
|
|
_, _, report = build_context(
|
|
adventure, settings, memories, knowledge=knowledge, window=window
|
|
)
|
|
except ContextOverflow as exc:
|
|
# M6: a dry run of a prompt that cannot be built is still an answer, and
|
|
# a more useful one than a 500. The reader opened this panel to find out
|
|
# what would be sent; "nothing, because the protected context does not
|
|
# fit, and here is by how much" is exactly that.
|
|
raise HTTPException(422, str(exc)) from exc
|
|
return report
|
|
|
|
|
|
@router.get("/{adventure_id}/derived")
|
|
def derived_status(
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
"""M6: whether background memory, summary and embedding work is healthy.
|
|
|
|
The surface that makes a dead memory bank findable. M2 shipped with the
|
|
whole bank failing inside a fire-and-forget task and nothing anywhere said
|
|
so — not the UI, not a log a player would read, not a failing test
|
|
(`BUILD-MILESTONES.md`, note from M2). This endpoint is where that now
|
|
shows.
|
|
"""
|
|
# Resolved once, not once per row: which summary the current head is
|
|
# entitled to. Asking inside the comprehension would be one query per
|
|
# summary, which is the shape M5 spent a finding removing.
|
|
eligible = summaries.current(db, adventure)
|
|
eligible_id = eligible.id if eligible is not None else None
|
|
status = derived.report(db, adventure.id)
|
|
return {
|
|
"status": status,
|
|
"failing": [row["kind"] for row in status if row["status"] == "failed"],
|
|
"summaries": [
|
|
{
|
|
"id": row.id,
|
|
"branch_id": row.branch_id,
|
|
"depth": row.depth,
|
|
"trigger": row.trigger,
|
|
"model": row.model_name,
|
|
"eligible": row.id == eligible_id,
|
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
|
"preview": row.text[:200],
|
|
}
|
|
for row in summaries.all_for(db, adventure)
|
|
],
|
|
}
|
|
|
|
|
|
@router.get("/{adventure_id}/actions/{action_id}/context")
|
|
def action_context(
|
|
adventure_id: int,
|
|
action_id: int,
|
|
db: Session = Depends(get_db),
|
|
adventure: models.Adventure = Depends(current_adventure),
|
|
):
|
|
action = db.get(models.Action, action_id)
|
|
if action is None or action.adventure_id != adventure_id:
|
|
raise HTTPException(404, "Action not found")
|
|
if action.context_snapshot is None:
|
|
raise HTTPException(404, "No context snapshot for this action")
|
|
return action.context_snapshot
|