Skip to content

feat(runtime-api): fork a thread at a named turn - #6580

Merged
Hmbown merged 7 commits into
codewhale-hq:mainfrom
gaord:feat/fork-at-turn
Sep 26, 2026
Merged

Hmbown merged 7 commits into
codewhale-hq:mainfrom
gaord:feat/fork-at-turn

Conversation

@gaord

@gaord gaord commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Refs #6483 — the saved-history alignment this fork cut rebases onto. The endpoint has no tracking issue of its own; the client half lands separately in the CodeWhale VSCode extension.

Adds one endpoint:

  • POST /v1/threads/{id}/fork-at-turn ({"turn_id": "turn_…"}) forks a thread at a named user turn. The fork keeps that turn and every turn before it and drops the turns after it; naming the last turn therefore keeps the whole conversation. The receipt is /undo's shape (thread, original_user_text, original_user_images) and carries the first dropped turn's prompt — what was asked next — so a client can put it back in the composer.

Why a named turn instead of another depth: a client cannot count these turns for itself. The transcript a client renders and the turn store this cuts are not the same list — steers, image-only prompts and injected handoffs each sit on one side only — so a depth the client computed is an off-by-one waiting to fork the wrong prefix while answering 201. Naming the turn moves that decision to the side that owns the list. The enumeration itself (user_turn_indices) is now one function shared by both cuts.

No file rollback: the branch left behind shares the workspace, so a fork stays a sibling conversation and only /undo and /patch-undo touch files. The suffix-cloning path is shared with the depth-relative cut (prepare_fork_from_cutoff), which is where the two anchors could otherwise drift apart in the saved-history alignment added by #6483 — that alignment names the first dropped turn for either cut.

Branching is also allowed while a turn is running, and does not interrupt it (the runtime owns the turn; a client that switches away only stops watching). Nothing here reads or writes the running turn, and the new test pins that the source record, the running turn's status and its items are untouched.

Tests (all on top of main @ 8a835d7):

  • cargo test -p codewhale-tui --lib fork_ → 31 passed / 0 failed (includes the two new endpoint tests: keep-the-anchor cut and returned prompt; whole-conversation cut with nothing to return; non-user turn → 400)
  • cargo test -p codewhale-tui --lib undo → 19 passed; --lib retry → 74 passed; --lib saved_history → 2 passed; --lib boundary → 71 passed
  • rustfmt --edition 2024 --check clean on the four touched files

