Skip to main content

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 the messages 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 by thread_id and regression risk is highest there.
  • Branch creation must cost O(document + preferences + todos + artifacts), not O(turns × document size): workspace_snapshots embeds 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

  1. Option A: branch = new thread_id, workspace copied as-of the branch point, messages seeded into the new checkpointer thread, parent link in a small conversation_branches table.
  2. Option B: add branch_id to every workspace table and make all repositories, tools, the dynamic prompt, and the SSE router branch-aware.
  3. 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 fresh thread_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 same AsyncPostgresSaver can read a thread written by create_react_agent and seed a brand-new thread with aupdate_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 with turn_index <= k are copied so the new thread is self-contained. Snapshots are never copied; history for turns ≤ k is resolved lazily by walking the conversation_branches lineage at read time.
  • Branch creation is 3-phase (SQL insert with status='pending', then checkpointer seed, then status='active'), with compensating cleanup (SQL rows plus best-effort adelete_thread) on failure. Readers only see active branches.
  • Turn numbering continues from the parent (the branch’s conversation_users.turn_index starts 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 reuses onLoadConversation verbatim).
  • 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 in requirements.txt were 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 messages channel: 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).
  • 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_thread raises NotImplementedError in langgraph-checkpoint 4.1.0)