Validates the three finalists by clone, build, test run and live local Ollama inference, then answers the fork question with measurements rather than static review. Recommendation: fork AI-DnD, confidence high. The Phase 0A call holds, but it was wrong that AI-DnD's undo is non-destructive — retry preserves the replaced take, undo hard-deletes it. A follow-up spike fixed that in 3 files (+130/-31): undo now moves a head cursor, redo round-trips, writing below a moved-back head forks and keeps the abandoned line, branch-scoped memory isolation survives, suite 627/632 with all 5 failures asserting the deleted-row behaviour that was replaced. Findings that change the plan: - AI-DnD cannot take a turn air-gapped as shipped; tiktoken fetches its encoding from a CDN. Proven on an internal Docker network, proven fixed by vendoring the file. - ai-adventure needs zero code for Ollama — two config lines — and its turn/head/checkpoint schema is the target model to build to. - Open Dungeon has zero automated tests and a positional summary watermark, making its branch retrofit larger than Phase 0A costed. - The world-state referee takes relative deltas; a 3B model sent absolute values under full context, so a wounded player ended at full health. Validation cannot catch this, so prefer ai-adventure's typed-event vocabulary when generalising narrative state. - Export/import recomputes head depth, so a round-trip silently undoes an undo. Must be fixed alongside the undo work. Docs only; no production code. Working tree from the runs stays untracked under phase0b/. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015gUPLuxLs8wypxZPEmccJu
9.0 KiB
Phase 0B — Spike: Non-Destructive Undo/Redo in AI-DnD
Date: 2026-09-01
Base: AI-DnD d72f7c1b, disposable copy at phase0b/spike/
Verdict: Passed on every criterion. The retrofit is small, centralized,
and costs 5 of 632 tests — all of which assert the deleted-row behavior that was
deliberately replaced.
The question
Can undo move a head cursor backward without deleting rows, does writing below a moved-back head fork a branch, and does Redo work — without breaking branch-scoped memory isolation?
What the change turned out to be
Three files, +130 / -31 lines, and roughly a fifth of the additions are comments and the new endpoint's docstring.
| File | Change |
|---|---|
app/context/lineage.py |
+26 / -3 |
app/routers/adventures/takes.py |
+74 / -27 |
app/routers/adventures/turns.py |
+30 / -1 |
1. Read path — cap the lineage at the head
The stored lineage records the newest branch entry uncapped (max_depth = None, meaning "through to the tip"), and older entries capped at their fork
depths. Path.clause and Path.contains now read an uncapped entry as capped at
self.tip, which is adventure.head_depth:
def _cap(self, max_depth: int | None) -> int | None:
return self.tip if max_depth is None else max_depth
This is the whole read-side change. It works because every read already funnels
through lineage.path_of(), so one substitution moves the entire application —
transcript paging, context assembly, attempts.preceding, and memory retrieval —
onto a head that can sit behind the deepest node.
A Path.uncapped() helper was added for the two callers that must deliberately
look past the head (redo, and the fork check).
prefix_covering already computed top = self.tip if max_depth is None else max_depth, so the windowing arithmetic was consistent with this reading before
the change; nothing there needed touching.
2. Undo — move the head instead of deleting
undo_turn keeps its turn lock, its "nothing to undo" guard and its
fork-boundary guard, keeps attempts.preceding + attempts.restore_state for
the state rollback, and replaces the two delete_turn calls plus
tree.refresh_head with one assignment:
adventure.head_depth = (first_removed.depth or 0) - 1
No db.delete. No memorybank.forget_node.
3. Write path — fork when writing below the head
turns.fork_if_behind_head runs in create_action beside the existing
_move_to_after. If any live node on the path sits deeper than the head, it calls
the already-existing tree.branch_at(db, adventure, adventure.head_depth),
which creates an empty branch leaving the path at that depth and does not touch
the branch being left. When the head is at the tip it does nothing, so a story
that is never undone forks exactly as often as before.
4. Redo — walk the head forward
New POST /adventures/{id}/redo. Reads the path uncapped, takes the next node
deeper than the head, and advances over the whole turn (player action + AI reply)
rather than half of it. 400 when there is nothing to redo.
Results
Undo deletes nothing; redo round-trips exactly
Six actions across three turns, live Ollama:
AFTER 3 TURNS head=(branch 1, depth 5) rows 1..6 all live
visible: [1 story, 2 ai, 3 do, 4 ai, 5 do, 6 ai]
UNDO x1 rows 6 -> 6 DELETED=0 head=(1, 3)
visible: [1 story, 2 ai, 3 do, 4 ai]
UNDO x2 more head=(1, -1)
visible: [] rows still 6
REDO x3 head=(1, 5) rows 6
visible: [1 story, 2 ai, 3 do, 4 ai, 5 do, 6 ai]
Elsewhere in the run, 11 consecutive undos were performed on a 23-action story with 0 rows deleted, which clears the specification's "at least five undo operations, unlimited preferred" comfortably.
Writing below a moved-back head forks, and the abandoned line survives
UNDO once, then write a different continuation:
1 story br1 d0 live 5 do br1 d4 live <- the abandoned tail,
2 ai br1 d1 live 6 ai br1 d5 live still live, still on br1
3 do br1 d2 live
4 ai br1 d3 live 7 do br2 d4 live <- the new line
8 ai br2 d5 live
branches: {id 1, parent null, fork_depth null, own_actions 6, is_head False}
{id 2, parent 1, fork_depth 3, own_actions 2, is_head True}
visible on the new line: [1, 2, 3, 4, 7, 8]
switch to branch 1 : [1, 2, 3, 4, 5, 6]
This is DECISIONS/005-branch-preserving-history.md behavior: the abandoned
future is retained as an alternate branch rather than erased, and it is reachable
through the existing branch switcher with no new UI concept.
Memory isolation still holds — the important regression check
A 23-action story with a real memory embedded via nomic-embed-text at depth 5.
Probed with terms unique to the turns an undo hides, plus terms from the turns it
keeps:
AT TIP (control) head_depth=22 memories_used=1 (similarity 0.689)
hidden-turn terms leaking: ['six bullets','warehouse nine','iron stairs',
'ledger','docks','office desk','inside my coat']
(correct — nothing is hidden at the tip)
visible-turn terms present: ['rainy city','desk drawer']
11 undos, 0 rows deleted (actions still 23)
AFTER UNDO head_depth=3 memories_used=0
hidden-turn terms leaking: NONE
visible-turn terms present: ['rainy city','desk drawer']
REDO back to tip head_depth=22 memories_used=1 (similarity 0.689)
The memory at depth 5 stops being retrieved when the head moves behind it and
becomes eligible again on redo — without being deleted. That is the property
the destructive undo bought by calling forget_node, recovered for free, because
Memory rows carry branch_id and depth and retrieval already goes through
the same capped clause.
One false alarm worth recording: an early probe reported
revolverleaking. It was legitimate visible history — actions 22 and 23 sit at depths 2 and 3, at or below the head. A second false alarm came from probing the context after the script had already redone to the tip. Both were resolved by re-probing inside a single script with an explicit control, which is why the table above reports the control run alongside the result.
Test suite: 627 passed, 5 failed
5 failed, 627 passed in 184s (0.8% of the suite)
tests/test_attempt_siblings.py::test_undo_takes_every_attempt_with_it
tests/test_branch_forking.py::test_undo_stops_at_the_fork
tests/test_state_revert.py::test_undo_reverts_state_to_before_the_turn
tests/test_state_revert.py::test_undo_of_bare_continue_uses_the_node_in_front
tests/test_state_revert.py::test_undo_prunes_memory_covering_removed_actions
Every one asserts that rows or memories were deleted:
assert [a.type for a in adv.actions] == ["start"](three of them)assert len(_rows(...)) == rows_before - 1assert texts == {"k"}— the pruned-memory set
Crucially, the state assertions inside those same tests still pass. In both
test_state_revert cases, assert adv.script_state == {"gold": 0} succeeds and
only the row-count line fails — state rollback is intact. And
test_undo_stops_at_the_fork still enforces its real subject: the guard
refusing to undo into a parent branch is untouched and still returns 400.
Also worth noting for the open question raised in the recommendation: the four
memory suites — test_memory_settling, test_memory_nodes,
test_memory_retrieval, test_memory_rewrite — and test_history_window all
passed unchanged. The cursor arithmetic did not need re-deriving.
Rough edges found, not fixed
- Undo can empty the story. The original guard refuses when the newest node
is
type == "start", but an adventure opened with a user-writtenstoryaction has nostartnode, so undo walks tohead_depth = -1and the transcript renders empty. Redo recovers it, but the floor should be the opening node rather than-1. - Retry and
add_takewere left on the old path. The spike only routed the ordinary write throughfork_if_behind_head. Retrying while the head is behind the tip is not yet defined and needs a decision — most likely the same fork. - No pruning story yet. Abandoned turns now accumulate. That matches the specification ("retained but marked disposable; cleanup later"), but nothing marks them disposable and no cleanup exists.
- Frontend untouched. There is no Redo button and the branch panel does not distinguish a line abandoned by undo from one forked deliberately.
Conclusion
The retrofit lands where the recommendation predicted: one chokepoint
(lineage.Path.clause), one stored head that already existed
(adventures.head_depth), and one branch primitive that already existed
(tree.branch_at). The unknown was whether the depth cap would disturb the
memory and summary cursors, and it does not.
The fork decision is sound. The remaining work on this axis is the four rough edges above plus named checkpoints, not a redesign.