End to end against a runtime built from this branch (isolated CODEWHALE_HOME, seeded from a real store, no model calls): GET-probe of the route answers 405 (what a client's capability probe needs); fork at an earlier turn keeps ["first"] and returns the next prompt; fork at the last turn keeps both turns and returns nothing; the source thread is unchanged; a non-user turn is 400 and an unknown thread is 404.

The client half (a per-turn "branch" row in the CodeWhale VSCode extension) lands separately; it is capability-gated on this route, so an older engine simply offers no row.

@gaord
gaord requested a review from Hmbown as a code owner September 25, 2026 13:05
@gaord
gaord force-pushed the feat/fork-at-turn branch 2 times, most recently from c6804ea to 8193c59 Compare September 26, 2026 03:41
@gaord
gaord marked this pull request as draft September 26, 2026 03:59
POST /v1/threads/{id}/fork-at-turn names the user turn a client is looking at
and keeps it and everything before it, dropping the turns after it. Naming the
last turn keeps the whole conversation — the same rule read at the end of the
thread — and the receipt carries the first dropped turn's prompt, which is
what a client puts back in the composer to continue from there.

The anchor is a turn id, not a distance from the tail: the transcript a client
renders and the turn store this cuts are not the same list (steers, image-only
prompts and injected handoffs each sit on one side only), so a depth the
client computed is an off-by-one waiting to fork the wrong prefix while
reporting success.

No file rollback: the branch left behind shares the workspace, so `fork` stays
a sibling conversation and only `/undo` and `/patch-undo` touch files. The
suffix-cloning path is shared with the depth-relative cut
(`prepare_fork_from_cutoff`), which is where the two anchors could otherwise
drift apart in the saved-session alignment — that alignment names the first
dropped turn for either cut.

Verified on 8a835d7, the saved-session alignment this rebases onto: cargo
test -p codewhale-tui --lib fork_ 30 passed / undo 19 passed / retry 74 passed
/ saved_history 2 passed / boundary 71 passed, rustfmt clean, and the endpoint
driven end-to-end against a runtime built from this branch (201, keeps the
anchor turn, returns the next prompt, source unchanged, 400 for a non-user
turn, 404 for an unknown thread).
Branching a conversation while a turn is still in flight is the case this
endpoint exists for — a person watching a turn go the wrong way should not have
to stop it (and lose what it has already done) to take another path. The test
seeds a thread whose newest turn is InProgress, forks at the finished turn
before it, and pins both halves: the fork keeps the turn it names and hands
back the question the running turn is answering, and the source thread — its
record, the running turn's status and its items — is not touched.

cargo test -p codewhale-tui --lib fork_ 31 passed (undo 19, retry 74).
Branching a thread — and the /undo, /retry and backtrack cuts that share its
body — took about a minute on a long conversation, past the point where a
client gives up on the request. Three costs added up: each turn's items were
found by scanning the store's entire items directory, every cloned item was
written with a full directory sweep and two syncs, and the saved transcript's
alignment rebuilt a projection for every candidate prefix.

A 45,000-item store with a 5 MB session went from 76.5s to 8.6s for a
two-turn branch, and from 115.9s to 11.5s for the same cut behind undo; on a
49,000-item store a release build branches at an earlier turn in 9.9s where it
took 92.2s. Items are read in one pass, published in one batch that still
fsyncs every file and the directory once, and the alignment is found in one
walk — the commit order (items, turns, then the thread) and the cleanup after
a failed publish are unchanged.

cargo test -p codewhale-tui --lib fork_/undo/retry/boundary/saved_session/
compaction/restore_ 418 passed, rustfmt clean, clippy clean under the CI allow
list, and the fork endpoint end-to-end against a runtime built from this
branch.
Once a conversation has been compacted, what is saved is the summary plus the
prompts that stayed verbatim: an abbreviation of the turns rather than their
transcript. A branch sliced that abbreviation and then claimed, through its
checkpoint, to cover turns its own session document did not carry — so the
branch opened as a list of questions, with the answers and tool calls that
went with them missing, and resuming it had nothing left to rebuild them from.

The turn records keep every item of every turn, so a branch of a compacted
conversation now rebuilds the turns it keeps from them — the conversation the
thread view shows — while an uncompacted source keeps copying its saved
transcript verbatim, exactly as before.

Reproduced on a real 15-turn conversation that had been compacted: the
branch's session document went from 12 messages, every one of them a prompt,
to 1004 messages (503 user, 501 assistant) carrying 501 tool calls, with the
source thread, its document and the workspace untouched. The regression test
fails on the pre-fix code, where the document holds the prompts alone.

cargo test -p codewhale-tui --lib fork_/undo/retry/boundary/saved_session/
compaction/restore_ 418 passed, rustfmt clean, clippy clean under the CI allow
list, and the fork endpoint end-to-end against a runtime built from this
branch (13/13 checks).
…#6580

Adds the 0.10.1 Unreleased "Added" entry for POST
/v1/threads/{id}/fork-at-turn, adds codewhale-hq#6580 to @gaord's credit line in
CHANGELOG.md and docs/CONTRIBUTORS.md, and regenerates
crates/tui/CHANGELOG.md and web/lib/changelog.generated.ts.
@gaord is already in UNRELEASED_CONTRIBUTORS.

Verification (docs-only change; no Rust touched):
- scripts/sync-changelog.sh --check: up to date
- scripts/release/check-versions.sh --range-audit-advisory: OK
- scripts/check-contributor-credit.py v0.10.0: 5 contributors, all credited
- web vitest lib/public-copy.test.ts: 6 passed / 0 failed
- blocking-calls, dead-code, command-crate-boundaries budgets, module_graph --check, cargo fmt --check: pass

Refs codewhale-hq#6580

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Hmbown Hmbown added this to the v0.10.1 milestone Sep 26, 2026
@gaord
gaord marked this pull request as ready for review September 26, 2026 05:50
CodeWhale Bot and others added 2 commits September 25, 2026 23:03
…paction

A named-turn fork cuts one past its anchor, and took the receipt (prompt,
images, output allowance, dropped_turn_id) from the turn at the cut. A
manual /compact is a turn of its own with no prompt, so branching at the
turn before one returned no prompt and named the compaction as dropped;
on a source whose saved history needed boundary alignment it refused the
fork outright.

The cut still drops everything after the anchor. The receipt and the
saved-history boundary now come from the first dropped *user* turn; when
no user turn is dropped, the whole saved transcript belongs to kept turns.
The depth-relative /undo path passes its own user-turn index, unchanged.

Tests: cargo test -p codewhale-tui --lib fork_ -> 33 passed, 0 failed
(new: fork_at_user_turn_receipt_skips_a_compaction_turn_after_the_anchor);
--lib backtrack -> 25 passed, 0 failed. cargo fmt --check clean; blocking
calls, dead code, command crate boundary and module graph gates pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A saved session is repaired on the way out: `resume_session` re-pairs tool
calls with their results, adds a notice for a call that never returned, and
writes the repair back. A fork rebuilt its prefix from the turn records and
fingerprinted that written shape, so its checkpoint named bytes no reader ever
sees. Everything downstream then rejected the fork — restoring it refused the
document, and resuming the session did not recognise the thread that held it
(`thread_holding_session` compares exactly that fingerprint), so it opened a
*second* thread for the same file. That second thread's save rebound only its
own checkpoint, leaving the fork's stale, and the fork's next message failed
with "Saved session … changed after this thread's checkpoint" — reported from
the VS Code client after branching a compacted conversation.

The checkpoint now fingerprints the document as it is read, which is also what
the file holds once the fork has written it: the repair and the write agree.

cargo test -p codewhale-tui --lib a_fork_fingerprints_the_document_its_readers_see
fails on the pre-fix code (the fork's own thread is not recognised) and passes
here; fork_/undo/retry/boundary/saved_session/compaction/restore_ 419 passed,
rustfmt clean, clippy clean under the CI allow list, and the resume path driven
end-to-end against a runtime built from this branch: one thread for the
session, no stale checkpoint, and the next turn reaching the provider.
@Hmbown
Hmbown merged commit d49fb9d into codewhale-hq:main Sep 26, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants