0003. Branch conversations as new threads seeded via checkpointer state, not per-table branch IDs
Context and problem statement
Issue #55 needs git-like versioning of a co-construction session: view the turn history as a graph, fork an alternative path at any completed turn, and restore the studio to an earlier state. Conversation state lives in two stores. The LangGraph checkpointer versions themessages channel per thread_id, while the four user-visible surfaces (documents, preferences, todos, artifacts) are plain rows keyed by thread_id with no version axis (documents is even unique(thread_id)). Forking only the transcript would leave two branches sharing one workspace, silently corrupting both. The installed AsyncPostgresSaver (langgraph-checkpoint-postgres 3.1.0) does not implement acopy_thread (the base-class method raises NotImplementedError), so there is no library primitive for copying a thread either.
Decision drivers
- Must not touch the SSE turn loop (
query.py), the dynamic prompt, or the tool layer; every one of them is keyed bythread_idand regression risk is highest there. - Branch creation must cost O(document + preferences + todos + artifacts), not O(turns × document size):
workspace_snapshotsembeds the full document body per turn, so copying history is quadratic. - No cross-store atomicity exists (the checkpointer uses its own psycopg connection), so failure handling must be compensating, not transactional.
- Restore must be non-destructive: checkpointer truncation plus multi-table deletes cannot be made atomic and would permanently discard history.
Considered options
- Option A: branch = new
thread_id, workspace copied as-of the branch point, messages seeded into the new checkpointer thread, parent link in a smallconversation_branchestable. - Option B: add
branch_idto every workspace table and make all repositories, tools, the dynamic prompt, and the SSE router branch-aware. - Option C: LangGraph-native time travel: fork checkpoints in place on the same thread via checkpoint ids.
Decision outcome
Chosen option: Option A, because a freshthread_id flows through the turn loop, tools, dynamic prompt, and checkpointer config unchanged, and the missing message-copy primitive can be replaced by one verified aupdate_state call.
Mechanics (verified by backend/scripts/spike_branch_seeding.py against the exact pinned versions: langgraph 1.2.0, langgraph-checkpoint 4.1.0, langgraph-checkpoint-postgres 3.1.0):
- A minimal one-node
StateGraph(MessagesState)compiled with the sameAsyncPostgresSavercan read a thread written bycreate_react_agentand seed a brand-new thread withaupdate_state(config, {"messages": prefix}, as_node="__start__"); no LLM client, tools, or prompt are needed for branching. - The message prefix for turn k is every message strictly before the (k+1)-th
HumanMessage. - The workspace copy resolves “state after turn k” as
snapshot(k+1)when a later turn exists, else the live rows; artifact rows withturn_index <= kare copied so the new thread is self-contained. Snapshots are never copied; history for turns ≤ k is resolved lazily by walking theconversation_brancheslineage at read time. - Branch creation is 3-phase (SQL insert with
status='pending', then checkpointer seed, thenstatus='active'), with compensating cleanup (SQL rows plus best-effortadelete_thread) on failure. Readers only seeactivebranches. - Turn numbering continues from the parent (the branch’s
conversation_users.turn_indexstarts at k), keeping turns aligned across siblings for future compare features.
Positive consequences
- Zero changes to
query.py,builder.py, the tools, or the SSE lifecycle; a branch is “just another conversation” to every existing code path (switching reusesonLoadConversationverbatim). - Branch cost is independent of history length.
- The decision map / graph endpoint is a pure read over existing snapshots plus the new lineage table.
Negative consequences
- Copied workspaces diverge by design; there is no merge, and cross-branch dedup of identical documents is not attempted.
- The graph read must walk lineage to assemble inherited history (depth capped at 50).
- Seeding semantics depend on the pinned langgraph stack; bumps must re-run
backend/tests/test_branch_seeding_integration.py(the floor pins inrequirements.txtwere replaced with exact pins for this reason).
Pros and cons of the options
Option A: new thread plus seeded state (chosen)
- + No blast radius into the turn loop or tools; high feasibility per issue #55’s analysis.
- + Verified end-to-end by a spike before any plumbing was built.
- − Data duplication at the branch point; lineage walk needed for inherited history.
Option B: branch_id on every workspace table
- + Cleaner relational model; cheap cross-branch queries.
- − Touches the repositories, dynamic prompt, all tools, the SSE router, and the turn contextvar, the highest-regression-risk surface in the codebase. Rejected for v1 (explicitly out of scope in #55).
Option C: in-place checkpoint forking (LangGraph time travel)
- + No message copying at all.
- − Versions only the
messageschannel: the four workspace tables have no checkpoint axis, so branches would share one workspace row set, exactly the corruption #55 calls out. Also a confusing UX (two histories inside one conversation).
Links
- Related ADRs: 0001-two-branch-model
- Related issues / PRs: #55 (also #48, #49, #50 discussions; #54 adjacent)
- External references: LangGraph checkpointer base class (
BaseCheckpointSaver.acopy_threadraisesNotImplementedErrorin langgraph-checkpoint 4.1.0)