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