返回 DeepSeek-Reasonix
branch.go
根目录 / internal / agent / branch.go
1 package agent
2
3 import (
4 "context"
5 "crypto/rand"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "path/filepath"
12 "sort"
13 "strings"
14 "sync/atomic"
15 "time"
16
17 fileencoding "reasonix/internal/fileutil/encoding"
18 "reasonix/internal/store"
19 )
20
21 // ErrSessionTitleChanged reports that a conditional rename observed a newer
22 // custom title and left it untouched.
23 var ErrSessionTitleChanged = errors.New("session title changed")
24
25 // BranchMeta is the small sidecar record that turns flat session files into a
26 // navigable conversation tree. The conversation itself remains in the .jsonl
27 // file; metadata lives beside it at <session>.meta.
28 type BranchMeta struct {
29 ID string `json:"id"`
30 Name string `json:"name,omitempty"`
31 ParentID string `json:"parent_id,omitempty"`
32 ForkTurn int `json:"fork_turn,omitempty"`
33 ForkMessageIndex int `json:"fork_message_index,omitempty"`
34 CreatedAt time.Time `json:"created_at"`
35 UpdatedAt time.Time `json:"updated_at"`
36 Scope string `json:"scope,omitempty"`
37 WorkspaceRoot string `json:"workspace_root,omitempty"`
38 TopicID string `json:"topic_id,omitempty"`
39 TopicTitle string `json:"topic_title,omitempty"`
40 CustomTitle string `json:"custom_title,omitempty"`
41 // TitleRevision is an opaque mutation identity for CustomTitle. It is
42 // independent from the transcript Revision: saving the same title again
43 // still advances this token so a delayed AI completion cannot pass an
44 // A→B→A value comparison.
45 TitleRevision string `json:"title_revision,omitempty"`
46 Model string `json:"model,omitempty"`
47 ModelIdentity string `json:"model_identity,omitempty"`
48 // TokenMode and AgentPreset are deprecated dual-write fields derived from
49 // QualityFloor; delivery writes "delivery", standard writes "full"/"".
50 TokenMode string `json:"token_mode,omitempty"`
51 AgentPreset string `json:"agent_preset,omitempty"`
52 // QualityFloor is the session delivery floor (standard|delivery). Loading
53 // a meta without it maps legacy AgentPreset/TokenMode "delivery" here.
54 QualityFloor string `json:"quality_floor,omitempty"`
55 Mode string `json:"mode,omitempty"`
56 ToolApprovalMode string `json:"tool_approval_mode,omitempty"`
57 Goal string `json:"goal,omitempty"`
58 Recovered bool `json:"recovered,omitempty"`
59 // VersionKind separates ordinary transcripts, recovery copies, and
60 // session-backed subagents. Older sidecars infer recovery from Recovered.
61 VersionKind SessionVersionKind `json:"version_kind,omitempty"`
62 VersionState SessionVersionState `json:"version_state,omitempty"`
63 ParentConversationID string `json:"parent_conversation_id,omitempty"`
64 ParentVersionID string `json:"parent_version_id,omitempty"`
65 BaseRevision int64 `json:"base_revision,omitempty"`
66 DiskRevision int64 `json:"disk_revision,omitempty"`
67 RecoveryReason string `json:"recovery_reason,omitempty"`
68 RecoveryDigest string `json:"recovery_digest,omitempty"`
69 // RecoveryDepth is 1 for new stable recovery branches. Older nested
70 // files may still carry a larger historical value.
71 RecoveryDepth int `json:"recovery_depth,omitempty"`
72 // RecoveryPreferred is a user's explicit choice among genuinely diverged
73 // recovery leaves. It changes the default open target, but never authorizes
74 // deletion and is cleared automatically if that leaf is no longer valid.
75 RecoveryPreferred bool `json:"recovery_preferred,omitempty"`
76 RecoveryPreferredDigest string `json:"recovery_preferred_digest,omitempty"`
77 Revision int64 `json:"revision,omitempty"`
78 ContentDigest string `json:"content_digest,omitempty"`
79 WriterID string `json:"writer_id,omitempty"`
80 // SchemaVersion identifies which BranchMeta version last wrote content-derived
81 // listing fields (Turns/Preview). Only snapshot/Fork/Branch stamp it; readers
82 // use it to distinguish authoritative current counts from legacy zeros.
83 SchemaVersion int `json:"schema_version,omitempty"`
84 // Turns/Preview accelerate listings; the listing identity binds them to the
85 // transcript generation they describe, so a failed projection write makes
86 // old counts visibly stale instead of silently reusable.
87 Turns int `json:"turns,omitempty"`
88 Preview string `json:"preview,omitempty"`
89 ListingRevision int64 `json:"listing_revision,omitempty"`
90 ListingContentDigest string `json:"listing_content_digest,omitempty"`
91 InFlightTurn *InFlightTurnMeta `json:"in_flight_turn,omitempty"`
92 // HeadID and its companions mirror the schema-2 log's selected head for
93 // listings that must not replay the log; they are absent for schema 1.
94 HeadID string `json:"head_id,omitempty"`
95 HeadCount int `json:"head_count,omitempty"`
96 LogSchema int `json:"log_schema,omitempty"`
97 LogGeneration int64 `json:"log_generation,omitempty"`
98 }
99
100 // SessionVersionKind is the durable identity class of a physical transcript.
101 // It is intentionally separate from Recovered for compatibility with older
102 // sidecars and from subagent metadata, which carries richer child lifecycle.
103 type SessionVersionKind string
104
105 const (
106 VersionNormal SessionVersionKind = "normal"
107 VersionRecovery SessionVersionKind = "recovery"
108 VersionSubagent SessionVersionKind = "subagent"
109 )
110
111 type SessionVersionState string
112
113 const (
114 VersionActive SessionVersionState = "active"
115 VersionPending SessionVersionState = "pending"
116 VersionResolved SessionVersionState = "resolved"
117 VersionTrashed SessionVersionState = "trashed"
118 )
119
120 func (m BranchMeta) EffectiveVersionKind() SessionVersionKind {
121 if m.VersionKind != "" {
122 return m.VersionKind
123 }
124 if m.Recovered {
125 return VersionRecovery
126 }
127 return VersionNormal
128 }
129
130 func (m BranchMeta) EffectiveVersionState() SessionVersionState {
131 if m.VersionState != "" {
132 return m.VersionState
133 }
134 return VersionActive
135 }
136
137 const (
138 // branchMetaCountsInitialVersion introduced content-derived Turns/Preview.
139 // Positive counts from this version remain authoritative.
140 branchMetaCountsInitialVersion = 1
141 // BranchMetaCountsVersion certifies that zero turns came from a successful,
142 // error-aware decode. Version 1 could cache a preview failure as zero turns.
143 BranchMetaCountsVersion = 2
144 )
145
146 // InFlightTurnMeta records the message-log boundary for a foreground turn that
147 // has started but not yet reached TurnDone. If the process exits mid-turn, a
148 // later resume can strip the partial assistant/tool tail without guessing.
149 type InFlightTurnMeta struct {
150 // ID makes marker cleanup compare-and-clear. Older sidecars omit it and are
151 // handled by the legacy index/time recovery path.
152 ID string `json:"id,omitempty"`
153 StartMessageIndex int `json:"start_message_index"`
154 PreserveUser bool `json:"preserve_user"`
155 StartedAt time.Time `json:"started_at"`
156 // StartRevision and StartDigest bind the legacy array boundary to the
157 // persisted transcript that existed when the turn began.
158 StartRevision int64 `json:"start_revision,omitempty"`
159 StartDigest string `json:"start_digest,omitempty"`
160 // CommitDigest is written before the final turn snapshot. If recovery sees
161 // this exact transcript on disk, the snapshot committed and only marker
162 // cleanup was interrupted; no message recovery is necessary.
163 CommitDigest string `json:"commit_digest,omitempty"`
164 // HeadID marks a schema-2 turn whose begin/end markers live in the log
165 // rather than in this sidecar; such markers are never persisted here.
166 HeadID string `json:"head_id,omitempty"`
167 }
168
169 func (m BranchMeta) DefaultScope() string {
170 switch m.Scope {
171 case "project":
172 return "project"
173 default:
174 return "global"
175 }
176 }
177
178 // BranchInfo combines sidecar metadata with the session file details needed for
179 // pickers and tree rendering.
180 type BranchInfo struct {
181 BranchMeta
182 Path string
183 ModTime time.Time
184 Preview string
185 Turns int
186 // HeadID and HeadKind are set for a head inside a schema-2 log; Path is
187 // then the log the head lives in and ID is the head id.
188 HeadID string
189 HeadKind string
190 }
191
192 func BranchID(path string) string {
193 if path == "" {
194 return ""
195 }
196 base := filepath.Base(path)
197 if ext := filepath.Ext(base); ext != "" {
198 base = strings.TrimSuffix(base, ext)
199 }
200 return base
201 }
202
203 func BranchMetaPath(sessionPath string) string {
204 return store.SessionMeta(sessionPath)
205 }
206
207 func LoadBranchMeta(sessionPath string) (BranchMeta, bool, error) {
208 metaPath := BranchMetaPath(sessionPath)
209 if metaPath == "" {
210 return BranchMeta{}, false, nil
211 }
212 b, err := fileencoding.ReadFileUTF8(metaPath)
213 if err != nil {
214 if os.IsNotExist(err) {
215 return BranchMeta{}, false, nil
216 }
217 return BranchMeta{}, false, err
218 }
219 var m BranchMeta
220 if err := json.Unmarshal(b, &m); err != nil {
221 // Treat an all-NUL/JSON-whitespace sidecar as a torn write so callers
222 // rebuild it; retain errors for partial JSON to avoid swallowing corruption.
223 if metaIsUnparseableAsAbsent(b) {
224 return BranchMeta{}, false, nil
225 }
226 return BranchMeta{}, false, fmt.Errorf("decode branch meta %s: %w", metaPath, err)
227 }
228 if m.ID == "" {
229 m.ID = BranchID(sessionPath)
230 }
231 m.sanitizeDisplayFields()
232 return m, true, nil
233 }
234
235 // metaIsUnparseableAsAbsent recognizes an empty or all-NUL/JSON-whitespace torn
236 // write that is safe to rebuild; other bytes indicate genuine corruption.
237 func metaIsUnparseableAsAbsent(b []byte) bool {
238 if len(b) == 0 {
239 return true
240 }
241 for _, c := range b {
242 if c != 0x00 && c != ' ' && c != '\t' && c != '\r' && c != '\n' {
243 return false
244 }
245 }
246 return true
247 }
248
249 // sanitizeDisplayFields cleans persisted display strings that older builds
250 // polluted with internal wrappers (memory-compiler execution contracts,
251 // transient blocks) — #5666. Every reader goes through LoadBranchMeta, so this
252 // is the single boundary; UserPreviewText is a no-op on clean text, and a
253 // field that was pure wrapper falls back to empty so callers use their normal
254 // fallbacks (preview, default title).
255 func (m *BranchMeta) sanitizeDisplayFields() {
256 m.TopicTitle = sanitizeStoredDisplayText(m.TopicTitle)
257 m.CustomTitle = sanitizeStoredDisplayText(m.CustomTitle)
258 m.Preview = sanitizeStoredDisplayText(m.Preview)
259 }
260
261 func sanitizeStoredDisplayText(s string) string {
262 if strings.TrimSpace(s) == "" {
263 return strings.TrimSpace(s)
264 }
265 return UserPreviewText(s)
266 }
267
268 // branchMetaReadBackoffs paces the re-reads of a branch-meta sidecar that
269 // failed to load. On Windows fileutil.ReplaceFile can fall back to a
270 // non-atomic in-place copy, so a concurrent reader may catch the sidecar
271 // half-written (an open/read error or truncated JSON). Those tears heal in
272 // milliseconds; a few short retries separate them from real corruption.
273 var branchMetaReadBackoffs = []time.Duration{20 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond}
274
275 // loadBranchMetaRetry reads the branch-meta sidecar like LoadBranchMeta but
276 // retries transient failures (I/O errors and undecodable JSON) before giving
277 // up. A missing sidecar is a legitimate state — a session that has never
278 // recorded meta — and returns ok=false immediately without retrying.
279 func loadBranchMetaRetry(sessionPath string) (BranchMeta, bool, error) {
280 var lastErr error
281 for attempt := 0; ; attempt++ {
282 meta, ok, err := LoadBranchMeta(sessionPath)
283 if err == nil {
284 return meta, ok, nil
285 }
286 lastErr = err
287 if attempt >= len(branchMetaReadBackoffs) {
288 return BranchMeta{}, false, lastErr
289 }
290 time.Sleep(branchMetaReadBackoffs[attempt])
291 }
292 }
293
294 func SaveBranchMeta(sessionPath string, m BranchMeta) error {
295 return UpdateBranchMeta(sessionPath, true, func(current *BranchMeta) error {
296 // SaveBranchMeta is the compatibility full-record writer. Callers that
297 // intentionally supply CustomTitle must still be able to change it; the
298 // cross-process lock held by UpdateBranchMeta makes that replacement
299 // authoritative. Transcript/listing writers use saveBranchMeta below,
300 // which preserves the title fields from the latest sidecar.
301 preserveBranchMetaPersistence(&m, *current, false)
302 *current = m
303 return nil
304 })
305 }
306
307 func SaveBranchMetaPreserveUpdated(sessionPath string, m BranchMeta) error {
308 return UpdateBranchMeta(sessionPath, false, func(current *BranchMeta) error {
309 preserveBranchMetaPersistence(&m, *current, false)
310 *current = m
311 return nil
312 })
313 }
314
315 // SaveBranchMetaPreserveUpdatedLocked is for callers that already hold
316 // LockSessionMetaPath for a larger read-modify-write transaction.
317 func SaveBranchMetaPreserveUpdatedLocked(sessionPath string, m BranchMeta) error {
318 return saveBranchMetaContextMode(context.Background(), sessionPath, m, false, false)
319 }
320
321 func saveBranchMeta(sessionPath string, m BranchMeta, touchUpdated bool) error {
322 return saveBranchMetaContextMode(context.Background(), sessionPath, m, touchUpdated, true)
323 }
324
325 func saveBranchMetaContext(ctx context.Context, sessionPath string, m BranchMeta, touchUpdated bool) error {
326 return saveBranchMetaContextMode(ctx, sessionPath, m, touchUpdated, true)
327 }
328
329 func saveBranchMetaTitle(sessionPath string, m BranchMeta) error {
330 return saveBranchMetaContextMode(context.Background(), sessionPath, m, false, false)
331 }
332
333 func saveBranchMetaContextMode(ctx context.Context, sessionPath string, m BranchMeta, touchUpdated, preserveTitle bool) error {
334 metaPath := BranchMetaPath(sessionPath)
335 if metaPath == "" {
336 return fmt.Errorf("empty session path")
337 }
338 now := time.Now().UTC()
339 if m.ID == "" {
340 m.ID = BranchID(sessionPath)
341 }
342 if m.CreatedAt.IsZero() {
343 m.CreatedAt = now
344 }
345 if touchUpdated {
346 m.UpdatedAt = now
347 } else if m.UpdatedAt.IsZero() {
348 if info, err := os.Stat(sessionPath); err == nil {
349 m.UpdatedAt = info.ModTime().UTC()
350 } else {
351 m.UpdatedAt = now
352 }
353 }
354 if existing, ok, err := LoadBranchMeta(sessionPath); err == nil && ok {
355 preserveBranchMetaPersistence(&m, existing, preserveTitle)
356 }
357 if err := os.MkdirAll(filepath.Dir(metaPath), 0o755); err != nil {
358 return err
359 }
360 b, err := marshalJSONIndentContext(ctx, m)
361 if err != nil {
362 return err
363 }
364 b = append(b, '\n')
365 return atomicWriteFileContext(ctx, metaPath, ".branch.*.tmp", "branch-meta", b, 0o600, false)
366 }
367
368 func preserveBranchMetaPersistence(next *BranchMeta, existing BranchMeta, preserveTitle ...bool) {
369 if next == nil {
370 return
371 }
372 // Title metadata is owned by the title mutation path, not by transcript
373 // snapshots or listing projection refreshes. A stale in-memory BranchMeta
374 // must never roll it back while preserving newer transcript fields.
375 if len(preserveTitle) == 0 || preserveTitle[0] {
376 next.CustomTitle = existing.CustomTitle
377 next.TitleRevision = existing.TitleRevision
378 }
379 if existing.Revision > next.Revision {
380 next.Revision = existing.Revision
381 next.ContentDigest = existing.ContentDigest
382 next.WriterID = existing.WriterID
383 preserveBranchMetaListingProjection(next, existing)
384 return
385 }
386 if existing.Revision == next.Revision {
387 if strings.TrimSpace(next.ContentDigest) == "" {
388 next.ContentDigest = existing.ContentDigest
389 }
390 if strings.TrimSpace(next.WriterID) == "" {
391 next.WriterID = existing.WriterID
392 }
393 if next.ListingRevision == 0 && existing.ListingRevision != 0 ||
394 strings.TrimSpace(next.ListingContentDigest) == "" && strings.TrimSpace(existing.ListingContentDigest) != "" {
395 preserveBranchMetaListingProjection(next, existing)
396 }
397 }
398 }
399
400 func preserveBranchMetaListingProjection(next *BranchMeta, existing BranchMeta) {
401 next.SchemaVersion = existing.SchemaVersion
402 next.Turns = existing.Turns
403 next.Preview = existing.Preview
404 next.ListingRevision = existing.ListingRevision
405 next.ListingContentDigest = existing.ListingContentDigest
406 }
407
408 func EnsureBranchMeta(sessionPath string) (BranchMeta, error) {
409 var out BranchMeta
410 err := UpdateBranchMeta(sessionPath, false, func(m *BranchMeta) error {
411 out = *m
412 return nil
413 })
414 return out, err
415 }
416
417 // EnsureBranchMetaLocked is for callers that already hold LockSessionMetaPath.
418 func EnsureBranchMetaLocked(sessionPath string) (BranchMeta, error) {
419 return ensureBranchMetaUnlocked(sessionPath)
420 }
421
422 func ensureBranchMetaUnlocked(sessionPath string) (BranchMeta, error) {
423 if sessionPath == "" {
424 return BranchMeta{}, fmt.Errorf("empty session path")
425 }
426 if m, ok, err := LoadBranchMeta(sessionPath); err != nil || ok {
427 return m, err
428 }
429 when := time.Now().UTC()
430 if info, err := os.Stat(sessionPath); err == nil {
431 when = info.ModTime().UTC()
432 }
433 m := BranchMeta{
434 ID: BranchID(sessionPath),
435 CreatedAt: when,
436 UpdatedAt: when,
437 }
438 return m, saveBranchMeta(sessionPath, m, false)
439 }
440
441 func TouchBranchMeta(sessionPath string) error {
442 return UpdateBranchMeta(sessionPath, false, func(m *BranchMeta) error {
443 m.UpdatedAt = time.Now().UTC()
444 return nil
445 })
446 }
447
448 func MarkSessionInFlightTurn(sessionPath string, startMessageIndex int, preserveUser bool) error {
449 _, err := BeginSessionInFlightTurn(sessionPath, startMessageIndex, preserveUser)
450 return err
451 }
452
453 var inFlightTurnSequence atomic.Uint64
454
455 // BeginSessionInFlightTurn writes a new marker and returns the exact marker so
456 // the owner can later clear only this turn. The baseline fields are learned from
457 // the branch sidecar before replacing its marker.
458 func BeginSessionInFlightTurn(sessionPath string, startMessageIndex int, preserveUser bool) (InFlightTurnMeta, error) {
459 if sessionPath == "" {
460 return InFlightTurnMeta{}, fmt.Errorf("empty session path")
461 }
462 // Read the baseline and install the marker under the same in-process save
463 // lock. Otherwise an autosave can advance the revision between the read and
464 // SetSessionInFlightTurn, leaving the marker bound to a stale baseline.
465 unlock, err := LockSessionMetaPath(sessionPath)
466 if err != nil {
467 return InFlightTurnMeta{}, err
468 }
469 defer unlock()
470 meta, err := ensureBranchMetaUnlocked(sessionPath)
471 if err != nil {
472 return InFlightTurnMeta{}, err
473 }
474 marker := InFlightTurnMeta{
475 ID: fmt.Sprintf("%s-%d-%d", SessionWriterID(), time.Now().UnixNano(), inFlightTurnSequence.Add(1)),
476 StartMessageIndex: startMessageIndex,
477 PreserveUser: preserveUser,
478 StartedAt: time.Now().UTC(),
479 }
480 marker.StartRevision = meta.Revision
481 marker.StartDigest = strings.TrimSpace(meta.ContentDigest)
482 marker.StartMessageIndex = max(marker.StartMessageIndex, 0)
483 meta.InFlightTurn = &marker
484 if err := saveBranchMeta(sessionPath, meta, false); err != nil {
485 return InFlightTurnMeta{}, err
486 }
487 return marker, nil
488 }
489
490 // SetSessionInFlightTurn writes an existing in-flight marker verbatim. It is
491 // used when a running turn moves to a recovery branch: preserving StartedAt is
492 // what lets crash recovery relocate the turn after an in-turn compaction has
493 // rewritten its original message index.
494 func SetSessionInFlightTurn(sessionPath string, marker InFlightTurnMeta) error {
495 startMessageIndex := max(marker.StartMessageIndex, 0)
496 // The sidecar is read-modify-write; the per-path save lock keeps concurrent
497 // writers (autosave's UpdateSessionMeta, listing backfill) from dropping
498 // each other's fields.
499 unlock, err := LockSessionMetaPath(sessionPath)
500 if err != nil {
501 return err
502 }
503 defer unlock()
504 m, err := ensureBranchMetaUnlocked(sessionPath)
505 if err != nil {
506 return err
507 }
508 marker.StartMessageIndex = startMessageIndex
509 if marker.StartedAt.IsZero() {
510 marker.StartedAt = time.Now().UTC()
511 }
512 m.InFlightTurn = &marker
513 return saveBranchMeta(sessionPath, m, false)
514 }
515
516 func ClearSessionInFlightTurn(sessionPath string) error {
517 _, err := ClearSessionInFlightTurnIfMatch(sessionPath, InFlightTurnMeta{})
518 return err
519 }
520
521 // ClearSessionInFlightTurnIfMatch clears a marker only when it still matches
522 // expected. A non-empty ID is authoritative; legacy markers without IDs fall
523 // back to the complete persisted marker shape for compatibility.
524 func ClearSessionInFlightTurnIfMatch(sessionPath string, expected InFlightTurnMeta) (bool, error) {
525 unlock, err := LockSessionMetaPath(sessionPath)
526 if err != nil {
527 return false, err
528 }
529 defer unlock()
530 m, ok, err := LoadBranchMeta(sessionPath)
531 if err != nil || !ok {
532 return false, err
533 }
534 if m.InFlightTurn == nil {
535 return false, nil
536 }
537 if expected.ID != "" {
538 if m.InFlightTurn.ID != expected.ID {
539 return false, nil
540 }
541 } else if expected.StartMessageIndex != 0 || expected.PreserveUser || !expected.StartedAt.IsZero() || expected.StartRevision != 0 || expected.StartDigest != "" {
542 if !sameInFlightTurn(*m.InFlightTurn, expected) {
543 return false, nil
544 }
545 }
546 m.InFlightTurn = nil
547 return true, saveBranchMeta(sessionPath, m, false)
548 }
549
550 // PrepareSessionInFlightTurnCommit binds the owned marker to the exact final
551 // transcript before that transcript is saved. Recovery can then distinguish a
552 // crash after the save from a crash during the turn without guessing from roles
553 // or array indexes.
554 func PrepareSessionInFlightTurnCommit(sessionPath string, expected InFlightTurnMeta, digest string) (InFlightTurnMeta, bool, error) {
555 digest = strings.TrimSpace(digest)
556 if sessionPath == "" || expected.ID == "" || digest == "" {
557 return InFlightTurnMeta{}, false, nil
558 }
559 unlock, err := LockSessionMetaPath(sessionPath)
560 if err != nil {
561 return InFlightTurnMeta{}, false, err
562 }
563 defer unlock()
564 m, ok, err := LoadBranchMeta(sessionPath)
565 if err != nil || !ok || m.InFlightTurn == nil {
566 return InFlightTurnMeta{}, false, err
567 }
568 if m.InFlightTurn.ID != expected.ID {
569 return InFlightTurnMeta{}, false, nil
570 }
571 updated := *m.InFlightTurn
572 updated.CommitDigest = digest
573 m.InFlightTurn = &updated
574 if err := saveBranchMeta(sessionPath, m, false); err != nil {
575 return InFlightTurnMeta{}, false, err
576 }
577 return updated, true, nil
578 }
579
580 func sameInFlightTurn(a, b InFlightTurnMeta) bool {
581 return a.ID == b.ID &&
582 a.StartMessageIndex == b.StartMessageIndex &&
583 a.PreserveUser == b.PreserveUser &&
584 a.StartedAt.Equal(b.StartedAt) &&
585 a.StartRevision == b.StartRevision &&
586 a.StartDigest == b.StartDigest &&
587 a.CommitDigest == b.CommitDigest
588 }
589
590 func ListBranches(dir string) ([]BranchInfo, error) {
591 entries, err := os.ReadDir(dir)
592 if err != nil {
593 if os.IsNotExist(err) {
594 return nil, nil
595 }
596 return nil, err
597 }
598 var out []BranchInfo
599 for _, e := range entries {
600 if e.IsDir() || !store.IsSessionTranscriptName(e.Name()) {
601 continue
602 }
603 info, err := e.Info()
604 if err != nil {
605 continue
606 }
607 path := filepath.Join(dir, e.Name())
608 if !IsVisibleSession(path) {
609 continue
610 }
611 preview, turns := previewSession(path)
612 if turns == 0 {
613 continue
614 }
615 meta, ok, err := LoadBranchMeta(path)
616 if err != nil {
617 continue
618 }
619 if !ok {
620 meta = BranchMeta{
621 ID: BranchID(path),
622 CreatedAt: info.ModTime().UTC(),
623 UpdatedAt: info.ModTime().UTC(),
624 }
625 }
626 if meta.ID == "" {
627 meta.ID = BranchID(path)
628 }
629 out = append(out, BranchInfo{
630 BranchMeta: meta,
631 Path: path,
632 ModTime: info.ModTime(),
633 Preview: preview,
634 Turns: turns,
635 })
636 }
637 sort.Slice(out, func(i, j int) bool {
638 if out[i].CreatedAt.Equal(out[j].CreatedAt) {
639 return out[i].ID < out[j].ID
640 }
641 return out[i].CreatedAt.Before(out[j].CreatedAt)
642 })
643 return out, nil
644 }
645
646 // RenameSession updates the user-chosen display title in the session's
647 // .jsonl.meta sidecar file. If no meta file exists yet, one is created. The
648 // topic title remains a separate grouping label, so explicit session names do
649 // not fight topic auto-titling.
650 func RenameSession(sessionPath string, title string) error {
651 _, err := renameSession(sessionPath, "", false, title)
652 return err
653 }
654
655 // SessionTitleSnapshot returns the title and an opaque mutation revision from
656 // one locked BranchMeta generation. Missing revisions are initialized before
657 // returning, upgrading old sidecars without changing their title.
658 func SessionTitleSnapshot(sessionPath string) (title, revision string, err error) {
659 if sessionPath == "" {
660 return "", "", fmt.Errorf("empty session path")
661 }
662 unlock, err := LockSessionMetaPath(sessionPath)
663 if err != nil {
664 return "", "", err
665 }
666 defer unlock()
667 m, err := ensureBranchMetaUnlocked(sessionPath)
668 if err != nil {
669 return "", "", err
670 }
671 if strings.TrimSpace(m.TitleRevision) == "" {
672 m.TitleRevision, err = newTitleRevision()
673 if err != nil {
674 return "", "", err
675 }
676 if err = saveBranchMetaTitle(sessionPath, m); err != nil {
677 return "", "", err
678 }
679 }
680 return m.CustomTitle, m.TitleRevision, nil
681 }
682
683 // RenameSessionIfTitleRevision atomically updates a title only when the opaque
684 // title mutation identity still matches. This detects A→B→A and same-value
685 // manual saves, unlike a text-only comparison.
686 func RenameSessionIfTitleRevision(sessionPath, expectedRevision, title string) error {
687 _, err := renameSession(sessionPath, expectedRevision, true, title)
688 return err
689 }
690
691 func renameSession(sessionPath, expectedRevision string, conditional bool, title string) (string, error) {
692 if sessionPath == "" {
693 return "", fmt.Errorf("empty session path")
694 }
695 // Read-modify-write on the sidecar: hold the per-path meta lock so a
696 // concurrent save (recordSessionContentRevision) can't have its Revision
697 // bump clobbered by a stale read-back here.
698 unlock, err := LockSessionMetaPath(sessionPath)
699 if err != nil {
700 return "", err
701 }
702 defer unlock()
703 m, err := ensureBranchMetaUnlocked(sessionPath)
704 if err != nil {
705 return "", err
706 }
707 if conditional && m.TitleRevision != expectedRevision {
708 return "", fmt.Errorf("%w: title revision changed", ErrSessionTitleChanged)
709 }
710 m.CustomTitle = strings.TrimSpace(title)
711 m.TitleRevision, err = newTitleRevision()
712 if err != nil {
713 return "", err
714 }
715 if err := saveBranchMetaTitle(sessionPath, m); err != nil {
716 return "", err
717 }
718 return m.TitleRevision, nil
719 }
720
721 func newTitleRevision() (string, error) {
722 var value [16]byte
723 if _, err := rand.Read(value[:]); err != nil {
724 return "", fmt.Errorf("generate title revision: %w", err)
725 }
726 return hex.EncodeToString(value[:]), nil
727 }
728
729 // LoadSessionModel reads the canonical provider/model ref saved beside a
730 // session transcript.
731 func LoadSessionModel(sessionPath string) (string, bool) {
732 model, _, ok := LoadSessionModelSelection(sessionPath)
733 return model, ok
734 }
735
736 // LoadSessionModelSelection reads model and identity from the same sidecar
737 // generation. Missing identity denotes a legacy, unacknowledged selection.
738 func LoadSessionModelSelection(sessionPath string) (string, string, bool) {
739 meta, ok, err := LoadBranchMeta(sessionPath)
740 if err != nil || !ok {
741 return "", "", false
742 }
743 model := strings.TrimSpace(meta.Model)
744 if model == "" {
745 return "", "", false
746 }
747 return model, meta.ModelIdentity, true
748 }
749
750 // SetBranchModelPreserveUpdated stores the canonical provider/model ref without
751 // changing the session activity timestamp.
752 func SetBranchModelPreserveUpdated(sessionPath, model string) error {
753 return setBranchModelSelection(sessionPath, model, nil)
754 }
755
756 // SetBranchModelSelectionPreserveUpdated atomically acknowledges the selected
757 // connection without changing the session's activity timestamp.
758 func SetBranchModelSelectionPreserveUpdated(sessionPath, model, identity string) error {
759 return setBranchModelSelection(sessionPath, model, &identity)
760 }
761
762 func setBranchModelSelection(sessionPath, model string, identity *string) error {
763 if sessionPath == "" {
764 return fmt.Errorf("empty session path")
765 }
766 unlock, err := LockSessionMetaPath(sessionPath)
767 if err != nil {
768 return err
769 }
770 defer unlock()
771 meta, err := ensureBranchMetaUnlocked(sessionPath)
772 if err != nil {
773 return err
774 }
775 setMetaModelSelection(&meta, model, identity)
776 return saveBranchMeta(sessionPath, meta, false)
777 }
778
779 func setMetaModelSelection(meta *BranchMeta, model string, identity *string) {
780 model = strings.TrimSpace(model)
781 if meta.Model != model {
782 meta.ModelIdentity = ""
783 }
784 meta.Model = model
785 if identity != nil {
786 meta.ModelIdentity = *identity
787 }
788 }
789
790 // UpdateSessionMeta refreshes the listing-only sidecar fields (model, preview,
791 // user-turn count) the sidebar and pickers read without decoding the .jsonl.
792 // markActivity bumps UpdatedAt (the autosave path passes true on a real turn);
793 // false preserves it (used to backfill legacy sessions during a read). An empty
794 // model leaves the stored model untouched.
795 func UpdateSessionMeta(sessionPath, model, preview string, turns int, markActivity bool) error {
796 if sessionPath == "" {
797 return fmt.Errorf("empty session path")
798 }
799 unlock, err := LockSessionMetaPath(sessionPath)
800 if err != nil {
801 return err
802 }
803 defer unlock()
804 m, err := ensureBranchMetaUnlocked(sessionPath)
805 if err != nil {
806 return err
807 }
808 if strings.TrimSpace(model) != "" {
809 setMetaModelSelection(&m, model, nil)
810 }
811 m.Preview = preview
812 m.Turns = turns
813 // These counts were derived from the current content, so mark them
814 // authoritative — listing can then trust Turns (even 0) without re-decoding.
815 m.SchemaVersion = BranchMetaCountsVersion
816 stampSessionListingProjection(&m)
817 return saveBranchMeta(sessionPath, m, markActivity)
818 }
819
819 lines GO