返回 DeepSeek-Reasonix
save.go
根目录 / internal / agent / save.go
1 package agent
2
3 import (
4 "bytes"
5 "context"
6 "crypto/rand"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "hash"
13 "log/slog"
14 "math"
15 "os"
16 "path/filepath"
17 "sort"
18 "strings"
19 "sync"
20 "time"
21 "unicode/utf8"
22
23 "reasonix/internal/fileutil"
24 fileencoding "reasonix/internal/fileutil/encoding"
25 "reasonix/internal/provider"
26 "reasonix/internal/store"
27 )
28
29 const (
30 cleanupPendingExt = ".cleanup-pending.json"
31 maxRecoveryParentStemBytes = 80
32 sessionLockSidecarSuffix = ".jsonl.lock"
33 sessionLeaseLockSidecarSuffix = ".jsonl.lease.lock"
34 sessionLeaseInfoSidecarSuffix = ".jsonl.lease.json"
35 guardianSidecarSuffix = ".guardian.jsonl"
36 // nameMaxBytes is the single-component filename limit shared by the
37 // filesystems Reasonix targets (APFS, ext4, NTFS all cap at 255).
38 nameMaxBytes = 255
39 // maxSessionBasenameBytes bounds transcript basenames that reconciliation
40 // leaves in place. Sidecars append up to ~16 bytes to the transcript name
41 // or its stem (".lease.lock", ".cleanup-pending.json", ".guardian.jsonl"),
42 // so 224 keeps every sidecar comfortably under nameMaxBytes with headroom
43 // for future suffixes. Names past this bound come from the pre-bounded
44 // recovery cascade and get renamed by reconcileOverlongSessionFilenames.
45 maxSessionBasenameBytes = 224
46 )
47
48 var (
49 sessionSaveLocks sync.Map
50 // sessionFileLockWait bounds cross-process save-lock acquisition. Session
51 // leases normally prevent competing writers, but CLI/legacy writers and a
52 // stalled process can still hold the compatibility .lock file. Navigation
53 // and desktop shutdown snapshot synchronously; waiting forever here wedges
54 // the UI and keeps the session lease (and WebView) alive indefinitely.
55 // Package vars let focused tests shorten the wait without slowing the suite.
56 sessionFileLockWait = 5 * time.Second
57 sessionFileLockPollInterval = 25 * time.Millisecond
58 sessionMetaLockWait = 5 * time.Second
59 ErrSessionSnapshotConflict = errors.New("session snapshot conflicts with newer transcript")
60 // ErrSessionExternallyRemoved means a live Session still has a verified
61 // baseline for path, but every authoritative transcript artifact disappeared.
62 // Treating it as a first save would silently recreate a file the user or an
63 // external cleanup tool deliberately removed.
64 ErrSessionExternallyRemoved = errors.New("session was removed while still open")
65 ErrSessionRecoveryNotNeeded = errors.New("session recovery not needed")
66 // ErrSessionFileLockHeld reports that another process kept the
67 // compatibility save lock for the full bounded acquisition window. Callers
68 // that are about to terminate can use this sentinel to persist a recovery
69 // branch without waiting on the same stalled file again.
70 ErrSessionFileLockHeld = errors.New("session file lock held")
71 // ErrSessionRecoveryDepthExceeded is retained for older callers. New
72 // recovery writes update one stable branch and no longer return it.
73 ErrSessionRecoveryDepthExceeded = errors.New("session recovery chain depth exceeded")
74 sessionWriterID = newSessionWriterID()
75 )
76
77 // SessionRecoveryMaxDepth is the historical nested-fork cap. New writes stamp
78 // RecoveryDepth=1 and update one stable path instead of deepening a chain.
79 const SessionRecoveryMaxDepth = 3
80
81 type sessionPersistState struct {
82 projectionPending bool
83 path string
84 digest [sha256.Size]byte
85 version uint64
86 revision int64
87 // revisionKnown marks revision as a real ledger value. It is false when
88 // the baseline was established while the meta sidecar was unreadable
89 // (torn or corrupt): the session must still open, but revision 0 must not
90 // pose as a baseline or every honest on-disk revision would read as a
91 // stale-runtime conflict. CAS checks fall back to digest+version until a
92 // successful save re-learns the revision.
93 revisionKnown bool
94 // saveVerified marks a baseline established by a completed save in this
95 // process, whose write path verified transcript and ledger agree. A
96 // baseline adopted at load time pairs the disk transcript with whatever
97 // the meta sidecar said — which can lag the transcript after an
98 // interrupted save — so only save-verified baselines may arm the
99 // snapshot no-op fast path; the first save after a load must run in full
100 // and heal a stale ledger.
101 saveVerified bool
102 ok bool
103 }
104
105 type sessionSaveMode int
106
107 const (
108 sessionSaveSnapshot sessionSaveMode = iota
109 sessionSaveRewrite
110 sessionSaveRewriteCompact
111 sessionSaveToolCheckpoint
112 )
113
114 type snapshotWriteDecision struct {
115 revision int64
116 reservedRevision int64
117 upToDate bool
118 appendFrom int
119 appendOnly bool
120 // repairLog is set when the on-disk event log was damaged (torn tail with
121 // a lost suffix, or nothing decodable): the safe write shape is a full
122 // rewrite that also compacts the log back to a healthy single event.
123 repairLog bool
124 // ledgerStale is set when the on-disk transcript already matches the
125 // snapshot but the meta ledger still describes older content — the
126 // aftermath of a save whose bytes landed and whose revision record then
127 // failed. The up-to-date path must heal the ledger instead of skipping it.
128 ledgerStale bool
129 }
130
131 type SessionSnapshotConflictKind string
132
133 const (
134 SessionSnapshotConflictStalePrefix SessionSnapshotConflictKind = "stale_prefix"
135 SessionSnapshotConflictDiverged SessionSnapshotConflictKind = "diverged"
136 )
137
138 type SessionSnapshotConflictError struct {
139 Path string
140 Kind SessionSnapshotConflictKind
141 ExistingMessages int
142 SnapshotMessages int
143 BaseRevision int64
144 DiskRevision int64
145 }
146
147 func (e *SessionSnapshotConflictError) Error() string {
148 if e == nil {
149 return ErrSessionSnapshotConflict.Error()
150 }
151 switch e.Kind {
152 case SessionSnapshotConflictStalePrefix:
153 return fmt.Sprintf("%s: %s has %d messages at revision %d; stale snapshot has %d messages from revision %d",
154 ErrSessionSnapshotConflict, e.Path, e.ExistingMessages, e.DiskRevision, e.SnapshotMessages, e.BaseRevision)
155 default:
156 return fmt.Sprintf("%s: %s diverged on disk (%d messages, revision %d) from snapshot (%d messages, revision %d)",
157 ErrSessionSnapshotConflict, e.Path, e.ExistingMessages, e.DiskRevision, e.SnapshotMessages, e.BaseRevision)
158 }
159 }
160
161 func (e *SessionSnapshotConflictError) Unwrap() error {
162 return ErrSessionSnapshotConflict
163 }
164
165 func SnapshotConflictKind(err error) (SessionSnapshotConflictKind, bool) {
166 var conflict *SessionSnapshotConflictError
167 if errors.As(err, &conflict) && conflict != nil {
168 return conflict.Kind, true
169 }
170 return "", false
171 }
172
173 const RecoveryBranchDefaultName = "Recovered unsaved changes from stale runtime"
174
175 type RecoveryBranchOptions struct {
176 OriginalPath string
177 Name string
178 Reason string
179 BranchMeta BranchMeta
180 BaseRevision int64
181 DiskRevision int64
182 }
183
184 type RecoveryBranchInfo struct {
185 Path string
186 Digest string
187 Existing bool
188 Meta BranchMeta
189 Preview string
190 Turns int
191 }
192
193 // Save persists the session using the normal CAS-protected snapshot protocol.
194 // It is kept as the convenient default for callers that do not need to spell
195 // out rewrite intent; it must never provide a force-overwrite escape hatch.
196 // The .jsonl file remains as a compatibility checkpoint and discovery anchor;
197 // the append-only event log is authoritative once present, while the .jsonl
198 // checkpoint also serves as the random-read model for history paging.
199 func (s *Session) Save(path string) error {
200 return s.saveObserved(path, sessionSaveSnapshot)
201 }
202
203 // SaveSnapshot writes a normal autosave/snapshot only when doing so cannot hide
204 // a newer transcript already on disk. Explicit history rewrites such as rewind,
205 // compaction, and cancel recovery should call SaveRewrite instead.
206 func (s *Session) SaveSnapshot(path string) error {
207 return s.saveObserved(path, sessionSaveSnapshot)
208 }
209
210 // SaveRewrite writes an intentional non-append history rewrite only while this
211 // Session still owns the current on-disk transcript baseline. It prevents a
212 // stale controller from force-rewinding a newer transcript written elsewhere.
213 func (s *Session) SaveRewrite(path string) error {
214 return s.saveObserved(path, sessionSaveRewrite)
215 }
216
217 // SaveRewriteCompact performs a CAS-protected rewrite and folds the event log
218 // to one replace record. It is for destructive maintenance such as redaction:
219 // retaining old WAL records would keep the removed bytes recoverable on disk.
220 func (s *Session) SaveRewriteCompact(path string) error {
221 return s.saveObserved(path, sessionSaveRewriteCompact)
222 }
223
224 func (s *Session) save(path string, mode sessionSaveMode) error {
225 if path == "" {
226 return fmt.Errorf("empty session path")
227 }
228 return s.withSessionSaveLocks(path, func() error {
229 return s.saveLocked(path, mode)
230 })
231 }
232
233 func (s *Session) withSessionSaveLocks(path string, fn func() error) error {
234 if strings.TrimSpace(path) == "" {
235 return fmt.Errorf("empty session path")
236 }
237 unlock := lockSessionSavePath(path)
238 defer unlock()
239 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
240 return fmt.Errorf("create session dir: %w", err)
241 }
242 unlockFile, err := lockSessionFile(path)
243 if err != nil {
244 return fmt.Errorf("lock session file: %w", err)
245 }
246 defer unlockFile()
247 return fn()
248 }
249
250 func sessionArtifactExists(path string) bool {
251 if _, err := os.Lstat(path); err == nil {
252 return true
253 }
254 for _, artifact := range store.SessionSidecarFiles(path) {
255 // A legacy v0 event transcript can share the native
256 // `<id>.events.jsonl` name with the destination being imported. It is
257 // intentionally left in place, and must not make SaveIfAbsent believe
258 // that the v1 `<id>.jsonl` destination already exists. Native event logs
259 // remain owned artifacts and still protect the destination from a second
260 // writer.
261 if artifact == store.SessionEventLog(path) {
262 probe, probeErr := probeSessionEventLog(path)
263 if probeErr != nil {
264 return true
265 }
266 if !probe.native {
267 continue
268 }
269 }
270 if _, err := os.Lstat(artifact); err == nil {
271 return true
272 }
273 }
274 return false
275 }
276
277 func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
278 baseRevision := int64(0)
279 releaseAuth, err := s.requireWriteAuthorityForSave(path)
280 if err != nil {
281 return err
282 }
283 defer releaseAuth()
284 observeUnleasedSessionWrite(path, mode)
285 // Heal an empty/missing checkpoint from a valid WAL before classification
286 // so a 0-byte .jsonl never forces a false diverged recovery.
287 if err := healEmptyCheckpointFromWAL(path); err != nil {
288 return err
289 }
290 if mode == sessionSaveSnapshot && s.snapshotUpToDate(path) {
291 // Nothing changed since the last successful save to this exact path:
292 // skip the rest of the save — including the full transcript serialize
293 // + digest + disk probe the up-to-date decision below would still
294 // pay. Desktop switch/close/prune paths snapshot defensively on every
295 // navigation, and on large sessions that per-save cost is the
296 // user-visible seconds of UI freeze in #6607. Version bookkeeping
297 // makes this exact: any Add/Replace/preview update bumps version, any
298 // rewrite bumps rewriteVersion, and load-time repairs or log damage
299 // disarm the fast path until a real save persists them. The check
300 // runs under the save locks, not before them, so a saver that waited
301 // on a concurrent writer still re-evaluates against the state it must
302 // persist when it finally enters the critical section.
303 return nil
304 }
305 // Capture the snapshot only while holding the save locks. Concurrent
306 // in-process savers (turn-end snapshot, periodic autosave, shutdown
307 // snapshot) that captured before locking could land out of order: the
308 // stalest capture written last would then read the newer transcript it
309 // lost the race to as a bogus stale-prefix conflict.
310 msgs, version, rewriteVersion := s.snapshotWithVersion()
311 digest, contentBytes, err := digestAndSizeSessionMessages(msgs)
312 if err != nil {
313 return err
314 }
315 probe, err := probeLogForSave(path)
316 if err != nil {
317 return err
318 }
319 if route := s.dagSaveRoute(path, probe); route != dagRouteSchemaOne {
320 return s.saveDAGLocked(path, mode, route, msgs, version, rewriteVersion, digest)
321 }
322 repairLog := false
323 deferProjection := mode.defersProjection()
324 ownedRewrite := mode.allowsOwnedRewrite()
325 decision, err := s.classifySnapshotWriteForCommit(path, msgs, digest, version, ownedRewrite, mode)
326 if err != nil {
327 return err
328 }
329 if decision.upToDate && mode != sessionSaveRewriteCompact {
330 // Disk already holds exactly this transcript. Rewriting it would only
331 // bump the revision, invalidating the persistence baseline of every
332 // other runtime resumed on this file and turning their next
333 // legitimate save into a stale-runtime conflict. Skip the write and
334 // adopt the current on-disk revision as this session's baseline.
335 if decision.ledgerStale {
336 // ...unless the ledger never learned about this transcript: a
337 // prior save landed its bytes and then failed to record the
338 // revision. Same-content retries are exactly the "later save"
339 // that failure deferred to, and skipping here would strand the
340 // ledger on the old digest forever. Record now, reproducing
341 // the state the interrupted save would have left.
342 revision, err := recordSessionContentRevision(path, digest, decision.revision, decision.reservedRevision)
343 if err != nil {
344 return err
345 }
346 displayModelCurrent := true
347 if err := writeSessionMessages(path, msgs); err != nil {
348 displayModelCurrent = false
349 slog.Warn("session: keeping save after display read-model repair failure", "path", path, "err", err)
350 }
351 if probe.native {
352 if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
353 // See the append path below: index loss must not fail a
354 // save whose transcript and revision already landed.
355 slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
356 }
357 }
358 if displayModelCurrent {
359 if err := refreshCheckpointDisplayIndex(path, msgs, digest, revision, -1, deferProjection); err != nil {
360 // The display index is a derived sidecar; transcript durability
361 // must not depend on rebuilding it successfully.
362 slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
363 }
364 }
365 s.markCheckpointPersisted(path, digest, version, revision, rewriteVersion, msgs, deferProjection)
366 return nil
367 }
368 s.refreshPendingCheckpointProjection(path, msgs, digest, decision.revision, deferProjection)
369 s.markCheckpointPersisted(path, digest, version, decision.revision, rewriteVersion, msgs, deferProjection)
370 return nil
371 }
372 if decision.appendOnly && probe.native && mode != sessionSaveRewriteCompact {
373 logSize := sessionEventLogSize(path)
374 displayModelCurrent := false
375 switch {
376 case logSize == 0:
377 if err := appendSessionReplaceEvent(path, msgs, digest, decision.revision, "snapshot"); err != nil {
378 return err
379 }
380 displayModelCurrent, err = appendSessionDisplayReadModel(path, msgs, decision.appendFrom, decision.revision)
381 case sessionEventLogOversized(logSize, contentBytes):
382 // Fold history into one replace event and refresh the random-read
383 // model atomically. Normal appends keep it current below too.
384 if err := compactSessionEventLog(path, msgs, digest, decision.revision, "compact"); err != nil {
385 return err
386 }
387 if err := writeSessionMessages(path, msgs); err != nil {
388 return err
389 }
390 displayModelCurrent = true
391 default:
392 if err := appendSessionAppendEvent(path, decision.appendFrom, msgs[decision.appendFrom:], digest, decision.revision); err != nil {
393 return err
394 }
395 displayModelCurrent, err = appendSessionDisplayReadModel(path, msgs, decision.appendFrom, decision.revision)
396 }
397 if err != nil {
398 slog.Warn("session: keeping save after display read-model append failure", "path", path, "err", err)
399 }
400 revision, err := recordSessionContentRevision(path, digest, decision.revision, decision.reservedRevision)
401 if err != nil {
402 return err
403 }
404 if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
405 // The event index is only a listing accelerator; the transcript
406 // and its revision are already durable above. Failing the save
407 // here would skip markPersisted and leave the in-memory baseline
408 // behind the disk state it just wrote, misreading the next save
409 // as a stale-runtime conflict.
410 slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
411 }
412 if displayModelCurrent {
413 if err := refreshCheckpointDisplayIndex(path, msgs, digest, revision, decision.appendFrom, deferProjection); err != nil {
414 // The append boundary lets the refresh extend the previous index
415 // instead of re-encoding the whole transcript.
416 slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
417 }
418 }
419 s.markCheckpointPersisted(path, digest, version, revision, rewriteVersion, msgs, deferProjection)
420 return nil
421 }
422 baseRevision = decision.revision
423 repairLog = decision.repairLog
424 // Full-rewrite path: new snapshots, intentional history rewrites, and
425 // damage repairs. The event log mutates first so a crash between the two
426 // writes leaves the newer transcript authoritative; the anchor rewrite
427 // keeps the compatibility .jsonl fresh for direct readers.
428 reason := mode.eventReason()
429 if repairLog {
430 reason = "repair"
431 }
432 logSize := sessionEventLogSize(path)
433 switch {
434 case !probe.native:
435 // A foreign file (legacy import leftover) squats the native log path.
436 // Never write into or over it — the session stays checkpoint-only.
437 case mode == sessionSaveRewriteCompact:
438 // Maintenance rewrites compact even a short log so redacted or otherwise
439 // removed bytes do not remain recoverable in historical WAL records.
440 if err := compactSessionEventLog(path, msgs, digest, baseRevision, reason); err != nil {
441 return err
442 }
443 case repairLog, sessionEventLogOversized(logSize, contentBytes):
444 if err := compactSessionEventLog(path, msgs, digest, baseRevision, reason); err != nil {
445 return err
446 }
447 default:
448 if err := appendSessionReplaceEvent(path, msgs, digest, baseRevision, reason); err != nil {
449 return err
450 }
451 }
452 if err := writeSessionMessages(path, msgs); err != nil {
453 return err
454 }
455 revision, err := recordSessionContentRevision(path, digest, baseRevision, decision.reservedRevision)
456 if err != nil {
457 return err
458 }
459 if probe.native {
460 if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
461 // See the append path above: index loss must not fail a save whose
462 // transcript and revision already landed.
463 slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
464 }
465 }
466 if err := refreshCheckpointDisplayIndex(path, msgs, digest, revision, -1, deferProjection); err != nil {
467 // Warn-only like the event index above: the display index is a pure
468 // derived sidecar and must never fail a save.
469 slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
470 }
471 s.markCheckpointPersisted(path, digest, version, revision, rewriteVersion, msgs, deferProjection)
472 return nil
473 }
474
475 // checkSnapshotWrite decides whether this session may write msgs over path, and
476 // whether the safe write shape is a no-op, append-only suffix, or full rewrite.
477 func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextDigest [sha256.Size]byte, nextVersion uint64, allowOwnedRewrite bool) (snapshotWriteDecision, error) {
478 baseState := s.persistState(path)
479 current, err := loadSessionUnlocked(path)
480 if err != nil {
481 if os.IsNotExist(err) {
482 if baseState.ok {
483 return snapshotWriteDecision{}, ErrSessionExternallyRemoved
484 }
485 return snapshotWriteDecision{}, nil
486 }
487 return snapshotWriteDecision{}, err
488 }
489 currentRevision, currentLedgerDigest, err := sessionContentRevision(path)
490 if err != nil {
491 return snapshotWriteDecision{}, err
492 }
493 existing := current.Snapshot()
494 existingDigest, err := digestSessionMessages(existing)
495 if err != nil {
496 return snapshotWriteDecision{}, err
497 }
498 // raw is the transcript as stored, before load-time normalization repaired
499 // it; it equals existing when no repair ran. The prefix checks below must
500 // be able to fall back to it: a mid-turn snapshot legitimately cuts an
501 // assistant tool call from its still-running result, normalization then
502 // fabricates a placeholder answer on load, and the live session's real
503 // result collides with that placeholder — misreading a pure append as
504 // divergence (and forking a bogus recovery branch).
505 raw, rawDigest := existing, existingDigest
506 rawDiffers := current.normalizedDirty && len(current.rawMessages) > 0
507 if rawDiffers {
508 raw = current.rawMessages
509 if rawDigest, err = digestSessionMessages(raw); err != nil {
510 return snapshotWriteDecision{}, err
511 }
512 }
513 contentUnchanged := bytes.Equal(existingDigest[:], nextDigest[:])
514 exactAppend := messagesHavePrefix(next, existing)
515 appendShaped := contentUnchanged || exactAppend || messagesHavePrefixWithCompatibleSystem(next, existing)
516 repairPending := current.normalizedDirty
517 if !appendShaped && rawDiffers {
518 rawUnchanged := bytes.Equal(rawDigest[:], nextDigest[:])
519 rawAppend := messagesHavePrefix(next, raw)
520 if rawUnchanged || rawAppend || messagesHavePrefixWithCompatibleSystem(next, raw) {
521 existing = raw
522 contentUnchanged = rawUnchanged
523 exactAppend = rawAppend
524 appendShaped = true
525 // The snapshot supersedes the repaired view — appending it lands
526 // the real tool results where the placeholders were fabricated —
527 // so no load-time repair is left to force a rewrite.
528 repairPending = false
529 }
530 }
531 if !appendShaped && baseState.ok && baseState.revisionKnown &&
532 baseState.revision == currentRevision && !contentUnchanged {
533 // Revision equality alone is not ownership proof. Require digest
534 // ancestry or a live generation-bound write authority covering path.
535 if s.ownsWritableBaseline(path, existingDigest, rawDigest, rawDiffers, currentRevision, currentLedgerDigest, nextVersion) {
536 appendShaped = true
537 }
538 }
539 if appendShaped {
540 // An unknown-revision baseline (meta sidecar unreadable at load) cannot
541 // vouch for revision equality; the digest/prefix checks above already
542 // vouch for the content, so only a known baseline arms the CAS check.
543 // Under an append-shaped write (at most a compatible leading-system
544 // swap) a stale revision is ledger drift — a reset sidecar, a
545 // same-content heal, or another runtime recording messages this
546 // snapshot already contains — unless the transcript was rewound.
547 // Locating the persisted baseline among the snapshot's prefixes and
548 // requiring the disk transcript to still reach it tells the two apart:
549 // drift keeps the baseline reachable, while a rewind cut below it and
550 // appending would resurrect the suffix another runtime removed.
551 if baseState.ok && baseState.revisionKnown && currentRevision != baseState.revision && !contentUnchanged &&
552 !appendCoversPersistedBaseline(next, existing, baseState.digest) {
553 return snapshotWriteDecision{}, snapshotConflict(path, existing, next, baseState.revision, currentRevision)
554 }
555 // A normalized-dirty load means LoadSession repaired the history on the
556 // way in: the digests match but the raw bytes on disk do not, so the
557 // repair still needs a real write to persist. A damaged event log
558 // likewise needs a real write (rewrite + compact) even when the
559 // replayable prefix already matches this snapshot.
560 decision := snapshotWriteDecision{
561 revision: currentRevision,
562 upToDate: contentUnchanged && !repairPending && !current.eventLogDamaged,
563 repairLog: current.eventLogDamaged,
564 }
565 // A ledger digest that describes different content than the transcript
566 // on disk is the aftermath of a save whose bytes landed and whose
567 // revision record then failed (crash or fail-closed record between the
568 // two writes). Only a non-empty mismatch counts: a missing sidecar or
569 // a legacy one without a digest is a legitimate state, and stamping it
570 // here would bump revisions other runtimes still hold as baselines.
571 if decision.upToDate && currentLedgerDigest != "" && currentLedgerDigest != digestString(nextDigest) {
572 decision.ledgerStale = true
573 }
574 // An append is only chain-safe when existing measures the transcript
575 // the event log actually replays. Under a pending load-time repair the
576 // normalized view differs from the raw log, so an append event indexed
577 // against it breaks the replay chain and orphans the appended suffix;
578 // fall through to the full rewrite, which also persists the repair.
579 if exactAppend && !contentUnchanged && len(existing) < len(next) && !current.eventLogDamaged && !repairPending {
580 decision.appendOnly = true
581 decision.appendFrom = len(existing)
582 }
583 return decision, nil
584 }
585 if allowOwnedRewrite {
586 if s.ownsWritableBaseline(path, existingDigest, rawDigest, rawDiffers, currentRevision, currentLedgerDigest, nextVersion) {
587 return snapshotWriteDecision{revision: currentRevision, repairLog: current.eventLogDamaged}, nil
588 }
589 }
590 // Bound controllers: missing/stale authority must not fork recovery.
591 if err := s.authorityErrorForPath(path); err != nil {
592 return snapshotWriteDecision{}, err
593 }
594 if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) ||
595 (rawDiffers && (messagesHavePrefix(raw, next) || messagesHavePrefixWithCompatibleSystem(raw, next))) {
596 return snapshotWriteDecision{}, &SessionSnapshotConflictError{
597 Path: path,
598 Kind: SessionSnapshotConflictStalePrefix,
599 ExistingMessages: len(existing),
600 SnapshotMessages: len(next),
601 BaseRevision: baseState.revision,
602 DiskRevision: currentRevision,
603 }
604 }
605 return snapshotWriteDecision{}, &SessionSnapshotConflictError{
606 Path: path,
607 Kind: SessionSnapshotConflictDiverged,
608 ExistingMessages: len(existing),
609 SnapshotMessages: len(next),
610 BaseRevision: baseState.revision,
611 DiskRevision: currentRevision,
612 }
613 }
614
615 func snapshotConflict(path string, existing, next []provider.Message, baseRevision, diskRevision int64) error {
616 kind := SessionSnapshotConflictDiverged
617 if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) {
618 kind = SessionSnapshotConflictStalePrefix
619 }
620 return &SessionSnapshotConflictError{
621 Path: path,
622 Kind: kind,
623 ExistingMessages: len(existing),
624 SnapshotMessages: len(next),
625 BaseRevision: baseRevision,
626 DiskRevision: diskRevision,
627 }
628 }
629
630 func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranchInfo, error) {
631 return s.saveRecoveryBranch(opts, false)
632 }
633
634 // SaveShutdownRecoveryBranch persists the current transcript to a distinct
635 // recovery branch after the normal shutdown snapshot failed with
636 // ErrSessionFileLockHeld. It deliberately does not re-lock or inspect the
637 // original session file: doing so would repeat the same bounded timeout and
638 // let process teardown discard the only remaining in-memory copy.
639 //
640 // The recovery filename includes this live Session's isolated lane, so another
641 // controller in the same process cannot replace the emergency copy.
642 // The result still uses the normal session, event-log, and branch-meta formats
643 // and is therefore discoverable and resumable through existing flows.
644 func (s *Session) SaveShutdownRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranchInfo, error) {
645 return s.saveRecoveryBranch(opts, true)
646 }
647
648 // SaveConflictRecoveryBranch writes the depth-cap isolated copy (one path per
649 // live Session). Subsequent conflicts from that Session rewrite it in place.
650 func (s *Session) SaveConflictRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranchInfo, error) {
651 return s.saveRecoveryBranch(opts, true)
652 }
653
654 func (s *Session) saveRecoveryBranch(opts RecoveryBranchOptions, shutdown bool) (RecoveryBranchInfo, error) {
655 originalPath := strings.TrimSpace(opts.OriginalPath)
656 if originalPath == "" {
657 return RecoveryBranchInfo{}, fmt.Errorf("empty original session path")
658 }
659 opts.OriginalPath = originalPath
660 msgs, version, rewriteVersion := s.snapshotWithVersion()
661 preview, turns := SessionPreviewFromMessages(msgs)
662 if turns == 0 {
663 return RecoveryBranchInfo{}, ErrSessionRecoveryNotNeeded
664 }
665 digest, err := digestSessionMessages(msgs)
666 if err != nil {
667 return RecoveryBranchInfo{}, err
668 }
669 digestText := digestString(digest)
670
671 if !shutdown {
672 unlockOriginal := lockSessionSavePath(originalPath)
673 unlockOriginalFile, lockErr := lockSessionFile(originalPath)
674 if lockErr != nil {
675 unlockOriginal()
676 return RecoveryBranchInfo{}, fmt.Errorf("lock original session file: %w", lockErr)
677 }
678 current, loadErr := loadSessionUnlocked(originalPath)
679 unlockOriginalFile()
680 unlockOriginal()
681 if loadErr != nil && !os.IsNotExist(loadErr) {
682 return RecoveryBranchInfo{}, loadErr
683 }
684 if loadErr == nil && current != nil {
685 existing := current.Snapshot()
686 existingDigest, digestErr := digestSessionMessages(existing)
687 if digestErr != nil {
688 return RecoveryBranchInfo{}, digestErr
689 }
690 covered := bytes.Equal(existingDigest[:], digest[:]) ||
691 messagesHavePrefix(existing, msgs) ||
692 messagesHavePrefixWithCompatibleSystem(existing, msgs)
693 if !covered && current.normalizedDirty && len(current.rawMessages) > 0 {
694 // Judge coverage against the pre-repair transcript too, for the
695 // same reason as checkSnapshotWrite: load-time normalization can
696 // reshape what is actually stored, and a recovery fork is only
697 // warranted when the stored bytes themselves fail to cover this
698 // snapshot.
699 raw := current.rawMessages
700 rawDigest, rawErr := digestSessionMessages(raw)
701 if rawErr != nil {
702 return RecoveryBranchInfo{}, rawErr
703 }
704 covered = bytes.Equal(rawDigest[:], digest[:]) ||
705 messagesHavePrefix(raw, msgs) ||
706 messagesHavePrefixWithCompatibleSystem(raw, msgs)
707 }
708 if covered {
709 return RecoveryBranchInfo{}, ErrSessionRecoveryNotNeeded
710 }
711 }
712 }
713
714 // One stable recovery file per (root branch, writer generation). Nested
715 // -recovery- names peel back to the root so conflicts update in place.
716 for range 8 {
717 recoveryPath, lane := s.isolatedRecoverySessionPath(originalPath)
718 info, collision, err := s.writeRecoveryBranchAtPath(recoveryPath, opts, msgs, digest,
719 version, rewriteVersion, preview, turns, digestText, 1, shutdown)
720 if err != nil {
721 return RecoveryBranchInfo{}, err
722 }
723 if !collision {
724 return info, nil
725 }
726 s.rotateRecoveryLane(lane)
727 }
728 return RecoveryBranchInfo{}, fmt.Errorf("allocate isolated recovery lane: too many existing collisions")
729 }
730
731 func recoveryParentStem(parent string) string {
732 parent = strings.TrimSpace(parent)
733 if parent == "" {
734 return "session"
735 }
736 sum := sha256.Sum256([]byte(parent))
737 if before, _, ok := strings.Cut(parent, "-recovery-"); ok {
738 base := strings.Trim(before, "-_. ")
739 if base == "" {
740 base = "session"
741 }
742 base = strings.Trim(truncateUTF8Bytes(base, maxRecoveryParentStemBytes), "-_. ")
743 if base == "" {
744 base = "session"
745 }
746 return fmt.Sprintf("%s-%x", base, sum[:6])
747 }
748 if len(parent) <= maxRecoveryParentStemBytes {
749 return parent
750 }
751 prefix := strings.Trim(truncateUTF8Bytes(parent, maxRecoveryParentStemBytes), "-_. ")
752 if prefix == "" {
753 prefix = "session"
754 }
755 return fmt.Sprintf("%s-%x", prefix, sum[:6])
756 }
757
758 func truncateUTF8Bytes(s string, max int) string {
759 if max <= 0 {
760 return ""
761 }
762 if len(s) <= max {
763 return s
764 }
765 used := 0
766 for i, r := range s {
767 size := utf8.RuneLen(r)
768 if size < 0 {
769 size = 1
770 }
771 if used+size > max {
772 return s[:i]
773 }
774 used += size
775 }
776 return s
777 }
778
779 func firstNonEmpty(values ...string) string {
780 for _, value := range values {
781 if strings.TrimSpace(value) != "" {
782 return value
783 }
784 }
785 return ""
786 }
787
788 func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]byte, existingRevision int64, existingLedgerDigest string, nextVersion uint64) bool {
789 state := s.persistState(path)
790 if !state.ok || state.version > nextVersion || !bytes.Equal(existingDigest[:], state.digest[:]) {
791 return false
792 }
793 // An unknown-revision baseline still owns the transcript it loaded — the
794 // digest+version match proves it. Requiring revision equality here would
795 // make every rewrite from such a baseline a permanent conflict, because
796 // the revision can only be re-learned by a successful save.
797 // A disk ledger with no recorded revision is the mirror case: recorded
798 // revisions start at 1, so revision 0 means the sidecar was deleted or
799 // rebuilt by a listing-only writer after this session's save. An absent
800 // claim cannot revoke the ownership the digest+version match proves.
801 if !state.revisionKnown || existingRevision == 0 || state.revision == existingRevision {
802 return true
803 }
804 // A foreign revision stamp whose recorded digest still describes these
805 // exact bytes (a same-content heal or no-op record by another runtime)
806 // vouches for no content of its own: the transcript is byte-for-byte what
807 // this session last persisted, so rewriting it destroys nothing of
808 // theirs — at worst the conflict moves to the stamper's next divergent
809 // save, where its in-memory history forks a recovery branch as usual.
810 // A stamp that disagrees with the on-disk transcript (or a legacy stamp
811 // with no digest) keeps revoking ownership: that is the aftermath of a
812 // save whose bytes and record split, the bytes cannot be attributed, and
813 // only the conservative conflict path preserves both sides.
814 return existingLedgerDigest == digestString(existingDigest)
815 }
816
817 // snapshotUpToDate reports whether a snapshot save to path is a provable
818 // no-op from in-memory bookkeeping alone: the last successful save went to
819 // this same path with a known ledger revision, the transcript version and
820 // rewrite version have not moved since, and no load-time repair or event-log
821 // damage is waiting to be persisted. Every one of these flags fails open —
822 // when any is unset or stale the caller falls through to the full save path,
823 // which re-derives the truth from disk.
824 func (s *Session) snapshotUpToDate(path string) bool {
825 key := canonicalSessionSavePath(path)
826 s.mu.RLock()
827 defer s.mu.RUnlock()
828 return s.persisted.ok &&
829 s.persisted.saveVerified &&
830 !s.persisted.projectionPending &&
831 s.persisted.path == key &&
832 s.persisted.version == s.version &&
833 s.persisted.revisionKnown &&
834 s.rewriteVersion == s.persistedRewriteVersion &&
835 !s.normalizedDirty &&
836 !s.eventLogDamaged
837 }
838
839 func (s *Session) persistState(path string) sessionPersistState {
840 key := canonicalSessionSavePath(path)
841 s.mu.RLock()
842 defer s.mu.RUnlock()
843 if s.persisted.ok && s.persisted.path == key {
844 return s.persisted
845 }
846 return sessionPersistState{}
847 }
848
849 // PersistedState is a read-only view of the baseline the session last
850 // persisted to (or loaded from) its transcript path. History paging uses it to
851 // validate a display-index sidecar against the live session without touching
852 // disk: an index built at the same revision with the same content digest
853 // describes exactly the persisted prefix of the in-memory log.
854 type PersistedState struct {
855 // Digest is the content digest of the persisted transcript.
856 Digest [sha256.Size]byte
857 // DigestHex is Digest in the hex form sidecars store.
858 DigestHex string
859 // Revision is the CAS ledger revision of the persisted transcript. It is
860 // meaningful only when RevisionKnown is true.
861 Revision int64
862 RevisionKnown bool
863 // RewriteEpoch is the highest rewriteVersion that has reached disk. It
864 // changes only when a content rewrite (compaction, rewind, …) is saved, so
865 // it doubles as a stable epoch token for history entry IDs: append-only
866 // saves keep it, rewrites bump it.
867 RewriteEpoch int
868 // AppendOnlyTail reports that no rewrite landed after the baseline, so the
869 // persisted transcript is still a prefix of the in-memory log (the tail,
870 // if any, is unsaved appends).
871 AppendOnlyTail bool
872 // UnchangedSincePersisted reports that the in-memory log is exactly the
873 // persisted transcript (no appends, no rewrites since the baseline).
874 UnchangedSincePersisted bool
875 }
876
877 // PersistedState returns the session's persistence baseline for path, or
878 // false when the session has never persisted to (or loaded from) that path.
879 func (s *Session) PersistedState(path string) (PersistedState, bool) {
880 key := canonicalSessionSavePath(path)
881 s.mu.RLock()
882 defer s.mu.RUnlock()
883 if !s.persisted.ok || s.persisted.path != key {
884 return PersistedState{}, false
885 }
886 return PersistedState{
887 Digest: s.persisted.digest,
888 DigestHex: digestString(s.persisted.digest),
889 Revision: s.persisted.revision,
890 RevisionKnown: s.persisted.revisionKnown,
891 RewriteEpoch: s.persistedRewriteVersion,
892 AppendOnlyTail: s.rewriteVersion == s.persistedRewriteVersion,
893 UnchangedSincePersisted: s.persisted.version == s.version,
894 }, true
895 }
896
897 // SessionContentIdentity returns the ledger identity of the authoritative
898 // persisted transcript without loading its message bodies. It is intended for
899 // derived sidecars such as the desktop display index: a matching checkpoint
900 // size alone cannot prove that offsets still describe the event-log-backed
901 // transcript. The bool is false for legacy sessions that have no digest in
902 // their branch metadata; callers must then validate against the transcript
903 // bytes directly.
904 func SessionContentIdentity(path string) (PersistedState, bool, error) {
905 revision, digestHex, err := sessionContentRevision(path)
906 if err != nil {
907 return PersistedState{}, false, err
908 }
909 if digestHex == "" {
910 return PersistedState{}, false, nil
911 }
912 var digest [sha256.Size]byte
913 decoded, err := hex.DecodeString(digestHex)
914 if err != nil || len(decoded) != len(digest) {
915 return PersistedState{}, false, fmt.Errorf("invalid session content digest")
916 }
917 copy(digest[:], decoded)
918 return PersistedState{
919 Digest: digest,
920 DigestHex: digestString(digest),
921 Revision: revision,
922 RevisionKnown: true,
923 }, true, nil
924 }
925
926 // sessionContentRevision reads the CAS ledger (revision + content digest) from
927 // the branch-meta sidecar. A missing sidecar is revision 0 — a session that
928 // has never recorded one. An unreadable sidecar is an error: reporting it as
929 // revision 0 would desync every runtime baseline from the ledger and turn the
930 // next honest save into a bogus conflict (and a recovery branch).
931 func sessionContentRevision(path string) (int64, string, error) {
932 meta, ok, err := loadBranchMetaRetry(path)
933 if err != nil {
934 return 0, "", err
935 }
936 if !ok {
937 return 0, "", nil
938 }
939 return meta.Revision, strings.TrimSpace(meta.ContentDigest), nil
940 }
941
942 func recordSessionContentRevision(path string, digest [sha256.Size]byte, baseRevision, reservedRevision int64) (int64, error) {
943 // Revision allocation is a read-modify-write transaction. Holding only the
944 // final SaveBranchMeta lock would still let another writer replace the
945 // sidecar between our read and write, so keep the ledger lock across the
946 // increment and read-back as well.
947 unlock, err := LockSessionMetaPath(path)
948 if err != nil {
949 return 0, err
950 }
951 defer unlock()
952
953 meta, ok, err := loadBranchMetaRetry(path)
954 if err != nil {
955 // Fail the save instead of rebuilding the ledger from a bad read: the
956 // transcript bytes already landed, so a later save can record the
957 // revision once the sidecar reads cleanly again — a content-bearing
958 // save lands here again, and a same-content retry heals through the
959 // up-to-date ledgerStale path in save().
960 return 0, err
961 }
962 if !ok {
963 meta = BranchMeta{ID: BranchID(path)}
964 }
965 if reservedRevision > 0 {
966 if reservedRevision != baseRevision+1 || meta.Revision != reservedRevision || meta.SchemaVersion != 0 {
967 return 0, fmt.Errorf("session revision reservation changed: base=%d reserved=%d current=%d schema=%d", baseRevision, reservedRevision, meta.Revision, meta.SchemaVersion)
968 }
969 } else {
970 if meta.Revision < baseRevision {
971 meta.Revision = baseRevision
972 }
973 if meta.Revision == math.MaxInt64 {
974 return 0, fmt.Errorf("session revision exhausted")
975 }
976 meta.Revision++
977 }
978 meta.ContentDigest = digestString(digest)
979 meta.WriterID = SessionWriterID()
980 if err := saveBranchMeta(path, meta, false); err != nil {
981 return 0, err
982 }
983 stored, ok, err := loadBranchMetaRetry(path)
984 if err != nil {
985 return 0, err
986 }
987 if ok && stored.Revision > 0 {
988 return stored.Revision, nil
989 }
990 return meta.Revision, nil
991 }
992
993 func digestString(digest [sha256.Size]byte) string {
994 return fmt.Sprintf("%x", digest[:])
995 }
996
997 func SessionWriterID() string {
998 return sessionWriterID
999 }
1000
1001 func newSessionWriterID() string {
1002 host, _ := os.Hostname()
1003 host = strings.TrimSpace(host)
1004 if host == "" {
1005 host = "unknown-host"
1006 }
1007 host = strings.Map(func(r rune) rune {
1008 switch {
1009 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
1010 return r
1011 default:
1012 return '-'
1013 }
1014 }, host)
1015 var nonce [8]byte
1016 if _, err := rand.Read(nonce[:]); err != nil {
1017 return fmt.Sprintf("%s-%d-%d", host, os.Getpid(), time.Now().UnixNano())
1018 }
1019 return fmt.Sprintf("%s-%d-%x", host, os.Getpid(), nonce[:])
1020 }
1021
1022 func digestSessionMessages(msgs []provider.Message) ([sha256.Size]byte, error) {
1023 digest, _, err := digestAndSizeSessionMessages(msgs)
1024 return digest, err
1025 }
1026
1027 func messageForSessionIdentity(m provider.Message) provider.Message {
1028 // CreatedAt and ID are local metadata. Keep them out of transcript identity
1029 // so older builds that ignore the optional fields can share the same event-
1030 // log revision and append without false conflicts.
1031 m.CreatedAt = 0
1032 m.ID = ""
1033 return m
1034 }
1035
1036 // digestAndSizeSessionMessages also reports the encoded transcript size, which
1037 // the save path uses to bound the event log relative to the live content.
1038 func digestAndSizeSessionMessages(msgs []provider.Message) ([sha256.Size]byte, int64, error) {
1039 h := sha256.New()
1040 size := int64(0)
1041 for _, m := range msgs {
1042 m = messageForSessionIdentity(m)
1043 b, err := json.Marshal(m)
1044 if err != nil {
1045 return [sha256.Size]byte{}, 0, err
1046 }
1047 if _, err := h.Write(b); err != nil {
1048 return [sha256.Size]byte{}, 0, err
1049 }
1050 if _, err := h.Write([]byte{'\n'}); err != nil {
1051 return [sha256.Size]byte{}, 0, err
1052 }
1053 size += int64(len(b)) + 1
1054 }
1055 var out [sha256.Size]byte
1056 copy(out[:], h.Sum(nil))
1057 return out, size, nil
1058 }
1059
1060 // sessionTranscriptHasher accumulates the transcript digest one message at a
1061 // time, exactly like digestAndSizeSessionMessages, so load paths hash during
1062 // decode instead of re-serializing. A nil receiver disables hashing.
1063 type sessionTranscriptHasher struct {
1064 h hash.Hash
1065 err error
1066 }
1067
1068 func newSessionTranscriptHasher() *sessionTranscriptHasher {
1069 return &sessionTranscriptHasher{h: sha256.New()}
1070 }
1071
1072 // rehash restarts accumulation over msgs (event-log replace records).
1073 func (s *sessionTranscriptHasher) rehash(msgs []provider.Message) {
1074 if s == nil {
1075 return
1076 }
1077 s.h.Reset()
1078 s.err = nil
1079 s.addAll(msgs)
1080 }
1081
1082 func (s *sessionTranscriptHasher) addAll(msgs []provider.Message) {
1083 for _, m := range msgs {
1084 s.add(m)
1085 }
1086 }
1087
1088 // add hashes one message and returns it for append chaining.
1089 func (s *sessionTranscriptHasher) add(m provider.Message) provider.Message {
1090 if s == nil || s.err != nil {
1091 return m
1092 }
1093 b, err := json.Marshal(messageForSessionIdentity(m))
1094 if err != nil {
1095 s.err = err
1096 return m
1097 }
1098 _, _ = s.h.Write(b)
1099 _, _ = s.h.Write([]byte{'\n'})
1100 return m
1101 }
1102
1103 // sum reports the accumulated digest; ok is false when hashing was disabled
1104 // or a message failed to encode, and callers fall back to digestSessionMessages.
1105 func (s *sessionTranscriptHasher) sum() (digest [sha256.Size]byte, ok bool) {
1106 if s == nil || s.err != nil {
1107 return digest, false
1108 }
1109 copy(digest[:], s.h.Sum(nil))
1110 return digest, true
1111 }
1112
1113 func messagesHavePrefix(full, prefix []provider.Message) bool {
1114 if len(prefix) > len(full) {
1115 return false
1116 }
1117 for i := range prefix {
1118 if !messagesEqualForStorage(full[i], prefix[i]) {
1119 return false
1120 }
1121 }
1122 return true
1123 }
1124
1125 // messagesPrefixDigestDepth returns the number of leading messages of msgs
1126 // whose storage digest equals target, or -1 when no prefix matches. The
1127 // digest accumulates exactly like digestAndSizeSessionMessages, so a match at
1128 // depth k means msgs[:k] has the same transcript identity as target.
1129 func messagesPrefixDigestDepth(msgs []provider.Message, target [sha256.Size]byte) int {
1130 h := sha256.New()
1131 sum := make([]byte, 0, sha256.Size)
1132 for i, m := range msgs {
1133 m = messageForSessionIdentity(m)
1134 b, err := json.Marshal(m)
1135 if err != nil {
1136 return -1
1137 }
1138 h.Write(b)
1139 h.Write([]byte{'\n'})
1140 sum = h.Sum(sum[:0])
1141 if bytes.Equal(sum, target[:]) {
1142 return i + 1
1143 }
1144 }
1145 return -1
1146 }
1147
1148 // appendCoversPersistedBaseline reports whether an append-shaped write (disk
1149 // transcript a prefix of next, modulo a compatible leading-system swap) still
1150 // covers everything this session ever persisted: the baseline digest must be
1151 // reachable as a prefix of the pending snapshot, and the disk transcript must
1152 // still extend at least to that depth. A shorter disk transcript means some
1153 // other runtime deliberately rewound below the baseline — appending over it
1154 // would resurrect the removed suffix, so the caller must conflict instead.
1155 func appendCoversPersistedBaseline(next, existing []provider.Message, baseDigest [sha256.Size]byte) bool {
1156 depth := messagesPrefixDigestDepth(next, baseDigest)
1157 if depth < 0 && len(next) > 0 && len(existing) > 0 &&
1158 next[0].Role == provider.RoleSystem && existing[0].Role == provider.RoleSystem &&
1159 !messagesEqualForStorage(next[0], existing[0]) {
1160 // A resume that swapped the system prompt persisted its baseline with
1161 // the previous system message — the one still on disk. Re-anchor the
1162 // search on that message so the swap alone doesn't hide the baseline.
1163 variant := append([]provider.Message{existing[0]}, next[1:]...)
1164 depth = messagesPrefixDigestDepth(variant, baseDigest)
1165 }
1166 return depth >= 0 && len(existing) >= depth
1167 }
1168
1169 func messagesHavePrefixWithCompatibleSystem(full, prefix []provider.Message) bool {
1170 full = messagesWithoutLeadingSystem(full)
1171 prefix = messagesWithoutLeadingSystem(prefix)
1172 return messagesHavePrefix(full, prefix)
1173 }
1174
1175 func messagesWithoutLeadingSystem(msgs []provider.Message) []provider.Message {
1176 if len(msgs) > 0 && msgs[0].Role == provider.RoleSystem {
1177 return msgs[1:]
1178 }
1179 return msgs
1180 }
1181
1182 func messagesEqualForStorage(a, b provider.Message) bool {
1183 a = messageForSessionIdentity(a)
1184 b = messageForSessionIdentity(b)
1185 ab, err := json.Marshal(a)
1186 if err != nil {
1187 return false
1188 }
1189 bb, err := json.Marshal(b)
1190 if err != nil {
1191 return false
1192 }
1193 return bytes.Equal(ab, bb)
1194 }
1195
1196 func messagesEqualForStorageList(a, b []provider.Message) bool {
1197 if len(a) != len(b) {
1198 return false
1199 }
1200 for i := range a {
1201 if !messagesEqualForStorage(a[i], b[i]) {
1202 return false
1203 }
1204 }
1205 return true
1206 }
1207
1208 func messagesCompatibleForStorageBaseline(a, b []provider.Message) bool {
1209 if messagesEqualForStorageList(a, b) {
1210 return true
1211 }
1212 return messagesEqualForStorageList(messagesWithoutLeadingSystem(a), messagesWithoutLeadingSystem(b))
1213 }
1214
1215 func lockSessionSavePath(path string) func() {
1216 key := canonicalSessionSavePath(path)
1217 v, _ := sessionSaveLocks.LoadOrStore(key, &sync.Mutex{})
1218 mu := v.(*sync.Mutex)
1219 mu.Lock()
1220 return mu.Unlock
1221 }
1222
1223 // tryLockSessionSavePath lets low-priority maintenance yield immediately to a
1224 // foreground save. The returned unlock is non-nil only when acquired.
1225 func tryLockSessionSavePath(path string) (func(), bool) {
1226 key := canonicalSessionSavePath(path)
1227 v, _ := sessionSaveLocks.LoadOrStore(key, &sync.Mutex{})
1228 mu := v.(*sync.Mutex)
1229 if !mu.TryLock() {
1230 return nil, false
1231 }
1232 return mu.Unlock, true
1233 }
1234
1235 // lockSessionFile waits briefly for the cross-process compatibility save lock.
1236 // A short overlap with a legitimate writer is allowed to settle, but an
1237 // stalled or indefinitely held lock fails the save instead of freezing tab
1238 // switching or application shutdown. The caller keeps its in-memory transcript
1239 // and can retry through the existing autosave/recovery paths.
1240 func lockSessionFile(path string) (func(), error) {
1241 wait := sessionFileLockWait
1242 poll := sessionFileLockPollInterval
1243 if poll <= 0 {
1244 poll = time.Millisecond
1245 }
1246 deadline := time.Now().Add(wait)
1247 for {
1248 unlock, err := tryLockSessionFile(path)
1249 if err == nil {
1250 return unlock, nil
1251 }
1252 if !errors.Is(err, ErrSessionFileLockHeld) {
1253 return nil, err
1254 }
1255 remaining := time.Until(deadline)
1256 if wait <= 0 || remaining <= 0 {
1257 return nil, ErrSessionFileLockHeld
1258 }
1259 if poll > remaining {
1260 poll = remaining
1261 }
1262 time.Sleep(poll)
1263 }
1264 }
1265
1266 // UpdateBranchMeta is the owner-level metadata API. The callback runs while
1267 // the cross-process metadata lock is held, so callers cannot accidentally
1268 // load a stale sidecar and overwrite fields written by another runtime.
1269 func UpdateBranchMeta(path string, touchUpdated bool, update func(*BranchMeta) error) error {
1270 unlock, err := LockSessionMetaPath(path)
1271 if err != nil {
1272 return err
1273 }
1274 defer unlock()
1275 m, err := ensureBranchMetaUnlocked(path)
1276 if err != nil {
1277 return err
1278 }
1279 if update != nil {
1280 if err := update(&m); err != nil {
1281 return err
1282 }
1283 }
1284 // The callback mutated the latest record while the cross-process lock was
1285 // held, so write it verbatim. Re-merging title fields here would undo an
1286 // intentional title mutation (including same-value saves that advance the
1287 // opaque title revision). Whole-record transcript/listing writers use the
1288 // preserving saveBranchMeta path instead.
1289 return saveBranchMetaContextMode(context.Background(), path, m, touchUpdated, false)
1290 }
1291
1292 // LoadSession reads a saved session into a fresh Session value. New sessions
1293 // replay the append-only event log; legacy sessions without an event log fall
1294 // back to the compatibility .jsonl checkpoint. A damaged log is replayed to its
1295 // last clean record (or the checkpoint when nothing decodes) and flagged so the
1296 // next save heals it with a rewrite-and-compact.
1297 // In-process loads share the save path mutex so they cannot observe a local
1298 // SaveSnapshot between appending an event-log record and refreshing the index.
1299 // Missing files surface as os.IsNotExist so callers can fall through to a
1300 // new session.
1301 func LoadSession(path string) (*Session, error) {
1302 unlock := lockSessionSavePath(path)
1303 defer unlock()
1304 return loadSessionUnlocked(path)
1305 }
1306
1307 func loadSessionUnlocked(path string) (*Session, error) {
1308 return loadSessionUnlockedWithContext(context.Background(), path, defaultSessionReplayLimits)
1309 }
1310
1311 func loadSessionUnlockedWithLimits(path string, limits sessionReplayLimits) (*Session, error) {
1312 return loadSessionUnlockedWithContext(context.Background(), path, limits)
1313 }
1314
1315 func loadSessionUnlockedWithContext(ctx context.Context, path string, limits sessionReplayLimits) (*Session, error) {
1316 return loadSessionUnlockedWithContextMode(ctx, path, limits, false)
1317 }
1318
1319 func loadSessionUnlockedWithContextMode(ctx context.Context, path string, limits sessionReplayLimits, rejectDamage bool) (*Session, error) {
1320 hasher := newSessionTranscriptHasher()
1321 res, err := loadSessionTranscript(ctx, path, limits, hasher)
1322 if err != nil {
1323 return nil, err
1324 }
1325 if rejectDamage && res.damaged {
1326 return nil, fmt.Errorf("%w: authoritative event log has no complete recoverable prefix", ErrSessionHistoryDamaged)
1327 }
1328 msgs := res.msgs
1329 s := &Session{Messages: msgs, eventLogDamaged: res.damaged, head: sessionHeadState{ref: res.head, dag: res.dag, headCount: res.headCount, state: res.state, openTurn: res.openTurn, events: res.events}}
1330 // Repair persisted-history-safe issues before anything reads the session.
1331 // Old sessions (pre adde2d3e) and interrupted turns can carry empty tool-call
1332 // names, dangling tool_calls, or half-streamed argument JSON that DeepSeek
1333 // rejects with a 400 on replay. Wire-only cleanup, such as dropping orphan
1334 // tool messages, stays in the provider send path so Save/LoadSession keeps
1335 // its round-trip contract. The fast path returns the input slice unchanged
1336 // for a well-formed history, so we detect an actual repair by comparing
1337 // slice headers: when NormalizeSession allocated a new backing array, the
1338 // session is marked dirty so the next Save persists the fix.
1339 normalized := NormalizeSession(s.Messages)
1340 normalized = migrateLegacyProviderContent(normalized)
1341 if len(normalized) != len(s.Messages) || (len(s.Messages) > 0 && &normalized[0] != &s.Messages[0]) {
1342 s.normalizedDirty = true
1343 // Keep the pre-repair transcript: checkSnapshotWrite must be able to
1344 // recognize a snapshot that extends the bytes actually on disk, which
1345 // the repaired view no longer represents (an interrupted tool turn
1346 // gets a placeholder result fabricated here that the live session
1347 // answered for real).
1348 s.rawMessages = msgs
1349 }
1350 s.Messages = normalized
1351 assignLegacyMessageIDs(path, s.Messages)
1352 // Decode already hashed the transcript; when the repairs above returned it
1353 // unchanged (the common case) reuse that digest, else re-hash the repair.
1354 digest, digestOK := hasher.sum()
1355 if s.normalizedDirty || !digestOK {
1356 if d, derr := digestSessionMessages(s.Messages); derr == nil {
1357 digest, digestOK = d, true
1358 } else {
1359 digestOK = false
1360 }
1361 }
1362 if digestOK {
1363 // Pair the raw pre-repair transcript when normalization changed it.
1364 diskView := s.Messages
1365 if s.normalizedDirty {
1366 diskView = s.rawMessages
1367 }
1368 if meta, ok, metaErr := loadBranchMetaRetry(path); metaErr != nil {
1369 // The sidecar exists but is unreadable even after retries (torn or
1370 // corrupt). The session must still open, but revision 0 must not
1371 // pose as a real baseline: the next save would misread the honest
1372 // on-disk revision as another runtime's write and fork a recovery
1373 // branch. Anchor the baseline on digest+version only until a
1374 // successful save re-learns the revision.
1375 s.markPersistedRevisionUnknown(path, digest, s.version, s.rewriteVersion, diskView)
1376 } else {
1377 revision := int64(0)
1378 if ok {
1379 revision = meta.Revision
1380 }
1381 s.markPersistedFromLoad(path, digest, s.version, revision, s.rewriteVersion, diskView)
1382 }
1383 }
1384 return s, nil
1385 }
1386
1387 // LoadSessionForMigration reads a frozen legacy transcript without applying
1388 // the interactive 128 MiB/record-count replay budgets. Callers must first stop
1389 // writers and freeze the source bytes; ordinary UI and runtime opens must keep
1390 // using LoadSession so an untrusted or corrupt live file cannot exhaust memory.
1391 func LoadSessionForMigration(ctx context.Context, path string) (*Session, error) {
1392 if ctx == nil {
1393 ctx = context.Background()
1394 }
1395 unlock := lockSessionSavePath(path)
1396 defer unlock()
1397 return loadSessionUnlockedWithContextMode(ctx, path, migrationSessionReplayLimits(), true)
1398 }
1399
1400 // SessionInfo summarises a saved session for the --resume picker: where it is on
1401 // disk, when it was created/last active, the first user message as a preview, and
1402 // a rough turn count.
1403 type SessionInfo struct {
1404 Path string
1405 CreatedAt time.Time
1406 LastActivityAt time.Time
1407 ModTime time.Time // compatibility alias for LastActivityAt
1408 Preview string
1409 Turns int
1410 CountsKnown bool
1411 Scope string
1412 WorkspaceRoot string
1413 TopicID string
1414 TopicTitle string
1415 CustomTitle string
1416 Recovered bool
1417 RecoveryReason string
1418 RecoveryDigest string
1419 ParentID string
1420 VersionKind SessionVersionKind
1421 VersionState SessionVersionState
1422 ParentConversationID string
1423 ParentVersionID string
1424 }
1425
1426 // CleanupPendingMeta records that a session was logically removed but still has
1427 // artifacts waiting for a background job to unwind before physical cleanup.
1428 type CleanupPendingMeta struct {
1429 Operation string `json:"operation"`
1430 CreatedAt int64 `json:"createdAt"`
1431 }
1432
1433 // CleanupPendingInfo describes one durable delayed-cleanup marker and the
1434 // session transcript it belongs to.
1435 type CleanupPendingInfo struct {
1436 SessionPath string
1437 MarkerPath string
1438 Meta CleanupPendingMeta
1439 }
1440
1441 // CleanupPendingPath returns the durable marker path for a session transcript.
1442 func CleanupPendingPath(sessionPath string) string {
1443 return store.SessionCleanupPending(sessionPath)
1444 }
1445
1446 // MarkCleanupPending hides a logically removed session from resume/list surfaces
1447 // until delayed physical cleanup has finished.
1448 func MarkCleanupPending(sessionPath, operation string) error {
1449 path := CleanupPendingPath(sessionPath)
1450 if path == "" {
1451 return nil
1452 }
1453 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
1454 return err
1455 }
1456 meta := CleanupPendingMeta{Operation: strings.TrimSpace(operation), CreatedAt: time.Now().UnixMilli()}
1457 b, err := json.MarshalIndent(meta, "", " ")
1458 if err != nil {
1459 return err
1460 }
1461 // The marker controls session visibility during delayed cleanup. Publish it
1462 // atomically so a crash cannot leave malformed JSON that hides the session
1463 // and blocks reconciliation on the next startup.
1464 return fileutil.AtomicWriteFile(path, b, 0o644)
1465 }
1466
1467 // ClearCleanupPending removes a delayed-cleanup marker after physical cleanup.
1468 func ClearCleanupPending(sessionPath string) error {
1469 path := CleanupPendingPath(sessionPath)
1470 if path == "" {
1471 return nil
1472 }
1473 if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
1474 return err
1475 }
1476 return nil
1477 }
1478
1479 // IsCleanupPending reports whether a session is hidden pending delayed cleanup.
1480 func IsCleanupPending(sessionPath string) bool {
1481 path := CleanupPendingPath(sessionPath)
1482 if path == "" {
1483 return false
1484 }
1485 _, err := os.Stat(path)
1486 return err == nil
1487 }
1488
1489 // IsVisibleSession reports whether a persisted session should appear on normal
1490 // user/agent-facing list, restore, and retrieval surfaces.
1491 func IsVisibleSession(sessionPath string) bool {
1492 return strings.TrimSpace(sessionPath) != "" && !IsCleanupPending(sessionPath)
1493 }
1494
1495 // ListCleanupPending returns delayed-cleanup markers left in dir. A missing
1496 // directory is not an error.
1497 func ListCleanupPending(dir string) ([]CleanupPendingInfo, error) {
1498 dir = strings.TrimSpace(dir)
1499 if dir == "" {
1500 return nil, nil
1501 }
1502 entries, err := os.ReadDir(dir)
1503 if err != nil {
1504 if os.IsNotExist(err) {
1505 return nil, nil
1506 }
1507 return nil, err
1508 }
1509 var out []CleanupPendingInfo
1510 for _, e := range entries {
1511 if e.IsDir() || !strings.HasSuffix(e.Name(), cleanupPendingExt) {
1512 continue
1513 }
1514 markerPath := filepath.Join(dir, e.Name())
1515 var meta CleanupPendingMeta
1516 b, err := fileencoding.ReadFileUTF8(markerPath)
1517 if err != nil {
1518 if os.IsNotExist(err) {
1519 continue
1520 }
1521 return nil, err
1522 }
1523 if strings.TrimSpace(string(b)) != "" {
1524 if err := json.Unmarshal(b, &meta); err != nil {
1525 return nil, fmt.Errorf("read cleanup-pending marker %s: %w", markerPath, err)
1526 }
1527 }
1528 name := strings.TrimSuffix(e.Name(), cleanupPendingExt) + ".jsonl"
1529 out = append(out, CleanupPendingInfo{
1530 SessionPath: filepath.Join(dir, name),
1531 MarkerPath: markerPath,
1532 Meta: meta,
1533 })
1534 }
1535 sort.Slice(out, func(i, j int) bool {
1536 return out[i].SessionPath < out[j].SessionPath
1537 })
1538 return out, nil
1539 }
1540
1541 // ReconcileCleanupPending retries physical cleanup for leftover delayed-cleanup
1542 // markers and stale lock/lease sidecars. It keeps going after individual
1543 // cleanup errors and returns them joined.
1544 func ReconcileCleanupPending(dir string, cleanup func(CleanupPendingInfo) error) error {
1545 var errs []error
1546 if err := ReconcileSessionSidecars(dir); err != nil {
1547 errs = append(errs, err)
1548 }
1549 if err := reconcileRecoveryTrashStages(dir); err != nil {
1550 errs = append(errs, err)
1551 }
1552 pending, err := ListCleanupPending(dir)
1553 if err != nil {
1554 errs = append(errs, err)
1555 return errors.Join(errs...)
1556 }
1557 for _, item := range pending {
1558 handled, err := reconcileRecoveryTrashPending(item)
1559 if !handled {
1560 if cleanup == nil {
1561 continue
1562 }
1563 err = cleanup(item)
1564 }
1565 if err != nil {
1566 errs = append(errs, fmt.Errorf("%s: %w", item.SessionPath, err))
1567 }
1568 }
1569 return errors.Join(errs...)
1570 }
1571
1572 // ReconcileSessionSidecars renames transcripts whose filenames outgrew their
1573 // sidecars and removes stale lock and lease files left beside sessions by
1574 // older runtimes. It never removes .jsonl transcripts; recovered conversations
1575 // may contain useful user history even when their names are ugly.
1576 func ReconcileSessionSidecars(dir string) error {
1577 dir = strings.TrimSpace(dir)
1578 if dir == "" {
1579 return nil
1580 }
1581 var errs []error
1582 if err := reconcileOverlongSessionFilenames(dir); err != nil {
1583 errs = append(errs, err)
1584 }
1585 // Re-list after the rename pass: it retires old names and their sidecars.
1586 entries, err := os.ReadDir(dir)
1587 if err != nil {
1588 if os.IsNotExist(err) {
1589 return errors.Join(errs...)
1590 }
1591 errs = append(errs, err)
1592 return errors.Join(errs...)
1593 }
1594 for _, e := range entries {
1595 if e.IsDir() {
1596 continue
1597 }
1598 name := e.Name()
1599 sidecarPath := filepath.Join(dir, name)
1600 switch {
1601 case strings.HasSuffix(name, sessionLeaseInfoSidecarSuffix):
1602 base := filepath.Join(dir, strings.TrimSuffix(name, ".lease.json"))
1603 if err := removeStaleSessionLeaseInfoSidecar(base, sidecarPath); err != nil {
1604 errs = append(errs, fmt.Errorf("%s: %w", sidecarPath, err))
1605 }
1606 case strings.HasSuffix(name, sessionLeaseLockSidecarSuffix):
1607 base := filepath.Join(dir, strings.TrimSuffix(name, ".lease.lock"))
1608 if err := removeStaleSessionLeaseLockSidecar(base, sidecarPath); err != nil {
1609 errs = append(errs, fmt.Errorf("%s: %w", sidecarPath, err))
1610 }
1611 case strings.HasSuffix(name, sessionLockSidecarSuffix):
1612 base := filepath.Join(dir, strings.TrimSuffix(name, ".lock"))
1613 if err := removeStaleSessionLockSidecar(base, sidecarPath); err != nil {
1614 errs = append(errs, fmt.Errorf("%s: %w", sidecarPath, err))
1615 }
1616 }
1617 }
1618 return errors.Join(errs...)
1619 }
1620
1621 func removeStaleSessionLockSidecar(basePath, sidecarPath string) error {
1622 basePath = canonicalSessionSavePath(basePath)
1623 if sessionLeaseHeldLocally(basePath) || SessionLeaseHeldByOtherRuntime(basePath) {
1624 return nil
1625 }
1626 lock, err := tryTakeSessionLockFile(sidecarPath)
1627 if err != nil {
1628 if errors.Is(err, ErrSessionFileLockHeld) {
1629 return nil
1630 }
1631 return err
1632 }
1633 // The removal is atomic with the release (unlink-under-flock on Unix,
1634 // delete-disposition on the held handle on Windows), so a concurrent
1635 // saver can never acquire a lock file that is being deleted under it.
1636 return lock.RemoveAndUnlock()
1637 }
1638
1639 // removeStaleSessionLeaseLockSidecar retires a leftover .lease.lock. The file
1640 // is the lease lock itself, so taking it non-blocking proves no runtime holds
1641 // the lease, and RemoveAndUnlock deletes it atomically with the release.
1642 func removeStaleSessionLeaseLockSidecar(basePath, _ string) error {
1643 basePath = canonicalSessionSavePath(basePath)
1644 if sessionLeaseHeldLocally(basePath) {
1645 return nil
1646 }
1647 lock, err := tryTakeSessionLeaseLock(basePath)
1648 if err != nil {
1649 if errors.Is(err, ErrSessionLeaseHeld) {
1650 return nil
1651 }
1652 return err
1653 }
1654 return lock.RemoveAndUnlock()
1655 }
1656
1657 // removeStaleSessionLeaseInfoSidecar retires a leftover .lease.json while
1658 // holding the lease lock, so no runtime can adopt the info file mid-removal.
1659 // The info file itself is never held open by anyone, so a plain remove under
1660 // the lock is safe on every platform.
1661 func removeStaleSessionLeaseInfoSidecar(basePath, sidecarPath string) error {
1662 basePath = canonicalSessionSavePath(basePath)
1663 if sessionLeaseHeldLocally(basePath) {
1664 return nil
1665 }
1666 lockPath := basePath + ".lease.lock"
1667 if _, err := os.Stat(lockPath); err == nil {
1668 unlock, err := tryLockSessionLeaseFile(basePath)
1669 if err != nil {
1670 if errors.Is(err, ErrSessionLeaseHeld) {
1671 return nil
1672 }
1673 return err
1674 }
1675 removeErr := os.Remove(sidecarPath)
1676 if unlock != nil {
1677 unlock()
1678 }
1679 if removeErr != nil && !os.IsNotExist(removeErr) {
1680 return removeErr
1681 }
1682 return nil
1683 } else if !os.IsNotExist(err) {
1684 return err
1685 }
1686 // No lease lock file: holders keep it present (and locked) for their whole
1687 // lifetime, so the leftover info sidecar has no owner to race with.
1688 if err := os.Remove(sidecarPath); err != nil && !os.IsNotExist(err) {
1689 return err
1690 }
1691 return nil
1692 }
1693
1694 func sessionLeaseHeldLocally(path string) bool {
1695 _, ok := sessionLeaseOwners.Load(CanonicalSessionPath(path))
1696 return ok
1697 }
1698
1699 // sessionLockSidecarFits reports whether basePath's .lock sidecar name stays
1700 // within the filesystem's per-component limit; past it, no process can hold
1701 // (or ever have held) the file lock, because the lock file cannot be created.
1702 func sessionLockSidecarFits(basePath string) bool {
1703 return len(filepath.Base(basePath))+len(".lock") <= nameMaxBytes
1704 }
1705
1706 // sessionLeaseSidecarFits is the lease-file analogue of sessionLockSidecarFits.
1707 func sessionLeaseSidecarFits(basePath string) bool {
1708 return len(filepath.Base(basePath))+len(".lease.lock") <= nameMaxBytes
1709 }
1710
1711 // reconcileOverlongSessionFilenames renames transcripts whose basenames grew
1712 // past maxSessionBasenameBytes — the leftover shape of the pre-bounded
1713 // recovery cascade (#5923), where lock and lease sidecars could no longer be
1714 // created and the session became unsaveable. The conversation bytes are kept
1715 // verbatim under a bounded name derived the same way new recovery branches
1716 // are named; branch meta moves along with its ID rewritten, and sessions
1717 // pointing at the old ID are re-parented so lineage survives the rename.
1718 func reconcileOverlongSessionFilenames(dir string) error {
1719 entries, err := os.ReadDir(dir)
1720 if err != nil {
1721 if os.IsNotExist(err) {
1722 return nil
1723 }
1724 return err
1725 }
1726 var errs []error
1727 renamed := map[string]string{} // old branch ID -> new branch ID
1728 for _, e := range entries {
1729 name := e.Name()
1730 if e.IsDir() || !store.IsSessionTranscriptName(name) {
1731 continue
1732 }
1733 if len(name) <= maxSessionBasenameBytes {
1734 continue
1735 }
1736 oldPath := filepath.Join(dir, name)
1737 if IsCleanupPending(oldPath) {
1738 // Being deleted; renaming would orphan the cleanup marker.
1739 continue
1740 }
1741 newID, err := renameOverlongSession(oldPath)
1742 if err != nil {
1743 errs = append(errs, fmt.Errorf("%s: %w", oldPath, err))
1744 }
1745 // A non-empty newID means the transcript rename landed even if some
1746 // sidecar migration failed; record it so children still re-parent —
1747 // this run is the only one that knows the old-to-new mapping.
1748 if newID != "" {
1749 renamed[BranchID(oldPath)] = newID
1750 }
1751 }
1752 if len(renamed) > 0 {
1753 if err := reparentSessionBranches(dir, renamed); err != nil {
1754 errs = append(errs, err)
1755 }
1756 }
1757 return errors.Join(errs...)
1758 }
1759
1760 // renameOverlongSession moves one overlong transcript to its bounded name and
1761 // migrates the sidecars that carry user state. It returns the new branch ID,
1762 // or "" when the session was skipped because a runtime may still own it.
1763 func renameOverlongSession(oldPath string) (string, error) {
1764 oldID := BranchID(oldPath)
1765 newID := recoveryParentStem(oldID)
1766 if newID == oldID {
1767 return "", nil
1768 }
1769 newPath := filepath.Join(filepath.Dir(oldPath), newID+".jsonl")
1770 if _, err := os.Stat(newPath); err == nil {
1771 return "", fmt.Errorf("rename target %s already exists", filepath.Base(newPath))
1772 } else if !os.IsNotExist(err) {
1773 return "", err
1774 }
1775 unlockOld := lockSessionSavePath(oldPath)
1776 defer unlockOld()
1777 unlockNew := lockSessionSavePath(newPath)
1778 defer unlockNew()
1779 if sessionLeaseHeldLocally(oldPath) {
1780 return "", nil
1781 }
1782 // Names past the sidecar limit cannot have lease or lock holders in any
1783 // process — the holder files themselves are uncreatable — so probing them
1784 // would only manufacture ENAMETOOLONG errors and wrongly skip the exact
1785 // sessions this pass exists to repair.
1786 if sessionLeaseSidecarFits(oldPath) && SessionLeaseHeldByOtherRuntime(oldPath) {
1787 return "", nil
1788 }
1789 var lockFile *sessionLockFile
1790 if sessionLockSidecarFits(oldPath) {
1791 lock, err := tryTakeSessionLockFile(oldPath + ".lock")
1792 if err != nil {
1793 if errors.Is(err, ErrSessionFileLockHeld) {
1794 return "", nil
1795 }
1796 return "", err
1797 }
1798 lockFile = lock
1799 }
1800 if err := os.Rename(oldPath, newPath); err != nil {
1801 // Nothing moved: the old transcript is intact and the next
1802 // reconciliation can retry, so its lock file stays in place too.
1803 if lockFile != nil {
1804 lockFile.Unlock()
1805 }
1806 return "", err
1807 }
1808 // The transcript is committed under its new name from here on. Sidecar
1809 // migration and lock cleanup failures are reported, but the new ID is
1810 // still returned so the caller re-parents children: the old name is gone,
1811 // and a later run would have no way to reconstruct this mapping.
1812 var errs []error
1813 if err := migrateSessionSidecars(oldPath, newPath, newID); err != nil {
1814 errs = append(errs, err)
1815 }
1816 // Retire the old disposable lease sidecars: any holder was ruled out
1817 // above, and nothing keeps these files open, so a plain remove is safe.
1818 if sessionLeaseSidecarFits(oldPath) {
1819 for _, stale := range []string{oldPath + ".lease.lock", oldPath + ".lease.json"} {
1820 if err := os.Remove(stale); err != nil && !os.IsNotExist(err) {
1821 errs = append(errs, err)
1822 }
1823 }
1824 }
1825 // The old .lock goes atomically with the release of the lock we hold on it.
1826 if lockFile != nil {
1827 if err := lockFile.RemoveAndUnlock(); err != nil {
1828 errs = append(errs, err)
1829 }
1830 }
1831 return newID, errors.Join(errs...)
1832 }
1833
1834 // migrateSessionSidecars moves the user-state sidecars of a renamed session:
1835 // branch meta (with its ID rewritten to match the new filename), goal state,
1836 // and the checkpoint/job directories. Lock and lease files are disposable and
1837 // are removed by the caller instead.
1838 func migrateSessionSidecars(oldPath, newPath, newID string) error {
1839 errs := []error{migratePinnedContextSidecar(oldPath, newPath, newID)}
1840 if len(filepath.Base(oldPath))+len(".meta") <= nameMaxBytes {
1841 if meta, ok, err := LoadBranchMeta(oldPath); err != nil {
1842 errs = append(errs, err)
1843 } else if ok {
1844 meta.ID = newID
1845 if err := SaveBranchMetaPreserveUpdated(newPath, meta); err != nil {
1846 errs = append(errs, err)
1847 } else if err := os.Remove(BranchMetaPath(oldPath)); err != nil && !os.IsNotExist(err) {
1848 errs = append(errs, err)
1849 }
1850 }
1851 }
1852 for _, pair := range [][2]string{
1853 {store.SessionGoalState(oldPath), store.SessionGoalState(newPath)},
1854 {store.SessionEventLog(oldPath), store.SessionEventLog(newPath)},
1855 {store.SessionEventLogDamaged(oldPath), store.SessionEventLogDamaged(newPath)},
1856 {store.SessionTurnEventLog(oldPath), store.SessionTurnEventLog(newPath)},
1857 {store.SessionTurnEventLogDamaged(oldPath), store.SessionTurnEventLogDamaged(newPath)},
1858 {store.SessionEventIndex(oldPath), store.SessionEventIndex(newPath)},
1859 {store.SessionConflictLog(oldPath), store.SessionConflictLog(newPath)},
1860 {store.SessionRecoveryState(oldPath), store.SessionRecoveryState(newPath)},
1861 {store.SessionCheckpointDir(oldPath), store.SessionCheckpointDir(newPath)},
1862 {store.SessionJobsDir(oldPath), store.SessionJobsDir(newPath)},
1863 {store.SessionInboxDir(oldPath), store.SessionInboxDir(newPath)},
1864 } {
1865 // A source name past the filesystem limit cannot exist; renaming it
1866 // would just manufacture ENAMETOOLONG instead of a clean not-exist.
1867 if len(filepath.Base(pair[0])) > nameMaxBytes {
1868 continue
1869 }
1870 if err := os.Rename(pair[0], pair[1]); err != nil && !os.IsNotExist(err) {
1871 errs = append(errs, err)
1872 }
1873 }
1874 return errors.Join(errs...)
1875 }
1876
1877 // reparentSessionBranches rewrites ParentID references from renamed branch IDs
1878 // to their bounded replacements so the branch tree stays connected.
1879 func reparentSessionBranches(dir string, renamed map[string]string) error {
1880 entries, err := os.ReadDir(dir)
1881 if err != nil {
1882 return err
1883 }
1884 var errs []error
1885 for _, e := range entries {
1886 name := e.Name()
1887 if e.IsDir() || !store.IsSessionTranscriptName(name) {
1888 continue
1889 }
1890 if len(name)+len(".meta") > nameMaxBytes {
1891 continue
1892 }
1893 path := filepath.Join(dir, name)
1894 unlock := lockSessionSavePath(path)
1895 meta, ok, err := LoadBranchMeta(path)
1896 if err == nil && ok {
1897 if newParent, hit := renamed[meta.ParentID]; hit && newParent != meta.ParentID {
1898 meta.ParentID = newParent
1899 err = SaveBranchMetaPreserveUpdated(path, meta)
1900 }
1901 }
1902 unlock()
1903 if err != nil {
1904 errs = append(errs, fmt.Errorf("%s: %w", path, err))
1905 }
1906 }
1907 return errors.Join(errs...)
1908 }
1909
1910 // ListSessionOrder returns every *.jsonl session under dir in the same
1911 // most-recently-active order used by ListSessions, using only file metadata and
1912 // branch sidecars. A missing directory is not an error.
1913 func ListSessionOrder(dir string) ([]SessionOrderInfo, error) {
1914 return ListSessionOrderWithRecoveryPreferenceResolver(dir, RecoveryPreferenceCurrent)
1915 }
1916
1917 // ListSessionOrderWithRecoveryPreferenceResolver lets catalog reconciliation
1918 // reuse its wave-local transcript snapshot when validating explicit choices.
1919 // Other callers keep the ordinary ListSessionOrder behavior above.
1920 func ListSessionOrderWithRecoveryPreferenceResolver(dir string, resolve RecoveryPreferenceResolver) ([]SessionOrderInfo, error) {
1921 if resolve == nil {
1922 resolve = RecoveryPreferenceCurrent
1923 }
1924 entries, err := os.ReadDir(dir)
1925 if err != nil {
1926 if os.IsNotExist(err) {
1927 return nil, nil
1928 }
1929 return nil, err
1930 }
1931 var out []SessionOrderInfo
1932 for _, e := range entries {
1933 if e.IsDir() || !store.IsSessionTranscriptName(e.Name()) {
1934 continue
1935 }
1936 info, err := e.Info()
1937 if err != nil {
1938 continue
1939 }
1940 full := filepath.Join(dir, e.Name())
1941 if !IsVisibleSession(full) {
1942 continue
1943 }
1944 contentMod := SessionContentModTime(full)
1945 if contentMod.IsZero() {
1946 contentMod = info.ModTime()
1947 }
1948 createdAt := info.ModTime()
1949 lastActivityAt := contentMod
1950 scope := "global"
1951 workspaceRoot := ""
1952 topicID := ""
1953 topicTitle := ""
1954 customTitle := ""
1955 recovered := false
1956 recoveryReason := ""
1957 recoveryDigest := ""
1958 parentID := ""
1959 versionKind := VersionNormal
1960 versionState := VersionActive
1961 parentConversationID := ""
1962 var headMirror BranchMeta
1963 parentVersionID := ""
1964 recoveryPreferred := false
1965 turns := 0
1966 preview := ""
1967 schemaVersion := 0
1968 revision := int64(0)
1969 contentDigest := ""
1970 listingRevision := int64(0)
1971 listingContentDigest := ""
1972 if meta, ok, err := LoadBranchMeta(full); err == nil && ok {
1973 if !meta.CreatedAt.IsZero() {
1974 createdAt = meta.CreatedAt
1975 }
1976 if !meta.UpdatedAt.IsZero() {
1977 lastActivityAt = meta.UpdatedAt
1978 }
1979 scope = meta.DefaultScope()
1980 workspaceRoot = meta.WorkspaceRoot
1981 topicID = meta.TopicID
1982 topicTitle = meta.TopicTitle
1983 customTitle = meta.CustomTitle
1984 recovered = meta.Recovered
1985 recoveryReason = meta.RecoveryReason
1986 recoveryDigest = meta.RecoveryDigest
1987 parentID = meta.ParentID
1988 versionKind = meta.EffectiveVersionKind()
1989 versionState = meta.EffectiveVersionState()
1990 parentConversationID = meta.ParentConversationID
1991 parentVersionID = meta.ParentVersionID
1992 recoveryPreferred = resolve(full, meta)
1993 turns = meta.Turns
1994 preview = meta.Preview
1995 schemaVersion = meta.SchemaVersion
1996 revision = meta.Revision
1997 contentDigest = meta.ContentDigest
1998 listingRevision = meta.ListingRevision
1999 listingContentDigest = meta.ListingContentDigest
2000 headMirror = meta
2001 }
2002 // Old recovery files may lack Recovered meta; filename still proves
2003 // automatic recovery lineage for catalog folding.
2004 if !recovered && LooksLikeRecoveryFilename(full) {
2005 recovered = true
2006 versionKind = VersionRecovery
2007 if parentID == "" {
2008 if parent, ok := RecoveryFilenameParentID(full); ok {
2009 parentID = parent
2010 }
2011 }
2012 }
2013 out = append(out, SessionOrderInfo{
2014 Path: full,
2015 CreatedAt: createdAt,
2016 LastActivityAt: lastActivityAt,
2017 ModTime: lastActivityAt,
2018 Scope: scope,
2019 WorkspaceRoot: workspaceRoot,
2020 TopicID: topicID,
2021 TopicTitle: topicTitle,
2022 CustomTitle: customTitle,
2023 Recovered: recovered,
2024 RecoveryReason: recoveryReason,
2025 RecoveryDigest: recoveryDigest,
2026 ParentID: parentID,
2027 VersionKind: versionKind,
2028 VersionState: versionState,
2029 ParentConversationID: parentConversationID,
2030 ParentVersionID: parentVersionID,
2031 RecoveryPreferred: recoveryPreferred,
2032 Turns: turns,
2033 Preview: preview,
2034 SchemaVersion: schemaVersion,
2035 Revision: revision,
2036 ContentDigest: contentDigest,
2037 ListingRevision: listingRevision,
2038 ListingContentDigest: listingContentDigest,
2039 HeadID: headMirror.HeadID,
2040 HeadCount: headMirror.HeadCount,
2041 LogSchema: headMirror.LogSchema,
2042 })
2043 }
2044 sort.Slice(out, func(i, j int) bool {
2045 if out[i].LastActivityAt.Equal(out[j].LastActivityAt) {
2046 return out[i].Path < out[j].Path
2047 }
2048 return out[i].LastActivityAt.After(out[j].LastActivityAt)
2049 })
2050 return out, nil
2051 }
2052
2053 // ListSessions returns every non-empty *.jsonl session under dir,
2054 // most-recently-active first, each with a preview line so the picker can show
2055 // something the user recognises. It never decodes a transcript: legacy counts
2056 // remain explicitly unknown until the session catalog's single repair worker
2057 // validates them. A missing directory is not an error.
2058 func ListSessions(dir string) ([]SessionInfo, error) {
2059 ordered, err := ListSessionOrder(dir)
2060 if err != nil {
2061 return nil, err
2062 }
2063 var out []SessionInfo
2064 for _, session := range ordered {
2065 preview, turns := session.Preview, session.Turns
2066 if !sessionListingProjectionFresh(session.SchemaVersion, turns, session.Revision, session.ListingRevision, session.ContentDigest, session.ListingContentDigest) {
2067 if !sessionArtifactsHaveContent(session.Path) {
2068 continue
2069 }
2070 if strings.TrimSpace(preview) == "" {
2071 preview = "History is being indexed — " + filepath.Base(session.Path)
2072 }
2073 out = append(out, sessionInfoFromOrder(session, preview, turns, false))
2074 continue
2075 }
2076 if turns == 0 {
2077 // Never had user interaction — an empty conversation that should not
2078 // appear in the history panel or the resume picker.
2079 continue
2080 }
2081 out = append(out, sessionInfoFromOrder(session, preview, turns, true))
2082 }
2083 return out, nil
2084 }
2085
2086 // SessionPreview returns the same preview and user-turn count used by
2087 // ListSessions for one session file.
2088 func SessionPreview(path string) (string, int) {
2089 return previewSession(path)
2090 }
2091
2092 // SessionPreviewWithError returns the same preview and user-turn count as
2093 // SessionPreview, but preserves read and decode failures for callers that must
2094 // not persist a fallback derived from an unreadable transcript.
2095 func SessionPreviewWithError(path string) (string, int, error) {
2096 return previewSessionWithError(path)
2097 }
2098
2099 // SessionPreviewFromMessages computes the same preview line and user-turn count
2100 // as previewSession, but from an in-memory message slice. Session.Save writes
2101 // exactly these messages to the .jsonl, so this is byte-for-byte equivalent to
2102 // decoding the file — letting the autosave path persist the counts into the
2103 // sidecar without a disk read.
2104 func SessionPreviewFromMessages(msgs []provider.Message) (string, int) {
2105 first := ""
2106 turns := 0
2107 for _, m := range msgs {
2108 if IsUserAuthoredTurnMessage(m) {
2109 turns++
2110 if first == "" {
2111 first = truncatePreview(previewProse(UserMessageText(m)))
2112 }
2113 }
2114 }
2115 return first, turns
2116 }
2117
2118 // previewSession returns the first user message (truncated) and the number of
2119 // user-role messages so the picker can show "5 turns · 'help me debug the…'".
2120 // Errors are swallowed — a malformed file just shows up with an empty preview.
2121 func previewSession(path string) (string, int) {
2122 preview, turns, _ := previewSessionWithError(path)
2123 return preview, turns
2124 }
2125
2126 // previewProse drops the leading @file references a prompt opens with so the
2127 // preview shows what was asked rather than a row of paths. A prompt that is
2128 // nothing but references keeps them — there is nothing else to show.
2129 func previewProse(s string) string {
2130 rest := strings.TrimLeft(s, " \t")
2131 for strings.HasPrefix(rest, "@") {
2132 end := strings.IndexAny(rest, " \t\r\n")
2133 if end < 0 {
2134 return s
2135 }
2136 next := strings.TrimLeft(rest[end:], " \t")
2137 if strings.TrimSpace(next) == "" {
2138 return s
2139 }
2140 rest = next
2141 }
2142 if rest == "" {
2143 return s
2144 }
2145 return rest
2146 }
2147
2148 // truncatePreview clamps a preview line to 80 runes with an ellipsis, matching
2149 // what the pickers render.
2150 func truncatePreview(s string) string {
2151 if r := []rune(s); len(r) > 80 {
2152 return string(r[:77]) + "…"
2153 }
2154 return s
2155 }
2156
2156 lines GO