返回 DeepSeek-Reasonix
session_checkpoint.go
根目录 / internal / agent / session_checkpoint.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7
8 "reasonix/internal/i18n"
9 "reasonix/internal/provider"
10 )
11
12 // SessionCheckpointBoundary identifies a semantic durability barrier. A
13 // checkpoint is intentionally absent from ordinary message, todo, approval,
14 // and terminal appends; those use the session's write-behind policy.
15 type SessionCheckpointBoundary string
16
17 const (
18 CheckpointBeforeModel SessionCheckpointBoundary = "before_model"
19 CheckpointBeforeTopTool SessionCheckpointBoundary = "before_top_level_tool"
20 // CheckpointUserAdmitted follows the admitted user message. It is advisory:
21 // a store that already made the message durable ignores it, and a failed
22 // save does not refuse the turn.
23 CheckpointUserAdmitted SessionCheckpointBoundary = "user_admitted"
24 )
25
26 // SessionCheckpointer makes the side-effect boundary explicit without coupling
27 // the agent loop to a concrete session backend.
28 type SessionCheckpointer interface {
29 CheckpointSession(context.Context, SessionCheckpointBoundary) error
30 }
31
32 // SessionEventRecorder accepts exact, already-formed provider messages at the
33 // point the agent commits them. It avoids reconstructing the authoritative
34 // event log later by comparing mutable conversation snapshots.
35 type SessionEventRecorder interface {
36 RecordSessionMessages(context.Context, string, []provider.Message) error
37 }
38
39 // SessionMessageMutationRecorder records an explicit mutation of one stable
40 // transcript message. Local recovery/authorization metadata often changes an
41 // existing message without changing provider-visible bytes; those mutations
42 // still need a typed event and must not be rediscovered later by diffing the
43 // mutable Session.Messages slice.
44 type SessionMessageMutationRecorder interface {
45 RecordSessionMessageUpsert(context.Context, string, provider.Message) error
46 }
47
48 // SessionModelContextCommit is an exact provider-visible projection produced by
49 // one context-maintenance transaction. OperationID must be stable across
50 // retries so the session log can deduplicate an accepted commit.
51 type SessionModelContextCommit struct {
52 OperationID string
53 Reason string
54 Messages []provider.Message
55 }
56
57 // SessionModelContextCommitResult distinguishes a rejection before the event
58 // log accepted the projection from a durability failure after acceptance. Once
59 // accepted, the Agent must retain the matching in-memory projection even when
60 // the durability wait returns an error.
61 type SessionModelContextCommitResult struct {
62 Accepted bool
63 Durable bool
64 }
65
66 // SessionModelContextRecorder durably records the exact context that the next
67 // provider request would receive. It must not call back into the Agent.
68 type SessionModelContextRecorder interface {
69 RecordSessionModelContext(context.Context, SessionModelContextCommit) (SessionModelContextCommitResult, error)
70 }
71
72 func (a *Agent) SetSessionCheckpointer(checkpointer SessionCheckpointer) {
73 if a != nil {
74 a.svc.sessionCheckpointer = checkpointer
75 }
76 }
77
78 func (a *Agent) checkpointSession(ctx context.Context, boundary SessionCheckpointBoundary) error {
79 if boundary == CheckpointBeforeModel {
80 if err := a.confirmPendingModelContext(ctx); err != nil {
81 return err
82 }
83 }
84 if a == nil || a.svc.sessionCheckpointer == nil {
85 return ctx.Err()
86 }
87 if err := a.svc.sessionCheckpointer.CheckpointSession(ctx, boundary); err != nil {
88 return sessionSaveFailure(err)
89 }
90 return ctx.Err()
91 }
92
93 // confirmPendingModelContext completes an accepted context-maintenance commit
94 // before another model request can be prepared or dispatched. It deliberately
95 // reuses the frozen operation ID and payload retained by the original attempt.
96 func (a *Agent) confirmPendingModelContext(ctx context.Context) error {
97 if a == nil {
98 return ctx.Err()
99 }
100 a.sess.compactionMu.Lock()
101 pending := a.sess.pendingModelContextCommit
102 if pending == nil {
103 a.sess.compactionMu.Unlock()
104 return ctx.Err()
105 }
106 recorder, ok := a.svc.sessionCheckpointer.(SessionModelContextRecorder)
107 if !ok {
108 a.sess.compactionMu.Unlock()
109 return errors.New("confirm pending model context: recorder unavailable")
110 }
111 commit := cloneSessionModelContextCommit(*pending)
112 result, err := recorder.RecordSessionModelContext(ctx, commit)
113 if err != nil {
114 a.sess.compactionMu.Unlock()
115 return sessionSaveFailure(fmt.Errorf("confirm pending model context: %w", err))
116 }
117 if !result.Accepted || !result.Durable {
118 a.sess.compactionMu.Unlock()
119 return sessionSaveFailure(errors.New("confirm pending model context: commit is not durable"))
120 }
121 if err := a.persistCompactionStateLocked(); err != nil {
122 a.sess.compactionMu.Unlock()
123 return sessionSaveFailure(fmt.Errorf("confirm pending model context sidecar: %w", err))
124 }
125 a.sess.pendingModelContextCommit = nil
126 a.sess.checkpointState = "applied"
127 var receipt *ContextMaintenanceReceipt
128 if a.sess.compactionState.LastReceipt != nil {
129 copy := *a.sess.compactionState.LastReceipt
130 receipt = &copy
131 }
132 a.sess.compactionMu.Unlock()
133 if receipt != nil {
134 a.emitContextMaintenance(receipt)
135 }
136 return ctx.Err()
137 }
138
139 func cloneSessionModelContextCommit(commit SessionModelContextCommit) SessionModelContextCommit {
140 commit.Messages = freezeProviderRequest(provider.Request{Messages: commit.Messages}).Messages
141 return commit
142 }
143
144 // admitUserMessage publishes an admitted user message and asks the session to
145 // make it durable before the model is sampled.
146 func (a *Agent) admitUserMessage(ctx context.Context, user provider.Message) {
147 emitAdmittedUserMessage(a.svc.sink, user)
148 if a.svc.sessionCheckpointer != nil {
149 _ = a.svc.sessionCheckpointer.CheckpointSession(ctx, CheckpointUserAdmitted)
150 }
151 }
152
153 func sessionSaveFailure(err error) error {
154 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
155 return err
156 }
157 return fmt.Errorf("%s: %w", i18n.M.SessionSaveRecovery, err)
158 }
159
160 func (a *Agent) appendCommittedMessages(ctx context.Context, reason string, messages ...provider.Message) error {
161 if a == nil || len(messages) == 0 {
162 return nil
163 }
164 for i := range messages {
165 if messages[i].ID == "" {
166 messages[i].ID = NewMessageID()
167 }
168 }
169 if recorder, ok := a.svc.sessionCheckpointer.(SessionEventRecorder); ok {
170 if err := recorder.RecordSessionMessages(ctx, reason, messages); err != nil {
171 return err
172 }
173 }
174 a.sess.conversation.AddBatch(messages...)
175 return nil
176 }
177
178 // ModelHistorySnapshot returns the exact context projection that the next
179 // provider request would receive. Compaction recorders use it after an
180 // installed projection rather than deriving context from summary prose.
181 func (a *Agent) ModelHistorySnapshot() []provider.Message {
182 if a == nil {
183 return nil
184 }
185 return append([]provider.Message(nil), a.modelVisibleMessages()...)
186 }
187
187 lines GO