1897 lines
67 KiB
Go
1897 lines
67 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"reasonix/internal/fileutil"
|
|
fileencoding "reasonix/internal/fileutil/encoding"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/secrets"
|
|
"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
|
|
ErrSessionSnapshotConflict = errors.New("session snapshot conflicts with newer transcript")
|
|
ErrSessionRecoveryNotNeeded = errors.New("session recovery not needed")
|
|
// 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")
|
|
errSessionFileLockHeld = errors.New("session file lock held")
|
|
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
|
|
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()
|
|
// 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()
|
|
// Durable transcripts are always redacted, independent of the live
|
|
// [secrets] redact_tool_output toggle. Digest consistency holds because
|
|
// Redact is deterministic and idempotent (see secrets.Redact): a loaded
|
|
// session's messages are already redacted, so re-redacting them here is a
|
|
// byte-for-byte no-op and the snapshot keeps its prefix relationship to
|
|
// the on-disk transcript — the same digest/prefix machinery that guards
|
|
// against bogus stale-prefix conflicts (#6083) sees identical bytes. The
|
|
// persisted baseline (markPersisted) is likewise recorded over the
|
|
// redacted form, matching what LoadSession will digest back.
|
|
msgs = secrets.RedactMessages(msgs)
|
|
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 {
|
|
// 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) {
|
|
originalPath := strings.TrimSpace(opts.OriginalPath)
|
|
if originalPath == "" {
|
|
return RecoveryBranchInfo{}, fmt.Errorf("empty original session path")
|
|
}
|
|
msgs, version, rewriteVersion := s.snapshotWithVersion()
|
|
// Same redaction contract as save(): recovery branches are durable
|
|
// transcripts too, and the coverage checks below compare against on-disk
|
|
// content that save() already redacted — comparing a raw snapshot against
|
|
// it would misread pure coverage as divergence and fork a bogus branch.
|
|
msgs = secrets.RedactMessages(msgs)
|
|
preview, turns := SessionPreviewFromMessages(msgs)
|
|
if turns == 0 {
|
|
return RecoveryBranchInfo{}, ErrSessionRecoveryNotNeeded
|
|
}
|
|
digest, err := digestSessionMessages(msgs)
|
|
if err != nil {
|
|
return RecoveryBranchInfo{}, err
|
|
}
|
|
digestText := digestString(digest)
|
|
|
|
unlockOriginal := lockSessionSavePath(originalPath)
|
|
unlockOriginalFile, lockErr := lockSessionFile(originalPath)
|
|
if lockErr != nil {
|
|
unlockOriginal()
|
|
return RecoveryBranchInfo{}, fmt.Errorf("lock original session file: %w", lockErr)
|
|
}
|
|
current, err := loadSessionUnlocked(originalPath)
|
|
unlockOriginalFile()
|
|
unlockOriginal()
|
|
if err != nil && !os.IsNotExist(err) {
|
|
return RecoveryBranchInfo{}, err
|
|
}
|
|
if err == 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 {
|
|
return RecoveryBranchInfo{}, fmt.Errorf("%w: %s is already %d recovery forks deep",
|
|
ErrSessionRecoveryDepthExceeded, originalPath, parentDepth)
|
|
}
|
|
|
|
recoveryPath := recoverySessionPath(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, parentDepth+1)
|
|
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, parentDepth+1)
|
|
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 recoveryParentStem(parent string) string {
|
|
parent = strings.TrimSpace(parent)
|
|
if parent == "" {
|
|
return "session"
|
|
}
|
|
sum := sha256.Sum256([]byte(parent))
|
|
if idx := strings.Index(parent, "-recovery-"); idx >= 0 {
|
|
base := strings.Trim(parent[:idx], "-_. ")
|
|
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)
|
|
}
|
|
|
|
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, 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, rewriteVersion)
|
|
}
|
|
|
|
func (s *Session) setPersistedBaseline(path string, digest [sha256.Size]byte, version uint64, revision int64, revisionKnown bool, rewriteVersion int) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.persisted = sessionPersistState{
|
|
path: canonicalSessionSavePath(path),
|
|
digest: digest,
|
|
version: version,
|
|
revision: revision,
|
|
revisionKnown: revisionKnown,
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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 {
|
|
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] is byte-for-byte the transcript that produced 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 {
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// 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
|
|
}
|
|
if runtime.GOOS == "windows" {
|
|
key = strings.ToLower(key)
|
|
}
|
|
return key
|
|
}
|
|
|
|
// 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)
|
|
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.markPersisted(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)
|
|
}
|
|
pending, err := ListCleanupPending(dir)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
return errors.Join(errs...)
|
|
}
|
|
if cleanup == nil {
|
|
return errors.Join(errs...)
|
|
}
|
|
for _, item := range pending {
|
|
if err := cleanup(item); 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.SessionEventIndex(oldPath), store.SessionEventIndex(newPath)},
|
|
{store.SessionConflictLog(oldPath), store.SessionConflictLog(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(m.Content) {
|
|
turns++
|
|
if first == "" {
|
|
first = truncatePreview(UserPreviewText(m.Content))
|
|
}
|
|
}
|
|
}
|
|
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(m.Content) {
|
|
turns++
|
|
if first == "" {
|
|
first = truncatePreview(UserPreviewText(m.Content))
|
|
}
|
|
}
|
|
}
|
|
return first, turns
|
|
}
|
|
|
|
// 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))
|
|
}
|