返回 DeepSeek-Reasonix
ledger.go
根目录 / internal / turnevent / ledger.go
1 // Package turnevent owns the local lifecycle ledger for a session. The ledger
2 // is a projection/recovery artifact only and never contributes to model input.
3 package turnevent
4
5 import (
6 "bytes"
7 "crypto/rand"
8 "encoding/hex"
9 "encoding/json"
10 "errors"
11 "fmt"
12 "os"
13 "path/filepath"
14 "slices"
15 "sync"
16 "time"
17
18 "reasonix/internal/event"
19 "reasonix/internal/eventwire"
20 "reasonix/internal/fileutil"
21 "reasonix/internal/store"
22 )
23
24 const (
25 legacySchemaVersion = 1
26 schemaVersion = 2
27
28 defaultCompactBytes int64 = 8 << 20
29 defaultCompactEvents = 4096
30 closeCompactBytes int64 = 256 << 10
31 terminalSummaryLimit = 16
32 replayMaxEvents = 512
33 replaySoftBytes int64 = 2 << 20
34 )
35
36 var ErrTurnLedgerUnavailable = errors.New("turn event ledger unavailable")
37
38 var atomicWriteLedgerFile = fileutil.AtomicWriteFileStrict
39
40 // UnsupportedSchemaError is deliberately distinct from corruption. A newer
41 // Reasonix may own the file, so the current process must leave it untouched.
42 type UnsupportedSchemaError struct{ Version int }
43
44 func (e *UnsupportedSchemaError) Error() string {
45 return fmt.Sprintf("unsupported turn event ledger schema %d", e.Version)
46 }
47
48 // Envelope is one durable runtime event. Dynamic routing fields stay local and
49 // are never injected into prompts or provider requests.
50 type Envelope struct {
51 SchemaVersion int `json:"schemaVersion"`
52 SessionID string `json:"sessionId"`
53 TurnID string `json:"turnId"`
54 Sequence uint64 `json:"seq"`
55 ItemID string `json:"itemId,omitempty"`
56 AttemptID string `json:"attemptId,omitempty"`
57 RuntimeEpoch string `json:"runtimeEpoch,omitempty"`
58 SubmissionID string `json:"submissionId,omitempty"`
59 Source string `json:"source,omitempty"`
60 Kind string `json:"kind"`
61 Status event.TurnStatus `json:"status"`
62 TranscriptRevision int64 `json:"transcriptRevision,omitempty"`
63 TranscriptDigest string `json:"transcriptDigest,omitempty"`
64 HeadID string `json:"headId,omitempty"`
65 RewriteEpoch uint64 `json:"rewriteEpoch,omitempty"`
66 LeafMessageID string `json:"leafMessageId,omitempty"`
67 CreatedAt int64 `json:"createdAt"`
68 Event eventwire.Event `json:"event"`
69 }
70
71 // TerminalSummary is the bounded, content-free history kept by checkpoints.
72 type TerminalSummary struct {
73 TurnID string `json:"turnId"`
74 TerminalSequence uint64 `json:"terminalSeq"`
75 Status event.TurnStatus `json:"status"`
76 Outcome string `json:"outcome,omitempty"`
77 RuntimeEpoch string `json:"runtimeEpoch,omitempty"`
78 SubmissionID string `json:"submissionId,omitempty"`
79 StartedAt int64 `json:"startedAt,omitempty"`
80 FinishedAt int64 `json:"finishedAt,omitempty"`
81 DurationMs int64 `json:"durationMs,omitempty"`
82 TranscriptRevision int64 `json:"transcriptRevision,omitempty"`
83 TranscriptDigest string `json:"transcriptDigest,omitempty"`
84 HeadID string `json:"headId,omitempty"`
85 LeafMessageID string `json:"leafMessageId,omitempty"`
86 }
87
88 // ReplayView is a bounded page plus the retained-history contract a frontend
89 // needs to distinguish an ordinary sequence gap from checkpoint compaction.
90 type ReplayView struct {
91 Events []Envelope `json:"events"`
92 FloorSequence uint64 `json:"floorSeq"`
93 LatestSequence uint64 `json:"latestSeq"`
94 NextAfterSequence uint64 `json:"nextAfterSeq"`
95 HasMore bool `json:"hasMore"`
96 ResetRequired bool `json:"resetRequired"`
97 TranscriptRevision int64 `json:"transcriptRevision,omitempty"`
98 TranscriptDigest string `json:"transcriptDigest,omitempty"`
99 HeadID string `json:"headId,omitempty"`
100 LeafMessageID string `json:"leafMessageId,omitempty"`
101 RuntimeEpoch string `json:"runtimeEpoch,omitempty"`
102 }
103
104 // PendingProjection is an unacknowledged terminal Turn whose full events must
105 // remain available until the Desktop display-only sidecar is rebuilt.
106 type PendingProjection struct {
107 TurnID string
108 Status event.TurnStatus
109 Events []Envelope
110 }
111
112 type diskEventRecord struct {
113 RecordType string `json:"recordType"`
114 Envelope
115 }
116
117 type projectionAckRecord struct {
118 SchemaVersion int `json:"schemaVersion"`
119 RecordType string `json:"recordType"`
120 TurnID string `json:"turnId"`
121 TerminalSequence uint64 `json:"terminalSeq"`
122 CreatedAt int64 `json:"createdAt"`
123 }
124
125 type checkpointRecord struct {
126 SchemaVersion int `json:"schemaVersion"`
127 RecordType string `json:"recordType"`
128 SessionID string `json:"sessionId"`
129 CompactedThroughSequence uint64 `json:"compactedThroughSeq"`
130 ProjectionCommittedThrough uint64 `json:"projectionCommittedThroughSeq"`
131 LastTurnID string `json:"lastTurnId,omitempty"`
132 LastStatus event.TurnStatus `json:"lastStatus,omitempty"`
133 TranscriptRevision int64 `json:"transcriptRevision,omitempty"`
134 TranscriptDigest string `json:"transcriptDigest,omitempty"`
135 HeadID string `json:"headId,omitempty"`
136 LeafMessageID string `json:"leafMessageId,omitempty"`
137 TerminalSummaries []TerminalSummary `json:"terminalSummaries"`
138 Todos []event.Todo `json:"todos"`
139 TodoWritten bool `json:"todoWritten"`
140 Recovery *event.RecoveryStatus `json:"recovery,omitempty"`
141 }
142
143 type routingMetadata struct {
144 runtimeEpoch string
145 submissionID string
146 }
147
148 // MetricsSnapshot contains counters only; no event content, ids or paths leave
149 // the ledger through this surface.
150 // Ledger serializes sequence allocation, file I/O, projection acknowledgement
151 // and checkpoint replacement for exactly one session actor lane.
152 type Ledger struct {
153 mu sync.Mutex
154 path string
155 damaged string
156 sessionID string
157
158 nextSeq uint64
159 ledgerTurnState
160 routing routingMetadata
161 nextRouting routingMetadata
162 transcript transcriptSnapshot
163 todos []event.Todo
164 todoWritten bool
165 recovery *event.RecoveryStatus
166
167 submissionTurns map[string]string
168 records []Envelope
169 summaries []TerminalSummary
170 projectionAcks map[string]uint64
171 compactedThrough uint64
172 projectionCommittedThrough uint64
173
174 writer *os.File
175 writeVersion int
176 fileSize int64
177 poisoned error
178 requireProjectionAck bool
179
180 compactBytes int64
181 compactEvents int
182 metrics MetricsSnapshot
183 }
184
185 type ledgerTurnState struct {
186 turnStartSeq uint64
187 turnStarted int64
188 active string
189 status event.TurnStatus
190 terminal bool
191 }
192
193 // NewMemory creates the compatibility runtime projection used by execution-v2.
194 // It allocates turn identities and retains reconnect envelopes for the lifetime
195 // of the process, but never opens or writes a sidecar. Durable business facts
196 // belong to session; this ledger adapts the existing event.Sink surface while
197 // clients move to SessionQuery.
198 func NewMemory(sessionID string) *Ledger {
199 return &Ledger{
200 sessionID: sessionID,
201 nextSeq: 1,
202 writeVersion: schemaVersion,
203 submissionTurns: make(map[string]string),
204 projectionAcks: make(map[string]uint64),
205 compactBytes: defaultCompactBytes,
206 compactEvents: defaultCompactEvents,
207 }
208 }
209
210 type parsedLedger struct {
211 records []Envelope
212 summaries []TerminalSummary
213 acks map[string]uint64
214 compactedThrough uint64
215 projectionCommitted uint64
216 checkpoint transcriptSnapshot
217 todos []event.Todo
218 todoWritten bool
219 recovery *event.RecoveryStatus
220 fileSize int64
221 sawV1 bool
222 }
223
224 // Open loads the valid prefix, isolates a recognized torn tail, and converts
225 // an orphaned non-terminal turn into interrupted. Tools are never replayed.
226 func Open(sessionPath, sessionID string) (*Ledger, error) {
227 l := &Ledger{
228 path: store.SessionTurnEventLog(sessionPath), damaged: store.SessionTurnEventLogDamaged(sessionPath),
229 sessionID: sessionID, nextSeq: 1, writeVersion: schemaVersion,
230 submissionTurns: make(map[string]string), projectionAcks: make(map[string]uint64),
231 compactBytes: defaultCompactBytes, compactEvents: defaultCompactEvents,
232 }
233 if l.path == "" {
234 return l, nil
235 }
236 parsed, err := l.readAndRepairLocked()
237 if err != nil {
238 return nil, err
239 }
240 l.records = parsed.records
241 l.summaries = append([]TerminalSummary(nil), parsed.summaries...)
242 l.projectionAcks = parsed.acks
243 l.compactedThrough = parsed.compactedThrough
244 l.projectionCommittedThrough = parsed.projectionCommitted
245 l.transcript = parsed.checkpoint
246 l.todos = append([]event.Todo(nil), parsed.todos...)
247 l.todoWritten = parsed.todoWritten
248 l.recovery = cloneRecoveryStatus(parsed.recovery)
249 l.fileSize = parsed.fileSize
250
251 pendingTools := make(map[string]eventwire.Tool)
252 pendingToolOrder := make([]string, 0)
253 for _, rec := range l.records {
254 if rec.Sequence >= l.nextSeq {
255 l.nextSeq = rec.Sequence + 1
256 }
257 if rec.TurnID != "" {
258 if rec.TurnID != l.active {
259 clear(pendingTools)
260 pendingToolOrder = pendingToolOrder[:0]
261 l.turnStartSeq = rec.Sequence
262 l.turnStarted = rec.CreatedAt
263 }
264 l.active = rec.TurnID
265 l.status = rec.Status
266 l.terminal = rec.Status.Terminal()
267 l.routing = routingMetadata{runtimeEpoch: rec.RuntimeEpoch, submissionID: rec.SubmissionID}
268 if rec.SubmissionID != "" {
269 l.submissionTurns[rec.SubmissionID] = rec.TurnID
270 }
271 l.transcript = transcriptSnapshot{revision: rec.TranscriptRevision, digest: rec.TranscriptDigest, headID: rec.HeadID, leafID: rec.LeafMessageID}
272 }
273 if rec.Event.Tool != nil && rec.Event.Tool.ID != "" {
274 switch rec.Kind {
275 case "tool_dispatch", "tool_started":
276 if _, exists := pendingTools[rec.Event.Tool.ID]; !exists {
277 pendingToolOrder = append(pendingToolOrder, rec.Event.Tool.ID)
278 }
279 pendingTools[rec.Event.Tool.ID] = *rec.Event.Tool
280 case "tool_result":
281 delete(pendingTools, rec.Event.Tool.ID)
282 }
283 }
284 if rec.Kind == "turn_started" {
285 l.todos = nil
286 l.todoWritten = false
287 l.recovery = nil
288 }
289 if rec.Kind == "tool_result" && rec.Event.Tool != nil && rec.Event.Tool.TodoWritten {
290 l.todos = append([]event.Todo(nil), rec.Event.Tool.Todos...)
291 l.todoWritten = true
292 }
293 if rec.Status == event.TurnRecoveryRequired && rec.Event.Recovery != nil {
294 l.recovery = cloneRecoveryStatus(rec.Event.Recovery)
295 }
296 }
297 if l.nextSeq <= l.compactedThrough {
298 l.nextSeq = l.compactedThrough + 1
299 }
300 for _, summary := range l.summaries {
301 if summary.SubmissionID != "" {
302 l.submissionTurns[summary.SubmissionID] = summary.TurnID
303 }
304 }
305 if parsed.sawV1 && l.active != "" && !l.terminal {
306 l.writeVersion = legacySchemaVersion
307 }
308 if len(l.records) == 0 && l.compactedThrough == 0 && legacyTranscriptExists(sessionPath) {
309 id, idErr := newTurnID()
310 if idErr != nil {
311 return nil, idErr
312 }
313 l.active, l.status, l.terminal = id, event.TurnQueued, false
314 l.turnStartSeq, l.turnStarted = l.nextSeq, time.Now().UnixMilli()
315 bootstrap := event.Event{Kind: event.TurnStatusChanged, TurnID: id, Status: event.TurnCompleted}
316 if _, ok, appendErr := l.appendLocked(bootstrap, event.TurnCompleted); appendErr != nil {
317 return nil, appendErr
318 } else if !ok {
319 return nil, fmt.Errorf("bootstrap legacy session %s: terminal append rejected", sessionID)
320 }
321 }
322 if err := l.recoverToolEffects(pendingTools, pendingToolOrder); err != nil {
323 return nil, err
324 }
325 return l, nil
326 }
327
328 func legacyTranscriptExists(sessionPath string) bool {
329 if sessionPath == "" {
330 return false
331 }
332 info, err := os.Stat(sessionPath)
333 return err == nil && !info.IsDir() && info.Size() > 0
334 }
335
336 func (l *Ledger) Begin() (string, error) {
337 if l == nil {
338 return "", nil
339 }
340 l.mu.Lock()
341 defer l.mu.Unlock()
342 if l.poisoned != nil {
343 return "", l.unavailableLocked()
344 }
345 if l.active != "" && !l.terminal {
346 return "", fmt.Errorf("turn %s is still active", l.active)
347 }
348 id, err := newTurnID()
349 if err != nil {
350 return "", err
351 }
352 l.active, l.status, l.terminal = id, event.TurnQueued, false
353 l.turnStartSeq, l.turnStarted = l.nextSeq, time.Now().UnixMilli()
354 l.routing = l.nextRouting
355 l.nextRouting = routingMetadata{runtimeEpoch: l.routing.runtimeEpoch}
356 if l.routing.submissionID != "" {
357 l.submissionTurns[l.routing.submissionID] = id
358 }
359 l.transcript = transcriptSnapshot{}
360 return id, nil
361 }
362
363 func (l *Ledger) SetRoutingMetadata(runtimeEpoch, submissionID string) {
364 if l == nil {
365 return
366 }
367 l.mu.Lock()
368 l.nextRouting = routingMetadata{runtimeEpoch: runtimeEpoch, submissionID: submissionID}
369 l.mu.Unlock()
370 }
371
372 // SetRuntimeEpoch binds a newly installed runtime without changing a queued
373 // submission identity. Active turns retain the routing captured by Begin.
374 func (l *Ledger) SetRuntimeEpoch(runtimeEpoch string) {
375 l.mu.Lock()
376 defer l.mu.Unlock()
377 l.nextRouting.runtimeEpoch = runtimeEpoch
378 }
379
380 func (l *Ledger) SetSubmissionID(submissionID string) {
381 l.mu.Lock()
382 l.nextRouting.submissionID = submissionID
383 l.mu.Unlock()
384 }
385
386 func (l *Ledger) RequireProjectionAck(required bool) {
387 if l == nil {
388 return
389 }
390 l.mu.Lock()
391 l.requireProjectionAck = required
392 l.mu.Unlock()
393 }
394
395 func (l *Ledger) ProjectionAckRequired() bool {
396 if l == nil {
397 return false
398 }
399 l.mu.Lock()
400 defer l.mu.Unlock()
401 return l.requireProjectionAck
402 }
403
404 func (l *Ledger) TurnIDForSubmission(submissionID string) string {
405 if l == nil || submissionID == "" {
406 return ""
407 }
408 l.mu.Lock()
409 defer l.mu.Unlock()
410 return l.submissionTurns[submissionID]
411 }
412
413 // ObserveRawEvent counts provider stream pressure before the coalescer. It
414 // intentionally records no content or routing identity.
415 func (l *Ledger) ObserveRawEvent(e event.Event) {
416 if l == nil || (e.Kind != event.Text && e.Kind != event.Reasoning) {
417 return
418 }
419 l.mu.Lock()
420 l.metrics.RawEvents++
421 l.mu.Unlock()
422 }
423
424 // ObserveProjectionRetry counts display-sidecar retry pressure without
425 // retaining the Turn identity, transcript content or filesystem path.
426 func (l *Ledger) ObserveProjectionRetry() {
427 if l == nil {
428 return
429 }
430 l.mu.Lock()
431 l.metrics.ProjectionRetries++
432 l.mu.Unlock()
433 }
434
435 func (l *Ledger) ActiveTurnID() string {
436 if l == nil {
437 return ""
438 }
439 l.mu.Lock()
440 defer l.mu.Unlock()
441 if l.terminal {
442 return ""
443 }
444 return l.active
445 }
446
447 func (l *Ledger) CurrentStatus() event.TurnStatus {
448 if l == nil {
449 return ""
450 }
451 l.mu.Lock()
452 defer l.mu.Unlock()
453 return l.status
454 }
455
456 func (l *Ledger) ProjectionCursor() (latest, replayAfter uint64) {
457 if l == nil {
458 return 0, 0
459 }
460 l.mu.Lock()
461 defer l.mu.Unlock()
462 latest = l.latestLocked()
463 replayAfter = latest
464 if l.active != "" && !l.terminal && l.turnStartSeq > 0 {
465 replayAfter = l.turnStartSeq - 1
466 }
467 return latest, replayAfter
468 }
469
470 func (l *Ledger) Append(e event.Event, status event.TurnStatus) (event.Event, bool, error) {
471 if l == nil {
472 return e, true, nil
473 }
474 l.mu.Lock()
475 defer l.mu.Unlock()
476 return l.appendLocked(e, status)
477 }
478
479 // AppendEnvelope returns the exact committed envelope under the append lock.
480 // Consumers can project it before publication without reconstructing routing
481 // from a later read (which may already belong to the next submission).
482 func (l *Ledger) AppendEnvelope(e event.Event, status event.TurnStatus) (event.Event, Envelope, bool, error) {
483 if l == nil {
484 return e, Envelope{}, false, errors.New("turn event ledger is unavailable")
485 }
486 l.mu.Lock()
487 defer l.mu.Unlock()
488 stamped, ok, err := l.appendLocked(e, status)
489 if err != nil || !ok || stamped.Sequence == 0 {
490 return stamped, Envelope{}, ok, err
491 }
492 if n := len(l.records); n > 0 && l.records[n-1].Sequence == stamped.Sequence {
493 return stamped, l.records[n-1], true, nil
494 }
495 // In-memory sessions deliberately have no WAL records, but use the same
496 // projection protocol and routing captured by this lock.
497 kind, _ := eventwire.KindName(stamped.Kind)
498 return stamped, Envelope{SchemaVersion: schemaVersion, SessionID: l.sessionID,
499 TurnID: stamped.TurnID, Sequence: stamped.Sequence, ItemID: stamped.ItemID,
500 AttemptID: stamped.AttemptID, RuntimeEpoch: l.routing.runtimeEpoch,
501 SubmissionID: l.routing.submissionID, Source: stamped.Source, Kind: kind,
502 Status: stamped.Status, CreatedAt: time.Now().UnixMilli(), Event: eventwire.ToWire(stamped),
503 TranscriptDigest: l.transcript.digest, TranscriptRevision: l.transcript.revision,
504 HeadID: l.transcript.headID, LeafMessageID: l.transcript.leafID, RewriteEpoch: l.transcript.rewriteEpoch}, true, nil
505 }
506
507 func (l *Ledger) appendLocked(e event.Event, status event.TurnStatus) (event.Event, bool, error) {
508 if l.poisoned != nil {
509 return e, false, l.unavailableLocked()
510 }
511 if l.active == "" {
512 return e, true, nil
513 }
514 if l.terminal {
515 return e, false, nil
516 }
517 if status == "" {
518 status = l.status
519 }
520 next, err := nextTurnStatus(l.status, status)
521 if err != nil {
522 return e, false, err
523 }
524 status = next
525 e.TurnID, e.Sequence, e.Status = l.active, l.nextSeq, status
526 e.SessionID, e.RuntimeEpoch, e.SubmissionID = l.sessionID, l.routing.runtimeEpoch, l.routing.submissionID
527 if l.path == "" {
528 w := eventwire.ToWire(e)
529 attemptID := e.AttemptID
530 if e.Kind == event.StreamAttempt {
531 attemptID = e.StreamAttempt.ID
532 } else if e.Tool.AttemptID != "" {
533 attemptID = e.Tool.AttemptID
534 }
535 kind, _ := eventwire.KindName(e.Kind)
536 rec := Envelope{
537 SchemaVersion: schemaVersion, SessionID: l.sessionID, TurnID: e.TurnID,
538 Sequence: e.Sequence, ItemID: e.ItemID, AttemptID: attemptID,
539 RuntimeEpoch: l.routing.runtimeEpoch, SubmissionID: l.routing.submissionID,
540 Source: e.Source, Kind: kind, Status: status, CreatedAt: time.Now().UnixMilli(), Event: w,
541 TranscriptRevision: l.transcript.revision, TranscriptDigest: l.transcript.digest,
542 HeadID: l.transcript.headID, LeafMessageID: l.transcript.leafID, RewriteEpoch: l.transcript.rewriteEpoch,
543 }
544 l.records = append(l.records, rec)
545 l.nextSeq++
546 l.status = status
547 if status == event.TurnRecoveryRequired && e.Recovery != nil {
548 l.recovery = cloneRecoveryStatus(e.Recovery)
549 }
550 if e.Kind == event.TurnStarted {
551 l.todos = nil
552 l.todoWritten = false
553 l.recovery = nil
554 } else if e.Kind == event.ToolResult && e.Tool.TodoWritten {
555 l.todos = append([]event.Todo(nil), e.Tool.Todos...)
556 l.todoWritten = true
557 }
558 if status.Terminal() {
559 l.terminal = true
560 l.addSummaryLocked(rec, e.Outcome)
561 }
562 return e, true, nil
563 }
564
565 w := eventwire.ToWire(e)
566 attemptID := e.AttemptID
567 if e.Kind == event.StreamAttempt {
568 attemptID = e.StreamAttempt.ID
569 } else if e.Tool.AttemptID != "" {
570 attemptID = e.Tool.AttemptID
571 }
572 kind, _ := eventwire.KindName(e.Kind)
573 rec := Envelope{
574 SchemaVersion: l.writeVersion, SessionID: l.sessionID, TurnID: e.TurnID,
575 Sequence: e.Sequence, ItemID: e.ItemID, AttemptID: attemptID,
576 RuntimeEpoch: l.routing.runtimeEpoch, SubmissionID: l.routing.submissionID,
577 Source: e.Source,
578 Kind: kind, Status: status, TranscriptRevision: l.transcript.revision,
579 TranscriptDigest: l.transcript.digest, HeadID: l.transcript.headID, LeafMessageID: l.transcript.leafID,
580 RewriteEpoch: l.transcript.rewriteEpoch,
581 CreatedAt: time.Now().UnixMilli(), Event: w,
582 }
583 var line []byte
584 if l.writeVersion == legacySchemaVersion {
585 line, err = json.Marshal(rec)
586 } else {
587 rec.SchemaVersion = schemaVersion
588 line, err = json.Marshal(diskEventRecord{RecordType: "event", Envelope: rec})
589 }
590 if err != nil {
591 return e, false, err
592 }
593 terminal := status.Terminal()
594 if err := l.appendLineLocked(line, terminal); err != nil {
595 return e, false, err
596 }
597 if e.Kind == event.ToolStarted && !terminal {
598 if err := l.writer.Sync(); err != nil {
599 return e, false, l.poisonLocked(err)
600 }
601 l.metrics.SyncCount++
602 }
603 l.records = append(l.records, rec)
604 if e.Kind == event.TurnStarted {
605 l.todos = nil
606 l.todoWritten = false
607 l.recovery = nil
608 } else if e.Kind == event.ToolResult && e.Tool.TodoWritten {
609 l.todos = append([]event.Todo(nil), e.Tool.Todos...)
610 l.todoWritten = true
611 }
612 if status == event.TurnRecoveryRequired && e.Recovery != nil {
613 l.recovery = cloneRecoveryStatus(e.Recovery)
614 }
615 if e.Kind == event.Text || e.Kind == event.Reasoning {
616 l.metrics.StreamRecords++
617 }
618 l.nextSeq++
619 l.status = status
620 if terminal {
621 l.terminal = true
622 l.addSummaryLocked(rec, e.Outcome)
623 if l.writeVersion == legacySchemaVersion {
624 l.writeVersion = schemaVersion
625 }
626 }
627 return e, true, nil
628 }
629
630 func (l *Ledger) appendLineLocked(line []byte, terminal bool) error {
631 started := time.Now()
632 defer func() { l.metrics.AppendLatencyBuckets[latencyBucket(time.Since(started))]++ }()
633 if err := l.ensureWriterLocked(); err != nil {
634 return l.poisonLocked(err)
635 }
636 payload := append(append([]byte(nil), line...), '\n')
637 if _, err := l.writer.Write(payload); err != nil {
638 return l.poisonLocked(err)
639 }
640 l.fileSize += int64(len(payload))
641 l.metrics.BytesWritten += uint64(len(payload))
642 if terminal {
643 if err := l.writer.Sync(); err != nil {
644 return l.poisonLocked(err)
645 }
646 l.metrics.SyncCount++
647 if err := l.closeWriterLocked(); err != nil {
648 return l.poisonLocked(err)
649 }
650 }
651 return nil
652 }
653
654 func (l *Ledger) ensureWriterLocked() error {
655 if l.path == "" || l.writer != nil {
656 return nil
657 }
658 if err := os.MkdirAll(filepath.Dir(l.path), 0o700); err != nil {
659 return err
660 }
661 f, err := os.OpenFile(l.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
662 if err != nil {
663 return err
664 }
665 l.writer = f
666 l.metrics.OpenCount++
667 return nil
668 }
669
670 func (l *Ledger) closeWriterLocked() error {
671 if l.writer == nil {
672 return nil
673 }
674 f := l.writer
675 l.writer = nil
676 err := f.Close()
677 l.metrics.CloseCount++
678 return err
679 }
680
681 func (l *Ledger) poisonLocked(err error) error {
682 if err == nil {
683 return nil
684 }
685 _ = l.closeWriterLocked()
686 if l.poisoned == nil {
687 l.poisoned = err
688 l.metrics.WriteFailures++
689 }
690 return l.unavailableLocked()
691 }
692
693 func (l *Ledger) unavailableLocked() error {
694 return fmt.Errorf("%w: %w", ErrTurnLedgerUnavailable, l.poisoned)
695 }
696
697 // AcknowledgeProjection records that the terminal display projection is
698 // durable (or that the consumer has no separate display store), then attempts
699 // bounded checkpoint compaction while the ledger is idle.
700 func (l *Ledger) AcknowledgeProjection(turnID string) error {
701 if l == nil || turnID == "" {
702 return nil
703 }
704 l.mu.Lock()
705 defer l.mu.Unlock()
706 if l.path == "" {
707 seq := l.terminalSequenceLocked(turnID)
708 if seq == 0 {
709 return fmt.Errorf("turn %s has no terminal event", turnID)
710 }
711 l.projectionAcks[turnID] = seq
712 return nil
713 }
714 if l.poisoned != nil {
715 return l.unavailableLocked()
716 }
717 seq := l.terminalSequenceLocked(turnID)
718 if seq == 0 {
719 return fmt.Errorf("turn %s has no durable terminal event", turnID)
720 }
721 if l.projectionAcks[turnID] >= seq || seq <= l.projectionCommittedThrough {
722 // Retry a failed checkpoint after its acknowledgement became durable.
723 // Retention work must not turn a committed projection into a storage error.
724 _ = l.maybeCompactLocked(false)
725 return nil
726 }
727 rec := projectionAckRecord{SchemaVersion: schemaVersion, RecordType: "projection_ack", TurnID: turnID, TerminalSequence: seq, CreatedAt: time.Now().UnixMilli()}
728 line, err := json.Marshal(rec)
729 if err != nil {
730 return err
731 }
732 if err := l.appendLineLocked(line, false); err != nil {
733 return err
734 }
735 l.projectionAcks[turnID] = seq
736 if l.terminal {
737 if err := l.closeWriterLocked(); err != nil {
738 return l.poisonLocked(err)
739 }
740 }
741 // The projection acknowledgement is the correctness boundary. Checkpoint
742 // compaction is best-effort: on failure AtomicWriteFileStrict leaves the old
743 // sidecar intact and the metrics surface records the retry signal.
744 _ = l.maybeCompactLocked(false)
745 return nil
746 }
747
748 func (l *Ledger) terminalSequenceLocked(turnID string) uint64 {
749 for _, rec := range slices.Backward(l.records) {
750 if rec.TurnID == turnID && rec.Status.Terminal() {
751 return rec.Sequence
752 }
753 }
754 for _, summary := range slices.Backward(l.summaries) {
755 if summary.TurnID == turnID {
756 return summary.TerminalSequence
757 }
758 }
759 return 0
760 }
761
762 func (l *Ledger) addSummaryLocked(rec Envelope, outcome string) {
763 l.summaries = appendTerminalSummary(l.summaries, terminalSummaryFor(rec, outcome, l.turnStarted))
764 }
765
766 // Replay returns a bounded page without rereading the whole sidecar. Open owns
767 // validation/index construction; reconnects use the retained in-memory index.
768 func (l *Ledger) Replay(after uint64) (ReplayView, error) {
769 started := time.Now()
770 view := ReplayView{Events: []Envelope{}}
771 if l == nil {
772 return view, nil
773 }
774 l.mu.Lock()
775 defer l.mu.Unlock()
776 if l.poisoned != nil {
777 return view, l.unavailableLocked()
778 }
779 latest := l.latestLocked()
780 floor := l.compactedThrough + 1
781 if len(l.records) > 0 {
782 floor = l.records[0].Sequence
783 }
784 view.FloorSequence = floor
785 view.LatestSequence = latest
786 view.ResetRequired = after < l.compactedThrough || after > latest
787 if view.ResetRequired {
788 l.metrics.ReplayResets++
789 }
790 view.TranscriptRevision = l.transcript.revision
791 view.TranscriptDigest = l.transcript.digest
792 view.HeadID, view.LeafMessageID = l.transcript.headID, l.transcript.leafID
793 view.RuntimeEpoch = l.routing.runtimeEpoch
794 effective := after
795 if effective < l.compactedThrough || effective > latest {
796 effective = l.compactedThrough
797 }
798 view.NextAfterSequence = effective
799 var pageBytes int64
800 for _, rec := range l.records {
801 if rec.Sequence <= effective {
802 continue
803 }
804 encoded, _ := json.Marshal(rec)
805 size := int64(len(encoded))
806 if len(view.Events) >= replayMaxEvents || (len(view.Events) > 0 && pageBytes+size > replaySoftBytes) {
807 break
808 }
809 view.Events = append(view.Events, rec)
810 pageBytes += size
811 view.NextAfterSequence = rec.Sequence
812 }
813 view.HasMore = view.NextAfterSequence < latest
814 l.metrics.ReplayEvents += uint64(len(view.Events))
815 l.metrics.ReplayBytes += uint64(pageBytes)
816 l.metrics.ReplayLatencyBuckets[latencyBucket(time.Since(started))]++
817 return view, nil
818 }
819
820 // EventsAfter is retained for non-desktop callers and compatibility tests.
821 func (l *Ledger) EventsAfter(after uint64) ([]Envelope, error) {
822 if l == nil {
823 return []Envelope{}, nil
824 }
825 l.mu.Lock()
826 defer l.mu.Unlock()
827 if l.poisoned != nil {
828 return nil, l.unavailableLocked()
829 }
830 out := make([]Envelope, 0)
831 for _, rec := range l.records {
832 if rec.Sequence > after {
833 out = append(out, rec)
834 }
835 }
836 return out, nil
837 }
838
839 // PendingProjections returns complete retained event groups for terminal Turns
840 // that do not yet have a durable display projection acknowledgement.
841 func (l *Ledger) PendingProjections() []PendingProjection {
842 if l == nil {
843 return []PendingProjection{}
844 }
845 l.mu.Lock()
846 defer l.mu.Unlock()
847 return l.pendingProjectionsLocked()
848 }
849
850 func (l *Ledger) pendingProjectionsLocked() []PendingProjection {
851 byTurn := make(map[string][]Envelope)
852 order := make([]string, 0)
853 seen := make(map[string]bool)
854 for _, rec := range l.records {
855 if rec.TurnID == "" {
856 continue
857 }
858 if !seen[rec.TurnID] {
859 seen[rec.TurnID] = true
860 order = append(order, rec.TurnID)
861 }
862 byTurn[rec.TurnID] = append(byTurn[rec.TurnID], rec)
863 }
864 out := make([]PendingProjection, 0)
865 for _, turnID := range order {
866 records := byTurn[turnID]
867 if len(records) == 0 {
868 continue
869 }
870 terminal := records[len(records)-1]
871 if !terminal.Status.Terminal() || terminal.Sequence <= l.projectionCommittedThrough || l.projectionAcks[turnID] >= terminal.Sequence {
872 continue
873 }
874 out = append(out, PendingProjection{TurnID: turnID, Status: terminal.Status, Events: append([]Envelope(nil), records...)})
875 }
876 return out
877 }
878
879 func (l *Ledger) latestLocked() uint64 {
880 if l.nextSeq == 0 {
881 return 0
882 }
883 return l.nextSeq - 1
884 }
885
886 // Compact forces an idle eligible-prefix checkpoint.
887 func (l *Ledger) Compact() error {
888 if l == nil {
889 return nil
890 }
891 l.mu.Lock()
892 defer l.mu.Unlock()
893 return l.compactLocked(true)
894 }
895
896 func (l *Ledger) maybeCompactLocked(onClose bool) error {
897 if l.active != "" && !l.terminal {
898 return nil
899 }
900 if !onClose && l.fileSize < l.compactBytes && len(l.records) < l.compactEvents {
901 return nil
902 }
903 if onClose && l.fileSize < closeCompactBytes {
904 return nil
905 }
906 return l.compactLocked(false)
907 }
908
909 func (l *Ledger) compactLocked(force bool) error {
910 started := time.Now()
911 if l.path == "" || (l.active != "" && !l.terminal) {
912 return nil
913 }
914 cutoff := l.compactedThrough
915 for _, rec := range l.records {
916 if !rec.Status.Terminal() {
917 continue
918 }
919 if l.projectionAcks[rec.TurnID] < rec.Sequence && rec.Sequence > l.projectionCommittedThrough {
920 break
921 }
922 cutoff = rec.Sequence
923 }
924 if cutoff <= l.compactedThrough {
925 return nil
926 }
927 if !force && l.fileSize < l.compactBytes && len(l.records) < l.compactEvents && l.fileSize < closeCompactBytes {
928 return nil
929 }
930 if err := l.closeWriterLocked(); err != nil {
931 return l.poisonLocked(err)
932 }
933 before := l.fileSize
934 last := TerminalSummary{}
935 for _, summary := range l.summaries {
936 if summary.TerminalSequence <= cutoff && summary.TerminalSequence >= last.TerminalSequence {
937 last = summary
938 }
939 }
940 checkpoint := checkpointRecord{
941 SchemaVersion: schemaVersion, RecordType: "checkpoint", SessionID: l.sessionID,
942 CompactedThroughSequence: cutoff, ProjectionCommittedThrough: cutoff,
943 LastTurnID: last.TurnID, LastStatus: last.Status,
944 TranscriptRevision: last.TranscriptRevision, TranscriptDigest: last.TranscriptDigest,
945 HeadID: last.HeadID, LeafMessageID: last.LeafMessageID,
946 TerminalSummaries: append([]TerminalSummary(nil), l.summaries...),
947 Todos: append([]event.Todo(nil), l.todos...), TodoWritten: l.todoWritten,
948 Recovery: cloneRecoveryStatus(l.recovery),
949 }
950 if checkpoint.TerminalSummaries == nil {
951 checkpoint.TerminalSummaries = []TerminalSummary{}
952 }
953 if checkpoint.Todos == nil {
954 checkpoint.Todos = []event.Todo{}
955 }
956 line, err := json.Marshal(checkpoint)
957 if err != nil {
958 return err
959 }
960 data := append(append([]byte(nil), line...), '\n')
961 retained := make([]Envelope, 0)
962 for _, rec := range l.records {
963 if rec.Sequence <= cutoff {
964 continue
965 }
966 rec.SchemaVersion = schemaVersion
967 line, err = json.Marshal(diskEventRecord{RecordType: "event", Envelope: rec})
968 if err != nil {
969 return err
970 }
971 data = append(data, line...)
972 data = append(data, '\n')
973 retained = append(retained, rec)
974 }
975 for turnID, seq := range l.projectionAcks {
976 if seq <= cutoff {
977 continue
978 }
979 line, err = json.Marshal(projectionAckRecord{SchemaVersion: schemaVersion, RecordType: "projection_ack", TurnID: turnID, TerminalSequence: seq, CreatedAt: time.Now().UnixMilli()})
980 if err != nil {
981 return err
982 }
983 data = append(data, line...)
984 data = append(data, '\n')
985 }
986 if err := atomicWriteLedgerFile(l.path, data, 0o600); err != nil {
987 l.metrics.CompactionFailures++
988 l.metrics.CompactLatencyBuckets[latencyBucket(time.Since(started))]++
989 return err
990 }
991 l.records = retained
992 l.compactedThrough = cutoff
993 l.projectionCommittedThrough = cutoff
994 l.fileSize = int64(len(data))
995 l.writeVersion = schemaVersion
996 for turnID, seq := range l.projectionAcks {
997 if seq <= cutoff {
998 delete(l.projectionAcks, turnID)
999 }
1000 }
1001 l.metrics.Compactions++
1002 l.metrics.BytesBeforeCompact += uint64(before)
1003 l.metrics.BytesAfterCompact += uint64(len(data))
1004 l.metrics.CompactLatencyBuckets[latencyBucket(time.Since(started))]++
1005 return nil
1006 }
1007
1008 // Close releases the active descriptor and opportunistically checkpoints an
1009 // idle ledger. It never manufactures a terminal event for an active Turn.
1010 func (l *Ledger) Close() error {
1011 if l == nil {
1012 return nil
1013 }
1014 l.mu.Lock()
1015 defer l.mu.Unlock()
1016 if err := l.closeWriterLocked(); err != nil {
1017 return l.poisonLocked(err)
1018 }
1019 return l.maybeCompactLocked(true)
1020 }
1021
1022 func (l *Ledger) readAndRepairLocked() (parsedLedger, error) {
1023 result := parsedLedger{records: []Envelope{}, summaries: []TerminalSummary{}, acks: make(map[string]uint64)}
1024 data, err := os.ReadFile(l.path)
1025 if errors.Is(err, os.ErrNotExist) {
1026 return result, nil
1027 }
1028 if err != nil {
1029 return result, err
1030 }
1031 result.fileSize = int64(len(data))
1032 validBytes := 0
1033 expectedSeq := uint64(1)
1034 seenRecord := false
1035 for validBytes < len(data) {
1036 rest := data[validBytes:]
1037 newline := bytes.IndexByte(rest, '\n')
1038 if newline < 0 {
1039 break
1040 }
1041 lineEnd := validBytes + newline
1042 line := bytes.TrimSpace(data[validBytes:lineEnd])
1043 if len(line) == 0 {
1044 validBytes = lineEnd + 1
1045 continue
1046 }
1047 var header struct {
1048 SchemaVersion int `json:"schemaVersion"`
1049 RecordType string `json:"recordType"`
1050 }
1051 if err := json.Unmarshal(line, &header); err != nil {
1052 break
1053 }
1054 if header.SchemaVersion > schemaVersion {
1055 return result, &UnsupportedSchemaError{Version: header.SchemaVersion}
1056 }
1057 if header.SchemaVersion <= 0 {
1058 goto damaged
1059 }
1060 switch header.SchemaVersion {
1061 case legacySchemaVersion:
1062 var rec Envelope
1063 if err := json.Unmarshal(line, &rec); err != nil || rec.Sequence != expectedSeq {
1064 goto damaged
1065 }
1066 result.sawV1 = true
1067 result.records = append(result.records, rec)
1068 expectedSeq++
1069 case schemaVersion:
1070 switch header.RecordType {
1071 case "checkpoint":
1072 if seenRecord {
1073 goto damaged
1074 }
1075 var checkpoint checkpointRecord
1076 if err := json.Unmarshal(line, &checkpoint); err != nil {
1077 goto damaged
1078 }
1079 result.compactedThrough = checkpoint.CompactedThroughSequence
1080 result.projectionCommitted = checkpoint.ProjectionCommittedThrough
1081 result.checkpoint = transcriptSnapshot{revision: checkpoint.TranscriptRevision, digest: checkpoint.TranscriptDigest, headID: checkpoint.HeadID, leafID: checkpoint.LeafMessageID}
1082 result.todos = append([]event.Todo(nil), checkpoint.Todos...)
1083 result.todoWritten = checkpoint.TodoWritten
1084 result.recovery = cloneRecoveryStatus(checkpoint.Recovery)
1085 result.summaries = append(result.summaries, checkpoint.TerminalSummaries...)
1086 expectedSeq = checkpoint.CompactedThroughSequence + 1
1087 case "event":
1088 var rec diskEventRecord
1089 if err := json.Unmarshal(line, &rec); err != nil || rec.Sequence != expectedSeq {
1090 goto damaged
1091 }
1092 result.records = append(result.records, rec.Envelope)
1093 expectedSeq++
1094 case "projection_ack":
1095 var ack projectionAckRecord
1096 if err := json.Unmarshal(line, &ack); err != nil || ack.TurnID == "" || ack.TerminalSequence == 0 {
1097 goto damaged
1098 }
1099 result.acks[ack.TurnID] = ack.TerminalSequence
1100 default:
1101 return result, fmt.Errorf("unsupported turn event record type %q", header.RecordType)
1102 }
1103 }
1104 seenRecord = true
1105 validBytes = lineEnd + 1
1106 }
1107
1108 damaged:
1109 if validBytes < len(data) {
1110 if err := os.WriteFile(l.damaged, data[validBytes:], 0o600); err != nil {
1111 return result, err
1112 }
1113 if err := os.Truncate(l.path, int64(validBytes)); err != nil {
1114 return result, err
1115 }
1116 result.fileSize = int64(validBytes)
1117 l.metrics.TornTails++
1118 }
1119 startedByTurn := make(map[string]int64)
1120 for _, rec := range result.records {
1121 if rec.TurnID != "" {
1122 if _, ok := startedByTurn[rec.TurnID]; !ok {
1123 startedByTurn[rec.TurnID] = rec.CreatedAt
1124 }
1125 }
1126 if !rec.Status.Terminal() {
1127 continue
1128 }
1129 result.summaries = appendTerminalSummary(result.summaries, terminalSummaryFor(rec, rec.Event.Outcome, startedByTurn[rec.TurnID]))
1130 }
1131 return result, nil
1132 }
1133
1134 func appendTerminalSummary(in []TerminalSummary, summary TerminalSummary) []TerminalSummary {
1135 for i := range in {
1136 if in[i].TurnID == summary.TurnID {
1137 in[i] = summary
1138 return in
1139 }
1140 }
1141 in = append(in, summary)
1142 if len(in) > terminalSummaryLimit {
1143 in = append([]TerminalSummary(nil), in[len(in)-terminalSummaryLimit:]...)
1144 }
1145 return in
1146 }
1147
1148 func nextTurnStatus(current, requested event.TurnStatus) (event.TurnStatus, error) {
1149 if current == "" || current == requested {
1150 return requested, nil
1151 }
1152 if current.Terminal() {
1153 return requested, fmt.Errorf("turn is already terminal (%s)", current)
1154 }
1155 if current == event.TurnCancelling && !requested.Terminal() {
1156 return event.TurnCancelling, nil
1157 }
1158 valid := false
1159 switch current {
1160 case event.TurnQueued:
1161 valid = requested == event.TurnInProgress || requested == event.TurnWaitingUser || requested == event.TurnCancelling || requested.Terminal()
1162 case event.TurnInProgress:
1163 valid = requested == event.TurnWaitingUser || requested == event.TurnCancelling || requested.Terminal()
1164 case event.TurnWaitingUser:
1165 valid = requested == event.TurnInProgress || requested == event.TurnCancelling || requested.Terminal()
1166 case event.TurnCancelling:
1167 valid = requested.Terminal()
1168 }
1169 if !valid {
1170 return requested, fmt.Errorf("invalid turn status transition %s -> %s", current, requested)
1171 }
1172 return requested, nil
1173 }
1174
1175 func newTurnID() (string, error) {
1176 var raw [16]byte
1177 if _, err := rand.Read(raw[:]); err != nil {
1178 return "", err
1179 }
1180 return "turn_" + hex.EncodeToString(raw[:]), nil
1181 }
1182
1182 lines GO