Files
DeepSeek-Reasonix/internal/agent/save.go
T
YHH 4fbbca6a99 refactor(go): adopt modern stdlib idioms and gate them (#7831)
* refactor(go): adopt modern stdlib idioms across kernel and desktop

Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/
usestdlibvars fixers plus hand-fixes for what they could not rewrite:
slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor,
maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int.
Net -549 lines with no behavior change.

Three loops in session.go stay index-based: slices.Backward yields element
copies, so the fixer's rewrite silently dropped writes to DecisionReceipts
and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49
slices.Backward sites were audited for that shape; only these three write
through the index, and they carry a nolint with the reason.

CaptureAfter's scan loop had an empty body and never ran any logic, so the
fixer could not express it. Drop it and the accessor it was the only caller
of. omitzero is deliberately untouched: turning omitempty into omitzero
changes the JSON written for nested structs, which is a wire-format change
to session files and the extension protocol.

* build(lint): gate the modern-idiom linters

Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both
modules are now clean for modernize, intrange, copyloopvar, and
usestdlibvars, so enable them rather than leaving the sweep as a one-off.

omitzero stays disabled with the reason inline; usetesting, perfsprint, and
errorlint still have residue and land separately.
2026-08-07 15:30:33 +08:00

2120 lines
77 KiB
Go

package agent
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"runtime"
"slices"
"sort"
"strings"
"sync"
"time"
"unicode/utf8"
"reasonix/internal/fileutil"
fileencoding "reasonix/internal/fileutil/encoding"
"reasonix/internal/provider"
"reasonix/internal/store"
)
const (
cleanupPendingExt = ".cleanup-pending.json"
maxRecoveryParentStemBytes = 80
sessionLockSidecarSuffix = ".jsonl.lock"
sessionLeaseLockSidecarSuffix = ".jsonl.lease.lock"
sessionLeaseInfoSidecarSuffix = ".jsonl.lease.json"
guardianSidecarSuffix = ".guardian.jsonl"
// nameMaxBytes is the single-component filename limit shared by the
// filesystems Reasonix targets (APFS, ext4, NTFS all cap at 255).
nameMaxBytes = 255
// maxSessionBasenameBytes bounds transcript basenames that reconciliation
// leaves in place. Sidecars append up to ~16 bytes to the transcript name
// or its stem (".lease.lock", ".cleanup-pending.json", ".guardian.jsonl"),
// so 224 keeps every sidecar comfortably under nameMaxBytes with headroom
// for future suffixes. Names past this bound come from the pre-bounded
// recovery cascade and get renamed by reconcileOverlongSessionFilenames.
maxSessionBasenameBytes = 224
)
var (
sessionSaveLocks sync.Map
// sessionFileLockWait bounds cross-process save-lock acquisition. Session
// leases normally prevent competing writers, but CLI/legacy writers and a
// stalled process can still hold the compatibility .lock file. Navigation
// and desktop shutdown snapshot synchronously; waiting forever here wedges
// the UI and keeps the session lease (and WebView) alive indefinitely.
// Package vars let focused tests shorten the wait without slowing the suite.
sessionFileLockWait = 5 * time.Second
sessionFileLockPollInterval = 25 * time.Millisecond
ErrSessionSnapshotConflict = errors.New("session snapshot conflicts with newer transcript")
ErrSessionRecoveryNotNeeded = errors.New("session recovery not needed")
// ErrSessionFileLockHeld reports that another process kept the
// compatibility save lock for the full bounded acquisition window. Callers
// that are about to terminate can use this sentinel to persist a recovery
// branch without waiting on the same stalled file again.
ErrSessionFileLockHeld = errors.New("session file lock held")
// ErrSessionRecoveryDepthExceeded refuses a recovery fork whose parent is
// already SessionRecoveryMaxDepth recovery forks deep. A chain that deep
// means saves keep conflicting on branches this runtime itself created;
// forking further multiplies session files without converging (#5993).
ErrSessionRecoveryDepthExceeded = errors.New("session recovery chain depth exceeded")
sessionWriterID = newSessionWriterID()
)
// SessionRecoveryMaxDepth bounds nested recovery forks: a normal session may
// fork a recovery branch (depth 1), which may itself fork twice more under
// genuine repeated incidents; past that the caller should stop forking and
// write onto the branch it already owns.
const SessionRecoveryMaxDepth = 3
type sessionPersistState struct {
path string
digest [sha256.Size]byte
version uint64
revision int64
// revisionKnown marks revision as a real ledger value. It is false when
// the baseline was established while the meta sidecar was unreadable
// (torn or corrupt): the session must still open, but revision 0 must not
// pose as a baseline or every honest on-disk revision would read as a
// stale-runtime conflict. CAS checks fall back to digest+version until a
// successful save re-learns the revision.
revisionKnown bool
// saveVerified marks a baseline established by a completed save in this
// process, whose write path verified transcript and ledger agree. A
// baseline adopted at load time pairs the disk transcript with whatever
// the meta sidecar said — which can lag the transcript after an
// interrupted save — so only save-verified baselines may arm the
// snapshot no-op fast path; the first save after a load must run in full
// and heal a stale ledger.
saveVerified bool
ok bool
}
type sessionSaveMode int
const (
sessionSaveForce sessionSaveMode = iota
sessionSaveSnapshot
sessionSaveRewrite
)
type snapshotWriteDecision struct {
revision int64
upToDate bool
appendFrom int
appendOnly bool
// repairLog is set when the on-disk event log was damaged (torn tail with
// a lost suffix, or nothing decodable): the safe write shape is a full
// rewrite that also compacts the log back to a healthy single event.
repairLog bool
// ledgerStale is set when the on-disk transcript already matches the
// snapshot but the meta ledger still describes older content — the
// aftermath of a save whose bytes landed and whose revision record then
// failed. The up-to-date path must heal the ledger instead of skipping it.
ledgerStale bool
}
type SessionSnapshotConflictKind string
const (
SessionSnapshotConflictStalePrefix SessionSnapshotConflictKind = "stale_prefix"
SessionSnapshotConflictDiverged SessionSnapshotConflictKind = "diverged"
)
type SessionSnapshotConflictError struct {
Path string
Kind SessionSnapshotConflictKind
ExistingMessages int
SnapshotMessages int
BaseRevision int64
DiskRevision int64
}
func (e *SessionSnapshotConflictError) Error() string {
if e == nil {
return ErrSessionSnapshotConflict.Error()
}
switch e.Kind {
case SessionSnapshotConflictStalePrefix:
return fmt.Sprintf("%s: %s has %d messages at revision %d; stale snapshot has %d messages from revision %d",
ErrSessionSnapshotConflict, e.Path, e.ExistingMessages, e.DiskRevision, e.SnapshotMessages, e.BaseRevision)
default:
return fmt.Sprintf("%s: %s diverged on disk (%d messages, revision %d) from snapshot (%d messages, revision %d)",
ErrSessionSnapshotConflict, e.Path, e.ExistingMessages, e.DiskRevision, e.SnapshotMessages, e.BaseRevision)
}
}
func (e *SessionSnapshotConflictError) Unwrap() error {
return ErrSessionSnapshotConflict
}
func SnapshotConflictKind(err error) (SessionSnapshotConflictKind, bool) {
var conflict *SessionSnapshotConflictError
if errors.As(err, &conflict) && conflict != nil {
return conflict.Kind, true
}
return "", false
}
const RecoveryBranchDefaultName = "Recovered unsaved changes from stale runtime"
type RecoveryBranchOptions struct {
OriginalPath string
Name string
Reason string
BranchMeta BranchMeta
}
type RecoveryBranchInfo struct {
Path string
Digest string
Existing bool
Meta BranchMeta
Preview string
Turns int
}
// Save persists the session using an append-only event log beside path. The
// .jsonl file remains as a compatibility checkpoint and discovery anchor; the
// event log is the authoritative transcript once present.
func (s *Session) Save(path string) error {
return s.save(path, sessionSaveForce)
}
// SaveSnapshot writes a normal autosave/snapshot only when doing so cannot hide
// a newer transcript already on disk. Explicit history rewrites such as rewind,
// compaction, and cancel recovery should call SaveRewrite instead.
func (s *Session) SaveSnapshot(path string) error {
return s.save(path, sessionSaveSnapshot)
}
// SaveRewrite writes an intentional non-append history rewrite only while this
// Session still owns the current on-disk transcript baseline. It prevents a
// stale controller from force-rewinding a newer transcript written elsewhere.
func (s *Session) SaveRewrite(path string) error {
return s.save(path, sessionSaveRewrite)
}
func (s *Session) save(path string, mode sessionSaveMode) error {
if path == "" {
return fmt.Errorf("empty session path")
}
baseRevision := int64(0)
unlock := lockSessionSavePath(path)
defer unlock()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create session dir: %w", err)
}
unlockFile, err := lockSessionFile(path)
if err != nil {
return fmt.Errorf("lock session file: %w", err)
}
defer unlockFile()
observeUnleasedSessionWrite(path, mode)
if mode == sessionSaveSnapshot && s.snapshotUpToDate(path) {
// Nothing changed since the last successful save to this exact path:
// skip the rest of the save — including the full transcript serialize
// + digest + disk probe the up-to-date decision below would still
// pay. Desktop switch/close/prune paths snapshot defensively on every
// navigation, and on large sessions that per-save cost is the
// user-visible seconds of UI freeze in #6607. Version bookkeeping
// makes this exact: any Add/Replace/preview update bumps version, any
// rewrite bumps rewriteVersion, and load-time repairs or log damage
// disarm the fast path until a real save persists them. The check
// runs under the save locks, not before them, so a saver that waited
// on a concurrent writer still re-evaluates against the state it must
// persist when it finally enters the critical section.
return nil
}
// Capture the snapshot only while holding the save locks. Concurrent
// in-process savers (turn-end snapshot, periodic autosave, shutdown
// snapshot) that captured before locking could land out of order: the
// stalest capture written last would then read the newer transcript it
// lost the race to as a bogus stale-prefix conflict.
msgs, version, rewriteVersion := s.snapshotWithVersion()
digest, contentBytes, err := digestAndSizeSessionMessages(msgs)
if err != nil {
return err
}
probe, err := probeSessionEventLog(path)
if err != nil {
return err
}
if probe.futureSchema {
return fmt.Errorf("session event log for %s uses schema %d; this build supports up to %d", path, probe.schemaVersion, sessionEventSchemaVersion)
}
if probe.native && probe.size > 0 {
// Drop any torn tail a crashed or disk-full append left behind before
// it can be buried under new records where replay would stop forever.
if err := repairSessionEventLogTail(path); err != nil {
return fmt.Errorf("repair session event log: %w", err)
}
}
repairLog := false
if mode != sessionSaveForce {
decision, err := s.checkSnapshotWrite(path, msgs, digest, version, mode == sessionSaveRewrite)
if err != nil {
return err
}
if decision.upToDate {
// Disk already holds exactly this transcript. Rewriting it would only
// bump the revision, invalidating the persistence baseline of every
// other runtime resumed on this file and turning their next
// legitimate save into a stale-runtime conflict. Skip the write and
// adopt the current on-disk revision as this session's baseline.
if decision.ledgerStale {
// ...unless the ledger never learned about this transcript: a
// prior save landed its bytes and then failed to record the
// revision. Same-content retries are exactly the "later save"
// that failure deferred to, and skipping here would strand the
// ledger on the old digest forever. Record now, reproducing
// the state the interrupted save would have left.
revision, err := recordSessionContentRevision(path, digest, decision.revision)
if err != nil {
return err
}
if probe.native {
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
// See the append path below: index loss must not fail a
// save whose transcript and revision already landed.
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
}
}
s.markPersisted(path, digest, version, revision, rewriteVersion)
return nil
}
s.markPersisted(path, digest, version, decision.revision, rewriteVersion)
return nil
}
if decision.appendOnly && probe.native {
logSize := sessionEventLogSize(path)
switch {
case logSize == 0:
if err := appendSessionReplaceEvent(path, msgs, digest, decision.revision, "snapshot"); err != nil {
return err
}
case sessionEventLogOversized(logSize, contentBytes):
// Checkpoint: fold history into one replace event and refresh
// the .jsonl anchor so direct readers and older binaries stay
// bounded-stale instead of frozen at first save.
if err := compactSessionEventLog(path, msgs, digest, decision.revision, "compact"); err != nil {
return err
}
if err := writeSessionMessages(path, msgs); err != nil {
return err
}
default:
if err := appendSessionAppendEvent(path, decision.appendFrom, msgs[decision.appendFrom:], digest, decision.revision); err != nil {
return err
}
}
revision, err := recordSessionContentRevision(path, digest, decision.revision)
if err != nil {
return err
}
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
// The event index is only a listing accelerator; the transcript
// and its revision are already durable above. Failing the save
// here would skip markPersisted and leave the in-memory baseline
// behind the disk state it just wrote, misreading the next save
// as a stale-runtime conflict.
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
}
s.markPersisted(path, digest, version, revision, rewriteVersion)
return nil
}
baseRevision = decision.revision
repairLog = decision.repairLog
} else if revision, _, err := sessionContentRevision(path); err != nil {
return err
} else {
baseRevision = revision
}
// Full-rewrite path: intentional history rewrites, damage repairs, and
// force saves. The event log mutates first so a crash between the two
// writes leaves the newer transcript authoritative; the anchor rewrite
// keeps the compatibility .jsonl fresh for direct readers.
reason := "save"
switch mode {
case sessionSaveSnapshot:
reason = "snapshot"
case sessionSaveRewrite:
reason = "rewrite"
}
if repairLog {
reason = "repair"
}
logSize := sessionEventLogSize(path)
switch {
case !probe.native:
// A foreign file (legacy import leftover) squats the native log path.
// Never write into or over it — the session stays checkpoint-only.
case mode == sessionSaveForce:
// Force saves are one-shot copies (subagents, guardian, migrations,
// forks): they never bootstrap an event log, and fold an existing one
// into a single replace event so the log cannot disagree with the
// anchor.
if logSize > 0 {
if err := compactSessionEventLog(path, msgs, digest, baseRevision, reason); err != nil {
return err
}
}
case repairLog, sessionEventLogOversized(logSize, contentBytes):
if err := compactSessionEventLog(path, msgs, digest, baseRevision, reason); err != nil {
return err
}
default:
if err := appendSessionReplaceEvent(path, msgs, digest, baseRevision, reason); err != nil {
return err
}
}
if err := writeSessionMessages(path, msgs); err != nil {
return err
}
revision, err := recordSessionContentRevision(path, digest, baseRevision)
if err != nil {
return err
}
if probe.native {
if err := writeSessionEventIndex(path, msgs, digest, revision); err != nil {
// See the append path above: index loss must not fail a save whose
// transcript and revision already landed.
slog.Warn("session: keeping save after event index write failure", "path", path, "err", err)
}
}
s.markPersisted(path, digest, version, revision, rewriteVersion)
return nil
}
func writeSessionMessages(path string, msgs []provider.Message) error {
// Write to a sibling tmp file then rename, so a crash mid-write can't
// leave a partial JSONL that won't reload. The fsync guards the anchor
// against power loss — it is the fallback when the event log is damaged.
tmp, err := os.CreateTemp(filepath.Dir(path), ".session.*.tmp")
if err != nil {
return fmt.Errorf("create session tmp: %w", err)
}
tmpPath := tmp.Name()
enc := json.NewEncoder(tmp)
for _, m := range msgs {
if err := enc.Encode(m); err != nil {
tmp.Close()
os.Remove(tmpPath)
return fmt.Errorf("encode message: %w", err)
}
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpPath)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpPath)
return err
}
if err := fileutil.ReplaceFile(tmpPath, path); err != nil {
os.Remove(tmpPath)
return err
}
return nil
}
// checkSnapshotWrite decides whether this session may write msgs over path, and
// whether the safe write shape is a no-op, append-only suffix, or full rewrite.
func (s *Session) checkSnapshotWrite(path string, next []provider.Message, nextDigest [sha256.Size]byte, nextVersion uint64, allowOwnedRewrite bool) (snapshotWriteDecision, error) {
current, err := loadSessionUnlocked(path)
if err != nil {
if os.IsNotExist(err) {
return snapshotWriteDecision{}, nil
}
return snapshotWriteDecision{}, err
}
currentRevision, currentLedgerDigest, err := sessionContentRevision(path)
if err != nil {
return snapshotWriteDecision{}, err
}
baseState := s.persistState(path)
existing := current.Snapshot()
existingDigest, err := digestSessionMessages(existing)
if err != nil {
return snapshotWriteDecision{}, err
}
// raw is the transcript as stored, before load-time normalization repaired
// it; it equals existing when no repair ran. The prefix checks below must
// be able to fall back to it: a mid-turn snapshot legitimately cuts an
// assistant tool call from its still-running result, normalization then
// fabricates a placeholder answer on load, and the live session's real
// result collides with that placeholder — misreading a pure append as
// divergence (and forking a bogus recovery branch).
raw, rawDigest := existing, existingDigest
rawDiffers := current.normalizedDirty && len(current.rawMessages) > 0
if rawDiffers {
raw = current.rawMessages
if rawDigest, err = digestSessionMessages(raw); err != nil {
return snapshotWriteDecision{}, err
}
}
contentUnchanged := bytes.Equal(existingDigest[:], nextDigest[:])
exactAppend := messagesHavePrefix(next, existing)
appendShaped := contentUnchanged || exactAppend || messagesHavePrefixWithCompatibleSystem(next, existing)
repairPending := current.normalizedDirty
if !appendShaped && rawDiffers {
rawUnchanged := bytes.Equal(rawDigest[:], nextDigest[:])
rawAppend := messagesHavePrefix(next, raw)
if rawUnchanged || rawAppend || messagesHavePrefixWithCompatibleSystem(next, raw) {
existing = raw
contentUnchanged = rawUnchanged
exactAppend = rawAppend
appendShaped = true
// The snapshot supersedes the repaired view — appending it lands
// the real tool results where the placeholders were fabricated —
// so no load-time repair is left to force a rewrite.
repairPending = false
}
}
if !appendShaped && baseState.ok && baseState.revisionKnown &&
baseState.revision == currentRevision && !contentUnchanged {
// Revision equality alone is not ownership proof: another writer can
// land transcript/event-log bytes and crash before advancing the
// ledger. Require the current bytes to still match this Session's
// persisted digest (or its pre-normalization raw form) before treating
// an internally reshaped snapshot as a safe full rewrite.
owned := s.ownsPersistedState(path, existingDigest, currentRevision, currentLedgerDigest, nextVersion)
if !owned && rawDiffers {
owned = s.ownsPersistedState(path, rawDigest, currentRevision, currentLedgerDigest, nextVersion)
}
if owned {
appendShaped = true
}
}
if appendShaped {
// An unknown-revision baseline (meta sidecar unreadable at load) cannot
// vouch for revision equality; the digest/prefix checks above already
// vouch for the content, so only a known baseline arms the CAS check.
// Under an append-shaped write (at most a compatible leading-system
// swap) a stale revision is ledger drift — a reset sidecar, a
// same-content heal, or another runtime recording messages this
// snapshot already contains — unless the transcript was rewound.
// Locating the persisted baseline among the snapshot's prefixes and
// requiring the disk transcript to still reach it tells the two apart:
// drift keeps the baseline reachable, while a rewind cut below it and
// appending would resurrect the suffix another runtime removed.
if baseState.ok && baseState.revisionKnown && currentRevision != baseState.revision && !contentUnchanged &&
!appendCoversPersistedBaseline(next, existing, baseState.digest) {
return snapshotWriteDecision{}, snapshotConflict(path, existing, next, baseState.revision, currentRevision)
}
// A normalized-dirty load means LoadSession repaired the history on the
// way in: the digests match but the raw bytes on disk do not, so the
// repair still needs a real write to persist. A damaged event log
// likewise needs a real write (rewrite + compact) even when the
// replayable prefix already matches this snapshot.
decision := snapshotWriteDecision{
revision: currentRevision,
upToDate: contentUnchanged && !repairPending && !current.eventLogDamaged,
repairLog: current.eventLogDamaged,
}
// A ledger digest that describes different content than the transcript
// on disk is the aftermath of a save whose bytes landed and whose
// revision record then failed (crash or fail-closed record between the
// two writes). Only a non-empty mismatch counts: a missing sidecar or
// a legacy one without a digest is a legitimate state, and stamping it
// here would bump revisions other runtimes still hold as baselines.
if decision.upToDate && currentLedgerDigest != "" && currentLedgerDigest != digestString(nextDigest) {
decision.ledgerStale = true
}
// An append is only chain-safe when existing measures the transcript
// the event log actually replays. Under a pending load-time repair the
// normalized view differs from the raw log, so an append event indexed
// against it breaks the replay chain and orphans the appended suffix;
// fall through to the full rewrite, which also persists the repair.
if exactAppend && !contentUnchanged && len(existing) < len(next) && !current.eventLogDamaged && !repairPending {
decision.appendOnly = true
decision.appendFrom = len(existing)
}
return decision, nil
}
if allowOwnedRewrite {
owned := s.ownsPersistedState(path, existingDigest, currentRevision, currentLedgerDigest, nextVersion)
if !owned && rawDiffers {
// The persisted baseline describes the bytes this session wrote, so
// a repaired view can never match it; ownership is judged against
// the raw transcript.
owned = s.ownsPersistedState(path, rawDigest, currentRevision, currentLedgerDigest, nextVersion)
}
if owned {
return snapshotWriteDecision{revision: currentRevision, repairLog: current.eventLogDamaged}, nil
}
}
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) ||
(rawDiffers && (messagesHavePrefix(raw, next) || messagesHavePrefixWithCompatibleSystem(raw, next))) {
return snapshotWriteDecision{}, &SessionSnapshotConflictError{
Path: path,
Kind: SessionSnapshotConflictStalePrefix,
ExistingMessages: len(existing),
SnapshotMessages: len(next),
BaseRevision: baseState.revision,
DiskRevision: currentRevision,
}
}
return snapshotWriteDecision{}, &SessionSnapshotConflictError{
Path: path,
Kind: SessionSnapshotConflictDiverged,
ExistingMessages: len(existing),
SnapshotMessages: len(next),
BaseRevision: baseState.revision,
DiskRevision: currentRevision,
}
}
func snapshotConflict(path string, existing, next []provider.Message, baseRevision, diskRevision int64) error {
kind := SessionSnapshotConflictDiverged
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) {
kind = SessionSnapshotConflictStalePrefix
}
return &SessionSnapshotConflictError{
Path: path,
Kind: kind,
ExistingMessages: len(existing),
SnapshotMessages: len(next),
BaseRevision: baseRevision,
DiskRevision: diskRevision,
}
}
func (s *Session) SaveRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranchInfo, error) {
return s.saveRecoveryBranch(opts, false)
}
// SaveShutdownRecoveryBranch persists the current transcript to a distinct
// recovery branch after the normal shutdown snapshot failed with
// ErrSessionFileLockHeld. It deliberately does not re-lock or inspect the
// original session file: doing so would repeat the same bounded timeout and
// let process teardown discard the only remaining in-memory copy.
//
// The recovery filename includes this process writer ID, so a stalled writer
// on the digest-deduplicated conflict path cannot block the emergency copy too.
// The result still uses the normal session, event-log, and branch-meta formats
// and is therefore discoverable and resumable through existing flows.
func (s *Session) SaveShutdownRecoveryBranch(opts RecoveryBranchOptions) (RecoveryBranchInfo, error) {
return s.saveRecoveryBranch(opts, true)
}
func (s *Session) saveRecoveryBranch(opts RecoveryBranchOptions, shutdown bool) (RecoveryBranchInfo, error) {
originalPath := strings.TrimSpace(opts.OriginalPath)
if originalPath == "" {
return RecoveryBranchInfo{}, fmt.Errorf("empty original session path")
}
msgs, version, rewriteVersion := s.snapshotWithVersion()
preview, turns := SessionPreviewFromMessages(msgs)
if turns == 0 {
return RecoveryBranchInfo{}, ErrSessionRecoveryNotNeeded
}
digest, err := digestSessionMessages(msgs)
if err != nil {
return RecoveryBranchInfo{}, err
}
digestText := digestString(digest)
if !shutdown {
unlockOriginal := lockSessionSavePath(originalPath)
unlockOriginalFile, lockErr := lockSessionFile(originalPath)
if lockErr != nil {
unlockOriginal()
return RecoveryBranchInfo{}, fmt.Errorf("lock original session file: %w", lockErr)
}
current, loadErr := loadSessionUnlocked(originalPath)
unlockOriginalFile()
unlockOriginal()
if loadErr != nil && !os.IsNotExist(loadErr) {
return RecoveryBranchInfo{}, loadErr
}
if loadErr == nil && current != nil {
existing := current.Snapshot()
existingDigest, digestErr := digestSessionMessages(existing)
if digestErr != nil {
return RecoveryBranchInfo{}, digestErr
}
covered := bytes.Equal(existingDigest[:], digest[:]) ||
messagesHavePrefix(existing, msgs) ||
messagesHavePrefixWithCompatibleSystem(existing, msgs)
if !covered && current.normalizedDirty && len(current.rawMessages) > 0 {
// Judge coverage against the pre-repair transcript too, for the
// same reason as checkSnapshotWrite: load-time normalization can
// reshape what is actually stored, and a recovery fork is only
// warranted when the stored bytes themselves fail to cover this
// snapshot.
raw := current.rawMessages
rawDigest, rawErr := digestSessionMessages(raw)
if rawErr != nil {
return RecoveryBranchInfo{}, rawErr
}
covered = bytes.Equal(rawDigest[:], digest[:]) ||
messagesHavePrefix(raw, msgs) ||
messagesHavePrefixWithCompatibleSystem(raw, msgs)
}
if covered {
return RecoveryBranchInfo{}, ErrSessionRecoveryNotNeeded
}
}
}
// Refuse to deepen a runaway chain: forking FROM a branch that is already
// at the depth cap only multiplies recovery files (#5993 reached 8 nested
// levels). The caller falls back to force-writing the branch it owns.
parentDepth := 0
if parentMeta, ok, metaErr := LoadBranchMeta(originalPath); metaErr == nil && ok && parentMeta.Recovered {
parentDepth = parentMeta.RecoveryDepth
if parentDepth <= 0 {
// Legacy recovery meta predating RecoveryDepth.
parentDepth = 1
}
}
if parentDepth >= SessionRecoveryMaxDepth && !shutdown {
return RecoveryBranchInfo{}, fmt.Errorf("%w: %s is already %d recovery forks deep",
ErrSessionRecoveryDepthExceeded, originalPath, parentDepth)
}
recoveryDepth := min(parentDepth+1,
// A shutdown copy is allowed even when the ordinary conflict chain is
// capped because losing the only in-memory transcript is worse than one
// additional branch. Keep the saturated depth so later ordinary saves
// still enforce the existing anti-cascade policy.
SessionRecoveryMaxDepth)
recoveryPath := recoverySessionPath(originalPath, digest)
if shutdown {
recoveryPath = shutdownRecoverySessionPath(originalPath, digest)
}
unlockRecovery := lockSessionSavePath(recoveryPath)
defer unlockRecovery()
unlockRecoveryFile, err := lockSessionFile(recoveryPath)
if err != nil {
return RecoveryBranchInfo{}, fmt.Errorf("lock recovery session file: %w", err)
}
defer unlockRecoveryFile()
if loaded, loadErr := loadSessionUnlocked(recoveryPath); loadErr == nil && loaded != nil {
existingDigest, digestErr := digestSessionMessages(loaded.Snapshot())
if digestErr != nil {
return RecoveryBranchInfo{}, digestErr
}
if bytes.Equal(existingDigest[:], digest[:]) {
meta, err := s.saveRecoveryBranchMeta(recoveryPath, opts, preview, turns, digestText, recoveryDepth)
if err != nil {
return RecoveryBranchInfo{}, err
}
s.markPersisted(recoveryPath, digest, version, meta.Revision, rewriteVersion)
return RecoveryBranchInfo{Path: recoveryPath, Digest: digestText, Existing: true, Meta: meta, Preview: preview, Turns: turns}, nil
}
} else if loadErr != nil && !os.IsNotExist(loadErr) {
return RecoveryBranchInfo{}, loadErr
}
if err := os.MkdirAll(filepath.Dir(recoveryPath), 0o755); err != nil {
return RecoveryBranchInfo{}, fmt.Errorf("create recovery session dir: %w", err)
}
// Log first, anchor second: a crash in between leaves the (authoritative)
// log holding the recovered transcript. A foreign file at the log path is
// left alone; the recovery stays checkpoint-only then.
recoveryProbe, err := probeSessionEventLog(recoveryPath)
if err != nil {
return RecoveryBranchInfo{}, err
}
if recoveryProbe.native {
if err := appendSessionReplaceEvent(recoveryPath, msgs, digest, 0, "recovery"); err != nil {
return RecoveryBranchInfo{}, err
}
}
if err := writeSessionMessages(recoveryPath, msgs); err != nil {
return RecoveryBranchInfo{}, err
}
meta, err := s.saveRecoveryBranchMeta(recoveryPath, opts, preview, turns, digestText, recoveryDepth)
if err != nil {
return RecoveryBranchInfo{}, err
}
if err := writeSessionEventIndex(recoveryPath, msgs, digest, meta.Revision); err != nil {
// The recovery transcript (log + checkpoint) and its meta are already
// durable; the index is only a listing accelerator. Failing here would
// discard a recovery that in fact succeeded and re-run the whole
// conflict path on the next save.
slog.Warn("session: keeping recovery branch after event index write failure",
"path", recoveryPath, "err", err)
}
s.markPersisted(recoveryPath, digest, version, meta.Revision, rewriteVersion)
return RecoveryBranchInfo{Path: recoveryPath, Digest: digestText, Meta: meta, Preview: preview, Turns: turns}, nil
}
func (s *Session) saveRecoveryBranchMeta(path string, opts RecoveryBranchOptions, preview string, turns int, digest string, depth int) (BranchMeta, error) {
meta := opts.BranchMeta
meta.ID = BranchID(path)
if strings.TrimSpace(meta.Name) == "" {
meta.Name = firstNonEmpty(strings.TrimSpace(opts.Name), RecoveryBranchDefaultName)
}
if strings.TrimSpace(meta.ParentID) == "" {
meta.ParentID = BranchID(opts.OriginalPath)
}
meta.ForkTurn = -1
meta.ForkMessageIndex = len(s.Snapshot())
meta.Preview = preview
meta.Turns = turns
meta.SchemaVersion = BranchMetaCountsVersion
meta.Recovered = true
meta.RecoveryReason = firstNonEmpty(strings.TrimSpace(opts.Reason), "session snapshot conflict")
meta.RecoveryDigest = digest
// Always stamped from the parent chain, never trusted from opts: callers
// copy tab/session meta wholesale and would carry a stale depth.
meta.RecoveryDepth = depth
if meta.Revision == 0 {
meta.Revision = 1
}
if strings.TrimSpace(meta.ContentDigest) == "" {
meta.ContentDigest = digest
}
if strings.TrimSpace(meta.WriterID) == "" {
meta.WriterID = SessionWriterID()
}
if err := SaveBranchMeta(path, meta); err != nil {
return BranchMeta{}, err
}
if stored, ok, err := LoadBranchMeta(path); err != nil {
return BranchMeta{}, err
} else if ok {
return stored, nil
}
return meta, nil
}
func recoverySessionPath(originalPath string, digest [sha256.Size]byte) string {
parent := recoveryParentStem(BranchID(originalPath))
return filepath.Join(filepath.Dir(originalPath), fmt.Sprintf("%s-recovery-%x.jsonl", parent, digest[:8]))
}
func shutdownRecoverySessionPath(originalPath string, digest [sha256.Size]byte) string {
parent := recoveryParentStem(BranchID(originalPath))
writerDigest := sha256.Sum256([]byte(SessionWriterID()))
return filepath.Join(filepath.Dir(originalPath),
fmt.Sprintf("%s-recovery-%x-%x.jsonl", parent, digest[:8], writerDigest[:6]))
}
func recoveryParentStem(parent string) string {
parent = strings.TrimSpace(parent)
if parent == "" {
return "session"
}
sum := sha256.Sum256([]byte(parent))
if before, _, ok := strings.Cut(parent, "-recovery-"); ok {
base := strings.Trim(before, "-_. ")
if base == "" {
base = "session"
}
base = strings.Trim(truncateUTF8Bytes(base, maxRecoveryParentStemBytes), "-_. ")
if base == "" {
base = "session"
}
return fmt.Sprintf("%s-%x", base, sum[:6])
}
if len(parent) <= maxRecoveryParentStemBytes {
return parent
}
prefix := strings.Trim(truncateUTF8Bytes(parent, maxRecoveryParentStemBytes), "-_. ")
if prefix == "" {
prefix = "session"
}
return fmt.Sprintf("%s-%x", prefix, sum[:6])
}
func truncateUTF8Bytes(s string, max int) string {
if max <= 0 {
return ""
}
if len(s) <= max {
return s
}
used := 0
for i, r := range s {
size := utf8.RuneLen(r)
if size < 0 {
size = 1
}
if used+size > max {
return s[:i]
}
used += size
}
return s
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
func (s *Session) ownsPersistedState(path string, existingDigest [sha256.Size]byte, existingRevision int64, existingLedgerDigest string, nextVersion uint64) bool {
state := s.persistState(path)
if !state.ok || state.version > nextVersion || !bytes.Equal(existingDigest[:], state.digest[:]) {
return false
}
// An unknown-revision baseline still owns the transcript it loaded — the
// digest+version match proves it. Requiring revision equality here would
// make every rewrite from such a baseline a permanent conflict, because
// the revision can only be re-learned by a successful save.
// A disk ledger with no recorded revision is the mirror case: recorded
// revisions start at 1, so revision 0 means the sidecar was deleted or
// rebuilt by a listing-only writer after this session's save. An absent
// claim cannot revoke the ownership the digest+version match proves.
if !state.revisionKnown || existingRevision == 0 || state.revision == existingRevision {
return true
}
// A foreign revision stamp whose recorded digest still describes these
// exact bytes (a same-content heal or no-op record by another runtime)
// vouches for no content of its own: the transcript is byte-for-byte what
// this session last persisted, so rewriting it destroys nothing of
// theirs — at worst the conflict moves to the stamper's next divergent
// save, where its in-memory history forks a recovery branch as usual.
// A stamp that disagrees with the on-disk transcript (or a legacy stamp
// with no digest) keeps revoking ownership: that is the aftermath of a
// save whose bytes and record split, the bytes cannot be attributed, and
// only the conservative conflict path preserves both sides.
return existingLedgerDigest == digestString(existingDigest)
}
// snapshotUpToDate reports whether a snapshot save to path is a provable
// no-op from in-memory bookkeeping alone: the last successful save went to
// this same path with a known ledger revision, the transcript version and
// rewrite version have not moved since, and no load-time repair or event-log
// damage is waiting to be persisted. Every one of these flags fails open —
// when any is unset or stale the caller falls through to the full save path,
// which re-derives the truth from disk.
func (s *Session) snapshotUpToDate(path string) bool {
key := canonicalSessionSavePath(path)
s.mu.RLock()
defer s.mu.RUnlock()
return s.persisted.ok &&
s.persisted.saveVerified &&
s.persisted.path == key &&
s.persisted.version == s.version &&
s.persisted.revisionKnown &&
s.rewriteVersion == s.persistedRewriteVersion &&
!s.normalizedDirty &&
!s.eventLogDamaged
}
func (s *Session) persistState(path string) sessionPersistState {
key := canonicalSessionSavePath(path)
s.mu.RLock()
defer s.mu.RUnlock()
if s.persisted.ok && s.persisted.path == key {
return s.persisted
}
return sessionPersistState{}
}
func (s *Session) markPersisted(path string, digest [sha256.Size]byte, version uint64, revision int64, rewriteVersion int) {
s.setPersistedBaseline(path, digest, version, revision, true, true, rewriteVersion)
}
// markPersistedFromLoad anchors the baseline a loader learned from disk. The
// ledger revision is real, but the pairing of transcript and ledger was not
// verified by a write — an interrupted earlier save can leave the ledger
// describing older content — so the baseline never arms the snapshot no-op
// fast path.
func (s *Session) markPersistedFromLoad(path string, digest [sha256.Size]byte, version uint64, revision int64, rewriteVersion int) {
s.setPersistedBaseline(path, digest, version, revision, true, false, rewriteVersion)
}
// markPersistedRevisionUnknown records a baseline whose ledger revision could
// not be learned because the meta sidecar was unreadable. The digest and
// version still anchor ownership checks; revision-based CAS stays disarmed
// until a successful save records the real revision via markPersisted.
func (s *Session) markPersistedRevisionUnknown(path string, digest [sha256.Size]byte, version uint64, rewriteVersion int) {
s.setPersistedBaseline(path, digest, version, 0, false, false, rewriteVersion)
}
func (s *Session) setPersistedBaseline(path string, digest [sha256.Size]byte, version uint64, revision int64, revisionKnown, saveVerified bool, rewriteVersion int) {
s.mu.Lock()
defer s.mu.Unlock()
s.persisted = sessionPersistState{
path: canonicalSessionSavePath(path),
digest: digest,
version: version,
revision: revision,
revisionKnown: revisionKnown,
saveVerified: saveVerified,
ok: true,
}
// rewriteVersion was captured together with the persisted snapshot; only
// move forward so a slower save that captured earlier cannot roll the
// baseline back below a rewrite a faster save already persisted.
if rewriteVersion > s.persistedRewriteVersion {
s.persistedRewriteVersion = rewriteVersion
}
if saveVerified {
// A completed save landed the current transcript — including any
// load-time normalization repair — and healed the on-disk event log
// (tail repair runs on every save; a damaged log forces the
// rewrite-and-compact shape). Leaving these flags set would disarm
// the snapshot no-op fast path for the rest of the process lifetime,
// so a session that was repaired once kept paying a full serialize +
// digest on every defensive snapshot. Nothing reads the live
// session's copies after a save: checkSnapshotWrite re-loads the
// on-disk state and consults that object's flags, not these.
s.normalizedDirty = false
s.rawMessages = nil
s.eventLogDamaged = false
}
}
// sessionContentRevision reads the CAS ledger (revision + content digest) from
// the branch-meta sidecar. A missing sidecar is revision 0 — a session that
// has never recorded one. An unreadable sidecar is an error: reporting it as
// revision 0 would desync every runtime baseline from the ledger and turn the
// next honest save into a bogus conflict (and a recovery branch).
func sessionContentRevision(path string) (int64, string, error) {
meta, ok, err := loadBranchMetaRetry(path)
if err != nil {
return 0, "", err
}
if !ok {
return 0, "", nil
}
return meta.Revision, strings.TrimSpace(meta.ContentDigest), nil
}
func recordSessionContentRevision(path string, digest [sha256.Size]byte, baseRevision int64) (int64, error) {
meta, ok, err := loadBranchMetaRetry(path)
if err != nil {
// Fail the save instead of rebuilding the ledger from a bad read: the
// transcript bytes already landed, so a later save can record the
// revision once the sidecar reads cleanly again — a content-bearing
// save lands here again, and a same-content retry heals through the
// up-to-date ledgerStale path in save().
return 0, err
}
if !ok {
meta = BranchMeta{ID: BranchID(path)}
}
if meta.Revision < baseRevision {
meta.Revision = baseRevision
}
meta.Revision++
meta.ContentDigest = digestString(digest)
meta.WriterID = SessionWriterID()
if err := SaveBranchMetaPreserveUpdated(path, meta); err != nil {
return 0, err
}
stored, ok, err := loadBranchMetaRetry(path)
if err != nil {
return 0, err
}
if ok && stored.Revision > 0 {
return stored.Revision, nil
}
return meta.Revision, nil
}
func digestString(digest [sha256.Size]byte) string {
return fmt.Sprintf("%x", digest[:])
}
func SessionWriterID() string {
return sessionWriterID
}
func newSessionWriterID() string {
host, _ := os.Hostname()
host = strings.TrimSpace(host)
if host == "" {
host = "unknown-host"
}
host = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_', r == '.':
return r
default:
return '-'
}
}, host)
var nonce [8]byte
if _, err := rand.Read(nonce[:]); err != nil {
return fmt.Sprintf("%s-%d-%d", host, os.Getpid(), time.Now().UnixNano())
}
return fmt.Sprintf("%s-%d-%x", host, os.Getpid(), nonce[:])
}
func digestSessionMessages(msgs []provider.Message) ([sha256.Size]byte, error) {
digest, _, err := digestAndSizeSessionMessages(msgs)
return digest, err
}
func messageForSessionIdentity(m provider.Message) provider.Message {
// CreatedAt is local display metadata. Keep it out of transcript identity
// so older builds that ignore the optional field can share the same event-
// log revision and append without false conflicts.
m.CreatedAt = 0
return m
}
// digestAndSizeSessionMessages also reports the encoded transcript size, which
// the save path uses to bound the event log relative to the live content.
func digestAndSizeSessionMessages(msgs []provider.Message) ([sha256.Size]byte, int64, error) {
h := sha256.New()
size := int64(0)
for _, m := range msgs {
m = messageForSessionIdentity(m)
b, err := json.Marshal(m)
if err != nil {
return [sha256.Size]byte{}, 0, err
}
if _, err := h.Write(b); err != nil {
return [sha256.Size]byte{}, 0, err
}
if _, err := h.Write([]byte{'\n'}); err != nil {
return [sha256.Size]byte{}, 0, err
}
size += int64(len(b)) + 1
}
var out [sha256.Size]byte
copy(out[:], h.Sum(nil))
return out, size, nil
}
func messagesHavePrefix(full, prefix []provider.Message) bool {
if len(prefix) > len(full) {
return false
}
for i := range prefix {
if !messagesEqualForStorage(full[i], prefix[i]) {
return false
}
}
return true
}
// messagesPrefixDigestDepth returns the number of leading messages of msgs
// whose storage digest equals target, or -1 when no prefix matches. The
// digest accumulates exactly like digestAndSizeSessionMessages, so a match at
// depth k means msgs[:k] has the same transcript identity as target.
func messagesPrefixDigestDepth(msgs []provider.Message, target [sha256.Size]byte) int {
h := sha256.New()
sum := make([]byte, 0, sha256.Size)
for i, m := range msgs {
m = messageForSessionIdentity(m)
b, err := json.Marshal(m)
if err != nil {
return -1
}
h.Write(b)
h.Write([]byte{'\n'})
sum = h.Sum(sum[:0])
if bytes.Equal(sum, target[:]) {
return i + 1
}
}
return -1
}
// appendCoversPersistedBaseline reports whether an append-shaped write (disk
// transcript a prefix of next, modulo a compatible leading-system swap) still
// covers everything this session ever persisted: the baseline digest must be
// reachable as a prefix of the pending snapshot, and the disk transcript must
// still extend at least to that depth. A shorter disk transcript means some
// other runtime deliberately rewound below the baseline — appending over it
// would resurrect the removed suffix, so the caller must conflict instead.
func appendCoversPersistedBaseline(next, existing []provider.Message, baseDigest [sha256.Size]byte) bool {
depth := messagesPrefixDigestDepth(next, baseDigest)
if depth < 0 && len(next) > 0 && len(existing) > 0 &&
next[0].Role == provider.RoleSystem && existing[0].Role == provider.RoleSystem &&
!messagesEqualForStorage(next[0], existing[0]) {
// A resume that swapped the system prompt persisted its baseline with
// the previous system message — the one still on disk. Re-anchor the
// search on that message so the swap alone doesn't hide the baseline.
variant := append([]provider.Message{existing[0]}, next[1:]...)
depth = messagesPrefixDigestDepth(variant, baseDigest)
}
return depth >= 0 && len(existing) >= depth
}
func messagesHavePrefixWithCompatibleSystem(full, prefix []provider.Message) bool {
full = messagesWithoutLeadingSystem(full)
prefix = messagesWithoutLeadingSystem(prefix)
return messagesHavePrefix(full, prefix)
}
func messagesWithoutLeadingSystem(msgs []provider.Message) []provider.Message {
if len(msgs) > 0 && msgs[0].Role == provider.RoleSystem {
return msgs[1:]
}
return msgs
}
func messagesEqualForStorage(a, b provider.Message) bool {
a = messageForSessionIdentity(a)
b = messageForSessionIdentity(b)
ab, err := json.Marshal(a)
if err != nil {
return false
}
bb, err := json.Marshal(b)
if err != nil {
return false
}
return bytes.Equal(ab, bb)
}
func messagesEqualForStorageList(a, b []provider.Message) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if !messagesEqualForStorage(a[i], b[i]) {
return false
}
}
return true
}
func messagesCompatibleForStorageBaseline(a, b []provider.Message) bool {
if messagesEqualForStorageList(a, b) {
return true
}
return messagesEqualForStorageList(messagesWithoutLeadingSystem(a), messagesWithoutLeadingSystem(b))
}
func lockSessionSavePath(path string) func() {
key := canonicalSessionSavePath(path)
v, _ := sessionSaveLocks.LoadOrStore(key, &sync.Mutex{})
mu := v.(*sync.Mutex)
mu.Lock()
return mu.Unlock
}
// lockSessionFile waits briefly for the cross-process compatibility save lock.
// A short overlap with a legitimate writer is allowed to settle, but an
// stalled or indefinitely held lock fails the save instead of freezing tab
// switching or application shutdown. The caller keeps its in-memory transcript
// and can retry through the existing autosave/recovery paths.
func lockSessionFile(path string) (func(), error) {
wait := sessionFileLockWait
poll := sessionFileLockPollInterval
if poll <= 0 {
poll = time.Millisecond
}
deadline := time.Now().Add(wait)
for {
unlock, err := tryLockSessionFile(path)
if err == nil {
return unlock, nil
}
if !errors.Is(err, ErrSessionFileLockHeld) {
return nil, err
}
remaining := time.Until(deadline)
if wait <= 0 || remaining <= 0 {
return nil, ErrSessionFileLockHeld
}
if poll > remaining {
poll = remaining
}
time.Sleep(poll)
}
}
// LockSessionMetaPath serializes a read-modify-write cycle on a session's
// sidecar metadata with every other writer in this process (Save, the
// UpdateSessionMeta family). Callers outside this package that load, mutate,
// and re-save branch meta must hold it for the whole cycle.
func LockSessionMetaPath(path string) func() {
return lockSessionSavePath(path)
}
func canonicalSessionSavePath(path string) string {
key := filepath.Clean(strings.TrimSpace(path))
if abs, err := filepath.Abs(key); err == nil {
key = abs
}
// Resolve physical identity, not just spelling. Otherwise a symlink or
// junction alias can acquire a second sidecar lock for the same transcript.
key = resolvePathThroughExistingAncestor(key)
if runtime.GOOS == "windows" {
if strings.HasPrefix(strings.ToUpper(key), `\\?\UNC\`) {
key = `\\` + key[len(`\\?\UNC\`):]
} else {
key = strings.TrimPrefix(key, `\\?\`)
}
key = strings.ToLower(key)
}
return key
}
// resolvePathThroughExistingAncestor resolves the deepest existing ancestor
// and appends every still-missing component. Fresh sessions can be nested under
// directories that have not been created yet; resolving only the immediate
// parent leaves aliases above that directory split into different lease keys.
func resolvePathThroughExistingAncestor(path string) string {
current := filepath.Clean(path)
missing := make([]string, 0, 4)
for {
if resolved, err := filepath.EvalSymlinks(current); err == nil {
for _, v := range slices.Backward(missing) {
resolved = filepath.Join(resolved, v)
}
return resolved
}
parent := filepath.Dir(current)
if parent == current {
return path
}
missing = append(missing, filepath.Base(current))
current = parent
}
}
// CanonicalSessionPath is the identity key of a session path: cleaned,
// absolute, and case-folded on Windows, matching the key form used by the
// lease registry and the save-path locks. Any runtime bookkeeping that
// compares or maps session paths (desktop tabs, detached runtimes) must use
// this exact form, or the same file splits into distinct keys — e.g.
// `C:\Users\...` vs the lease's lowercased `c:\users\...`. Empty input stays
// empty instead of resolving to the working directory.
func CanonicalSessionPath(path string) string {
if strings.TrimSpace(path) == "" {
return ""
}
return canonicalSessionSavePath(path)
}
// LoadSession reads a saved session into a fresh Session value. New sessions
// replay the append-only event log; legacy sessions without an event log fall
// back to the compatibility .jsonl checkpoint. A damaged log is replayed to its
// last clean record (or the checkpoint when nothing decodes) and flagged so the
// next save heals it with a rewrite-and-compact.
// In-process loads share the save path mutex so they cannot observe a local
// SaveSnapshot between appending an event-log record and refreshing the index.
// Missing files surface as os.IsNotExist so callers can fall through to a
// new session.
func LoadSession(path string) (*Session, error) {
unlock := lockSessionSavePath(path)
defer unlock()
return loadSessionUnlocked(path)
}
func loadSessionUnlocked(path string) (*Session, error) {
msgs, _, damaged, err := loadSessionMessages(path)
if err != nil {
return nil, err
}
s := &Session{Messages: msgs, eventLogDamaged: damaged}
// Repair persisted-history-safe issues before anything reads the session.
// Old sessions (pre adde2d3e) and interrupted turns can carry empty tool-call
// names, dangling tool_calls, or half-streamed argument JSON that DeepSeek
// rejects with a 400 on replay. Wire-only cleanup, such as dropping orphan
// tool messages, stays in the provider send path so Save/LoadSession keeps
// its round-trip contract. The fast path returns the input slice unchanged
// for a well-formed history, so we detect an actual repair by comparing
// slice headers: when NormalizeSession allocated a new backing array, the
// session is marked dirty so the next Save persists the fix.
normalized := NormalizeSession(s.Messages)
normalized = migrateLegacyProviderContent(normalized)
if len(normalized) != len(s.Messages) || (len(s.Messages) > 0 && &normalized[0] != &s.Messages[0]) {
s.normalizedDirty = true
// Keep the pre-repair transcript: checkSnapshotWrite must be able to
// recognize a snapshot that extends the bytes actually on disk, which
// the repaired view no longer represents (an interrupted tool turn
// gets a placeholder result fabricated here that the live session
// answered for real).
s.rawMessages = msgs
}
s.Messages = normalized
if digest, err := digestSessionMessages(s.Messages); err == nil {
if meta, ok, metaErr := loadBranchMetaRetry(path); metaErr != nil {
// The sidecar exists but is unreadable even after retries (torn or
// corrupt). The session must still open, but revision 0 must not
// pose as a real baseline: the next save would misread the honest
// on-disk revision as another runtime's write and fork a recovery
// branch. Anchor the baseline on digest+version only until a
// successful save re-learns the revision.
s.markPersistedRevisionUnknown(path, digest, s.version, s.rewriteVersion)
} else {
revision := int64(0)
if ok {
revision = meta.Revision
}
s.markPersistedFromLoad(path, digest, s.version, revision, s.rewriteVersion)
}
}
return s, nil
}
// SessionInfo summarises a saved session for the --resume picker: where it is on
// disk, when it was created/last active, the first user message as a preview, and
// a rough turn count.
type SessionInfo struct {
Path string
CreatedAt time.Time
LastActivityAt time.Time
ModTime time.Time // compatibility alias for LastActivityAt
Preview string
Turns int
Scope string
WorkspaceRoot string
TopicID string
TopicTitle string
CustomTitle string
Recovered bool
RecoveryReason string
RecoveryDigest string
ParentID string
}
// SessionOrderInfo is the lightweight sidecar/mtime ordering record shared by
// session pickers and prompt-history navigation. It intentionally avoids reading
// JSONL content; callers that need previews can layer that on afterwards.
type SessionOrderInfo struct {
Path string
CreatedAt time.Time
LastActivityAt time.Time
ModTime time.Time // compatibility alias for LastActivityAt
Scope string
WorkspaceRoot string
TopicID string
TopicTitle string
CustomTitle string
Recovered bool
RecoveryReason string
RecoveryDigest string
ParentID string
// Turns and Preview are the cached listing fields from the sidecar; SchemaVersion
// >= agent.BranchMetaCountsVersion means they were recorded from content and can
// be trusted (even Turns == 0). ListSessions uses them to skip the whole-file decode.
Turns int
Preview string
SchemaVersion int
}
// CleanupPendingMeta records that a session was logically removed but still has
// artifacts waiting for a background job to unwind before physical cleanup.
type CleanupPendingMeta struct {
Operation string `json:"operation"`
CreatedAt int64 `json:"createdAt"`
}
// CleanupPendingInfo describes one durable delayed-cleanup marker and the
// session transcript it belongs to.
type CleanupPendingInfo struct {
SessionPath string
MarkerPath string
Meta CleanupPendingMeta
}
// CleanupPendingPath returns the durable marker path for a session transcript.
func CleanupPendingPath(sessionPath string) string {
return store.SessionCleanupPending(sessionPath)
}
// MarkCleanupPending hides a logically removed session from resume/list surfaces
// until delayed physical cleanup has finished.
func MarkCleanupPending(sessionPath, operation string) error {
path := CleanupPendingPath(sessionPath)
if path == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
meta := CleanupPendingMeta{Operation: strings.TrimSpace(operation), CreatedAt: time.Now().UnixMilli()}
b, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
return os.WriteFile(path, b, 0o644)
}
// ClearCleanupPending removes a delayed-cleanup marker after physical cleanup.
func ClearCleanupPending(sessionPath string) error {
path := CleanupPendingPath(sessionPath)
if path == "" {
return nil
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// IsCleanupPending reports whether a session is hidden pending delayed cleanup.
func IsCleanupPending(sessionPath string) bool {
path := CleanupPendingPath(sessionPath)
if path == "" {
return false
}
_, err := os.Stat(path)
return err == nil
}
// IsVisibleSession reports whether a persisted session should appear on normal
// user/agent-facing list, restore, and retrieval surfaces.
func IsVisibleSession(sessionPath string) bool {
return strings.TrimSpace(sessionPath) != "" && !IsCleanupPending(sessionPath)
}
// ListCleanupPending returns delayed-cleanup markers left in dir. A missing
// directory is not an error.
func ListCleanupPending(dir string) ([]CleanupPendingInfo, error) {
dir = strings.TrimSpace(dir)
if dir == "" {
return nil, nil
}
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []CleanupPendingInfo
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), cleanupPendingExt) {
continue
}
markerPath := filepath.Join(dir, e.Name())
var meta CleanupPendingMeta
b, err := fileencoding.ReadFileUTF8(markerPath)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
if strings.TrimSpace(string(b)) != "" {
if err := json.Unmarshal(b, &meta); err != nil {
return nil, fmt.Errorf("read cleanup-pending marker %s: %w", markerPath, err)
}
}
name := strings.TrimSuffix(e.Name(), cleanupPendingExt) + ".jsonl"
out = append(out, CleanupPendingInfo{
SessionPath: filepath.Join(dir, name),
MarkerPath: markerPath,
Meta: meta,
})
}
sort.Slice(out, func(i, j int) bool {
return out[i].SessionPath < out[j].SessionPath
})
return out, nil
}
// ReconcileCleanupPending retries physical cleanup for leftover delayed-cleanup
// markers and stale lock/lease sidecars. It keeps going after individual
// cleanup errors and returns them joined.
func ReconcileCleanupPending(dir string, cleanup func(CleanupPendingInfo) error) error {
var errs []error
if err := ReconcileSessionSidecars(dir); err != nil {
errs = append(errs, err)
}
if err := reconcileRecoveryTrashStages(dir); err != nil {
errs = append(errs, err)
}
pending, err := ListCleanupPending(dir)
if err != nil {
errs = append(errs, err)
return errors.Join(errs...)
}
for _, item := range pending {
handled, err := reconcileRecoveryTrashPending(item)
if !handled {
if cleanup == nil {
continue
}
err = cleanup(item)
}
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", item.SessionPath, err))
}
}
return errors.Join(errs...)
}
// ReconcileSessionSidecars renames transcripts whose filenames outgrew their
// sidecars and removes stale lock and lease files left beside sessions by
// older runtimes. It never removes .jsonl transcripts; recovered conversations
// may contain useful user history even when their names are ugly.
func ReconcileSessionSidecars(dir string) error {
dir = strings.TrimSpace(dir)
if dir == "" {
return nil
}
var errs []error
if err := reconcileOverlongSessionFilenames(dir); err != nil {
errs = append(errs, err)
}
// Re-list after the rename pass: it retires old names and their sidecars.
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return errors.Join(errs...)
}
errs = append(errs, err)
return errors.Join(errs...)
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
sidecarPath := filepath.Join(dir, name)
switch {
case strings.HasSuffix(name, sessionLeaseInfoSidecarSuffix):
base := filepath.Join(dir, strings.TrimSuffix(name, ".lease.json"))
if err := removeStaleSessionLeaseInfoSidecar(base, sidecarPath); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", sidecarPath, err))
}
case strings.HasSuffix(name, sessionLeaseLockSidecarSuffix):
base := filepath.Join(dir, strings.TrimSuffix(name, ".lease.lock"))
if err := removeStaleSessionLeaseLockSidecar(base, sidecarPath); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", sidecarPath, err))
}
case strings.HasSuffix(name, sessionLockSidecarSuffix):
base := filepath.Join(dir, strings.TrimSuffix(name, ".lock"))
if err := removeStaleSessionLockSidecar(base, sidecarPath); err != nil {
errs = append(errs, fmt.Errorf("%s: %w", sidecarPath, err))
}
}
}
return errors.Join(errs...)
}
func removeStaleSessionLockSidecar(basePath, sidecarPath string) error {
basePath = canonicalSessionSavePath(basePath)
if sessionLeaseHeldLocally(basePath) || SessionLeaseHeldByOtherRuntime(basePath) {
return nil
}
lock, err := tryTakeSessionLockFile(sidecarPath)
if err != nil {
if errors.Is(err, ErrSessionFileLockHeld) {
return nil
}
return err
}
// The removal is atomic with the release (unlink-under-flock on Unix,
// delete-disposition on the held handle on Windows), so a concurrent
// saver can never acquire a lock file that is being deleted under it.
return lock.RemoveAndUnlock()
}
// removeStaleSessionLeaseLockSidecar retires a leftover .lease.lock. The file
// is the lease lock itself, so taking it non-blocking proves no runtime holds
// the lease, and RemoveAndUnlock deletes it atomically with the release.
func removeStaleSessionLeaseLockSidecar(basePath, sidecarPath string) error {
basePath = canonicalSessionSavePath(basePath)
if sessionLeaseHeldLocally(basePath) {
return nil
}
lock, err := tryTakeSessionLockFile(sidecarPath)
if err != nil {
if errors.Is(err, ErrSessionFileLockHeld) {
return nil
}
return err
}
return lock.RemoveAndUnlock()
}
// removeStaleSessionLeaseInfoSidecar retires a leftover .lease.json while
// holding the lease lock, so no runtime can adopt the info file mid-removal.
// The info file itself is never held open by anyone, so a plain remove under
// the lock is safe on every platform.
func removeStaleSessionLeaseInfoSidecar(basePath, sidecarPath string) error {
basePath = canonicalSessionSavePath(basePath)
if sessionLeaseHeldLocally(basePath) {
return nil
}
lockPath := basePath + ".lease.lock"
if _, err := os.Stat(lockPath); err == nil {
unlock, err := tryLockSessionLeaseFile(basePath)
if err != nil {
if errors.Is(err, ErrSessionLeaseHeld) {
return nil
}
return err
}
removeErr := os.Remove(sidecarPath)
if unlock != nil {
unlock()
}
if removeErr != nil && !os.IsNotExist(removeErr) {
return removeErr
}
return nil
} else if !os.IsNotExist(err) {
return err
}
// No lease lock file: holders keep it present (and locked) for their whole
// lifetime, so the leftover info sidecar has no owner to race with.
if err := os.Remove(sidecarPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func sessionLeaseHeldLocally(path string) bool {
_, ok := sessionLeaseOwners.Load(canonicalSessionSavePath(path))
return ok
}
// sessionLockSidecarFits reports whether basePath's .lock sidecar name stays
// within the filesystem's per-component limit; past it, no process can hold
// (or ever have held) the file lock, because the lock file cannot be created.
func sessionLockSidecarFits(basePath string) bool {
return len(filepath.Base(basePath))+len(".lock") <= nameMaxBytes
}
// sessionLeaseSidecarFits is the lease-file analogue of sessionLockSidecarFits.
func sessionLeaseSidecarFits(basePath string) bool {
return len(filepath.Base(basePath))+len(".lease.lock") <= nameMaxBytes
}
// reconcileOverlongSessionFilenames renames transcripts whose basenames grew
// past maxSessionBasenameBytes — the leftover shape of the pre-bounded
// recovery cascade (#5923), where lock and lease sidecars could no longer be
// created and the session became unsaveable. The conversation bytes are kept
// verbatim under a bounded name derived the same way new recovery branches
// are named; branch meta moves along with its ID rewritten, and sessions
// pointing at the old ID are re-parented so lineage survives the rename.
func reconcileOverlongSessionFilenames(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
var errs []error
renamed := map[string]string{} // old branch ID -> new branch ID
for _, e := range entries {
name := e.Name()
if e.IsDir() || !store.IsSessionTranscriptName(name) {
continue
}
if len(name) <= maxSessionBasenameBytes {
continue
}
oldPath := filepath.Join(dir, name)
if IsCleanupPending(oldPath) {
// Being deleted; renaming would orphan the cleanup marker.
continue
}
newID, err := renameOverlongSession(oldPath)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", oldPath, err))
}
// A non-empty newID means the transcript rename landed even if some
// sidecar migration failed; record it so children still re-parent —
// this run is the only one that knows the old-to-new mapping.
if newID != "" {
renamed[BranchID(oldPath)] = newID
}
}
if len(renamed) > 0 {
if err := reparentSessionBranches(dir, renamed); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// renameOverlongSession moves one overlong transcript to its bounded name and
// migrates the sidecars that carry user state. It returns the new branch ID,
// or "" when the session was skipped because a runtime may still own it.
func renameOverlongSession(oldPath string) (string, error) {
oldID := BranchID(oldPath)
newID := recoveryParentStem(oldID)
if newID == oldID {
return "", nil
}
newPath := filepath.Join(filepath.Dir(oldPath), newID+".jsonl")
if _, err := os.Stat(newPath); err == nil {
return "", fmt.Errorf("rename target %s already exists", filepath.Base(newPath))
} else if !os.IsNotExist(err) {
return "", err
}
unlockOld := lockSessionSavePath(oldPath)
defer unlockOld()
unlockNew := lockSessionSavePath(newPath)
defer unlockNew()
if sessionLeaseHeldLocally(oldPath) {
return "", nil
}
// Names past the sidecar limit cannot have lease or lock holders in any
// process — the holder files themselves are uncreatable — so probing them
// would only manufacture ENAMETOOLONG errors and wrongly skip the exact
// sessions this pass exists to repair.
if sessionLeaseSidecarFits(oldPath) && SessionLeaseHeldByOtherRuntime(oldPath) {
return "", nil
}
var lockFile *sessionLockFile
if sessionLockSidecarFits(oldPath) {
lock, err := tryTakeSessionLockFile(oldPath + ".lock")
if err != nil {
if errors.Is(err, ErrSessionFileLockHeld) {
return "", nil
}
return "", err
}
lockFile = lock
}
if err := os.Rename(oldPath, newPath); err != nil {
// Nothing moved: the old transcript is intact and the next
// reconciliation can retry, so its lock file stays in place too.
if lockFile != nil {
lockFile.Unlock()
}
return "", err
}
// The transcript is committed under its new name from here on. Sidecar
// migration and lock cleanup failures are reported, but the new ID is
// still returned so the caller re-parents children: the old name is gone,
// and a later run would have no way to reconstruct this mapping.
var errs []error
if err := migrateSessionSidecars(oldPath, newPath, newID); err != nil {
errs = append(errs, err)
}
// Retire the old disposable lease sidecars: any holder was ruled out
// above, and nothing keeps these files open, so a plain remove is safe.
if sessionLeaseSidecarFits(oldPath) {
for _, stale := range []string{oldPath + ".lease.lock", oldPath + ".lease.json"} {
if err := os.Remove(stale); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
}
// The old .lock goes atomically with the release of the lock we hold on it.
if lockFile != nil {
if err := lockFile.RemoveAndUnlock(); err != nil {
errs = append(errs, err)
}
}
return newID, errors.Join(errs...)
}
// migrateSessionSidecars moves the user-state sidecars of a renamed session:
// branch meta (with its ID rewritten to match the new filename), goal state,
// and the checkpoint/job directories. Lock and lease files are disposable and
// are removed by the caller instead.
func migrateSessionSidecars(oldPath, newPath, newID string) error {
var errs []error
if len(filepath.Base(oldPath))+len(".meta") <= nameMaxBytes {
if meta, ok, err := LoadBranchMeta(oldPath); err != nil {
errs = append(errs, err)
} else if ok {
meta.ID = newID
if err := SaveBranchMetaPreserveUpdated(newPath, meta); err != nil {
errs = append(errs, err)
} else if err := os.Remove(BranchMetaPath(oldPath)); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
}
for _, pair := range [][2]string{
{store.SessionGoalState(oldPath), store.SessionGoalState(newPath)},
{store.SessionEventLog(oldPath), store.SessionEventLog(newPath)},
{store.SessionEventLogDamaged(oldPath), store.SessionEventLogDamaged(newPath)},
{store.SessionEventIndex(oldPath), store.SessionEventIndex(newPath)},
{store.SessionConflictLog(oldPath), store.SessionConflictLog(newPath)},
{store.SessionRecoveryState(oldPath), store.SessionRecoveryState(newPath)},
{store.SessionCheckpointDir(oldPath), store.SessionCheckpointDir(newPath)},
{store.SessionJobsDir(oldPath), store.SessionJobsDir(newPath)},
} {
// A source name past the filesystem limit cannot exist; renaming it
// would just manufacture ENAMETOOLONG instead of a clean not-exist.
if len(filepath.Base(pair[0])) > nameMaxBytes {
continue
}
if err := os.Rename(pair[0], pair[1]); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// reparentSessionBranches rewrites ParentID references from renamed branch IDs
// to their bounded replacements so the branch tree stays connected.
func reparentSessionBranches(dir string, renamed map[string]string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
var errs []error
for _, e := range entries {
name := e.Name()
if e.IsDir() || !store.IsSessionTranscriptName(name) {
continue
}
if len(name)+len(".meta") > nameMaxBytes {
continue
}
path := filepath.Join(dir, name)
unlock := lockSessionSavePath(path)
meta, ok, err := LoadBranchMeta(path)
if err == nil && ok {
if newParent, hit := renamed[meta.ParentID]; hit && newParent != meta.ParentID {
meta.ParentID = newParent
err = SaveBranchMetaPreserveUpdated(path, meta)
}
}
unlock()
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", path, err))
}
}
return errors.Join(errs...)
}
// ListSessionOrder returns every *.jsonl session under dir in the same
// most-recently-active order used by ListSessions, using only file metadata and
// branch sidecars. A missing directory is not an error.
func ListSessionOrder(dir string) ([]SessionOrderInfo, error) {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
var out []SessionOrderInfo
for _, e := range entries {
if e.IsDir() || !store.IsSessionTranscriptName(e.Name()) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
full := filepath.Join(dir, e.Name())
if !IsVisibleSession(full) {
continue
}
contentMod := SessionContentModTime(full)
if contentMod.IsZero() {
contentMod = info.ModTime()
}
createdAt := info.ModTime()
lastActivityAt := contentMod
scope := "global"
workspaceRoot := ""
topicID := ""
topicTitle := ""
customTitle := ""
recovered := false
recoveryReason := ""
recoveryDigest := ""
parentID := ""
turns := 0
preview := ""
schemaVersion := 0
if meta, ok, err := LoadBranchMeta(full); err == nil && ok {
if !meta.CreatedAt.IsZero() {
createdAt = meta.CreatedAt
}
if !meta.UpdatedAt.IsZero() {
lastActivityAt = meta.UpdatedAt
}
scope = meta.DefaultScope()
workspaceRoot = meta.WorkspaceRoot
topicID = meta.TopicID
topicTitle = meta.TopicTitle
customTitle = meta.CustomTitle
recovered = meta.Recovered
recoveryReason = meta.RecoveryReason
recoveryDigest = meta.RecoveryDigest
parentID = meta.ParentID
turns = meta.Turns
preview = meta.Preview
schemaVersion = meta.SchemaVersion
}
out = append(out, SessionOrderInfo{
Path: full,
CreatedAt: createdAt,
LastActivityAt: lastActivityAt,
ModTime: lastActivityAt,
Scope: scope,
WorkspaceRoot: workspaceRoot,
TopicID: topicID,
TopicTitle: topicTitle,
CustomTitle: customTitle,
Recovered: recovered,
RecoveryReason: recoveryReason,
RecoveryDigest: recoveryDigest,
ParentID: parentID,
Turns: turns,
Preview: preview,
SchemaVersion: schemaVersion,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].LastActivityAt.Equal(out[j].LastActivityAt) {
return out[i].Path < out[j].Path
}
return out[i].LastActivityAt.After(out[j].LastActivityAt)
})
return out, nil
}
// ListSessions returns every non-empty *.jsonl session under dir,
// most-recently-active first, each with a preview line so the picker can show
// something the user recognises. A missing directory is not an error — it just
// means there's nothing to resume yet.
func ListSessions(dir string) ([]SessionInfo, error) {
ordered, err := ListSessionOrder(dir)
if err != nil {
return nil, err
}
var out []SessionInfo
for _, session := range ordered {
preview, turns := session.Preview, session.Turns
if session.SchemaVersion < BranchMetaCountsVersion {
// The sidecar's counts weren't recorded from content (a legacy session
// from before they were persisted). Decode the .jsonl once, then backfill
// + stamp the sidecar so every later listing is O(1) — and so a genuinely
// empty session is recorded once instead of being re-decoded forever.
preview, turns = previewSession(session.Path)
// Best-effort: a failure here just means we decode again next time.
_ = UpdateSessionMeta(session.Path, "", preview, turns, false)
}
if turns == 0 {
// Never had user interaction — an empty conversation that should not
// appear in the history panel or the resume picker.
continue
}
out = append(out, SessionInfo{
Path: session.Path,
CreatedAt: session.CreatedAt,
LastActivityAt: session.LastActivityAt,
ModTime: session.ModTime,
Preview: preview,
Turns: turns,
Scope: session.Scope,
WorkspaceRoot: session.WorkspaceRoot,
TopicID: session.TopicID,
TopicTitle: session.TopicTitle,
CustomTitle: session.CustomTitle,
Recovered: session.Recovered,
RecoveryReason: session.RecoveryReason,
RecoveryDigest: session.RecoveryDigest,
ParentID: session.ParentID,
})
}
return out, nil
}
// SessionPreview returns the same preview and user-turn count used by
// ListSessions for one session file.
func SessionPreview(path string) (string, int) {
return previewSession(path)
}
// SessionPreviewFromMessages computes the same preview line and user-turn count
// as previewSession, but from an in-memory message slice. Session.Save writes
// exactly these messages to the .jsonl, so this is byte-for-byte equivalent to
// decoding the file — letting the autosave path persist the counts into the
// sidecar without a disk read.
func SessionPreviewFromMessages(msgs []provider.Message) (string, int) {
first := ""
turns := 0
for _, m := range msgs {
if m.Role == provider.RoleUser && IsUserAuthoredTurn(UserMessageText(m)) {
turns++
if first == "" {
first = truncatePreview(previewProse(UserMessageText(m)))
}
}
}
return first, turns
}
// previewSession returns the first user message (truncated) and the number of
// user-role messages so the picker can show "5 turns · 'help me debug the…'".
// Errors are swallowed — a malformed file just shows up with an empty preview.
func previewSession(path string) (string, int) {
msgs, _, _, err := loadSessionMessages(path)
if err != nil {
return "", 0
}
first := ""
turns := 0
for _, m := range msgs {
if m.Role == provider.RoleUser && IsUserAuthoredTurn(UserMessageText(m)) {
turns++
if first == "" {
first = truncatePreview(previewProse(UserMessageText(m)))
}
}
}
return first, turns
}
// previewProse drops the leading @file references a prompt opens with so the
// preview shows what was asked rather than a row of paths. A prompt that is
// nothing but references keeps them — there is nothing else to show.
func previewProse(s string) string {
rest := strings.TrimLeft(s, " \t")
for strings.HasPrefix(rest, "@") {
end := strings.IndexAny(rest, " \t\r\n")
if end < 0 {
return s
}
next := strings.TrimLeft(rest[end:], " \t")
if strings.TrimSpace(next) == "" {
return s
}
rest = next
}
if rest == "" {
return s
}
return rest
}
// truncatePreview clamps a preview line to 80 runes with an ellipsis, matching
// what the pickers render.
func truncatePreview(s string) string {
if r := []rune(s); len(r) > 80 {
return string(r[:77]) + "…"
}
return s
}
// ContinueSessionPath returns where a conversation carried into a rebuilt
// controller (model switch, config change) should keep auto-saving: its existing
// file when it has one, so the continued session stays a single file instead of
// the old one being orphaned as an identical duplicate (#2807). A session with no
// file yet gets a fresh path; "" when persistence is disabled.
func ContinueSessionPath(prevPath, dir, model string) string {
if prevPath != "" {
return prevPath
}
if dir == "" {
return ""
}
return NewSessionPath(dir, model)
}
// NewSessionPath returns the path to use for a fresh session, namespaced by
// the model so the filename hints at what the conversation was with. dir is
// typically config.SessionDir().
func NewSessionPath(dir, model string) string {
safe := strings.NewReplacer("/", "-", "\\", "-", ":", "-", "<", "-", ">", "-", "\"", "-", "|", "-", "?", "-", "*", "-").Replace(model)
if safe == "" {
safe = "session"
}
return filepath.Join(dir, fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405.000000000"), safe))
}