| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "log/slog" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "reflect" |
| 12 | "slices" |
| 13 | "strings" |
| 14 | |
| 15 | "reasonix/internal/agent" |
| 16 | "reasonix/internal/event" |
| 17 | "reasonix/internal/provider" |
| 18 | "reasonix/internal/session" |
| 19 | ) |
| 20 | |
| 21 | // Fork branches the conversation at the start of turn into a NEW session file, |
| 22 | // preserving the current one as the branch point, and switches to the branch. Code |
| 23 | // is untouched (it's a conversation operation). Like a conversation rewind it needs |
| 24 | // the live boundary, so it is unavailable for resumed-session turns and refused |
| 25 | // while a turn runs. Returns the new session path. |
| 26 | func (c *Controller) Fork(turn int) (string, error) { |
| 27 | return c.ForkNamed(turn, "") |
| 28 | } |
| 29 | |
| 30 | func (c *Controller) ForkNamed(turn int, name string) (string, error) { |
| 31 | return c.forkNamed(turn, name, true) |
| 32 | } |
| 33 | |
| 34 | // ForkSession copies the conversation at the start of turn into a new session |
| 35 | // file without switching this controller to it. Desktop uses this to open the |
| 36 | // branch in a new tab while the source tab keeps its current transcript. |
| 37 | func (c *Controller) ForkSession(turn int, name string) (string, error) { |
| 38 | return c.forkNamed(turn, name, false) |
| 39 | } |
| 40 | |
| 41 | func (c *Controller) forkNamed(turn int, name string, switchToFork bool) (string, error) { |
| 42 | if err := c.beginRotation(); err != nil { |
| 43 | if errors.Is(err, errTurnRunningRotation) { |
| 44 | return "", c.rewindFail(fmt.Errorf("cannot fork while a turn is running")) |
| 45 | } |
| 46 | return "", c.rewindFail(err) |
| 47 | } |
| 48 | defer c.endRotation() |
| 49 | return c.forkNamedReady(turn, name, switchToFork, agent.HeadKindFork) |
| 50 | } |
| 51 | |
| 52 | // forkNamedReady forks at a completed turn boundary into an independent child |
| 53 | // session. The parent log remains immutable from the child's point of view; |
| 54 | // switchToFork controls only whether this controller adopts the child. |
| 55 | func (c *Controller) forkNamedReady(turn int, name string, switchToFork bool, kind string) (string, error) { |
| 56 | if c.executor == nil { |
| 57 | return "", c.rewindFail(fmt.Errorf("checkpoints unavailable")) |
| 58 | } |
| 59 | if c.sessionEngineEnabled() { |
| 60 | return c.forkNamedSession(turn, name, switchToFork) |
| 61 | } |
| 62 | if c.sessionDir == "" { |
| 63 | return "", c.rewindFail(fmt.Errorf("fork needs session persistence, which is disabled")) |
| 64 | } |
| 65 | boundary, hasBound := c.checkpoints.boundary(turn) |
| 66 | if !hasBound { |
| 67 | return "", c.rewindFail(fmt.Errorf("fork unavailable for turn %d (resumed session)", turn)) |
| 68 | } |
| 69 | // Persist the current conversation first so the branch point survives, then |
| 70 | // seed a fresh session with the messages up to the fork and switch to it. |
| 71 | if err := c.Snapshot(); err != nil { |
| 72 | slog.Warn("controller: pre-fork snapshot", "err", err) |
| 73 | } |
| 74 | parentPath := c.SessionPath() |
| 75 | parentID := agent.BranchID(parentPath) |
| 76 | src := c.executor.Session().Snapshot() |
| 77 | if boundary > len(src) { |
| 78 | boundary = len(src) |
| 79 | } |
| 80 | forked := append([]provider.Message(nil), src[:boundary]...) |
| 81 | sess := agent.NewSession("") |
| 82 | sess.Messages = forked |
| 83 | |
| 84 | newPath := agent.NewSessionPath(c.sessionDir, c.label) |
| 85 | if err := sess.SaveIfAbsent(newPath); err != nil { |
| 86 | return "", c.rewindFail(err) |
| 87 | } |
| 88 | if err := c.publishSessionChild(newPath, forked); err != nil { |
| 89 | _ = os.Remove(newPath) |
| 90 | return "", c.rewindFail(fmt.Errorf("publish v3 fork: %w", err)) |
| 91 | } |
| 92 | if _, err := sess.CopyValidContextProjection(parentPath, newPath); err != nil { |
| 93 | slog.Warn("controller: fork did not inherit context projection", "err", err) |
| 94 | } |
| 95 | forkPreview, forkTurns := agent.SessionPreviewFromMessages(forked) |
| 96 | if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{ |
| 97 | Name: strings.TrimSpace(name), |
| 98 | ParentID: parentID, |
| 99 | ForkTurn: turn, |
| 100 | ForkMessageIndex: boundary, |
| 101 | Preview: forkPreview, |
| 102 | Turns: forkTurns, |
| 103 | SchemaVersion: agent.BranchMetaCountsVersion, |
| 104 | Model: c.selection.ref, |
| 105 | ModelIdentity: c.selection.identity, |
| 106 | }); err != nil { |
| 107 | return "", c.rewindFail(err) |
| 108 | } |
| 109 | if switchToFork { |
| 110 | commitTransition, err := c.prepareSessionTransition(newPath, "fork", sess) |
| 111 | if err != nil { |
| 112 | return "", c.rewindFail(fmt.Errorf("bind fork session: %w", err)) |
| 113 | } |
| 114 | // See snapshotMu: the swap must not interleave with an in-flight save. |
| 115 | c.snapshotMu.Lock() |
| 116 | commitTransition.publish() |
| 117 | // Load the child sidecar when the covered prefix survived the fork. The |
| 118 | // loader rebinds its lineage key without touching the parent's sidecar. |
| 119 | c.bindExecutorProjection(newPath, true) |
| 120 | c.ResetPlannerSession() |
| 121 | c.rebindCheckpoints(newPath) |
| 122 | // A historical fork rewinds before later failures, so it starts with no |
| 123 | // active recovery event even though it inherits the session preference. |
| 124 | c.loadRecoveryState(newPath) |
| 125 | if c.guardianSess != nil { |
| 126 | c.guardianSess.Reset() |
| 127 | } |
| 128 | // Switching into the fork is a new logical session for temporary files. |
| 129 | c.rotateSessionTemp() |
| 130 | c.snapshotMu.Unlock() |
| 131 | } |
| 132 | c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, |
| 133 | Text: fmt.Sprintf("forked conversation at turn %d into a new session", turn)}) |
| 134 | return newPath, nil |
| 135 | } |
| 136 | |
| 137 | func (c *Controller) CheckpointHasBoundary(turn int) bool { |
| 138 | boundary, ok := c.checkpoints.boundary(turn) |
| 139 | if !ok { |
| 140 | return false |
| 141 | } |
| 142 | // After compaction or a head switch the key may point past the current |
| 143 | // message log; treat those turns as "no boundary" so the UI can disable |
| 144 | // the button. Len is lock-guarded for the frontend goroutines calling this. |
| 145 | return boundary <= c.executor.Session().Len() |
| 146 | } |
| 147 | |
| 148 | // Branch copies the current conversation into a child branch and switches to it. |
| 149 | // Unlike Fork, it branches at the current tip and does not require a checkpoint. |
| 150 | func (c *Controller) Branch(name string) (string, error) { |
| 151 | if c.executor == nil { |
| 152 | return "", c.rewindFail(fmt.Errorf("branch unavailable")) |
| 153 | } |
| 154 | if c.sessionDir == "" { |
| 155 | return "", c.rewindFail(fmt.Errorf("branch needs session persistence, which is disabled")) |
| 156 | } |
| 157 | // Hold the rotation gate across the Snapshot and the switch below so a turn |
| 158 | // cannot start mid-branch and then have its session replaced. |
| 159 | if err := c.beginRotation(); err != nil { |
| 160 | if errors.Is(err, errTurnRunningRotation) { |
| 161 | return "", c.rewindFail(fmt.Errorf("cannot branch while a turn is running")) |
| 162 | } |
| 163 | return "", c.rewindFail(err) |
| 164 | } |
| 165 | defer c.endRotation() |
| 166 | if c.sessionEngineEnabled() { |
| 167 | _, runtime, _ := c.v3Binding() |
| 168 | if runtime == nil { |
| 169 | return "", c.rewindFail(session.ErrSessionNotRunning) |
| 170 | } |
| 171 | turns := runtime.Session().ExecutionSnapshot().Projection.Turns |
| 172 | if len(turns) == 0 { |
| 173 | return "", c.rewindFail(fmt.Errorf("nothing to branch yet")) |
| 174 | } |
| 175 | return c.forkNamedSession(len(turns), name, true) |
| 176 | } |
| 177 | if !c.executor.Session().HasContent() { |
| 178 | return "", c.rewindFail(fmt.Errorf("nothing to branch yet")) |
| 179 | } |
| 180 | if err := c.Snapshot(); err != nil { |
| 181 | return "", c.rewindFail(err) |
| 182 | } |
| 183 | parentPath := c.SessionPath() |
| 184 | parentID := agent.BranchID(parentPath) |
| 185 | src := c.executor.Session().Snapshot() |
| 186 | branched := append([]provider.Message(nil), src...) |
| 187 | sess := agent.NewSession("") |
| 188 | sess.Messages = branched |
| 189 | |
| 190 | newPath := agent.NewSessionPath(c.sessionDir, c.label) |
| 191 | if err := sess.SaveIfAbsent(newPath); err != nil { |
| 192 | return "", c.rewindFail(err) |
| 193 | } |
| 194 | if err := c.publishSessionChild(newPath, branched); err != nil { |
| 195 | _ = os.Remove(newPath) |
| 196 | return "", c.rewindFail(fmt.Errorf("publish v3 branch: %w", err)) |
| 197 | } |
| 198 | if _, err := sess.CopyValidContextProjection(parentPath, newPath); err != nil { |
| 199 | slog.Warn("controller: branch did not inherit context projection", "err", err) |
| 200 | } |
| 201 | branchPreview, branchTurns := agent.SessionPreviewFromMessages(branched) |
| 202 | if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{ |
| 203 | Name: strings.TrimSpace(name), |
| 204 | ParentID: parentID, |
| 205 | ForkTurn: -1, |
| 206 | ForkMessageIndex: len(branched), |
| 207 | Preview: branchPreview, |
| 208 | Turns: branchTurns, |
| 209 | SchemaVersion: agent.BranchMetaCountsVersion, |
| 210 | Model: c.selection.ref, |
| 211 | ModelIdentity: c.selection.identity, |
| 212 | }); err != nil { |
| 213 | return "", c.rewindFail(err) |
| 214 | } |
| 215 | commitTransition, err := c.prepareSessionTransition(newPath, "branch", sess) |
| 216 | if err != nil { |
| 217 | return "", c.rewindFail(fmt.Errorf("bind branch session: %w", err)) |
| 218 | } |
| 219 | // See snapshotMu: the swap must not interleave with an in-flight save. |
| 220 | c.snapshotMu.Lock() |
| 221 | commitTransition.publish() |
| 222 | c.bindExecutorProjection(newPath, true) |
| 223 | c.ResetPlannerSession() |
| 224 | c.rebindCheckpoints(newPath) |
| 225 | if c.guardianSess != nil { |
| 226 | c.guardianSess.Reset() |
| 227 | } |
| 228 | c.carryRecoveryState(newPath) |
| 229 | c.rotateSessionTemp() |
| 230 | c.snapshotMu.Unlock() |
| 231 | c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, |
| 232 | Text: fmt.Sprintf("created branch %s", agent.BranchID(newPath))}) |
| 233 | return newPath, nil |
| 234 | } |
| 235 | |
| 236 | // forkNamedSession creates a child from an exact persisted turn boundary. The |
| 237 | // compatibility integer is resolved only against the typed turn index; no |
| 238 | // message count, transcript snapshot, or sidecar participates. |
| 239 | func (c *Controller) forkNamedSession(turn int, name string, switchToFork bool) (string, error) { |
| 240 | service, parent, _ := c.v3Binding() |
| 241 | if service == nil || parent == nil { |
| 242 | return "", session.ErrSessionNotRunning |
| 243 | } |
| 244 | projection := parent.Session().ExecutionSnapshot().Projection |
| 245 | completed := make([]session.TurnBoundary, 0, len(projection.Turns)) |
| 246 | for _, boundary := range projection.Turns { |
| 247 | if boundary.EndSequence != 0 { |
| 248 | completed = append(completed, boundary) |
| 249 | } |
| 250 | } |
| 251 | if turn < 1 || turn > len(completed) { |
| 252 | return "", fmt.Errorf("fork unavailable for completed turn %d", turn) |
| 253 | } |
| 254 | child, err := service.Fork(context.Background(), parent.Ref(), completed[turn-1].TurnID, "") |
| 255 | if err != nil { |
| 256 | return "", err |
| 257 | } |
| 258 | closeChild := true |
| 259 | defer func() { |
| 260 | if closeChild { |
| 261 | _ = service.Close(context.Background(), child.Ref()) |
| 262 | } |
| 263 | }() |
| 264 | if title := strings.TrimSpace(name); title != "" { |
| 265 | payload, marshalErr := json.Marshal(map[string]string{"title": title}) |
| 266 | if marshalErr != nil { |
| 267 | return "", marshalErr |
| 268 | } |
| 269 | if _, appendErr := child.Session().AppendBatch(context.Background(), "fork-title:"+child.Ref().SessionID, []session.Event{{Kind: "session/title", Payload: payload}}); appendErr != nil { |
| 270 | return "", appendErr |
| 271 | } |
| 272 | } |
| 273 | if _, err := child.Session().Flush(context.Background()); err != nil { |
| 274 | return "", err |
| 275 | } |
| 276 | if !switchToFork { |
| 277 | return child.Ref().SessionID, nil |
| 278 | } |
| 279 | prepared := agent.NewSession("").CloneWithMessages(child.Session().ExecutionSnapshot().Projection.ModelMessages) |
| 280 | _, err = c.publishSessionRuntime(child, prepared, true) |
| 281 | if err != nil { |
| 282 | return "", err |
| 283 | } |
| 284 | closeChild = false |
| 285 | c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, |
| 286 | Text: fmt.Sprintf("forked conversation at completed turn %d into session %s", turn, child.Ref().SessionID)}) |
| 287 | return child.Ref().SessionID, nil |
| 288 | } |
| 289 | |
| 290 | // Branches lists saved conversation branches in this controller's session dir. |
| 291 | func (c *Controller) Branches() ([]agent.BranchInfo, error) { |
| 292 | if c.sessionDir == "" { |
| 293 | return nil, fmt.Errorf("session persistence is disabled") |
| 294 | } |
| 295 | if err := c.Snapshot(); err != nil { |
| 296 | return nil, err |
| 297 | } |
| 298 | branches, err := agent.ListBranches(c.sessionDir) |
| 299 | if err != nil { |
| 300 | return nil, err |
| 301 | } |
| 302 | return c.withHeadBranches(branches), nil |
| 303 | } |
| 304 | |
| 305 | func (c *Controller) SwitchBranch(ref string) (agent.BranchInfo, error) { |
| 306 | ref = strings.TrimSpace(ref) |
| 307 | if ref == "" { |
| 308 | return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("usage: /switch <branch id|name>")) |
| 309 | } |
| 310 | // Hold the rotation gate across the branch listing/load and the switch so a |
| 311 | // turn cannot start between the check and the SetSession below. |
| 312 | if err := c.beginRotation(); err != nil { |
| 313 | if errors.Is(err, errTurnRunningRotation) { |
| 314 | return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("cannot switch branches while a turn is running")) |
| 315 | } |
| 316 | return agent.BranchInfo{}, c.rewindFail(err) |
| 317 | } |
| 318 | defer c.endRotation() |
| 319 | branches, err := c.Branches() |
| 320 | if err != nil { |
| 321 | return agent.BranchInfo{}, c.rewindFail(err) |
| 322 | } |
| 323 | match, err := resolveBranch(branches, ref) |
| 324 | if err != nil { |
| 325 | return agent.BranchInfo{}, c.rewindFail(err) |
| 326 | } |
| 327 | if !agent.IsVisibleSession(match.Path) { |
| 328 | return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("branch %q not found", ref)) |
| 329 | } |
| 330 | if err := c.ValidateSessionModel(match.Path); err != nil { |
| 331 | return agent.BranchInfo{}, c.rewindFail(err) |
| 332 | } |
| 333 | if match.HeadID != "" { |
| 334 | loadedHead, err := agent.LoadSessionHeadReadOnly(match.Path, match.HeadID) |
| 335 | if err != nil { |
| 336 | return agent.BranchInfo{}, c.rewindFail(err) |
| 337 | } |
| 338 | loaded := agent.NewSession("") |
| 339 | loaded.Messages = loadedHead.Snapshot() |
| 340 | newPath := agent.NewSessionPath(c.sessionDir, c.label) |
| 341 | if err := loaded.SaveIfAbsent(newPath); err != nil { |
| 342 | return agent.BranchInfo{}, c.rewindFail(err) |
| 343 | } |
| 344 | if err := c.publishSessionChild(newPath, loaded.Messages); err != nil { |
| 345 | _ = os.Remove(newPath) |
| 346 | return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("migrate legacy head: %w", err)) |
| 347 | } |
| 348 | preview, turns := agent.SessionPreviewFromMessages(loaded.Messages) |
| 349 | if err := agent.SaveBranchMeta(newPath, agent.BranchMeta{ |
| 350 | Name: strings.TrimSpace(match.Name), ParentID: agent.BranchID(match.Path), ForkTurn: -1, |
| 351 | ForkMessageIndex: len(loaded.Messages), Preview: preview, Turns: turns, |
| 352 | SchemaVersion: agent.BranchMetaCountsVersion, Model: c.selection.ref, ModelIdentity: c.selection.identity, |
| 353 | }); err != nil { |
| 354 | return agent.BranchInfo{}, c.rewindFail(err) |
| 355 | } |
| 356 | match = agent.BranchInfo{BranchMeta: agent.BranchMeta{ID: agent.BranchID(newPath), Name: match.Name, ParentID: agent.BranchID(match.Path)}, Path: newPath, Preview: preview, Turns: turns} |
| 357 | commitTransition, err := c.prepareSessionTransition(newPath, "migrate-legacy-head", loaded) |
| 358 | if err != nil { |
| 359 | return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("bind migrated head: %w", err)) |
| 360 | } |
| 361 | c.snapshotMu.Lock() |
| 362 | commitTransition.publish() |
| 363 | c.bindExecutorProjection(newPath, true) |
| 364 | c.ResetPlannerSession() |
| 365 | c.rebindCheckpoints(newPath) |
| 366 | c.loadGuardianSession() |
| 367 | c.loadRecoveryState(newPath) |
| 368 | c.rotateSessionTemp() |
| 369 | c.snapshotMu.Unlock() |
| 370 | c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "continued legacy version as an independent session"}) |
| 371 | return match, nil |
| 372 | } |
| 373 | loaded, err := agent.LoadSession(match.Path) |
| 374 | if err != nil { |
| 375 | return agent.BranchInfo{}, c.rewindFail(err) |
| 376 | } |
| 377 | commitTransition, err := c.prepareSessionTransition(match.Path, "switch", loaded) |
| 378 | if err != nil { |
| 379 | return agent.BranchInfo{}, c.rewindFail(fmt.Errorf("bind switched session: %w", err)) |
| 380 | } |
| 381 | // See snapshotMu: the swap must not interleave with an in-flight save. |
| 382 | c.snapshotMu.Lock() |
| 383 | commitTransition.publish() |
| 384 | c.bindExecutorProjection(match.Path, true) |
| 385 | c.ResetPlannerSession() |
| 386 | c.rebindCheckpoints(match.Path) |
| 387 | c.loadGuardianSession() |
| 388 | c.loadRecoveryState(match.Path) |
| 389 | c.rotateSessionTemp() |
| 390 | c.snapshotMu.Unlock() |
| 391 | c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, |
| 392 | Text: fmt.Sprintf("switched to branch %s", branchDisplayName(match))}) |
| 393 | return match, nil |
| 394 | } |
| 395 | |
| 396 | // ResolveBranchRef resolves a /switch-style branch reference (id, unique |
| 397 | // prefix, name, or path) against a branch listing, using the same matching |
| 398 | // rules as SwitchBranch. Frontends use it to learn the target session path |
| 399 | // before switching — e.g. to move their session lease first. |
| 400 | func ResolveBranchRef(branches []agent.BranchInfo, ref string) (agent.BranchInfo, error) { |
| 401 | return resolveBranch(branches, strings.TrimSpace(ref)) |
| 402 | } |
| 403 | |
| 404 | func resolveBranch(branches []agent.BranchInfo, ref string) (agent.BranchInfo, error) { |
| 405 | refLower := strings.ToLower(ref) |
| 406 | var matches []agent.BranchInfo |
| 407 | for _, b := range branches { |
| 408 | nameLower := strings.ToLower(strings.TrimSpace(b.Name)) |
| 409 | switch { |
| 410 | case b.ID == ref || strings.EqualFold(b.ID, ref): |
| 411 | return b, nil |
| 412 | case b.HeadID != "" && b.HeadID == ref: |
| 413 | return b, nil |
| 414 | case b.Name != "" && nameLower == refLower: |
| 415 | matches = append(matches, b) |
| 416 | case strings.HasPrefix(strings.ToLower(b.ID), refLower): |
| 417 | matches = append(matches, b) |
| 418 | case strings.HasPrefix(strings.ToLower(shortBranchID(b.ID)), refLower): |
| 419 | matches = append(matches, b) |
| 420 | case b.Path == ref: |
| 421 | return b, nil |
| 422 | } |
| 423 | } |
| 424 | if len(matches) == 1 { |
| 425 | return matches[0], nil |
| 426 | } |
| 427 | if len(matches) > 1 { |
| 428 | return agent.BranchInfo{}, fmt.Errorf("branch %q is ambiguous", ref) |
| 429 | } |
| 430 | return agent.BranchInfo{}, fmt.Errorf("branch %q not found", ref) |
| 431 | } |
| 432 | |
| 433 | func branchDisplayName(b agent.BranchInfo) string { |
| 434 | if strings.TrimSpace(b.Name) != "" { |
| 435 | return fmt.Sprintf("%s (%s)", b.Name, b.ID) |
| 436 | } |
| 437 | return b.ID |
| 438 | } |
| 439 | |
| 440 | // afterHeadSwitch re-derives the per-transcript runtime state after the |
| 441 | // session moved to another head of the same log. Callers hold snapshotMu. |
| 442 | func (c *Controller) afterHeadSwitch(path string) { |
| 443 | c.bindExecutorProjection(path, true) |
| 444 | c.ResetPlannerSession() |
| 445 | if c.guardianSess != nil { |
| 446 | c.guardianSess.Reset() |
| 447 | } |
| 448 | c.rotateSessionTemp() |
| 449 | c.emitHeadEvents() |
| 450 | // Same path, different transcript: serve and remote clients rebind on this |
| 451 | // barrier exactly as they do for a resume; local desktop tabs learn the |
| 452 | // head in the desktop PR. |
| 453 | c.sink.Emit(event.Event{Kind: event.SessionChanged, SessionPath: path, SessionReset: true}) |
| 454 | } |
| 455 | |
| 456 | // withHeadBranches lists the heads of the current schema-2 log as branches: |
| 457 | // the main head keeps the file's identity so the tree stays rooted at the |
| 458 | // log, and every other head hangs under its parent head. |
| 459 | func (c *Controller) withHeadBranches(branches []agent.BranchInfo) []agent.BranchInfo { |
| 460 | // Existing heads are exposed for read/navigation only. Selecting one |
| 461 | // materializes an independent session before execution. |
| 462 | sess := c.loggedTurnSession() |
| 463 | if sess == nil { |
| 464 | return branches |
| 465 | } |
| 466 | path := c.SessionPath() |
| 467 | heads, err := agent.ListSessionHeads(path) |
| 468 | if err != nil || len(heads) <= 1 { |
| 469 | return branches |
| 470 | } |
| 471 | fileID := agent.BranchID(path) |
| 472 | headID := func(id string) string { |
| 473 | if id == agent.SessionMainHead || id == "" { |
| 474 | return fileID |
| 475 | } |
| 476 | return id |
| 477 | } |
| 478 | var file agent.BranchInfo |
| 479 | out := make([]agent.BranchInfo, 0, len(branches)+len(heads)) |
| 480 | for _, b := range branches { |
| 481 | if agent.CanonicalSessionPath(b.Path) == agent.CanonicalSessionPath(path) { |
| 482 | file = b |
| 483 | continue |
| 484 | } |
| 485 | out = append(out, b) |
| 486 | } |
| 487 | for _, h := range heads { |
| 488 | if h.Retired { |
| 489 | continue |
| 490 | } |
| 491 | info := file |
| 492 | info.Path = path |
| 493 | info.HeadID, info.HeadKind = h.ID, h.Kind |
| 494 | info.ID = headID(h.ID) |
| 495 | info.Turns, info.Preview = h.Turns, h.Preview |
| 496 | if h.ID != agent.SessionMainHead { |
| 497 | info.Name = h.Name |
| 498 | info.ParentID = headID(h.ParentHead) |
| 499 | info.ForkTurn, info.ForkMessageIndex = -1, 0 |
| 500 | info.CreatedAt = h.CreatedAt |
| 501 | } |
| 502 | out = append(out, info) |
| 503 | } |
| 504 | return out |
| 505 | } |
| 506 | |
| 507 | // sessionHeadPolicy groups the frontend's choice between in-log heads and |
| 508 | // separate session files for branch operations. |
| 509 | type sessionHeadPolicy struct { |
| 510 | fileBranchesOnly bool |
| 511 | } |
| 512 | |
| 513 | // headBranchSession returns the session when branch operations may create |
| 514 | // heads inside its schema-2 log, nil when the frontend asked for files. |
| 515 | func (c *Controller) headBranchSession() *agent.Session { |
| 516 | // New writes always materialize an independent child session. Existing |
| 517 | // schema-2 heads remain discoverable through the legacy read adapter, but |
| 518 | // they are never extended or used as a second writable head. |
| 519 | return nil |
| 520 | } |
| 521 | |
| 522 | // publishSessionChild creates a self-contained child before any UI/session switch. |
| 523 | // When the selected message prefix is an exact completed-turn boundary it |
| 524 | // copies the parent's immutable event batches. Legacy or pre-first-turn cuts |
| 525 | // are imported as history only and carry no activity or authorization state. |
| 526 | func (c *Controller) publishSessionChild(newPath string, messages []provider.Message) error { |
| 527 | childDir := sessionDirectory(newPath) |
| 528 | childID := agent.BranchID(newPath) |
| 529 | if childDir == "" || childID == "" { |
| 530 | return fmt.Errorf("invalid child identity") |
| 531 | } |
| 532 | if parent := c.sessionEventStore(); parent != nil { |
| 533 | if _, err := parent.Flush(context.Background()); err != nil { |
| 534 | return err |
| 535 | } |
| 536 | commits, err := session.Replay(sessionDirectory(c.SessionPath()), nil) |
| 537 | if err != nil { |
| 538 | return err |
| 539 | } |
| 540 | for i, v := range slices.Backward(commits) { |
| 541 | commit := v |
| 542 | if len(commit.Events) == 0 || commit.Events[len(commit.Events)-1].Kind != "turn/end" { |
| 543 | continue |
| 544 | } |
| 545 | projection, projectErr := session.Project(commits[:i+1]) |
| 546 | if projectErr != nil { |
| 547 | return projectErr |
| 548 | } |
| 549 | if reflect.DeepEqual(projection.Messages, messages) { |
| 550 | _, forkErr := parent.Fork(context.Background(), childDir, childID, commit.LastSequence()) |
| 551 | return forkErr |
| 552 | } |
| 553 | } |
| 554 | projected, projectErr := session.Project(commits) |
| 555 | if projectErr != nil { |
| 556 | return projectErr |
| 557 | } |
| 558 | if len(projected.Turns) > 0 { |
| 559 | return fmt.Errorf("selected history is not an exact completed v3 turn boundary") |
| 560 | } |
| 561 | } |
| 562 | if err := os.MkdirAll(filepath.Dir(childDir), 0o700); err != nil { |
| 563 | return err |
| 564 | } |
| 565 | child, err := session.CreateStore(childDir, childID) |
| 566 | if err != nil { |
| 567 | return err |
| 568 | } |
| 569 | payload, marshalErr := json.Marshal(map[string]any{"messages": messages}) |
| 570 | if marshalErr == nil { |
| 571 | _, marshalErr = child.Append(context.Background(), session.Batch{OperationID: "history-import", Events: []session.Event{{Kind: "legacy/import", Payload: payload}}}) |
| 572 | } |
| 573 | if marshalErr == nil { |
| 574 | _, marshalErr = child.Flush(context.Background()) |
| 575 | } |
| 576 | return errors.Join(marshalErr, child.Close(context.Background())) |
| 577 | } |
| 578 | |
| 579 | // SessionHead reports the schema-2 head the live session is on; ok is false |
| 580 | // for schema-1 sessions, whose branches are still separate files. |
| 581 | func (c *Controller) SessionHead() (agent.HeadRef, bool) { |
| 582 | if c == nil || c.executor == nil || c.executor.Session() == nil { |
| 583 | return agent.HeadRef{}, false |
| 584 | } |
| 585 | return c.executor.Session().Head() |
| 586 | } |
| 587 |