| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "crypto/sha256" |
| 5 | "fmt" |
| 6 | "log/slog" |
| 7 | "math" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/provider" |
| 11 | ) |
| 12 | |
| 13 | func (s *Session) classifySnapshotWriteForCommit(path string, msgs []provider.Message, digest [sha256.Size]byte, version uint64, ownedRewrite bool, mode sessionSaveMode) (snapshotWriteDecision, error) { |
| 14 | decision, err := s.classifySnapshotWrite(path, msgs, digest, version, ownedRewrite) |
| 15 | if err != nil || decision.upToDate && mode != sessionSaveRewriteCompact && !decision.ledgerStale { |
| 16 | return decision, err |
| 17 | } |
| 18 | // Invalidate before any transcript mutation or stale-ledger repair so an |
| 19 | // interrupted commit cannot leave the previous listing self-certified. |
| 20 | reservedRevision, err := invalidateSessionListingProjection(path) |
| 21 | if err != nil { |
| 22 | return decision, fmt.Errorf("invalidate session listing projection: %w", err) |
| 23 | } |
| 24 | decision.reservedRevision = reservedRevision |
| 25 | return decision, nil |
| 26 | } |
| 27 | |
| 28 | func (s *Session) markPersistedWithListing(path string, digest [sha256.Size]byte, version uint64, revision int64, rewriteVersion int, msgs []provider.Message) { |
| 29 | // Pair the persisted-message view with the baseline: writer-bound saves |
| 30 | // use it to classify append shapes without reloading the transcript. |
| 31 | s.setPersistedBaseline(path, digest, version, revision, true, true, rewriteVersion, msgs) |
| 32 | persistSessionListingProjection(path, msgs, revision, digestString(digest)) |
| 33 | } |
| 34 | |
| 35 | // invalidateSessionListingProjection makes cached counts untrusted before a |
| 36 | // transcript commit begins. If the transcript lands but its revision ledger |
| 37 | // does not, readers must decode/repair instead of certifying the previous |
| 38 | // generation's preview from an internally consistent but stale sidecar. |
| 39 | func invalidateSessionListingProjection(path string) (int64, error) { |
| 40 | if !sessionArtifactsHaveContent(path) { |
| 41 | // A brand-new session has no prior projection to reuse. It also needs to |
| 42 | // retain the historical first committed revision of one. |
| 43 | return 0, nil |
| 44 | } |
| 45 | var reservedRevision int64 |
| 46 | err := UpdateBranchMeta(path, false, func(meta *BranchMeta) error { |
| 47 | // Reserve the next transcript generation so stale whole-sidecar writers |
| 48 | // preserve this invalidation. The transcript commit finalizes the same |
| 49 | // revision, keeping the WAL and content ledger in one generation. |
| 50 | if meta.Revision == math.MaxInt64 { |
| 51 | return fmt.Errorf("session revision exhausted") |
| 52 | } |
| 53 | meta.Revision++ |
| 54 | reservedRevision = meta.Revision |
| 55 | meta.WriterID = SessionWriterID() |
| 56 | meta.SchemaVersion = 0 |
| 57 | return nil |
| 58 | }) |
| 59 | return reservedRevision, err |
| 60 | } |
| 61 | |
| 62 | func persistSessionListingProjection(path string, msgs []provider.Message, revision int64, contentDigest string) { |
| 63 | preview, turns := SessionPreviewFromMessages(msgs) |
| 64 | if err := UpdateBranchMeta(path, false, func(meta *BranchMeta) error { |
| 65 | // The transcript commit and this repairable projection are separate |
| 66 | // critical sections. A newer writer may already have advanced the |
| 67 | // sidecar, so only publish counts for the generation this save committed. |
| 68 | contentDigest = strings.TrimSpace(contentDigest) |
| 69 | if revision <= 0 || meta.Revision != revision || strings.TrimSpace(meta.ContentDigest) != contentDigest { |
| 70 | return nil |
| 71 | } |
| 72 | meta.Preview = preview |
| 73 | meta.Turns = turns |
| 74 | meta.SchemaVersion = BranchMetaCountsVersion |
| 75 | meta.ListingRevision = revision |
| 76 | meta.ListingContentDigest = contentDigest |
| 77 | return nil |
| 78 | }); err != nil { |
| 79 | // JSONL/event log already committed. Listing metadata is a repairable |
| 80 | // projection and must never turn a successful transcript save into an |
| 81 | // application error. |
| 82 | slog.Warn("session: listing metadata update deferred", "path", path, "err", err) |
| 83 | } |
| 84 | } |
| 85 |