mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
1982 lines
74 KiB
Go
1982 lines
74 KiB
Go
package sessionstore
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"reasonix/internal/base/filelock"
|
|
"reasonix/internal/base/fileutil"
|
|
fileencoding "reasonix/internal/base/fileutil/encoding"
|
|
"reasonix/internal/contract/provider"
|
|
"reasonix/internal/state/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
|
|
sessionMetaLockWait = 5 * time.Second
|
|
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
|
|
disk persistedDisk
|
|
}
|
|
|
|
type sessionSaveMode int
|
|
|
|
const (
|
|
sessionSaveSnapshot sessionSaveMode = iota
|
|
sessionSaveRewrite
|
|
sessionSaveRewriteCompact
|
|
)
|
|
|
|
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
|
|
supersedes bool // lease-authorized write over a disagreeing transcript
|
|
// 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
|
|
}
|
|
|
|
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 the normal CAS-protected snapshot protocol.
|
|
// It is kept as the convenient default for callers that do not need to spell
|
|
// out rewrite intent; it must never provide a force-overwrite escape hatch.
|
|
// The .jsonl file remains as a compatibility checkpoint and discovery anchor;
|
|
// the append-only event log is authoritative once present, while the .jsonl
|
|
// checkpoint also serves as the random-read model for history paging.
|
|
func (s *Session) Save(path string) error {
|
|
return s.saveObserved(path, sessionSaveSnapshot)
|
|
}
|
|
|
|
// 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.saveObserved(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.saveObserved(path, sessionSaveRewrite)
|
|
}
|
|
|
|
// SaveRewriteCompact performs a CAS-protected rewrite and folds the event log
|
|
// to one replace record. It is for destructive maintenance such as redaction:
|
|
// retaining old WAL records would keep the removed bytes recoverable on disk.
|
|
func (s *Session) SaveRewriteCompact(path string) error {
|
|
return s.saveObserved(path, sessionSaveRewriteCompact)
|
|
}
|
|
|
|
func (s *Session) save(path string, mode sessionSaveMode) error {
|
|
if path == "" {
|
|
return fmt.Errorf("empty session path")
|
|
}
|
|
return s.withSessionSaveLocks(path, func() error {
|
|
return s.saveLocked(path, mode)
|
|
})
|
|
}
|
|
|
|
func (s *Session) withSessionSaveLocks(path string, fn func() error) error {
|
|
if strings.TrimSpace(path) == "" {
|
|
return fmt.Errorf("empty session path")
|
|
}
|
|
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()
|
|
return fn()
|
|
}
|
|
|
|
func sessionArtifactExists(path string) bool {
|
|
return sessionArtifactExistsExcept(path)
|
|
}
|
|
|
|
// sessionArtifactExistsExcept is sessionArtifactExists without the named
|
|
// sidecars: an index is rebuilt from the transcript, so with no transcript
|
|
// beside it, it says nothing about whether the conversation exists.
|
|
func sessionArtifactExistsExcept(path string, ignore ...string) bool {
|
|
if _, err := os.Lstat(path); err == nil {
|
|
return true
|
|
}
|
|
for _, artifact := range store.SessionSidecarFiles(path) {
|
|
if slices.Contains(ignore, artifact) {
|
|
continue
|
|
}
|
|
// A legacy v0 event transcript can share the native
|
|
// `<id>.events.jsonl` name with the destination being imported. It is
|
|
// intentionally left in place, and must not make SaveIfAbsent believe
|
|
// that the v1 `<id>.jsonl` destination already exists. Native event logs
|
|
// remain owned artifacts and still protect the destination from a second
|
|
// writer.
|
|
if artifact == store.SessionEventLog(path) {
|
|
probe, probeErr := probeSessionEventLog(path)
|
|
if probeErr != nil {
|
|
return true
|
|
}
|
|
if !probe.native {
|
|
continue
|
|
}
|
|
}
|
|
if _, err := os.Lstat(artifact); err == nil {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *Session) saveLocked(path string, mode sessionSaveMode) error {
|
|
baseRevision := int64(0)
|
|
releaseAuth, err := s.requireWriteAuthorityForSave(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer releaseAuth()
|
|
observeUnleasedSessionWrite(path, mode)
|
|
// Heal an empty/missing checkpoint from a valid WAL before classification
|
|
// so a 0-byte .jsonl never forces a false diverged recovery.
|
|
if err := healEmptyCheckpointFromWAL(path); err != nil {
|
|
return err
|
|
}
|
|
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, prefixDigest, err := digestSizeAndPrefix(context.Background(), msgs, s.diskPrefixLen(path))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
probe, err := probeSessionEventLog(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := probeRefusesSave(path, probe, msgs); err != nil {
|
|
return err
|
|
}
|
|
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
|
|
ownedRewrite := mode == sessionSaveRewrite || mode == sessionSaveRewriteCompact
|
|
decision, err := s.snapshotDecision(path, msgs, digest, prefixDigest, version, ownedRewrite)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if decision.upToDate && mode != sessionSaveRewriteCompact {
|
|
// 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
|
|
}
|
|
displayModelCurrent := true
|
|
if err := writeSessionMessages(path, msgs); err != nil {
|
|
displayModelCurrent = false
|
|
slog.Warn("session: keeping save after display read-model repair failure", "path", path, "err", 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)
|
|
}
|
|
}
|
|
if displayModelCurrent {
|
|
if err := refreshSessionDisplayIndex(path, msgs, digest, revision, -1); err != nil {
|
|
// The display index is a derived sidecar; transcript durability
|
|
// must not depend on rebuilding it successfully.
|
|
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
|
|
}
|
|
}
|
|
s.markPersistedWithListing(path, digest, version, revision, rewriteVersion, msgs)
|
|
return nil
|
|
}
|
|
s.markPersistedWithListing(path, digest, version, decision.revision, rewriteVersion, msgs)
|
|
return nil
|
|
}
|
|
if decision.appendOnly && probe.native && mode != sessionSaveRewriteCompact {
|
|
logSize := sessionEventLogSize(path)
|
|
displayModelCurrent := false
|
|
switch {
|
|
case logSize == 0:
|
|
if err := appendSessionReplaceEvent(path, msgs, digest, decision.revision, "snapshot"); err != nil {
|
|
return err
|
|
}
|
|
displayModelCurrent, err = appendSessionDisplayReadModel(path, msgs, decision.appendFrom, decision.revision)
|
|
case sessionEventLogOversized(logSize, contentBytes, msgs):
|
|
// Fold history into one replace event and refresh the random-read
|
|
// model atomically. Normal appends keep it current below too.
|
|
if err := compactSessionEventLog(path, msgs, digest, decision.revision, "compact"); err != nil {
|
|
return err
|
|
}
|
|
if err := writeSessionMessages(path, msgs); err != nil {
|
|
return err
|
|
}
|
|
displayModelCurrent = true
|
|
default:
|
|
if err := appendSessionAppendEvent(path, decision.appendFrom, msgs[decision.appendFrom:], digest, decision.revision); err != nil {
|
|
return err
|
|
}
|
|
displayModelCurrent, err = appendSessionDisplayReadModel(path, msgs, decision.appendFrom, decision.revision)
|
|
}
|
|
if err != nil {
|
|
slog.Warn("session: keeping save after display read-model append failure", "path", path, "err", err)
|
|
}
|
|
// A declined in-place append must not leave the checkpoint describing
|
|
// the transcript from before this save; republishing it also re-arms
|
|
// the append fast path for every later save.
|
|
displayModelCurrent, indexFrom := settleDisplayReadModel(path, msgs, displayModelCurrent, decision.appendFrom)
|
|
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)
|
|
}
|
|
if displayModelCurrent {
|
|
if err := refreshSessionDisplayIndex(path, msgs, digest, revision, indexFrom); err != nil {
|
|
// The append boundary lets the refresh extend the previous index
|
|
// instead of re-encoding the whole transcript.
|
|
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
|
|
}
|
|
}
|
|
s.markPersistedWithListing(path, digest, version, revision, rewriteVersion, msgs)
|
|
return nil
|
|
}
|
|
baseRevision = decision.revision
|
|
repairLog = decision.repairLog
|
|
// Full-rewrite path: new snapshots, intentional history rewrites, and
|
|
// damage repairs. 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"
|
|
case sessionSaveRewriteCompact:
|
|
reason = "rewrite-compact"
|
|
}
|
|
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 == sessionSaveRewriteCompact:
|
|
// Maintenance rewrites compact even a short log so redacted or otherwise
|
|
// removed bytes do not remain recoverable in historical WAL records.
|
|
if err := compactSessionEventLog(path, msgs, digest, baseRevision, reason); err != nil {
|
|
return err
|
|
}
|
|
case repairLog, compositionRemoved(probe.schemaVersion, msgs), sessionEventLogOversized(logSize, contentBytes, msgs):
|
|
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
|
|
}
|
|
}
|
|
keepSupersededTranscript(path, decision.supersedes)
|
|
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)
|
|
}
|
|
}
|
|
if err := refreshSessionDisplayIndex(path, msgs, digest, revision, -1); err != nil {
|
|
// Warn-only like the event index above: the display index is a pure
|
|
// derived sidecar and must never fail a save.
|
|
slog.Warn("session: keeping save after display index write failure", "path", path, "err", err)
|
|
}
|
|
s.markPersistedWithListing(path, digest, version, revision, rewriteVersion, msgs)
|
|
return nil
|
|
}
|
|
|
|
func writeSessionMessages(path string, msgs []provider.Message) error {
|
|
if err := refuseDegradedCheckpointRewrite(path, msgs); err != nil {
|
|
return err
|
|
}
|
|
// 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 := loadSnapshotBaseline(path, next)
|
|
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. Require digest
|
|
// ancestry or a live generation-bound write authority covering path.
|
|
if s.ownsWritableBaseline(path, existingDigest, rawDigest, rawDiffers, currentRevision, currentLedgerDigest, nextVersion) {
|
|
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 {
|
|
if s.ownsWritableBaseline(path, existingDigest, rawDigest, rawDiffers, currentRevision, currentLedgerDigest, nextVersion) {
|
|
return snapshotWriteDecision{revision: currentRevision, repairLog: current.eventLogDamaged}, nil
|
|
}
|
|
}
|
|
// Who holds the lease decides; see leaseholderDecision.
|
|
if decision, handled, err := s.leaseholderDecision(path, currentRevision, current.eventLogDamaged); handled {
|
|
return decision, err
|
|
}
|
|
if messagesHavePrefix(existing, next) || messagesHavePrefixWithCompatibleSystem(existing, next) ||
|
|
(rawDiffers && (messagesHavePrefix(raw, next) || messagesHavePrefixWithCompatibleSystem(raw, next))) {
|
|
return snapshotWriteDecision{}, snapshotConflictOfKind(path, SessionSnapshotConflictStalePrefix,
|
|
existing, next, baseState.revision, currentRevision)
|
|
}
|
|
return snapshotWriteDecision{}, snapshotConflictOfKind(path, SessionSnapshotConflictDiverged,
|
|
existing, next, baseState.revision, currentRevision)
|
|
}
|
|
|
|
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 live Session's isolated lane, so another
|
|
// controller in the same process cannot replace the emergency copy.
|
|
// 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)
|
|
}
|
|
|
|
// SaveConflictRecoveryBranch writes the depth-cap isolated copy (one path per
|
|
// live Session). Subsequent conflicts from that Session rewrite it in place.
|
|
func (s *Session) SaveConflictRecoveryBranch(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")
|
|
}
|
|
opts.OriginalPath = originalPath
|
|
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 preserves the stale transcript in a writer-specific
|
|
// isolated branch instead of replacing the contested canonical branch.
|
|
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)
|
|
|
|
return s.writeRecoveryBranchIsolated(originalPath, opts, msgs, digest,
|
|
version, rewriteVersion, preview, turns, digestText, recoveryDepth, shutdown)
|
|
}
|
|
|
|
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.RecoveryRootID = recoveryRootOf(opts.OriginalPath, meta.ParentID)
|
|
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()
|
|
}
|
|
// Keep any in-flight turn marker across isolated in-place rewrites.
|
|
if err := saveBranchMetaKeepInFlightTurn(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 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{}
|
|
}
|
|
|
|
// PersistedState is a read-only view of the baseline the session last
|
|
// persisted to (or loaded from) its transcript path. History paging uses it to
|
|
// validate a display-index sidecar against the live session without touching
|
|
// disk: an index built at the same revision with the same content digest
|
|
// describes exactly the persisted prefix of the in-memory log.
|
|
type PersistedState struct {
|
|
// Digest is the content digest of the persisted transcript.
|
|
Digest [sha256.Size]byte
|
|
// DigestHex is Digest in the hex form sidecars store.
|
|
DigestHex string
|
|
// Revision is the CAS ledger revision of the persisted transcript. It is
|
|
// meaningful only when RevisionKnown is true.
|
|
Revision int64
|
|
RevisionKnown bool
|
|
// RewriteEpoch is the highest rewriteVersion that has reached disk. It
|
|
// changes only when a content rewrite (compaction, rewind, …) is saved, so
|
|
// it doubles as a stable epoch token for history entry IDs: append-only
|
|
// saves keep it, rewrites bump it.
|
|
RewriteEpoch int
|
|
// AppendOnlyTail reports that no rewrite landed after the baseline, so the
|
|
// persisted transcript is still a prefix of the in-memory log (the tail,
|
|
// if any, is unsaved appends).
|
|
AppendOnlyTail bool
|
|
// UnchangedSincePersisted reports that the in-memory log is exactly the
|
|
// persisted transcript (no appends, no rewrites since the baseline).
|
|
UnchangedSincePersisted bool
|
|
}
|
|
|
|
// PersistedState returns the session's persistence baseline for path, or
|
|
// false when the session has never persisted to (or loaded from) that path.
|
|
func (s *Session) PersistedState(path string) (PersistedState, bool) {
|
|
key := canonicalSessionSavePath(path)
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if !s.persisted.ok || s.persisted.path != key {
|
|
return PersistedState{}, false
|
|
}
|
|
return PersistedState{
|
|
Digest: s.persisted.digest,
|
|
DigestHex: digestString(s.persisted.digest),
|
|
Revision: s.persisted.revision,
|
|
RevisionKnown: s.persisted.revisionKnown,
|
|
RewriteEpoch: s.persistedRewriteVersion,
|
|
AppendOnlyTail: s.rewriteVersion == s.persistedRewriteVersion,
|
|
UnchangedSincePersisted: s.persisted.version == s.version,
|
|
}, true
|
|
}
|
|
|
|
// SessionContentIdentity returns the ledger identity of the authoritative
|
|
// persisted transcript without loading its message bodies. It is intended for
|
|
// derived sidecars such as the desktop display index: a matching checkpoint
|
|
// size alone cannot prove that offsets still describe the event-log-backed
|
|
// transcript. The bool is false for legacy sessions that have no digest in
|
|
// their branch metadata; callers must then validate against the transcript
|
|
// bytes directly.
|
|
func SessionContentIdentity(path string) (PersistedState, bool, error) {
|
|
revision, digestHex, err := sessionContentRevision(path)
|
|
if err != nil {
|
|
return PersistedState{}, false, err
|
|
}
|
|
if digestHex == "" {
|
|
return PersistedState{}, false, nil
|
|
}
|
|
var digest [sha256.Size]byte
|
|
decoded, err := hex.DecodeString(digestHex)
|
|
if err != nil || len(decoded) != len(digest) {
|
|
return PersistedState{}, false, fmt.Errorf("invalid session content digest")
|
|
}
|
|
copy(digest[:], decoded)
|
|
return PersistedState{
|
|
Digest: digest,
|
|
DigestHex: digestString(digest),
|
|
Revision: revision,
|
|
RevisionKnown: true,
|
|
}, true, nil
|
|
}
|
|
|
|
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) {
|
|
// Revision allocation is a read-modify-write transaction. Holding only the
|
|
// final SaveBranchMeta lock would still let another writer replace the
|
|
// sidecar between our read and write, so keep the ledger lock across the
|
|
// increment and read-back as well.
|
|
unlock, err := LockSessionMetaPath(path)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer unlock()
|
|
|
|
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
|
|
}
|
|
var persisted *BranchMeta
|
|
if ok {
|
|
read := meta
|
|
persisted = &read
|
|
} else {
|
|
meta = BranchMeta{ID: BranchID(path)}
|
|
}
|
|
if meta.Revision < baseRevision {
|
|
meta.Revision = baseRevision
|
|
}
|
|
meta.Revision++
|
|
meta.ContentDigest = digestString(digest)
|
|
meta.WriterID = SessionWriterID()
|
|
if err := saveBranchMetaAfter(path, meta, false, persisted); err != nil {
|
|
return 0, err
|
|
}
|
|
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) {
|
|
return digestAndSizeSessionMessagesContext(context.Background(), msgs)
|
|
}
|
|
|
|
func digestAndSizeSessionMessagesContext(ctx context.Context, msgs []provider.Message) ([sha256.Size]byte, int64, error) {
|
|
digest, size, _, err := digestSizeAndPrefix(ctx, msgs, -1)
|
|
return digest, size, err
|
|
}
|
|
|
|
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 complete branch-meta read-modify-write
|
|
// cycle with both goroutines in this process and other Reasonix processes.
|
|
// Callers must hold it from the first read through the final replace.
|
|
func LockSessionMetaPath(path string) (func(), error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return nil, fmt.Errorf("empty session path")
|
|
}
|
|
canonical := canonicalSessionSavePath(path)
|
|
if err := os.MkdirAll(filepath.Dir(canonical), 0o755); err != nil {
|
|
return nil, fmt.Errorf("create session metadata dir: %w", err)
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), sessionMetaLockWait)
|
|
releaseFile, err := filelock.Acquire(ctx, sessionMetaLockPath(canonical))
|
|
cancel()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lock session metadata: %w", err)
|
|
}
|
|
return func() {
|
|
releaseFile()
|
|
}, nil
|
|
}
|
|
|
|
func sessionMetaLockPath(path string) string {
|
|
canonical := canonicalSessionSavePath(path)
|
|
digest := sha256.Sum256([]byte(canonical))
|
|
return filepath.Join(filepath.Dir(canonical), "."+hex.EncodeToString(digest[:8])+".meta.lock")
|
|
}
|
|
|
|
// UpdateBranchMeta is the owner-level metadata API. The callback runs while
|
|
// the cross-process metadata lock is held, so callers cannot accidentally
|
|
// load a stale sidecar and overwrite fields written by another runtime.
|
|
func UpdateBranchMeta(path string, touchUpdated bool, update func(*BranchMeta) error) error {
|
|
unlock, err := LockSessionMetaPath(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer unlock()
|
|
m, err := ensureBranchMetaUnlocked(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
persisted := m
|
|
if update != nil {
|
|
if err := update(&m); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return saveBranchMetaAfter(path, m, touchUpdated, &persisted)
|
|
}
|
|
|
|
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) {
|
|
if err := PrepareSessionPath(path); err != nil {
|
|
return nil, err
|
|
}
|
|
unlock := lockSessionSavePath(path)
|
|
defer unlock()
|
|
return loadSessionUnlocked(path)
|
|
}
|
|
|
|
func loadSessionUnlocked(path string) (*Session, error) {
|
|
return loadSessionUnlockedContext(context.Background(), path)
|
|
}
|
|
|
|
func loadSessionUnlockedContext(ctx context.Context, path string) (*Session, error) {
|
|
limits := defaultSessionReplayLimits
|
|
limits.ctx = ctx
|
|
msgs, _, damaged, err := loadSessionMessagesWithLimits(path, limits)
|
|
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 err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
if digest, _, err := digestAndSizeSessionMessagesContext(ctx, 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)
|
|
}
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
|
|
// 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
|
|
}
|
|
// The marker controls session visibility during delayed cleanup. Publish it
|
|
// atomically so a crash cannot leave malformed JSON that hides the session
|
|
// and blocks reconciliation on the next startup.
|
|
return fileutil.AtomicWriteFile(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 := preserveV4Removal(sessionPath); err != nil {
|
|
return err
|
|
}
|
|
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) && !isV4SessionRemoved(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 sidecars, and reclaims transcripts holding no user message.
|
|
// 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 := reconcileSessionDir(dir, cleanup); err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
if err := reconcileStaleArtifacts(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 := reconcilePending(dir, item, cleanup)
|
|
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 sessionSidecarMoves(oldPath, 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...)
|
|
}
|
|
|
|
// ListSessions returns complete listing metadata, replaying uncached foreign
|
|
// sessions. Interactive callers use ListNativeSessions and LegacyListings.
|
|
func ListSessions(dir string) ([]SessionInfo, error) {
|
|
out, err := ListNativeSessions(dir)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return withV4Sessions(dir, out), nil
|
|
}
|
|
|
|
// ListNativeSessions reads metadata without consulting foreign transcript stores.
|
|
func ListNativeSessions(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 sessionListingCountsNeedRefresh(session.SchemaVersion, turns) {
|
|
if !sessionArtifactsHaveContent(session.Path) {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(preview) == "" {
|
|
preview = "History is being indexed — " + filepath.Base(session.Path)
|
|
}
|
|
out = append(out, sessionInfoFromOrder(session, preview, turns, false))
|
|
continue
|
|
}
|
|
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, sessionInfoFromOrder(session, preview, turns, true))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|