返回 DeepSeek-Reasonix
branch_ops.go
根目录 / internal / control / branch_ops.go
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
587 lines GO