返回 DeepSeek-Reasonix
session_head_ops.go
根目录 / internal / agent / session_head_ops.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "strings"
9 "time"
10
11 "reasonix/internal/store"
12 )
13
14 // ErrSessionHeadUnknown reports a head id that the log does not contain.
15 var ErrSessionHeadUnknown = errors.New("session head not found")
16
17 // ErrSessionNotDAG reports a head operation on a schema-1 session.
18 var ErrSessionNotDAG = errors.New("session log is not schema 2")
19
20 // ForkHead starts a new head at fromMessageID (empty means the root) and moves
21 // this session onto it: the in-memory transcript becomes that prefix and the
22 // next save appends behind it. kind is HeadKindFork or HeadKindRewind; the
23 // previous head keeps its full chain as a version. It returns the new head id.
24 func (s *Session) ForkHead(path, fromMessageID, kind, name string) (string, error) {
25 if kind == "" {
26 kind = HeadKindFork
27 }
28 newHead := NewHeadID()
29 var st *sessionDAGState
30 err := s.withSessionSaveLocks(path, func() error {
31 var err error
32 st, err = s.dagStateForHeadOp(path)
33 if err != nil {
34 return err
35 }
36 ref, _ := s.Head()
37 current := ref.HeadID
38 if current == "" || st.heads[current] == nil {
39 current = st.selectedHead()
40 }
41 if fromMessageID != "" && st.nodes[fromMessageID] == nil {
42 return fmt.Errorf("fork head: message %s: %w", fromMessageID, ErrSessionHeadUnknown)
43 }
44 now := time.Now().UTC()
45 entries := []sessionDAGEntry{
46 {Type: sessionDAGTypeFork, Head: current, NewHead: newHead, From: fromMessageID, Kind: kind, Name: strings.TrimSpace(name), At: now},
47 {Type: sessionDAGTypeSelect, Head: newHead, Reason: kind, At: now},
48 }
49 return appendHeadEntries(path, st, entries)
50 })
51 if err != nil {
52 return "", err
53 }
54 s.adoptHead(st, newHead, path)
55 return newHead, nil
56 }
57
58 // SwitchHead moves this session onto an existing head and records the choice
59 // with a select marker, so a later open lands on it too.
60 func (s *Session) SwitchHead(path, headID string) error {
61 var st *sessionDAGState
62 err := s.withSessionSaveLocks(path, func() error {
63 var err error
64 st, err = s.dagStateForHeadOp(path)
65 if err != nil {
66 return err
67 }
68 h := st.heads[headID]
69 if h == nil {
70 return fmt.Errorf("switch head %s: %w", headID, ErrSessionHeadUnknown)
71 }
72 if h.retired {
73 return fmt.Errorf("switch head %s: head is retired", headID)
74 }
75 if st.selected == headID {
76 return nil
77 }
78 return appendHeadEntries(path, st, []sessionDAGEntry{{Type: sessionDAGTypeSelect, Head: headID, Reason: "switch", At: time.Now().UTC()}})
79 })
80 if err != nil {
81 return err
82 }
83 s.adoptHead(st, headID, path)
84 return nil
85 }
86
87 // LoadSessionHeadReadOnly materializes one legacy schema-2 head without
88 // appending a select marker or changing the source log's default head. New
89 // runtimes use it to migrate historical heads into independent v3 sessions.
90 func LoadSessionHeadReadOnly(path, headID string) (*Session, error) {
91 return loadSessionHeadReadOnlyWithLimits(context.Background(), path, headID, defaultSessionReplayLimits)
92 }
93
94 // LoadSessionHeadForMigration materializes one head from a frozen legacy DAG
95 // without applying cumulative interactive-history replay budgets.
96 func LoadSessionHeadForMigration(ctx context.Context, path, headID string) (*Session, error) {
97 if ctx == nil {
98 ctx = context.Background()
99 }
100 return loadSessionHeadReadOnlyWithLimits(ctx, path, headID, migrationSessionReplayLimits())
101 }
102
103 func loadSessionHeadReadOnlyWithLimits(ctx context.Context, path, headID string, limits sessionReplayLimits) (*Session, error) {
104 st, err := replayDAGForHeadOpReadOnlyWithLimits(ctx, path, limits)
105 if err != nil {
106 return nil, err
107 }
108 head := st.heads[headID]
109 if head == nil || head.retired {
110 return nil, fmt.Errorf("load head %s: %w", headID, ErrSessionHeadUnknown)
111 }
112 s := NewSession("")
113 s.adoptHead(st, headID, path)
114 return s, nil
115 }
116
117 func replayDAGForHeadOpReadOnlyWithLimits(ctx context.Context, path string, limits sessionReplayLimits) (*sessionDAGState, error) {
118 probe, err := probeSessionEventLog(path)
119 if err != nil {
120 return nil, err
121 }
122 if !probe.dag {
123 return nil, ErrSessionNotDAG
124 }
125 st, err := replaySessionDAG(ctx, store.SessionEventLog(path), limits)
126 if err != nil {
127 return nil, err
128 }
129 if st.damaged {
130 return nil, fmt.Errorf("legacy session has an incomplete tail and is read-only")
131 }
132 return st, nil
133 }
134
135 // SelectSessionHead records the default head of a session that is not open
136 // in this process (the versions UI acting on a closed conversation).
137 func SelectSessionHead(path, headID string) error {
138 return appendSessionHeadMarker(path, headID, func(st *sessionDAGState) (sessionDAGEntry, error) {
139 if st.heads[headID].retired {
140 return sessionDAGEntry{}, fmt.Errorf("select head %s: head is retired", headID)
141 }
142 return sessionDAGEntry{Type: sessionDAGTypeSelect, Head: headID, Reason: "select"}, nil
143 })
144 }
145
146 // RetireSessionHead marks a head as cleaned up. Its exclusive entries are
147 // dropped at the next single-writer rotation; until then it is hidden.
148 func RetireSessionHead(path, headID string) error {
149 return appendSessionHeadMarker(path, headID, func(st *sessionDAGState) (sessionDAGEntry, error) {
150 if st.selectedHead() == headID {
151 return sessionDAGEntry{}, fmt.Errorf("retire head %s: it is the selected head", headID)
152 }
153 return sessionDAGEntry{Type: sessionDAGTypeRetire, Head: headID}, nil
154 })
155 }
156
157 // RenameSessionHead sets the display name of a head.
158 func RenameSessionHead(path, headID, name string) error {
159 return appendSessionHeadMarker(path, headID, func(*sessionDAGState) (sessionDAGEntry, error) {
160 return sessionDAGEntry{Type: sessionDAGTypeRename, Head: headID, Name: strings.TrimSpace(name)}, nil
161 })
162 }
163
164 func appendSessionHeadMarker(path, headID string, build func(*sessionDAGState) (sessionDAGEntry, error)) error {
165 if strings.TrimSpace(path) == "" || headID == "" {
166 return fmt.Errorf("session head marker: path and head id are required")
167 }
168 unlock := lockSessionSavePath(path)
169 defer unlock()
170 unlockFile, err := lockSessionFile(path)
171 if err != nil {
172 return fmt.Errorf("lock session file: %w", err)
173 }
174 defer unlockFile()
175 st, err := replayDAGForHeadOp(path)
176 if err != nil {
177 return err
178 }
179 if st.heads[headID] == nil {
180 return fmt.Errorf("head %s: %w", headID, ErrSessionHeadUnknown)
181 }
182 entry, err := build(st)
183 if err != nil {
184 return err
185 }
186 entry.At = time.Now().UTC()
187 return appendHeadEntries(path, st, []sessionDAGEntry{entry})
188 }
189
190 // dagStateForHeadOp returns the replayed graph for a head operation on an
191 // open session, reusing the cached state like a save does.
192 func (s *Session) dagStateForHeadOp(path string) (*sessionDAGState, error) {
193 if _, ok := s.Head(); !ok {
194 return nil, ErrSessionNotDAG
195 }
196 return s.dagStateForSave(context.Background(), path, time.Now().UTC())
197 }
198
199 func replayDAGForHeadOp(path string) (*sessionDAGState, error) {
200 probe, err := probeSessionEventLog(path)
201 if err != nil {
202 return nil, err
203 }
204 if !probe.dag {
205 return nil, ErrSessionNotDAG
206 }
207 st, err := replaySessionDAG(context.Background(), store.SessionEventLog(path), defaultSessionReplayLimits)
208 if err != nil {
209 return nil, err
210 }
211 if st.damaged {
212 if err := settleDAGTail(context.Background(), path, st, time.Now().UTC()); err != nil {
213 return nil, err
214 }
215 }
216 return st, nil
217 }
218
219 // appendHeadEntries lands marker entries, folds them into st, and refreshes
220 // the head index and meta mirror so listings see the change at once.
221 func appendHeadEntries(path string, st *sessionDAGState, entries []sessionDAGEntry) error {
222 tail := st.lastGoodEnd
223 if _, err := appendSessionDAGEntries(path, entries, true); err != nil {
224 return err
225 }
226 if err := st.replayFrom(context.Background(), tail, defaultSessionReplayLimits); err != nil {
227 return err
228 }
229 if st.damaged {
230 return fmt.Errorf("session log %s: head markers did not replay", path)
231 }
232 if err := writeSessionDAGIndex(context.Background(), path, st); err != nil {
233 slog.Warn("session: head index write after head marker failed", "path", path, "err", err)
234 }
235 selected := st.selectedHead()
236 if err := UpdateBranchMeta(path, false, func(meta *BranchMeta) error {
237 meta.HeadID = selected
238 meta.HeadCount = len(st.heads)
239 meta.LogSchema = sessionDAGSchemaVersion
240 meta.LogGeneration = st.generation
241 return nil
242 }); err != nil {
243 slog.Warn("session: head metadata update after head marker failed", "path", path, "err", err)
244 }
245 return nil
246 }
247
248 // adoptHead points the live session at headID: the transcript becomes the
249 // head's materialized chain and the persisted baseline is that chain, so the
250 // next save diffs against it instead of the previous head.
251 func (s *Session) adoptHead(st *sessionDAGState, headID, path string) {
252 msgs, _ := st.materialize(headID)
253 digest, err := digestSessionMessages(msgs)
254 s.mu.Lock()
255 s.Messages = msgs
256 s.version++
257 s.rewriteVersion++
258 version, rewriteVersion := s.version, s.rewriteVersion
259 s.head.ref = HeadRef{HeadID: headID, LeafID: st.heads[headID].leaf, LogGeneration: st.generation, LogOffset: st.lastGoodEnd}
260 s.head.dag = true
261 s.head.state = st
262 s.head.headCount = len(st.heads)
263 s.head.openTurn = st.heads[headID].openTurn
264 s.mu.Unlock()
265 if err == nil {
266 revision, _, _ := sessionContentRevision(path)
267 s.setPersistedBaseline(path, digest, version, revision, true, true, rewriteVersion, msgs)
268 }
269 }
270
270 lines GO