返回 DeepSeek-Reasonix
session_events.go
根目录 / internal / agent / session_events.go
1 package agent
2
3 import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log/slog"
12 "os"
13 "path/filepath"
14 "time"
15
16 "reasonix/internal/fileutil"
17 fileencoding "reasonix/internal/fileutil/encoding"
18 "reasonix/internal/provider"
19 "reasonix/internal/store"
20 )
21
22 const (
23 sessionEventSchemaVersion = 1
24 sessionEventTypeReplace = "replace"
25 sessionEventTypeAppend = "append"
26 // sessionEventReplayMaxBytes caps decoder input before encoding/json can
27 // allocate an arbitrarily large record. The ceiling still accommodates
28 // image-bearing histories while keeping corrupt logs from exhausting RAM.
29 sessionEventReplayMaxBytes = int64(128 << 20)
30 // A byte limit alone is insufficient: a compact JSON array can expand into
31 // a much larger graph of messages and event records after decoding.
32 sessionEventReplayMaxRecords = 100_000
33 sessionEventReplayMaxMessages = 100_000
34 sessionEventReplayMaxCollectionItems = 100_000
35 sessionEventProbeMaxBytes = int64(4 << 10)
36 // sessionEventLogCompactFloor is the smallest log size that can trigger
37 // event-log maintenance, so short sessions never pay a checkpoint rewrite.
38 sessionEventLogCompactFloor = int64(256 << 10)
39 // sessionEventLogCompactFactor bounds the log at this multiple of the live
40 // transcript's encoded size; past it the log is rewritten to one replace
41 // event so replace-heavy histories (rewind and recovery) cannot grow the
42 // file without bound.
43 sessionEventLogCompactFactor = int64(4)
44 )
45
46 // ErrSessionReplayLimitExceeded identifies a session that was left untouched
47 // because replaying it would exceed the process safety budget. Callers must not
48 // fall back to an older checkpoint: the event log may contain newer turns.
49 var ErrSessionReplayLimitExceeded = errors.New("session history exceeds safe replay limits")
50
51 // ErrSessionHistoryDamaged marks a frozen source whose authoritative event
52 // log cannot be proven complete. Migration must fail closed instead of falling
53 // back to an older checkpoint and silently dropping newer turns.
54 var ErrSessionHistoryDamaged = errors.New("session history is damaged")
55
56 // SessionReplayLimitError carries machine-readable diagnostics while keeping
57 // Error free of local paths for Desktop surfaces that display startup errors.
58 type SessionReplayLimitError struct {
59 Path string
60 Resource string
61 Value int64
62 Limit int64
63 }
64
65 func (e *SessionReplayLimitError) Error() string {
66 if e == nil {
67 return ErrSessionReplayLimitExceeded.Error()
68 }
69 return fmt.Sprintf("%s: %s=%d, limit=%d; session files were left unchanged",
70 ErrSessionReplayLimitExceeded, e.Resource, e.Value, e.Limit)
71 }
72
73 func (e *SessionReplayLimitError) Unwrap() error {
74 return ErrSessionReplayLimitExceeded
75 }
76
77 type sessionReplayLimits struct {
78 maxBytes int64
79 maxRecords int
80 maxMessages int
81 maxCollectionItems int
82 }
83
84 var defaultSessionReplayLimits = sessionReplayLimits{
85 maxBytes: sessionEventReplayMaxBytes,
86 maxRecords: sessionEventReplayMaxRecords,
87 maxMessages: sessionEventReplayMaxMessages,
88 maxCollectionItems: sessionEventReplayMaxCollectionItems,
89 }
90
91 // migrationSessionReplayLimits removes cumulative interactive-history caps for
92 // an already frozen migration source. The migration path is still expected to
93 // move large payloads into its target store as it reads them; these values only
94 // keep the legacy decoder from rejecting valid historical totals before that
95 // conversion can happen.
96 func migrationSessionReplayLimits() sessionReplayLimits {
97 return sessionReplayLimits{
98 maxBytes: int64(^uint64(0)>>1) - 1,
99 maxRecords: int(^uint(0) >> 1),
100 maxMessages: int(^uint(0) >> 1),
101 maxCollectionItems: int(^uint(0) >> 1),
102 }
103 }
104
105 func sessionReplayLimitError(path, resource string, value, limit int64) error {
106 err := &SessionReplayLimitError{Path: path, Resource: resource, Value: value, Limit: limit}
107 slog.Warn("session: refusing unsafe event-log replay",
108 "path", path, "resource", resource, "value", value, "limit", limit)
109 return err
110 }
111
112 type sessionEventRecord struct {
113 SchemaVersion int `json:"schema_version"`
114 Type string `json:"type"`
115 Revision int64 `json:"revision,omitempty"`
116 BaseRevision int64 `json:"base_revision,omitempty"`
117 MessageIndex int `json:"message_index,omitempty"`
118 Messages []provider.Message `json:"messages,omitempty"`
119 ContentDigest string `json:"content_digest,omitempty"`
120 WriterID string `json:"writer_id,omitempty"`
121 Reason string `json:"reason,omitempty"`
122 CreatedAt time.Time `json:"created_at"`
123 }
124
125 // sessionEventWireRecord keeps the messages array encoded until the replay
126 // budget has been checked. Decoding directly into sessionEventRecord would
127 // materialize every provider.Message before replay could enforce maxMessages.
128 type sessionEventWireRecord struct {
129 SchemaVersion int `json:"schema_version"`
130 Type string `json:"type"`
131 Revision int64 `json:"revision,omitempty"`
132 BaseRevision int64 `json:"base_revision,omitempty"`
133 MessageIndex int `json:"message_index,omitempty"`
134 Messages json.RawMessage `json:"messages,omitempty"`
135 ContentDigest string `json:"content_digest,omitempty"`
136 WriterID string `json:"writer_id,omitempty"`
137 Reason string `json:"reason,omitempty"`
138 CreatedAt time.Time `json:"created_at"`
139 }
140
141 type sessionEventIndex struct {
142 SchemaVersion int `json:"schema_version"`
143 LogSize int64 `json:"log_size"`
144 MessageCount int `json:"message_count"`
145 Revision int64 `json:"revision"`
146 ContentDigest string `json:"content_digest"`
147 WriterID string `json:"writer_id"`
148 UpdatedAt time.Time `json:"updated_at"`
149 }
150
151 func SessionEventLogPath(sessionPath string) string {
152 return store.SessionEventLog(sessionPath)
153 }
154
155 func SessionEventIndexPath(sessionPath string) string {
156 return store.SessionEventIndex(sessionPath)
157 }
158
159 func sessionEventLogSize(sessionPath string) int64 {
160 path := store.SessionEventLog(sessionPath)
161 if path == "" {
162 return 0
163 }
164 info, err := os.Stat(path)
165 if err != nil || info.IsDir() {
166 return 0
167 }
168 return info.Size()
169 }
170
171 func sessionEventLogOversized(logSize, contentBytes int64) bool {
172 limit := sessionEventLogCompactFloor
173 if scaled := contentBytes * sessionEventLogCompactFactor; scaled > limit {
174 limit = scaled
175 }
176 return logSize > limit
177 }
178
179 // sessionEventReplay is the result of a tolerant event-log replay: the
180 // transcript up to the last cleanly applied record, plus enough bookkeeping
181 // for writers to self-heal a torn tail.
182 type sessionEventReplay struct {
183 msgs []provider.Message
184 // collectionItems counts the elements in every JSON array nested below a
185 // live message. Keeping this alongside msgs bounds slices such as tool calls,
186 // images, memory citations, and interrupted-turn recovery metadata without
187 // coupling replay safety to today's provider.Message field list.
188 collectionItems int
189 // times mirrors msgs with each message's record CreatedAt. Replace events
190 // collapse per-turn history, so their messages get the zero time and
191 // callers fall back to coarser timestamps.
192 times []time.Time
193 // records counts cleanly applied events.
194 records int
195 // lastGoodEnd is the byte offset just past the last cleanly applied
196 // record; truncating the log here drops only undecodable bytes.
197 lastGoodEnd int64
198 // size is the log size that was replayed.
199 size int64
200 // damaged is set when replay stopped early on a torn/corrupt record or a
201 // broken append chain. The prefix in msgs is still a valid historical
202 // state.
203 damaged bool
204 }
205
206 // replaySessionEventLog decodes an event log tolerantly: decoding stops at the
207 // first record that fails to parse or chain, and the state up to that point is
208 // returned with damaged=true so writers can self-heal. Unsupported schema
209 // versions and unknown event types stay hard errors — they mean a newer writer
210 // owns this log, and truncating it would discard that writer's data.
211 func replaySessionEventLog(path string) (sessionEventReplay, error) {
212 return replaySessionEventLogWithLimits(path, defaultSessionReplayLimits, nil)
213 }
214
215 func replaySessionEventLogWithLimits(path string, limits sessionReplayLimits, hasher *sessionTranscriptHasher) (sessionEventReplay, error) {
216 return replaySessionEventLogWithContext(context.Background(), path, limits, hasher)
217 }
218
219 func replaySessionEventLogWithContext(ctx context.Context, path string, limits sessionReplayLimits, hasher *sessionTranscriptHasher) (sessionEventReplay, error) {
220 if err := ctx.Err(); err != nil {
221 return sessionEventReplay{}, err
222 }
223 f, err := os.Open(path)
224 if err != nil {
225 return sessionEventReplay{}, err
226 }
227 defer f.Close()
228 info, err := f.Stat()
229 if err != nil {
230 return sessionEventReplay{}, err
231 }
232 replay := sessionEventReplay{size: info.Size()}
233 if info.Size() > limits.maxBytes {
234 return replay, sessionReplayLimitError(path, "encoded_bytes", info.Size(), limits.maxBytes)
235 }
236 // Stat and read are not atomic across processes. LimitReader keeps a log
237 // that grows after Stat inside the same byte budget.
238 limited := &io.LimitedReader{R: &contextReader{ctx: ctx, reader: f}, N: limits.maxBytes + 1}
239 dec := json.NewDecoder(limited)
240 for {
241 if err := ctx.Err(); err != nil {
242 return replay, err
243 }
244 var rec sessionEventWireRecord
245 if err := dec.Decode(&rec); err != nil {
246 if ctxErr := ctx.Err(); ctxErr != nil {
247 return replay, ctxErr
248 }
249 if limited.N == 0 {
250 return replay, sessionReplayLimitError(path, "encoded_bytes", limits.maxBytes+1, limits.maxBytes)
251 }
252 if errors.Is(err, io.EOF) {
253 return replay, nil
254 }
255 replay.damaged = true
256 return replay, nil
257 }
258 if rec.SchemaVersion != sessionEventSchemaVersion {
259 return replay, fmt.Errorf("decode session event log %s: unsupported schema version %d", path, rec.SchemaVersion)
260 }
261 if replay.records >= limits.maxRecords {
262 return replay, sessionReplayLimitError(path, "event_records", int64(replay.records+1), int64(limits.maxRecords))
263 }
264 switch rec.Type {
265 case sessionEventTypeReplace:
266 msgs, collectionItems, err := decodeSessionEventMessages(ctx, path, rec.Messages, 0, 0, limits)
267 if err != nil {
268 if ctxErr := ctx.Err(); ctxErr != nil {
269 return replay, ctxErr
270 }
271 if errors.Is(err, ErrSessionReplayLimitExceeded) {
272 return replay, err
273 }
274 replay.damaged = true
275 return replay, nil
276 }
277 replay.msgs = msgs
278 replay.collectionItems = collectionItems
279 replay.times = make([]time.Time, len(replay.msgs))
280 hasher.rehash(msgs)
281 case sessionEventTypeAppend:
282 if rec.MessageIndex != len(replay.msgs) {
283 replay.damaged = true
284 return replay, nil
285 }
286 msgs, collectionItems, err := decodeSessionEventMessages(ctx, path, rec.Messages, len(replay.msgs), replay.collectionItems, limits)
287 if err != nil {
288 if ctxErr := ctx.Err(); ctxErr != nil {
289 return replay, ctxErr
290 }
291 if errors.Is(err, ErrSessionReplayLimitExceeded) {
292 return replay, err
293 }
294 replay.damaged = true
295 return replay, nil
296 }
297 replay.msgs = append(replay.msgs, msgs...)
298 replay.collectionItems = collectionItems
299 for range msgs {
300 replay.times = append(replay.times, rec.CreatedAt)
301 }
302 hasher.addAll(msgs)
303 default:
304 return replay, fmt.Errorf("decode session event log %s: unsupported event type %q", path, rec.Type)
305 }
306 replay.records++
307 replay.lastGoodEnd = dec.InputOffset()
308 }
309 }
310
311 // decodeSessionEventMessages preflights both the top-level message count and
312 // every nested JSON collection before constructing provider.Message values.
313 // The token walk is independent of today's provider.Message fields, so future
314 // slice fields inherit the same aggregate object-graph bound automatically.
315 func decodeSessionEventMessages(
316 ctx context.Context,
317 path string,
318 raw json.RawMessage,
319 existingMessages, existingCollectionItems int,
320 limits sessionReplayLimits,
321 ) ([]provider.Message, int, error) {
322 trimmed := bytes.TrimSpace(raw)
323 if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
324 return nil, existingCollectionItems, nil
325 }
326 messageCount, collectionItems, err := preflightSessionEventMessages(
327 ctx, path, trimmed, existingMessages, existingCollectionItems, limits,
328 )
329 if err != nil {
330 return nil, existingCollectionItems, err
331 }
332 dec := json.NewDecoder(&contextReader{ctx: ctx, reader: bytes.NewReader(trimmed)})
333 tok, err := dec.Token()
334 if err != nil {
335 return nil, existingCollectionItems, err
336 }
337 if delim, ok := tok.(json.Delim); !ok || delim != '[' {
338 return nil, existingCollectionItems, fmt.Errorf("messages must be an array")
339 }
340 msgs := make([]provider.Message, 0, messageCount)
341 for dec.More() {
342 if err := ctx.Err(); err != nil {
343 return nil, existingCollectionItems, err
344 }
345 var msg provider.Message
346 if err := dec.Decode(&msg); err != nil {
347 return nil, existingCollectionItems, err
348 }
349 msgs = append(msgs, msg)
350 }
351 if _, err := dec.Token(); err != nil {
352 return nil, existingCollectionItems, err
353 }
354 return msgs, collectionItems, nil
355 }
356
357 // repairSessionEventLogTail truncates undecodable bytes left by a crash or
358 // disk-full append so the next append cannot bury them mid-log where replay
359 // would stop forever. Callers must hold the session file lock. The event
360 // index's LogSize doubles as a cheap intact check so the common case never
361 // re-reads the log.
362 func repairSessionEventLogTail(sessionPath string) error {
363 path := store.SessionEventLog(sessionPath)
364 if path == "" {
365 return nil
366 }
367 info, err := os.Stat(path)
368 if err != nil {
369 if os.IsNotExist(err) {
370 return nil
371 }
372 return err
373 }
374 if info.IsDir() || info.Size() == 0 {
375 return nil
376 }
377 if idx, err := readSessionEventIndex(sessionPath); err == nil && idx != nil && idx.LogSize == info.Size() {
378 return nil
379 }
380 replay, err := replaySessionEventLog(path)
381 if err != nil {
382 return err
383 }
384 if replay.lastGoodEnd >= replay.size {
385 return nil
386 }
387 // Salvage the bytes the truncation below discards. A torn tail is usually
388 // one partial record, but replay also stops at a buried undecodable or
389 // out-of-order record (e.g. two runtimes interleaving appends on one log) —
390 // then everything past it, including intact turns, would be silently and
391 // permanently lost (#6607). Preservation is best-effort: it must not block
392 // the repair (the log has to become appendable again either way), and its
393 // most likely failure — a full disk — is the same condition that tears
394 // tails in the first place.
395 if preserveErr := preserveDamagedEventLogTail(sessionPath, path, replay.lastGoodEnd, replay.size); preserveErr != nil {
396 slog.Warn("session: could not preserve damaged event log tail; truncating anyway",
397 "path", path, "from", replay.lastGoodEnd, "size", replay.size, "err", preserveErr)
398 }
399 if err := os.Truncate(path, replay.lastGoodEnd); err != nil {
400 return err
401 }
402 if replay.lastGoodEnd == 0 {
403 return nil
404 }
405 // The truncation point sits exactly at the end of a JSON value; restore
406 // the trailing newline so the file stays line-oriented for external tools.
407 f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600)
408 if err != nil {
409 return err
410 }
411 if err := f.Chmod(0o600); err != nil {
412 _ = f.Close()
413 return err
414 }
415 if _, err := f.Write([]byte{'\n'}); err != nil {
416 f.Close()
417 return err
418 }
419 return f.Close()
420 }
421
422 // preserveDamagedEventLogTail appends the about-to-be-truncated byte range of
423 // the event log to the .damaged salvage sidecar, prefixed with a one-line JSON
424 // header recording when and where the bytes came from. The sidecar is a
425 // forensic artifact for recovery, never replayed by the loader, and is removed
426 // with the session's other sidecars on delete.
427 func preserveDamagedEventLogTail(sessionPath, logPath string, from, to int64) error {
428 if to <= from {
429 return nil
430 }
431 src, err := os.Open(logPath)
432 if err != nil {
433 return err
434 }
435 defer src.Close()
436 if _, err := src.Seek(from, io.SeekStart); err != nil {
437 return err
438 }
439 dst, err := os.OpenFile(store.SessionEventLogDamaged(sessionPath), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
440 if err != nil {
441 return err
442 }
443 header := fmt.Sprintf("{\"damaged_tail\":true,\"preserved_at\":%q,\"log_offset\":%d,\"bytes\":%d}\n",
444 time.Now().UTC().Format(time.RFC3339), from, to-from)
445 if _, err := dst.WriteString(header); err != nil {
446 dst.Close()
447 return err
448 }
449 if _, err := io.CopyN(dst, src, to-from); err != nil && !errors.Is(err, io.EOF) {
450 dst.Close()
451 return err
452 }
453 if _, err := dst.WriteString("\n"); err != nil {
454 dst.Close()
455 return err
456 }
457 return dst.Close()
458 }
459
460 func appendSessionEvent(sessionPath string, rec sessionEventRecord, sync bool) error {
461 path := store.SessionEventLog(sessionPath)
462 if path == "" {
463 return fmt.Errorf("empty session event log path")
464 }
465 fileutil.Crash("wal-append", path)
466 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
467 return err
468 }
469 rec.SchemaVersion = sessionEventSchemaVersion
470 if rec.CreatedAt.IsZero() {
471 rec.CreatedAt = time.Now().UTC()
472 }
473 if rec.WriterID == "" {
474 rec.WriterID = SessionWriterID()
475 }
476 buf, err := json.Marshal(rec)
477 if err != nil {
478 return fmt.Errorf("encode session event: %w", err)
479 }
480 buf = append(buf, '\n')
481 f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
482 if err != nil {
483 return fmt.Errorf("open session event log: %w", err)
484 }
485 // The event log carries the complete transcript. Chmod after opening so
486 // upgrading a pre-v0.53-boundary 0644 sidecar tightens the existing inode
487 // before any unredacted message is appended; OpenFile's perm only applies
488 // when the file is newly created.
489 if err := f.Chmod(0o600); err != nil {
490 _ = f.Close()
491 return fmt.Errorf("protect session event log: %w", err)
492 }
493 if _, err := f.Write(buf); err != nil {
494 _ = f.Close()
495 return fmt.Errorf("append session event: %w", err)
496 }
497 if sync {
498 if err := f.Sync(); err != nil {
499 _ = f.Close()
500 return err
501 }
502 }
503 return f.Close()
504 }
505
506 func appendSessionReplaceEvent(sessionPath string, msgs []provider.Message, digest [sha256.Size]byte, baseRevision int64, reason string) error {
507 // Replace events carry the whole transcript and mark intentional history
508 // rewrites; they are rare and fsynced so a power cut cannot lose one.
509 return appendSessionEvent(sessionPath, sessionEventRecord{
510 Type: sessionEventTypeReplace,
511 Revision: baseRevision + 1,
512 BaseRevision: baseRevision,
513 MessageIndex: 0,
514 Messages: append([]provider.Message(nil), msgs...),
515 ContentDigest: digestString(digest),
516 Reason: reason,
517 }, true)
518 }
519
520 func appendSessionAppendEvent(sessionPath string, messageIndex int, msgs []provider.Message, digest [sha256.Size]byte, baseRevision int64) error {
521 if len(msgs) == 0 {
522 return nil
523 }
524 return appendSessionEvent(sessionPath, sessionEventRecord{
525 Type: sessionEventTypeAppend,
526 Revision: baseRevision + 1,
527 BaseRevision: baseRevision,
528 MessageIndex: messageIndex,
529 Messages: append([]provider.Message(nil), msgs...),
530 ContentDigest: digestString(digest),
531 }, true)
532 }
533
534 // compactSessionEventLog rewrites the log as a single replace event via an
535 // atomic tmp+fsync+rename, so readers observe either the old log or the
536 // compacted one and never a partial state. It also heals a damaged log by
537 // construction.
538 func compactSessionEventLog(sessionPath string, msgs []provider.Message, digest [sha256.Size]byte, baseRevision int64, reason string) error {
539 path := store.SessionEventLog(sessionPath)
540 if path == "" {
541 return fmt.Errorf("empty session event log path")
542 }
543 rec := sessionEventRecord{
544 SchemaVersion: sessionEventSchemaVersion,
545 Type: sessionEventTypeReplace,
546 Revision: baseRevision + 1,
547 BaseRevision: baseRevision,
548 Messages: append([]provider.Message(nil), msgs...),
549 ContentDigest: digestString(digest),
550 WriterID: SessionWriterID(),
551 Reason: reason,
552 CreatedAt: time.Now().UTC(),
553 }
554 buf, err := json.Marshal(rec)
555 if err != nil {
556 return fmt.Errorf("encode session event: %w", err)
557 }
558 buf = append(buf, '\n')
559 return fileutil.AtomicWriteFile(path, buf, 0o600)
560 }
561
562 func readSessionEventIndex(sessionPath string) (*sessionEventIndex, error) {
563 path := store.SessionEventIndex(sessionPath)
564 if path == "" {
565 return nil, nil
566 }
567 b, err := fileencoding.ReadFileUTF8(path)
568 if err != nil {
569 return nil, err
570 }
571 var idx sessionEventIndex
572 if err := json.Unmarshal(b, &idx); err != nil {
573 return nil, err
574 }
575 if idx.SchemaVersion != sessionEventSchemaVersion {
576 return nil, fmt.Errorf("unsupported session event index schema %d", idx.SchemaVersion)
577 }
578 return &idx, nil
579 }
580
581 func writeSessionEventIndex(path string, msgs []provider.Message, digest [sha256.Size]byte, revision int64) error {
582 return writeSessionEventIndexContext(context.Background(), path, msgs, digest, revision)
583 }
584
585 func writeSessionEventIndexContext(ctx context.Context, path string, msgs []provider.Message, digest [sha256.Size]byte, revision int64) error {
586 indexPath := store.SessionEventIndex(path)
587 if indexPath == "" {
588 return nil
589 }
590 if err := ctx.Err(); err != nil {
591 return err
592 }
593 logInfo, err := os.Stat(store.SessionEventLog(path))
594 if err != nil {
595 if os.IsNotExist(err) {
596 // No log means nothing for the index to describe; drop a stale
597 // index left by migration or manual sidecar cleanup.
598 if err := os.Remove(indexPath); err != nil && !os.IsNotExist(err) {
599 return err
600 }
601 return nil
602 }
603 return err
604 }
605 idx := sessionEventIndex{
606 SchemaVersion: sessionEventSchemaVersion,
607 LogSize: logInfo.Size(),
608 MessageCount: len(msgs),
609 Revision: revision,
610 ContentDigest: digestString(digest),
611 WriterID: SessionWriterID(),
612 UpdatedAt: time.Now().UTC(),
613 }
614 b, err := marshalJSONIndentContext(ctx, idx)
615 if err != nil {
616 return err
617 }
618 b = append(b, '\n')
619 return atomicWriteFileContext(ctx, indexPath, ".session-event-index.*.tmp", "event-index", b, 0o600, false)
620 }
621
621 lines GO