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