Keep every retried attempt instead of deleting it

Retry used to delete the last AI action and generate a replacement, so the
discarded narration was simply gone. The row survives now: each attempt is
appended to actions.variants with variant_index naming the live one, and a
ChatGPT-style pager under the message browses them.

Action.text still mirrors the active variant, so the context builder, memory
bank, summarizer and export needed no changes. A variant carries only what
differs between attempts -- the text, the reasoning, and the state it
produced -- never the assembled prompt, which is identical across attempts of
one turn and is the bulk of context_snapshot.

Only the last message can be switched, restoring the script/world state that
attempt produced; earlier turns were written as a continuation of whatever is
active there, so theirs are read-only previews.

Three things that would otherwise bite:

- generate_turn now wraps _generate_turn and watches for a save sentinel. If
  the generator ends without it (provider error, empty reply, script stop,
  client hangup) it re-applies the previous variant -- otherwise a failed
  retry leaves rolled-back stats under un-rolled-back text.
- Retry reuses the turn's own index rather than next_index, or the clock the
  world-state cooldowns run on advances on a re-run of the same turn.
- Editing a message rewrites the active variant too, or paging away and back
  silently reverts the edit.

Migrations 34/35 verified as an upgrade against a populated database, not
just a fresh schema. test_state_revert's retry test asserted the old delete
behaviour and was rewritten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UeQVy5bEjLhfgWNc27Efet
This commit is contained in:
parththakkar106
2026-08-02 20:44:06 +05:30
co-authored by Claude Opus 5
parent be6dbb0f24
commit 906ba423d8
9 changed files with 734 additions and 22 deletions
+9 -3
View File
@@ -178,8 +178,8 @@ def test_snapshot_state_handles_non_dict(db):
# ---------------------------------------------------------------- retry
def test_retry_restores_state_before_regenerating(db, monkeypatch):
# Retry deletes the last AI action and must roll the scoreboard back to that
# action's snapshot so regeneration doesn't stack output mutations.
# Retry must roll the scoreboard back to the AI action's snapshot so
# regeneration doesn't stack output mutations on the discarded attempt.
user, adv = _make_adventure(db, {"gold": 20}) # 20 = double-applied bug value
_add(db, adv, 0, "start")
_add(db, adv, 1, "do", state_before={"gold": 0})
@@ -197,5 +197,11 @@ def test_retry_restores_state_before_regenerating(db, monkeypatch):
adventures.retry_action(adv.id, request=None, db=db, user=user)
assert adv.script_state == {"gold": 10}
assert [a.type for a in adv.actions] == ["start", "do"]
# The row survives now (it used to be deleted) so the discarded attempt
# stays readable — it's kept as variant 0.
assert [a.type for a in adv.actions] == ["start", "do", "ai"]
last = adv.actions[-1]
assert len(last.variants) == 1
assert last.variant_index == 0
assert last.variants[0]["script_state"] == {"gold": 20} # the attempt's outcome
adventures._active_turns.discard(adv.id)