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