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