| 1 | # Persistent turn fork implementation report |
| 2 | |
| 3 | Date: 2026-09-14 |
| 4 | Base: `main-v2` at `236db8c16` |
| 5 | |
| 6 | A completed turn can now be forked into an independent child session while its |
| 7 | parent keeps running, while the parent is read-only, and after a restart. The |
| 8 | authority for "this turn ended" is the persisted event log, never a checkpoint: |
| 9 | checkpoints keep their own job of file rollback. |
| 10 | |
| 11 | ## Delivered behavior |
| 12 | |
| 13 | - **Turn boundaries come from the log.** The session projection records two |
| 14 | facts per completed turn: `MessageID`, the stable transcript identity of the |
| 15 | turn's final text reply, and `BoundarySequence`, the last sequence of the |
| 16 | commit that closed the turn. Eligibility is computed after the complete |
| 17 | closing commit has been projected: the turn must be closed, the boundary must |
| 18 | be the commit tail, and no interaction or tool authority may remain. |
| 19 | Completion is never inferred from answer text, elapsed time, or run state. |
| 20 | - **A cut covers a whole commit.** `BoundarySequence` is the closing commit's |
| 21 | last sequence, not the `turn/end` event's. A turn end and the state that ends |
| 22 | with it can share one commit, and cutting at the event would either inherit |
| 23 | half an operation or be refused by the safe-prefix check. Both refusals keep |
| 24 | their own typed reason (`ErrForkActiveAuthority`, `ErrForkBoundaryNotAtomic`) |
| 25 | and the commit is never trimmed to fit. |
| 26 | - **One target query for every read path.** `ForkTarget` / `ForkTargetSet` / |
| 27 | `ForkTargets` / `ForkSequence` answer from committed events only, so a live |
| 28 | source, a source owned by another process, and a cold read of a closed session |
| 29 | produce the same targets. The open turn is listed with `available: false, |
| 30 | reason: turn_open`, which is what lets a surface disable exactly that turn |
| 31 | instead of every turn while the session runs. |
| 32 | - **Legacy history is refused, not guessed.** A source that keeps messages |
| 33 | without turn records reports `verifiable: false` and an empty target list. |
| 34 | - **Read-only and cold sources fork.** `Session.Fork` no longer refuses a |
| 35 | session without a leased writer, and the cold read handle exposes its |
| 36 | directory, so a child copies the parent's owned files without acquiring the |
| 37 | parent's writer lease. A parent owned by another process keeps its lease and |
| 38 | its running task. |
| 39 | - **Creation is separate from navigation.** `Service.CreateFork` publishes the |
| 40 | child and returns its identity without opening a runtime, switching a |
| 41 | controller, or writing the parent. `Controller.CreateForkSession` takes no |
| 42 | rotation gate, so a running parent keeps running. The desktop opens the child |
| 43 | in a new tab afterwards; when that attach fails, the result still carries the |
| 44 | child session id and a recoverable error, and the child is never deleted. |
| 45 | - **Unknown results survive restart.** Desktop owns `operationId` and persists a |
| 46 | pending record in `fork-operations.json` before local writes or network |
| 47 | requests. Timeouts, disconnects, decode failures, and uncertain internal |
| 48 | errors retain it. The record becomes completed before success reaches the UI |
| 49 | and is removed only after the child is adopted. A later intentional click |
| 50 | then receives a new id and may create another child from the same turn. |
| 51 | - **Remote creation does not take over the parent.** Fenced `GET /fork-targets` and |
| 52 | `POST /fork-session` are create-only and leave the foreground session, the |
| 53 | broadcast binding, and the lease untouched. They are advertised as |
| 54 | `session-fork-targets-v1`; a desktop talking to a server without it reports |
| 55 | the server as unsupported rather than falling back to `/fork`. The response |
| 56 | names the authoritative source; create requires `sourceSessionId`, `turnId`, |
| 57 | `boundarySequence`, and `operationId`, and refusals use structured JSON. |
| 58 | - **The button follows the persisted log, not checkpoints.** The transcript |
| 59 | matches a turn to its target by stable message identity |
| 60 | (`ForkTargetView.messageId` against the rendered answer's message id), so live |
| 61 | completion, paged history, and cold restore share one mapping. Creation carries |
| 62 | the target's source identity and boundary, so a tab switch cannot reinterpret |
| 63 | an inherited turn id in another session. Fork no longer |
| 64 | reads checkpoints or the session-wide running flag; the unfinished turn alone |
| 65 | stays unavailable while a turn runs. Checkpoints continue to drive file |
| 66 | rewind only. |
| 67 | - **Every refusal names itself.** The fork entry reports its own state — turn not |
| 68 | finished, targets still loading, boundary unverifiable, server unsupported, |
| 69 | stale source, creation in flight — localized in English, Simplified Chinese, and Traditional |
| 70 | Chinese, and failures reach the existing notice channel instead of being |
| 71 | swallowed. |
| 72 | - **Legacy paths are unchanged.** `Fork`, `ForkForTab`, `ForkWorktreeForTab`, |
| 73 | `ForkRemoteTab`, `POST /fork`, and every rewind scope keep their previous |
| 74 | semantics and rotation protection, including the dirty-worktree checks. The |
| 75 | switching fork commands remain available to the CLI and to older clients even |
| 76 | though the desktop UI no longer calls them. |
| 77 | |
| 78 | ## Compatibility result |
| 79 | |
| 80 | The durable conversation format did not change. The v4 log, manifest, frame |
| 81 | codec, and content store are untouched, and the child is written in the current |
| 82 | format. Desktop adds a separate host-owned operation journal, and the rebuildable |
| 83 | recovery projection version advances so old cached projections cannot omit the |
| 84 | new availability field. |
| 85 | |
| 86 | | Field or format | Old-data behavior | New-reader behavior | Previous-reader behavior | Conclusion | |
| 87 | | --- | --- | --- | --- | --- | |
| 88 | | `events.frames`, `manifest.json`, frames, `.content-v1` | unchanged | reads as before | reads new writes | no format change | |
| 89 | | `Projection`, `TurnBoundary` (+availability) | durable events unchanged | recomputed from complete commits | old recovery projection v1 is rejected and rebuilt | safe cache invalidation | |
| 90 | | `fork-operations.json` | absent | created atomically on the Desktop host and removed after acknowledgement | ignored | additive host state | |
| 91 | | Host RPC contract | pre-release correction | anchors replace turn-only create arguments; acknowledgement added | capability was not published | safe to correct in place | |
| 92 | | Serve capability set | additive token | advertises `session-fork-targets-v1` | older desktop uses `/fork` | safe | |
| 93 | | Rewind checkpoints (`.ckpt/` sidecars) | unchanged | unchanged; rewind still uses them | unchanged | safe | |
| 94 | |
| 95 | Pre-existing inconsistency found while testing, not introduced here and left |
| 96 | as-is: `projectLegacyImport` accepts a `source` field on `legacy/import`, but |
| 97 | `internal/session/history_index.go` decodes the same event under |
| 98 | `DisallowUnknownFields` with only `messages`, so an import event carrying |
| 99 | `source` fails a history-index rebuild. The only production writer emits |
| 100 | `{"messages": …}`, so no current path triggers it; a future writer that adds |
| 101 | `source` would break cold history paging. |
| 102 | |
| 103 | ## Cache contract |
| 104 | |
| 105 | `scripts/check-cache-impact.sh` reports **"No cache-sensitive prompt/tool files |
| 106 | changed."** No provider-visible prompt, memory prefix, tool schema, or provider |
| 107 | request serialization was touched, so no cache-hit warning applies. |
| 108 | |
| 109 | The new projection fields are not part of `provider.Message`, and |
| 110 | `ModelMessages` construction is unchanged; `TestProviderRequestBytesSurviveSessionV4RoundTrip` |
| 111 | passes. A child inherits the exact event prefix through the target boundary, so |
| 112 | its model context is the projection the parent had at that boundary, and no UI |
| 113 | anchor, disable reason, or operation id enters a model message. |
| 114 | |
| 115 | This report does not claim a cache hit-rate effect for the child's first |
| 116 | request. The verified statement is narrower: the parent's request bytes are |
| 117 | unchanged, and the child's inherited prefix equals the parent's projection at |
| 118 | the target boundary. |
| 119 | |
| 120 | ## Verification evidence |
| 121 | |
| 122 | Independently re-run in the worktree, not only reported by the implementing |
| 123 | agent: |
| 124 | |
| 125 | | Command | Result | |
| 126 | | --- | --- | |
| 127 | | `go test ./internal/session ./internal/control ./internal/serve ./internal/servecontract/... -count=1` | ok on the merged final tree | |
| 128 | | `go test ./internal/session -run 'ForkTarget\|CreateFork\|ForkAvailability' -race -count=1` | ok | |
| 129 | | `cd desktop && go test -race -run 'ForkTargets\|CreateFork\|ForkOperation\|ForkedSessionLocator' -count=1 .` | ok | |
| 130 | | `go test ./... -run '^$' && go build ./internal/... ./cmd/...` | ok | |
| 131 | | root and Desktop `golangci-lint run --timeout=5m ./...` | 0 issues | |
| 132 | | `go run ./tools/repolint` | clean (1,230 baselined findings) | |
| 133 | | `scripts/check-cache-impact.sh` | no cache-sensitive files changed | |
| 134 | | `go run ./tools/desktopinventory -check` | current, 761 entries | |
| 135 | | `cd desktop && go test -run 'HostContract\|HostCommandOwners\|HostShellRemote' -count=1 .` | ok | |
| 136 | | `cd desktop && go test -count=1 .` | ok on the merged final tree | |
| 137 | | `cd desktop/frontend && pnpm build` | ok, typecheck and bundle budgets included | |
| 138 | | `tsx src/__tests__/turn-fork-transcript.test.tsx` | ok | |
| 139 | | `node scripts/run-tests.mjs --keep-going` (frontend) | all 360 suites passed | |
| 140 | | Locale parity across `en.ts` / `zh.ts` / `zh-TW.ts` | all 11 `chat.branch*` keys present in each | |
| 141 | | `node bench/fork-targets.mjs` (Chromium, real Transcript) | PASS on the final tree | |
| 142 | | `node bench/fork-targets-app.mjs` (built app, `/?mock=1`) | PASS on the final tree | |
| 143 | | `make lint-cross` | root linux/darwin/windows clean; stopped on four pre-existing unused Desktop linux tray stubs, unchanged from `origin/main-v2` | |
| 144 | |
| 145 | The browser bench runs against the real `Transcript` with isolated fixture data |
| 146 | and reads the rendered DOM, not internal state: |
| 147 | |
| 148 | - A completed turn renders an enabled entry: `aria-disabled` absent, |
| 149 | tooltip and `aria-label` read "Branch into a new conversation" in English and |
| 150 | "在新对话中分支" in Simplified Chinese. |
| 151 | - The unfinished trailing turn renders `aria-disabled="true"` with the reason |
| 152 | "This turn has not finished yet, so it has no boundary to branch from." |
| 153 | ("该轮次尚未结束,还没有可供分支的边界。"). |
| 154 | - A source whose history keeps no turn records renders the boundary as |
| 155 | unverifiable ("该轮次在会话记录中没有可确认的分支边界。"). |
| 156 | - Clicking dispatches the target's source session, generation, stable `turnId`, |
| 157 | and boundary. Desktop, rather than the renderer, assigns operation ids. |
| 158 | - In the built app, a click adopts the child tab; with the fixture forced into an |
| 159 | attach failure, the notice names the created child and a **second click returns |
| 160 | the same child**. After acknowledgement, another click may create a second |
| 161 | intentional child. |
| 162 | |
| 163 | The session tests cover: a completed turn listed after the controller is gone |
| 164 | and the session is re-opened read-only; a cold fork inheriting only the prefix |
| 165 | through the target turn; an open trailing turn leaving earlier turns forkable; an |
| 166 | unknown turn id refused instead of redirected to the newest turn; a cut covering |
| 167 | the whole commit that closed the turn; idempotent retry per operation id; a |
| 168 | read-only source yielding a writable child with the source log unchanged; a |
| 169 | boundary whose commit leaves execution authority open refused with its own |
| 170 | reason and publishing nothing; authority resolved later in the same atomic |
| 171 | commit accepted; source replacement refused as `stale_source`; operation |
| 172 | recovery across host reconstruction; and message-only history reported unverifiable. |
| 173 | |
| 174 | ## Known gaps |
| 175 | |
| 176 | - **A fork requested while another rewind is committing is no longer blocked in |
| 177 | the UI.** That is the intended consequence of dropping the session-wide |
| 178 | disable; the host refuses it with its own reason instead. |
| 179 | - An imported `legacy/import` event carrying a `source` field would fail a |
| 180 | history-index rebuild (see Compatibility result). Pre-existing, not triggered |
| 181 | by any current writer. |
| 182 | |
| 183 | ## Deliberately omitted evidence |
| 184 | |
| 185 | - **Remote fork in a browser.** The remote create path is exercised only by node |
| 186 | tests (anchored call shape, structured refusal, acknowledgement). No browser |
| 187 | run covers it, because that needs a live Serve surface to attach to. |
| 188 | - **The two bench gates are not wired into `package.json`.** They are runnable by |
| 189 | the commands above but do not yet run in CI, so nothing prevents them from |
| 190 | rotting. |
| 191 | |
| 192 | - **Packaged desktop and native shell.** No package was built, signed, or |
| 193 | launched, so production-mode shell/service startup is unverified. |
| 194 | - **Windows and Linux runtime.** Root cross-platform lint passed for linux, |
| 195 | darwin, and windows. Desktop linux cross-lint remains blocked by four |
| 196 | pre-existing unused tray stubs that are unchanged from `origin/main-v2`; no |
| 197 | packaged application was run on either platform. |
| 198 | - **Live provider calls.** No real-API run was made; the child's inherited |
| 199 | context is verified against persisted projections, not against a provider. |
| 200 | - **Cross-process lease behaviour under contention.** The read-only path is |
| 201 | designed to take no lock and the tests cover cold reads after close, but no |
| 202 | test drives two live processes contending for one session. |
| 203 | |
| 204 | ## Fixture finding worth keeping |
| 205 | |
| 206 | The locale switch in the bench was initially asserted synchronously and failed |
| 207 | about once in seven runs. The cause is a property of the app, not of the fork |
| 208 | work: `src/lib/i18n.tsx` loads a locale dictionary on demand and `translate` |
| 209 | falls back to English until the chunk resolves, so the first render after a |
| 210 | switch legitimately shows English. Measured apply latency was 12-98 ms across |
| 211 | ten runs, so nothing is lost or stuck — the page simply never promised a |
| 212 | synchronous switch. The bench now waits on the rendered value |
| 213 | (`page.waitForFunction`, 10 s bound) instead of sleeping, and passed on the |
| 214 | final tree. Any future browser check that reads localized text must do the |
| 215 | same. |
| 216 |