返回 DeepSeek-Reasonix
session_listing_repair.go
根目录 / internal / agent / session_listing_repair.go
1 package agent
2
3 import (
4 "context"
5 "crypto/sha256"
6 "errors"
7 "fmt"
8 "os"
9 "strings"
10
11 "reasonix/internal/store"
12 )
13
14 type SessionListingRepairStatus string
15
16 const (
17 SessionListingRepairApplied SessionListingRepairStatus = "applied"
18 SessionListingRepairAlreadyCurrent SessionListingRepairStatus = "already_current"
19 SessionListingRepairSourceChanged SessionListingRepairStatus = "source_changed"
20 SessionListingRepairDamaged SessionListingRepairStatus = "damaged"
21 SessionListingRepairUnsupported SessionListingRepairStatus = "unsupported"
22 )
23
24 var ErrSessionListingRepairBusy = errors.New("session listing repair source is busy")
25
26 // SessionListingRepairResult describes one convergent repair attempt. Preview
27 // and Turns are safe to publish only for applied/already_current results.
28 type SessionListingRepairResult struct {
29 Status SessionListingRepairStatus
30 Preview string
31 Turns int
32 LedgerRepaired bool
33 ContentFingerprint string
34 MetaFingerprint string
35 }
36
37 // SessionListingGeneration identifies the transcript/event-log generation and
38 // its metadata sidecar while all writer locks for the session remain held.
39 type SessionListingGeneration struct {
40 ContentFingerprint string
41 MetaFingerprint string
42 }
43
44 // TryLockSessionListingGeneration fences catalog publication against a
45 // foreground writer. The caller must invoke the returned unlock function.
46 func TryLockSessionListingGeneration(path string) (SessionListingGeneration, func(), error) {
47 path = strings.TrimSpace(path)
48 if path == "" {
49 return SessionListingGeneration{}, nil, fmt.Errorf("empty session path")
50 }
51 unlock, err := lockSessionListingRepair(path)
52 if err != nil {
53 return SessionListingGeneration{}, nil, err
54 }
55 return SessionListingGeneration{
56 ContentFingerprint: sessionListingCatalogContentFingerprint(path),
57 MetaFingerprint: sessionListingCatalogFileFingerprint(BranchMetaPath(path)),
58 }, unlock, nil
59 }
60
61 // RepairSessionListingProjection repairs one session generation while holding
62 // only that session's save/file/meta locks. Foreground saves win immediately;
63 // callers persist a retry instead of waiting behind active work.
64 func RepairSessionListingProjection(ctx context.Context, path string) (result SessionListingRepairResult, resultErr error) {
65 path = strings.TrimSpace(path)
66 if path == "" {
67 return SessionListingRepairResult{}, fmt.Errorf("empty session path")
68 }
69 if err := ctx.Err(); err != nil {
70 return SessionListingRepairResult{}, err
71 }
72 unlock, err := lockSessionListingRepair(path)
73 if err != nil {
74 return SessionListingRepairResult{}, err
75 }
76 defer unlock()
77 defer func() {
78 result.ContentFingerprint = sessionListingCatalogContentFingerprint(path)
79 result.MetaFingerprint = sessionListingCatalogFileFingerprint(BranchMetaPath(path))
80 }()
81 meta, metaOK, err := LoadBranchMeta(path)
82 if err != nil {
83 if isDamagedSessionRepairError(err) {
84 return SessionListingRepairResult{Status: SessionListingRepairDamaged}, nil
85 }
86 return SessionListingRepairResult{}, err
87 }
88 if !metaOK {
89 meta = BranchMeta{ID: BranchID(path)}
90 }
91 if result, handled, err := repairSessionListingFromIndex(path, meta); handled || err != nil {
92 return result, err
93 }
94 return repairSessionListingFromReplay(ctx, path, meta)
95 }
96
97 func lockSessionListingRepair(path string) (func(), error) {
98 unlockSave, ok := tryLockSessionSavePath(path)
99 if !ok {
100 return nil, ErrSessionListingRepairBusy
101 }
102 unlockFile, err := tryLockSessionFile(path)
103 if err != nil {
104 unlockSave()
105 if errors.Is(err, ErrSessionFileLockHeld) {
106 return nil, ErrSessionListingRepairBusy
107 }
108 return nil, err
109 }
110 unlockMeta, ok, err := tryLockSessionMetaPath(path)
111 if err != nil {
112 unlockFile()
113 unlockSave()
114 return nil, err
115 }
116 if !ok {
117 unlockFile()
118 unlockSave()
119 return nil, ErrSessionListingRepairBusy
120 }
121 return func() { unlockMeta(); unlockFile(); unlockSave() }, nil
122 }
123
124 func repairSessionListingFromIndex(path string, meta BranchMeta) (SessionListingRepairResult, bool, error) {
125 preview, turns, ok, unsupported, err := indexedSessionListing(path, meta)
126 if err != nil {
127 return SessionListingRepairResult{}, true, err
128 }
129 if unsupported {
130 return SessionListingRepairResult{Status: SessionListingRepairUnsupported}, true, nil
131 }
132 if !ok {
133 return SessionListingRepairResult{}, false, nil
134 }
135 if sessionListingProjectionFresh(meta.SchemaVersion, meta.Turns, meta.Revision,
136 meta.ListingRevision, meta.ContentDigest, meta.ListingContentDigest) &&
137 meta.Preview == preview && meta.Turns == turns {
138 return SessionListingRepairResult{Status: SessionListingRepairAlreadyCurrent, Preview: preview, Turns: turns}, true, nil
139 }
140 meta.Preview, meta.Turns, meta.SchemaVersion = preview, turns, BranchMetaCountsVersion
141 stampSessionListingProjection(&meta)
142 if err := saveBranchMeta(path, meta, false); err != nil {
143 return SessionListingRepairResult{}, true, err
144 }
145 return SessionListingRepairResult{Status: SessionListingRepairApplied, Preview: preview, Turns: turns}, true, nil
146 }
147
148 func repairSessionListingFromReplay(ctx context.Context, path string, meta BranchMeta) (SessionListingRepairResult, error) {
149 before, err := sessionRepairContentFingerprint(path)
150 if err != nil {
151 return SessionListingRepairResult{}, err
152 }
153 msgs, state, repairable, err := loadSessionDisplayMessagesContextUnlocked(ctx, path)
154 if err != nil {
155 if ctxErr := ctx.Err(); ctxErr != nil {
156 return SessionListingRepairResult{}, ctxErr
157 }
158 if isUnsupportedSessionRepairError(err) {
159 return SessionListingRepairResult{Status: SessionListingRepairUnsupported}, nil
160 }
161 if isDamagedSessionRepairError(err) {
162 return SessionListingRepairResult{Status: SessionListingRepairDamaged}, nil
163 }
164 return SessionListingRepairResult{}, err
165 }
166 if !repairable {
167 return SessionListingRepairResult{Status: SessionListingRepairDamaged}, nil
168 }
169 if err := ctx.Err(); err != nil {
170 return SessionListingRepairResult{}, err
171 }
172 after, err := sessionRepairContentFingerprint(path)
173 if err != nil {
174 return SessionListingRepairResult{}, err
175 }
176 if before != after {
177 return SessionListingRepairResult{Status: SessionListingRepairSourceChanged}, nil
178 }
179
180 preview, turns := SessionPreviewFromMessages(msgs)
181 ledgerCurrent := meta.Revision > 0 && strings.TrimSpace(meta.ContentDigest) == state.DigestHex
182 if meta.Recovered && strings.TrimSpace(meta.RecoveryDigest) != state.DigestHex {
183 ledgerCurrent = false
184 }
185 ledgerRepaired := !ledgerCurrent
186 if ledgerRepaired {
187 meta.Revision = max(int64(1), meta.Revision+1)
188 meta.ContentDigest = state.DigestHex
189 if meta.Recovered || strings.TrimSpace(meta.RecoveryDigest) != "" {
190 meta.RecoveryDigest = state.DigestHex
191 }
192 meta.WriterID = SessionWriterID()
193 }
194 meta.Preview = preview
195 meta.Turns = turns
196 meta.SchemaVersion = BranchMetaCountsVersion
197 stampSessionListingProjection(&meta)
198
199 // The display index ranges describe the compatibility JSONL exactly, so
200 // refresh it from this single authoritative replay before publishing meta.
201 if err := writeSessionMessagesContext(ctx, path, msgs); err != nil {
202 return SessionListingRepairResult{}, fmt.Errorf("write session display read model: %w", err)
203 }
204 if err := refreshSessionEventIndexContext(ctx, path, msgs, state.Digest, meta.Revision); err != nil {
205 return SessionListingRepairResult{}, err
206 }
207 idx, err := BuildSessionDisplayIndexContext(ctx, msgs, meta.Revision, true, state.Digest)
208 if err != nil {
209 return SessionListingRepairResult{}, fmt.Errorf("encode session display index: %w", err)
210 }
211 if err := WriteSessionDisplayIndexContext(ctx, store.SessionDisplayIndex(path), idx); err != nil {
212 return SessionListingRepairResult{}, err
213 }
214 if err := saveBranchMetaContext(ctx, path, meta, false); err != nil {
215 return SessionListingRepairResult{}, err
216 }
217 return SessionListingRepairResult{
218 Status: SessionListingRepairApplied, Preview: preview, Turns: turns, LedgerRepaired: ledgerRepaired,
219 }, nil
220 }
221
222 func indexedSessionListing(path string, meta BranchMeta) (preview string, turns int, ok, unsupported bool, err error) {
223 digest := strings.TrimSpace(meta.ContentDigest)
224 if meta.Revision <= 0 || digest == "" || meta.Recovered && strings.TrimSpace(meta.RecoveryDigest) != digest {
225 return "", 0, false, false, nil
226 }
227 idx, err := LoadSessionDisplayIndex(store.SessionDisplayIndex(path))
228 if err != nil || idx == nil || !idx.ListingPreviewKnown || !idx.RevisionKnown ||
229 idx.Revision != meta.Revision || idx.ContentDigest != digest {
230 return "", 0, false, false, nil
231 }
232 info, err := os.Stat(path)
233 if err != nil {
234 return "", 0, false, false, err
235 }
236 if info.IsDir() || info.Size() != idx.TranscriptSize {
237 return "", 0, false, false, nil
238 }
239 indexInfo, err := os.Stat(store.SessionDisplayIndex(path))
240 if err != nil || !indexInfo.ModTime().After(SessionContentModTime(path)) {
241 return "", 0, false, false, nil
242 }
243 probe, err := probeSessionEventLog(path)
244 if err != nil {
245 return "", 0, false, false, err
246 }
247 if probe.futureSchema {
248 return "", 0, false, true, nil
249 }
250 if probe.native && probe.size > 0 {
251 eventIdx, err := readSessionEventIndex(path)
252 if err != nil || eventIdx == nil || eventIdx.LogSize != probe.size ||
253 eventIdx.Revision != meta.Revision || eventIdx.ContentDigest != digest ||
254 eventIdx.MessageCount != idx.MessageCount {
255 return "", 0, false, false, nil
256 }
257 eventInfo, eventErr := os.Stat(store.SessionEventIndex(path))
258 logInfo, logErr := os.Stat(store.SessionEventLog(path))
259 if eventErr != nil || logErr != nil || !eventInfo.ModTime().After(logInfo.ModTime()) {
260 return "", 0, false, false, nil
261 }
262 }
263 return idx.ListingPreview, idx.AuthoredTurns, true, false, nil
264 }
265
266 func sessionRepairContentFingerprint(path string) (string, error) {
267 h := sha256.New()
268 for _, artifact := range []string{path, store.SessionEventLog(path)} {
269 info, err := os.Stat(artifact)
270 if err != nil {
271 if os.IsNotExist(err) {
272 _, _ = fmt.Fprintf(h, "missing\x00")
273 continue
274 }
275 return "", err
276 }
277 _, _ = fmt.Fprintf(h, "%d\x00%d\x00%d\x00", info.Size(), info.ModTime().UnixNano(), info.Mode())
278 }
279 return fmt.Sprintf("%x", h.Sum(nil)), nil
280 }
281
282 func sessionListingCatalogFileFingerprint(path string) string {
283 info, err := os.Stat(path)
284 if err != nil {
285 return ""
286 }
287 return fmt.Sprintf("%d:%d", info.Size(), info.ModTime().UnixNano())
288 }
289
290 func sessionListingCatalogContentFingerprint(path string) string {
291 return sessionListingCatalogFileFingerprint(path) + "|" + sessionListingCatalogFileFingerprint(store.SessionEventLog(path))
292 }
293
294 func isUnsupportedSessionRepairError(err error) bool {
295 return errors.Is(err, ErrSessionReplayLimitExceeded) ||
296 strings.Contains(err.Error(), "unsupported schema") ||
297 strings.Contains(err.Error(), "supports up to") ||
298 strings.Contains(err.Error(), "unsupported event type")
299 }
300
301 func isDamagedSessionRepairError(err error) bool {
302 text := err.Error()
303 return strings.Contains(text, "decode session transcript") ||
304 strings.Contains(text, "decode session event log") ||
305 strings.Contains(text, "decode ")
306 }
307
307 lines GO