| 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 |