返回 DeepSeek-Reasonix
persistent-turn-fork-report.md
根目录 / docs / persistent-turn-fork-report.md
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
216 lines MARKDOWN