返回 DeepSeek-Reasonix
branch.go
根目录 / internal / agent / branch.go
1 package agent
2
3 import (
4 "encoding/json"
5 "fmt"
6 "os"
7 "path/filepath"
8 "sort"
9 "strings"
10 "time"
11
12 "reasonix/internal/fileutil"
13 fileencoding "reasonix/internal/fileutil/encoding"
14 "reasonix/internal/store"
15 )
16
17 // BranchMeta is the small sidecar record that turns flat session files into a
18 // navigable conversation tree. The conversation itself remains in the .jsonl
19 // file; metadata lives beside it at <session>.meta.
20 type BranchMeta struct {
21 ID string `json:"id"`
22 Name string `json:"name,omitempty"`
23 ParentID string `json:"parent_id,omitempty"`
24 ForkTurn int `json:"fork_turn,omitempty"`
25 ForkMessageIndex int `json:"fork_message_index,omitempty"`
26 CreatedAt time.Time `json:"created_at"`
27 UpdatedAt time.Time `json:"updated_at"`
28 Scope string `json:"scope,omitempty"`
29 WorkspaceRoot string `json:"workspace_root,omitempty"`
30 TopicID string `json:"topic_id,omitempty"`
31 TopicTitle string `json:"topic_title,omitempty"`
32 CustomTitle string `json:"custom_title,omitempty"`
33 Model string `json:"model,omitempty"`
34 TokenMode string `json:"token_mode,omitempty"`
35 Mode string `json:"mode,omitempty"`
36 ToolApprovalMode string `json:"tool_approval_mode,omitempty"`
37 Goal string `json:"goal,omitempty"`
38 Recovered bool `json:"recovered,omitempty"`
39 RecoveryReason string `json:"recovery_reason,omitempty"`
40 RecoveryDigest string `json:"recovery_digest,omitempty"`
41 // RecoveryDepth counts how many recovery forks separate this branch from a
42 // normal session (1 = forked from a normal session). SaveRecoveryBranch
43 // refuses to fork past SessionRecoveryMaxDepth so a conflict loop cannot
44 // spawn unbounded nested recovery chains (#5993 reached 8 levels). Legacy
45 // recovery metas without the field are treated as depth 1.
46 RecoveryDepth int `json:"recovery_depth,omitempty"`
47 Revision int64 `json:"revision,omitempty"`
48 ContentDigest string `json:"content_digest,omitempty"`
49 WriterID string `json:"writer_id,omitempty"`
50 // SchemaVersion records the BranchMeta version that last wrote the listing
51 // fields (Turns/Preview) FROM the session's content. It is stamped only by the
52 // writers that actually derive those counts — Controller.snapshot's
53 // UpdateSessionMeta and Fork/Branch — never by EnsureBranchMeta / TouchBranchMeta
54 // / rename / set-model, which don't know the turn count. So ListSessions can
55 // tell a meta whose counts are authoritative (>= BranchMetaCountsVersion: trust
56 // Turns even when 0 = genuinely empty) from a legacy/contentless one
57 // (< version: decode once, then backfill + stamp).
58 SchemaVersion int `json:"schema_version,omitempty"`
59 // Turns and Preview are listing-only fields the desktop sidebar and CLI
60 // pickers show ("5 turns · 'help me debug…'") without decoding the whole
61 // .jsonl. The autosave path (Controller.snapshot) keeps them fresh from the
62 // in-memory conversation, so ListSessions stays O(1) per session instead of
63 // O(file size). Gated by SchemaVersion (above), not Turns == 0, so a
64 // genuinely-empty session is recorded once and never re-decoded.
65 Turns int `json:"turns,omitempty"`
66 Preview string `json:"preview,omitempty"`
67 InFlightTurn *InFlightTurnMeta `json:"in_flight_turn,omitempty"`
68 }
69
70 // BranchMetaCountsVersion is stamped into BranchMeta.SchemaVersion whenever a
71 // writer records Turns/Preview from session content (UpdateSessionMeta,
72 // Fork/Branch). Bump it when the meaning of those listing fields changes so
73 // existing listings re-derive them instead of trusting a stale cache.
74 const BranchMetaCountsVersion = 1
75
76 // InFlightTurnMeta records the message-log boundary for a foreground turn that
77 // has started but not yet reached TurnDone. If the process exits mid-turn, a
78 // later resume can strip the partial assistant/tool tail without guessing.
79 type InFlightTurnMeta struct {
80 StartMessageIndex int `json:"start_message_index"`
81 PreserveUser bool `json:"preserve_user"`
82 StartedAt time.Time `json:"started_at"`
83 }
84
85 func (m BranchMeta) DefaultScope() string {
86 switch m.Scope {
87 case "project":
88 return "project"
89 default:
90 return "global"
91 }
92 }
93
94 // BranchInfo combines sidecar metadata with the session file details needed for
95 // pickers and tree rendering.
96 type BranchInfo struct {
97 BranchMeta
98 Path string
99 ModTime time.Time
100 Preview string
101 Turns int
102 }
103
104 func BranchID(path string) string {
105 if path == "" {
106 return ""
107 }
108 base := filepath.Base(path)
109 if ext := filepath.Ext(base); ext != "" {
110 base = strings.TrimSuffix(base, ext)
111 }
112 return base
113 }
114
115 func BranchMetaPath(sessionPath string) string {
116 return store.SessionMeta(sessionPath)
117 }
118
119 func LoadBranchMeta(sessionPath string) (BranchMeta, bool, error) {
120 metaPath := BranchMetaPath(sessionPath)
121 if metaPath == "" {
122 return BranchMeta{}, false, nil
123 }
124 b, err := fileencoding.ReadFileUTF8(metaPath)
125 if err != nil {
126 if os.IsNotExist(err) {
127 return BranchMeta{}, false, nil
128 }
129 return BranchMeta{}, false, err
130 }
131 var m BranchMeta
132 if err := json.Unmarshal(b, &m); err != nil {
133 return BranchMeta{}, false, fmt.Errorf("decode branch meta %s: %w", metaPath, err)
134 }
135 if m.ID == "" {
136 m.ID = BranchID(sessionPath)
137 }
138 m.sanitizeDisplayFields()
139 return m, true, nil
140 }
141
142 // sanitizeDisplayFields cleans persisted display strings that older builds
143 // polluted with internal wrappers (memory-compiler execution contracts,
144 // transient blocks) — #5666. Every reader goes through LoadBranchMeta, so this
145 // is the single boundary; UserPreviewText is a no-op on clean text, and a
146 // field that was pure wrapper falls back to empty so callers use their normal
147 // fallbacks (preview, default title).
148 func (m *BranchMeta) sanitizeDisplayFields() {
149 m.TopicTitle = sanitizeStoredDisplayText(m.TopicTitle)
150 m.CustomTitle = sanitizeStoredDisplayText(m.CustomTitle)
151 m.Preview = sanitizeStoredDisplayText(m.Preview)
152 }
153
154 func sanitizeStoredDisplayText(s string) string {
155 if strings.TrimSpace(s) == "" {
156 return strings.TrimSpace(s)
157 }
158 return UserPreviewText(s)
159 }
160
161 // branchMetaReadBackoffs paces the re-reads of a branch-meta sidecar that
162 // failed to load. On Windows fileutil.ReplaceFile can fall back to a
163 // non-atomic in-place copy, so a concurrent reader may catch the sidecar
164 // half-written (an open/read error or truncated JSON). Those tears heal in
165 // milliseconds; a few short retries separate them from real corruption.
166 var branchMetaReadBackoffs = []time.Duration{20 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond}
167
168 // loadBranchMetaRetry reads the branch-meta sidecar like LoadBranchMeta but
169 // retries transient failures (I/O errors and undecodable JSON) before giving
170 // up. A missing sidecar is a legitimate state — a session that has never
171 // recorded meta — and returns ok=false immediately without retrying.
172 func loadBranchMetaRetry(sessionPath string) (BranchMeta, bool, error) {
173 var lastErr error
174 for attempt := 0; ; attempt++ {
175 meta, ok, err := LoadBranchMeta(sessionPath)
176 if err == nil {
177 return meta, ok, nil
178 }
179 lastErr = err
180 if attempt >= len(branchMetaReadBackoffs) {
181 return BranchMeta{}, false, lastErr
182 }
183 time.Sleep(branchMetaReadBackoffs[attempt])
184 }
185 }
186
187 func SaveBranchMeta(sessionPath string, m BranchMeta) error {
188 return saveBranchMeta(sessionPath, m, true)
189 }
190
191 func SaveBranchMetaPreserveUpdated(sessionPath string, m BranchMeta) error {
192 return saveBranchMeta(sessionPath, m, false)
193 }
194
195 func saveBranchMeta(sessionPath string, m BranchMeta, touchUpdated bool) error {
196 metaPath := BranchMetaPath(sessionPath)
197 if metaPath == "" {
198 return fmt.Errorf("empty session path")
199 }
200 now := time.Now().UTC()
201 if m.ID == "" {
202 m.ID = BranchID(sessionPath)
203 }
204 if m.CreatedAt.IsZero() {
205 m.CreatedAt = now
206 }
207 if touchUpdated || m.UpdatedAt.IsZero() {
208 m.UpdatedAt = now
209 }
210 if existing, ok, err := LoadBranchMeta(sessionPath); err == nil && ok {
211 preserveBranchMetaPersistence(&m, existing)
212 }
213 if err := os.MkdirAll(filepath.Dir(metaPath), 0o755); err != nil {
214 return err
215 }
216 b, err := json.MarshalIndent(m, "", " ")
217 if err != nil {
218 return err
219 }
220 b = append(b, '\n')
221 tmp, err := os.CreateTemp(filepath.Dir(metaPath), ".branch.*.tmp")
222 if err != nil {
223 return err
224 }
225 tmpPath := tmp.Name()
226 if _, err := tmp.Write(b); err != nil {
227 tmp.Close()
228 os.Remove(tmpPath)
229 return err
230 }
231 if err := tmp.Close(); err != nil {
232 os.Remove(tmpPath)
233 return err
234 }
235 if err := fileutil.ReplaceFile(tmpPath, metaPath); err != nil {
236 os.Remove(tmpPath)
237 return err
238 }
239 return nil
240 }
241
242 func preserveBranchMetaPersistence(next *BranchMeta, existing BranchMeta) {
243 if next == nil {
244 return
245 }
246 if existing.Revision > next.Revision {
247 next.Revision = existing.Revision
248 next.ContentDigest = existing.ContentDigest
249 next.WriterID = existing.WriterID
250 return
251 }
252 if existing.Revision == next.Revision {
253 if strings.TrimSpace(next.ContentDigest) == "" {
254 next.ContentDigest = existing.ContentDigest
255 }
256 if strings.TrimSpace(next.WriterID) == "" {
257 next.WriterID = existing.WriterID
258 }
259 }
260 }
261
262 func EnsureBranchMeta(sessionPath string) (BranchMeta, error) {
263 if sessionPath == "" {
264 return BranchMeta{}, fmt.Errorf("empty session path")
265 }
266 if m, ok, err := LoadBranchMeta(sessionPath); err != nil || ok {
267 return m, err
268 }
269 when := time.Now().UTC()
270 if info, err := os.Stat(sessionPath); err == nil {
271 when = info.ModTime().UTC()
272 }
273 m := BranchMeta{
274 ID: BranchID(sessionPath),
275 CreatedAt: when,
276 UpdatedAt: when,
277 }
278 return m, saveBranchMeta(sessionPath, m, false)
279 }
280
281 func TouchBranchMeta(sessionPath string) error {
282 unlock := lockSessionSavePath(sessionPath)
283 defer unlock()
284 m, err := EnsureBranchMeta(sessionPath)
285 if err != nil {
286 return err
287 }
288 m.UpdatedAt = time.Now().UTC()
289 return saveBranchMeta(sessionPath, m, false)
290 }
291
292 func MarkSessionInFlightTurn(sessionPath string, startMessageIndex int, preserveUser bool) error {
293 return SetSessionInFlightTurn(sessionPath, InFlightTurnMeta{
294 StartMessageIndex: startMessageIndex,
295 PreserveUser: preserveUser,
296 StartedAt: time.Now().UTC(),
297 })
298 }
299
300 // SetSessionInFlightTurn writes an existing in-flight marker verbatim. It is
301 // used when a running turn moves to a recovery branch: preserving StartedAt is
302 // what lets crash recovery relocate the turn after an in-turn compaction has
303 // rewritten its original message index.
304 func SetSessionInFlightTurn(sessionPath string, marker InFlightTurnMeta) error {
305 startMessageIndex := marker.StartMessageIndex
306 if startMessageIndex < 0 {
307 startMessageIndex = 0
308 }
309 // The sidecar is read-modify-write; the per-path save lock keeps concurrent
310 // writers (autosave's UpdateSessionMeta, listing backfill) from dropping
311 // each other's fields.
312 unlock := lockSessionSavePath(sessionPath)
313 defer unlock()
314 m, err := EnsureBranchMeta(sessionPath)
315 if err != nil {
316 return err
317 }
318 marker.StartMessageIndex = startMessageIndex
319 if marker.StartedAt.IsZero() {
320 marker.StartedAt = time.Now().UTC()
321 }
322 m.InFlightTurn = &marker
323 return SaveBranchMetaPreserveUpdated(sessionPath, m)
324 }
325
326 func ClearSessionInFlightTurn(sessionPath string) error {
327 unlock := lockSessionSavePath(sessionPath)
328 defer unlock()
329 m, ok, err := LoadBranchMeta(sessionPath)
330 if err != nil || !ok {
331 return err
332 }
333 if m.InFlightTurn == nil {
334 return nil
335 }
336 m.InFlightTurn = nil
337 return SaveBranchMetaPreserveUpdated(sessionPath, m)
338 }
339
340 func ListBranches(dir string) ([]BranchInfo, error) {
341 entries, err := os.ReadDir(dir)
342 if err != nil {
343 if os.IsNotExist(err) {
344 return nil, nil
345 }
346 return nil, err
347 }
348 var out []BranchInfo
349 for _, e := range entries {
350 if e.IsDir() || !store.IsSessionTranscriptName(e.Name()) {
351 continue
352 }
353 info, err := e.Info()
354 if err != nil {
355 continue
356 }
357 path := filepath.Join(dir, e.Name())
358 if !IsVisibleSession(path) {
359 continue
360 }
361 preview, turns := previewSession(path)
362 if turns == 0 {
363 continue
364 }
365 meta, ok, err := LoadBranchMeta(path)
366 if err != nil {
367 continue
368 }
369 if !ok {
370 meta = BranchMeta{
371 ID: BranchID(path),
372 CreatedAt: info.ModTime().UTC(),
373 UpdatedAt: info.ModTime().UTC(),
374 }
375 }
376 if meta.ID == "" {
377 meta.ID = BranchID(path)
378 }
379 out = append(out, BranchInfo{
380 BranchMeta: meta,
381 Path: path,
382 ModTime: info.ModTime(),
383 Preview: preview,
384 Turns: turns,
385 })
386 }
387 sort.Slice(out, func(i, j int) bool {
388 if out[i].CreatedAt.Equal(out[j].CreatedAt) {
389 return out[i].ID < out[j].ID
390 }
391 return out[i].CreatedAt.Before(out[j].CreatedAt)
392 })
393 return out, nil
394 }
395
396 // RenameSession updates the user-chosen display title in the session's
397 // .jsonl.meta sidecar file. If no meta file exists yet, one is created. The
398 // topic title remains a separate grouping label, so explicit session names do
399 // not fight topic auto-titling.
400 func RenameSession(sessionPath string, title string) error {
401 if sessionPath == "" {
402 return fmt.Errorf("empty session path")
403 }
404 // Read-modify-write on the sidecar: hold the per-path meta lock so a
405 // concurrent save (recordSessionContentRevision) can't have its Revision
406 // bump clobbered by a stale read-back here.
407 unlock := lockSessionSavePath(sessionPath)
408 defer unlock()
409 m, err := EnsureBranchMeta(sessionPath)
410 if err != nil {
411 return err
412 }
413 m.CustomTitle = strings.TrimSpace(title)
414 return SaveBranchMetaPreserveUpdated(sessionPath, m)
415 }
416
417 // LoadSessionModel reads the canonical provider/model ref saved beside a
418 // session transcript.
419 func LoadSessionModel(sessionPath string) (string, bool) {
420 meta, ok, err := LoadBranchMeta(sessionPath)
421 if err != nil || !ok {
422 return "", false
423 }
424 model := strings.TrimSpace(meta.Model)
425 if model == "" {
426 return "", false
427 }
428 return model, true
429 }
430
431 // SetBranchModelPreserveUpdated stores the canonical provider/model ref without
432 // changing the session activity timestamp.
433 func SetBranchModelPreserveUpdated(sessionPath, model string) error {
434 if sessionPath == "" {
435 return fmt.Errorf("empty session path")
436 }
437 unlock := lockSessionSavePath(sessionPath)
438 defer unlock()
439 meta, err := EnsureBranchMeta(sessionPath)
440 if err != nil {
441 return err
442 }
443 meta.Model = strings.TrimSpace(model)
444 return SaveBranchMetaPreserveUpdated(sessionPath, meta)
445 }
446
447 // UpdateSessionMeta refreshes the listing-only sidecar fields (model, preview,
448 // user-turn count) the sidebar and pickers read without decoding the .jsonl.
449 // markActivity bumps UpdatedAt (the autosave path passes true on a real turn);
450 // false preserves it (used to backfill legacy sessions during a read). An empty
451 // model leaves the stored model untouched.
452 func UpdateSessionMeta(sessionPath, model, preview string, turns int, markActivity bool) error {
453 if sessionPath == "" {
454 return fmt.Errorf("empty session path")
455 }
456 unlock := lockSessionSavePath(sessionPath)
457 defer unlock()
458 m, err := EnsureBranchMeta(sessionPath)
459 if err != nil {
460 return err
461 }
462 if strings.TrimSpace(model) != "" {
463 m.Model = strings.TrimSpace(model)
464 }
465 m.Preview = preview
466 m.Turns = turns
467 // These counts were derived from the current content, so mark them
468 // authoritative — listing can then trust Turns (even 0) without re-decoding.
469 m.SchemaVersion = BranchMetaCountsVersion
470 return saveBranchMeta(sessionPath, m, markActivity)
471 }
472
472 lines GO