返回 DeepSeek-Reasonix
fork_tab.go
根目录 / desktop / fork_tab.go
1 package main
2
3 import (
4 "errors"
5 "fmt"
6 "log/slog"
7 "path/filepath"
8 "strings"
9 "sync/atomic"
10
11 "reasonix/internal/agent"
12 "reasonix/internal/config"
13 "reasonix/internal/control"
14 "reasonix/internal/worktree"
15 )
16
17 const rewindForkAttachError = "conversation fork was created but could not be opened; open the recovery branch from session history"
18
19 // forkTabBeforePublishHookForTest forces the persistence-to-publish interleaving.
20 var forkTabBeforePublishHookForTest atomic.Pointer[func()]
21
22 type forkedSessionTabOpen struct {
23 tab TabMeta
24 workspaceReferenced bool
25 }
26
27 // ForkWorktreeResultView distinguishes a real isolated fork from a safe shared
28 // fallback and from a dirty-source refusal. The ordinary ForkForTab contract is
29 // intentionally unchanged for embedded frontend/backend compatibility.
30 type ForkWorktreeResultView struct {
31 Tab TabMeta `json:"tab"`
32 Isolated bool `json:"isolated"`
33 FallbackToShared bool `json:"fallbackToShared,omitempty"`
34 SourceDirty bool `json:"sourceDirty,omitempty"`
35 Branch string `json:"branch,omitempty"`
36 }
37
38 // forkForTabWithOptions forks the requested source tab, optionally creating an
39 // isolated Git worktree for the new tab so changes in the fork do not mutate the
40 // source workspace.
41 func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace bool) (ForkWorktreeResultView, error) {
42 sourceTab, ctrl := a.tabAndCtrlByID(tabID)
43 if sourceTab == nil || ctrl == nil {
44 return ForkWorktreeResultView{}, nil
45 }
46 if a.tabIsReadOnly(sourceTab) {
47 return ForkWorktreeResultView{}, readOnlyChannelErr()
48 }
49 if err := a.ensureTabControllerWorkspace(sourceTab); err != nil {
50 return ForkWorktreeResultView{}, err
51 }
52 a.mu.RLock()
53 if a.tabs[sourceTab.ID] != sourceTab || sourceTab.Ctrl == nil {
54 a.mu.RUnlock()
55 return ForkWorktreeResultView{}, nil
56 }
57 ctrl = sourceTab.Ctrl
58 scope := sourceTab.Scope
59 srcRoot := sourceTab.WorkspaceRoot
60 a.mu.RUnlock()
61
62 result := ForkWorktreeResultView{}
63 var created worktree.Result
64 if isolateWorkspace {
65 if scope != "project" || strings.TrimSpace(srcRoot) == "" {
66 result.FallbackToShared = true
67 } else {
68 avail := inspectDeliveryWorktree(a.bootContext(), srcRoot)
69 if !avail.Available {
70 result.FallbackToShared = true
71 } else if avail.SourceDirty {
72 result.SourceDirty = true
73 return result, nil
74 } else {
75 var createErr error
76 created, createErr = func() (worktree.Result, error) {
77 releaseAdmission, err := a.beginWorkspaceRuntimeAdmission(srcRoot)
78 if err != nil {
79 return worktree.Result{}, err
80 }
81 defer releaseAdmission()
82 return createDeliveryWorktree(a.bootContext(), srcRoot, config.DeliveryWorktreeDir())
83 }()
84 if createErr != nil {
85 return ForkWorktreeResultView{}, fmt.Errorf("create isolated fork worktree: %w", createErr)
86 }
87 if created.SourceDirty {
88 if rollbackErr := rollbackDeliveryWorktree(a.bootContext(), created); rollbackErr != nil {
89 return ForkWorktreeResultView{}, fmt.Errorf("source changed while creating isolated worktree at %s; automatic cleanup failed: %w", created.WorktreeRoot, rollbackErr)
90 }
91 result.SourceDirty = true
92 return result, nil
93 }
94 result.Isolated = true
95 result.Branch = created.Branch
96 }
97 }
98 }
99
100 // Chat forks always become independent sessions so the source remains in
101 // the sidebar and the child can be addressed, renamed, and reopened on its
102 // own. This also applies to schema-2 transcripts; in-log heads remain an
103 // implementation detail for recovery and rewind operations.
104 newPath, err := ctrl.ForkSession(turn, "")
105 if err != nil {
106 return ForkWorktreeResultView{}, a.rollbackUnusedForkWorktree(created, err)
107 }
108 exclusiveV3 := false
109 if identity, ok := ctrl.(control.IdentityLifecycle); ok {
110 exclusiveV3 = identity.UsesExclusiveSession()
111 }
112 if !exclusiveV3 {
113 if err := copyPinnedContextState(ctrl.SessionPath(), newPath); err != nil {
114 cleanupErr := removeDesktopSessionArtifacts(newPath)
115 return ForkWorktreeResultView{}, a.rollbackUnusedForkWorktree(created, errors.Join(err, cleanupErr))
116 }
117 }
118 locator := forkedSessionLocator{SessionPath: newPath}
119 if exclusiveV3 {
120 locator = forkedSessionLocator{SessionID: newPath}
121 if err := a.attachForkedDesktopSession(a.bootContext(), sourceTab, newPath); err != nil {
122 return ForkWorktreeResultView{}, a.rollbackUnusedForkWorktree(created, fmt.Errorf("publish fork workspace membership: %w", err))
123 }
124 }
125 opened, err := a.openForkedSessionTabWithWorkspace(sourceTab, locator, created.WorkspaceRoot)
126 result.Tab = opened.tab
127 if err != nil {
128 if opened.workspaceReferenced {
129 return result, err
130 }
131 return ForkWorktreeResultView{}, a.rollbackUnusedForkWorktree(created, err)
132 }
133 if result.Tab.ID == "" {
134 if opened.workspaceReferenced {
135 return result, errors.New(rewindForkAttachError)
136 }
137 return ForkWorktreeResultView{}, a.rollbackUnusedForkWorktree(created, errors.New(rewindForkAttachError))
138 }
139 return result, nil
140 }
141
142 func (a *App) rollbackUnusedForkWorktree(created worktree.Result, cause error) error {
143 if strings.TrimSpace(created.WorktreeRoot) == "" {
144 return cause
145 }
146 if err := rollbackDeliveryWorktree(a.bootContext(), created); err != nil {
147 return errors.Join(cause, fmt.Errorf("preserve unused isolated worktree at %s after cleanup failed: %w", created.WorktreeRoot, err))
148 }
149 return cause
150 }
151
152 // openForkedSessionTab attaches an already-written fork session to a new tab.
153 // The source tab keeps its controller and transcript. The fork becomes active
154 // only while the source tab still owns focus.
155 func (a *App) openForkedSessionTab(sourceTab *WorkspaceTab, newPath string) (TabMeta, error) {
156 locator := forkedSessionLocator{SessionPath: newPath}
157 if identity, ok := sourceTab.Ctrl.(control.IdentityLifecycle); ok && identity.UsesExclusiveSession() {
158 locator = forkedSessionLocator{SessionID: newPath}
159 }
160 opened, err := a.openForkedSessionTabWithWorkspace(sourceTab, locator, "")
161 return opened.tab, err
162 }
163
164 // forkedSessionLocator prevents an immutable v3 session id from entering the
165 // legacy path catalog, where filepath.Dir("session-id") would become ".".
166 type forkedSessionLocator struct {
167 SessionID string
168 SessionPath string
169 }
170
171 func normalizeForkedSessionLocator(sourceTab *WorkspaceTab, locator forkedSessionLocator) (forkedSessionLocator, error) {
172 locator.SessionID = strings.TrimSpace(locator.SessionID)
173 locator.SessionPath = strings.TrimSpace(locator.SessionPath)
174 if sourceTab == nil || (locator.SessionID == "") == (locator.SessionPath == "") {
175 return forkedSessionLocator{}, fmt.Errorf("fork tab needs exactly one session id or session path")
176 }
177 if locator.SessionPath == "." || (locator.SessionPath != "" && filepath.Base(locator.SessionPath) == locator.SessionPath) {
178 return forkedSessionLocator{}, fmt.Errorf("fork tab needs a concrete session path")
179 }
180 return locator, nil
181 }
182
183 // openForkedSessionTabWithWorkspace attaches an already-written fork session to a new tab,
184 // optionally overriding the workspace root (e.g. for isolated Git worktrees).
185 func (a *App) openForkedSessionTabWithWorkspace(sourceTab *WorkspaceTab, locator forkedSessionLocator, workspaceRootOverride string) (forkedSessionTabOpen, error) {
186 locator, err := normalizeForkedSessionLocator(sourceTab, locator)
187 if err != nil {
188 return forkedSessionTabOpen{}, err
189 }
190 a.mu.RLock()
191 if a.tabs[sourceTab.ID] != sourceTab {
192 a.mu.RUnlock()
193 return forkedSessionTabOpen{}, nil
194 }
195 scope := sourceTab.Scope
196 workspaceRoot := sourceTab.WorkspaceRoot
197 if strings.TrimSpace(workspaceRootOverride) != "" {
198 workspaceRoot = workspaceRootOverride
199 }
200 sourceTitle := sourceTab.TopicTitle
201 model := sourceTab.model
202 effort := cloneStringPtr(sourceTab.effort)
203 mode := currentTabMode(sourceTab)
204 toolApprovalMode := currentTabToolApprovalMode(sourceTab)
205 disabledMCP := cloneServerViewMap(sourceTab.disabledMCP)
206 mcpOrder := append([]string(nil), sourceTab.mcpOrder...)
207 a.mu.RUnlock()
208 if scope == "project" {
209 releaseAdmission, err := a.beginWorkspaceRuntimeAdmission(workspaceRoot)
210 if err != nil {
211 return forkedSessionTabOpen{}, err
212 }
213 defer releaseAdmission()
214 }
215
216 topicID := newTopicID()
217 topicTitle := a.forkTopicTitle(sourceTitle)
218 exclusiveV3 := false
219 if identity, ok := sourceTab.Ctrl.(control.IdentityLifecycle); ok {
220 exclusiveV3 = identity.UsesExclusiveSession()
221 }
222 titleRoot := workspaceRoot
223 if scope == "global" {
224 titleRoot = ""
225 }
226 if !exclusiveV3 {
227 if err := setTopicTitle(titleRoot, topicID, topicTitle); err != nil {
228 return forkedSessionTabOpen{}, err
229 }
230 m, _ := agent.EnsureBranchMeta(locator.SessionPath)
231 m.Scope = scope
232 m.WorkspaceRoot = workspaceRoot
233 m.TopicID = topicID
234 m.TopicTitle = topicTitle
235 if err := agent.SaveBranchMeta(locator.SessionPath, m); err != nil {
236 return forkedSessionTabOpen{}, err
237 }
238 invalidateTopicSessionIndexForPath(locator.SessionPath)
239 }
240 opened := forkedSessionTabOpen{workspaceReferenced: strings.TrimSpace(workspaceRootOverride) != ""}
241
242 if opened.workspaceReferenced && scope == "project" {
243 rememberWorkspace(workspaceRoot)
244 if err := prependTopicInProjectsFile(workspaceRoot, topicID, true); err != nil {
245 slog.Warn("desktop: persist isolated fork topic", "workspace", workspaceRoot, "topic", topicID, "err", err)
246 }
247 a.registerProjectRoot(workspaceRoot)
248 }
249 if hook := forkTabBeforePublishHookForTest.Load(); hook != nil {
250 (*hook)()
251 }
252
253 a.mu.Lock()
254 if a.tabs[sourceTab.ID] != sourceTab {
255 a.mu.Unlock()
256 return opened, nil
257 }
258 newTabID := a.newUniqueTabIDLocked()
259 childPath, childID := locator.SessionPath, locator.SessionID
260 if exclusiveV3 != (childID != "") {
261 a.mu.Unlock()
262 return opened, fmt.Errorf("fork tab locator does not match the source session engine")
263 }
264 tab := &WorkspaceTab{
265 ID: newTabID,
266 Scope: scope,
267 WorkspaceRoot: workspaceRoot,
268 TopicID: topicID,
269 TopicTitle: topicTitle,
270 topicTitleSource: topicTitleSourceManual,
271 SessionPath: childPath,
272 SessionID: childID,
273 model: model,
274 effort: effort,
275 mode: mode,
276 toolApprovalMode: toolApprovalMode,
277 disabledMCP: disabledMCP,
278 mcpOrder: mcpOrder,
279 }
280 tab.sink = &tabEventSink{tabID: newTabID, app: a}
281 a.tabs[newTabID] = tab
282 a.tabOrder = append(a.tabOrder, newTabID)
283 activateFork := a.activeTabID == sourceTab.ID
284 if activateFork {
285 a.activeTabID = newTabID
286 }
287 a.saveTabsLocked()
288 meta := a.tabMeta(tab, activateFork)
289 a.mu.Unlock()
290
291 if opened.workspaceReferenced && scope == "project" {
292 if activateFork {
293 saveWorkspace(workspaceRoot)
294 }
295 }
296 if childPath != "" {
297 a.emitProjectTreeChangedForSessionDirs(sessionDirectoryForPath(childPath))
298 } else {
299 a.emitProjectTreeChangedEvent()
300 }
301 a.startTabControllerBuild(tab)
302 opened.tab = meta
303 return opened, nil
304 }
305
306 // attachForkedRewindTab fails closed when the durable branch cannot be attached
307 // to a tab. In particular, callers must not treat the source tab as the rewind
308 // target and accidentally resubmit the edited prompt into the parent session.
309 func (a *App) attachForkedRewindTab(sourceTab *WorkspaceTab, view RewindResultView) RewindResultView {
310 meta, err := a.openForkedSessionTab(sourceTab, view.Branch)
311 if err != nil || meta.ID == "" {
312 slog.Warn("rewind: fork created but tab attach failed", "err", err)
313 view.OK = false
314 view.Partial = true
315 view.Error = rewindForkAttachError
316 return view
317 }
318 view.TabID = meta.ID
319 view.Tab = &meta
320 return view
321 }
322
322 lines GO