返回 DeepSeek-Reasonix
save_dag_plan.go
根目录 / internal / agent / save_dag_plan.go
1 package agent
2
3 import (
4 "encoding/json"
5 "time"
6
7 "reasonix/internal/provider"
8 )
9
10 // dagWritePlan is the batch a save appends for one head: at most one fork or
11 // rewind, the overlays for messages that changed in place, and the new tail.
12 type dagWritePlan struct {
13 head string
14 entries []sessionDAGEntry
15 appendFrom int
16 pureAppend bool
17 forked bool
18 rewound bool
19 otherWriter string
20 renames map[string]string
21 }
22
23 // dagDiff describes how the in-memory transcript departs from the persisted
24 // chain: the common prefix and what changed inside it. adopted maps the ids
25 // of in-memory messages that matched a persisted message by content only, so
26 // two sessions that produced the same transcript independently converge.
27 type dagDiff struct {
28 k int
29 patches []int
30 redacts []int
31 systemChange bool
32 rewriteAt int
33 adopted map[string]string
34 }
35
36 // dagHeadView is one head's persisted chain as the planner sees it.
37 type dagHeadView struct {
38 id string
39 head *sessionDAGHead
40 persisted []provider.Message
41 chain []string
42 prepended bool
43 }
44
45 // nodeID maps a persisted index to its node id; "" is the prepended system
46 // override, which is not a node.
47 func (v dagHeadView) nodeID(i int) string {
48 if v.prepended {
49 if i == 0 {
50 return ""
51 }
52 return v.chain[i-1]
53 }
54 return v.chain[i]
55 }
56
57 func (v dagHeadView) parentFor(k int) string {
58 if k <= 0 {
59 return ""
60 }
61 return v.nodeID(k - 1)
62 }
63
64 // planDAGWrite diffs msgs against the head's persisted chain by id. Messages
65 // past the common prefix are appended; a changed message inside it becomes a
66 // patch (local fields), a system override, a redaction (compact mode), or,
67 // for any other provider-visible edit, a rewind followed by re-appends. A
68 // chain another writer extended underneath this session forks a concurrent
69 // head; a session that merely fell behind reports a stale-prefix conflict.
70 func (s *Session) planDAGWrite(path string, st *sessionDAGState, msgs []provider.Message, mode sessionSaveMode, now time.Time) (*dagWritePlan, error) {
71 s.mu.RLock()
72 ref := s.head.ref
73 // truncatedLocally: the transcript is a strict prefix of what this session
74 // last persisted or loaded, so a shorter transcript is its own truncation
75 // (cancel strip, rewind) rather than a sign that it fell behind disk.
76 truncatedLocally := len(msgs) < len(s.persistedMessages) && messagesHavePrefix(s.persistedMessages, msgs)
77 s.mu.RUnlock()
78 head := ref.HeadID
79 if head == "" || st.heads[head] == nil {
80 head = st.selectedHead()
81 }
82 view := dagHeadView{id: head, head: st.heads[head]}
83 view.persisted, _ = st.materialize(head)
84 view.chain = st.chainIDs(head)
85 view.prepended = len(view.persisted) == len(view.chain)+1
86 // owned: this session's baseline is exactly the head's leaf, so anything
87 // shorter or different in memory is this session's own rewrite.
88 owned := ref.HeadID == head && ref.LeafID == view.head.leaf
89
90 diff := diffDAGTranscript(view.persisted, msgs, mode)
91 plan := &dagWritePlan{head: head, appendFrom: -1, renames: diff.adopted}
92 parent, err := plan.moveHead(path, st, view, diff, msgs, owned, truncatedLocally, mode, now)
93 if err != nil {
94 return nil, err
95 }
96 if err := plan.addOverlays(view, diff, msgs, now); err != nil {
97 return nil, err
98 }
99 if err := plan.addAppends(st, msgs, diff.k, parent, now); err != nil {
100 return nil, err
101 }
102 plan.pureAppend = !diff.systemChange && len(diff.patches) == 0 && len(diff.redacts) == 0 &&
103 !plan.forked && !plan.rewound && diff.k == len(view.persisted) && diff.k < len(msgs)
104 if plan.pureAppend {
105 plan.appendFrom = diff.k
106 }
107 return plan, nil
108 }
109
110 func diffDAGTranscript(persisted, msgs []provider.Message, mode sessionSaveMode) dagDiff {
111 d := dagDiff{rewriteAt: -1, adopted: map[string]string{}}
112 for d.k < len(persisted) && d.k < len(msgs) {
113 if persisted[d.k].ID != msgs[d.k].ID {
114 if !messagesEqualForStorage(persisted[d.k], msgs[d.k]) {
115 break
116 }
117 d.adopted[msgs[d.k].ID] = persisted[d.k].ID
118 msgs[d.k].ID = persisted[d.k].ID
119 }
120 d.k++
121 }
122 for i := 0; i < d.k && d.rewriteAt < 0; i++ {
123 if messagesEqualForStorage(msgs[i], persisted[i]) {
124 continue
125 }
126 switch {
127 case messagesWireEqual(msgs[i], persisted[i]):
128 d.patches = append(d.patches, i)
129 case i == 0 && msgs[0].Role == provider.RoleSystem && persisted[0].Role == provider.RoleSystem:
130 d.systemChange = true
131 case mode == sessionSaveRewriteCompact:
132 d.redacts = append(d.redacts, i)
133 default:
134 d.rewriteAt = i
135 }
136 }
137 if d.rewriteAt >= 0 {
138 d.k = d.rewriteAt
139 }
140 return d
141 }
142
143 // moveHead decides whether the save continues the head in place, rewinds it
144 // (the session owns the leaf, so a shorter or edited transcript is its own
145 // rewrite), forks a concurrent head (someone else's messages sit past the
146 // common prefix), or must report that the session merely fell behind. It
147 // returns the parent id the appended tail hangs from.
148 func (p *dagWritePlan) moveHead(path string, st *sessionDAGState, view dagHeadView, diff dagDiff, msgs []provider.Message, owned, truncatedLocally bool, mode sessionSaveMode, now time.Time) (string, error) {
149 behind := diff.k < len(view.persisted) && diff.k == len(msgs) && diff.rewriteAt < 0
150 diverged := diff.k < len(view.persisted) && (diff.k < len(msgs) || diff.rewriteAt >= 0)
151 switch {
152 case behind && !truncatedLocally:
153 return "", &SessionSnapshotConflictError{
154 Path: path, Kind: SessionSnapshotConflictStalePrefix,
155 ExistingMessages: len(view.persisted), SnapshotMessages: len(msgs),
156 }
157 case (behind || diverged) && owned:
158 parent := view.parentFor(diff.k)
159 p.rewound = true
160 p.entries = append(p.entries, sessionDAGEntry{Type: sessionDAGTypeRewind, Head: view.id, To: parent, Cause: rewindCause(mode, diff.rewriteAt >= 0), At: now})
161 return parent, nil
162 case behind || diverged:
163 p.head = NewHeadID()
164 p.forked = true
165 if leafNode := st.nodes[view.head.leaf]; leafNode != nil {
166 p.otherWriter = leafNode.writer
167 }
168 parent := view.parentFor(diff.k)
169 p.entries = append(p.entries, sessionDAGEntry{Type: sessionDAGTypeFork, Head: view.id, NewHead: p.head, From: parent, Kind: HeadKindConcurrent, At: now})
170 return parent, nil
171 }
172 return view.head.leaf, nil
173 }
174
175 // addOverlays emits the system override, patches, and redactions for
176 // messages that changed inside the persisted prefix.
177 func (p *dagWritePlan) addOverlays(view dagHeadView, diff dagDiff, msgs []provider.Message, now time.Time) error {
178 if diff.systemChange {
179 raw, err := encodeSessionDAGMessage(msgs[0])
180 if err != nil {
181 return err
182 }
183 p.entries = append(p.entries, sessionDAGEntry{Type: sessionDAGTypeSystem, Head: p.head, Msgs: raw, At: now})
184 }
185 for _, i := range diff.patches {
186 raw, err := encodeSessionDAGMessage(msgs[i])
187 if err != nil {
188 return err
189 }
190 if id := view.nodeID(i); id != "" {
191 p.entries = append(p.entries, sessionDAGEntry{Type: sessionDAGTypePatch, Head: p.head, Target: id, Msgs: raw, At: now})
192 } else {
193 p.entries = append(p.entries, sessionDAGEntry{Type: sessionDAGTypeSystem, Head: p.head, Msgs: raw, At: now})
194 }
195 }
196 if len(diff.redacts) == 0 {
197 return nil
198 }
199 targets := make(map[string]json.RawMessage, len(diff.redacts))
200 for _, i := range diff.redacts {
201 raw, err := encodeSessionDAGMessage(msgs[i])
202 if err != nil {
203 return err
204 }
205 if id := view.nodeID(i); id != "" {
206 targets[id] = raw
207 }
208 }
209 if len(targets) > 0 {
210 p.entries = append(p.entries, sessionDAGEntry{Type: sessionDAGTypeRedact, Head: p.head, Targets: targets, Reason: "redaction", At: now})
211 }
212 return nil
213 }
214
215 // addAppends chains msgs[from:] behind parent. A message whose id already
216 // names a node (a re-append after a rewind) gets a fresh id, recorded in
217 // renames so the live session learns it.
218 func (p *dagWritePlan) addAppends(st *sessionDAGState, msgs []provider.Message, from int, parent string, now time.Time) error {
219 parentDigest := ""
220 if n := st.nodes[parent]; n != nil {
221 parentDigest = n.digest
222 }
223 for j := from; j < len(msgs); j++ {
224 m := msgs[j]
225 if _, exists := st.nodes[m.ID]; exists || m.ID == "" {
226 fresh := NewMessageID()
227 if m.ID != "" {
228 p.renames[m.ID] = fresh
229 }
230 m.ID = fresh
231 msgs[j].ID = fresh
232 }
233 e, err := newSessionDAGMessageEntry(p.head, parent, parentDigest, "", m, now)
234 if err != nil {
235 return err
236 }
237 p.entries = append(p.entries, e)
238 parent, parentDigest = m.ID, e.Digest
239 }
240 return nil
241 }
242
243 func rewindCause(mode sessionSaveMode, contentEdit bool) string {
244 switch {
245 case contentEdit:
246 return "content_edit"
247 case mode == sessionSaveRewrite, mode == sessionSaveRewriteCompact:
248 return "rewrite"
249 default:
250 return "truncate"
251 }
252 }
253
254 // messagesWireEqual reports whether two versions of a message would reach a
255 // provider identically, so the difference is safe to store as a patch.
256 func messagesWireEqual(a, b provider.Message) bool {
257 if a.Role != b.Role || a.LocalOnly != b.LocalOnly {
258 return false
259 }
260 return providerVisibleFingerprint([]provider.Message{a}) == providerVisibleFingerprint([]provider.Message{b})
261 }
262
263 // applyIDRenames writes the fresh ids of re-appended messages back into the
264 // live session so the next save recognizes them as persisted.
265 func (p *dagWritePlan) applyIDRenames(s *Session) {
266 if len(p.renames) == 0 {
267 return
268 }
269 s.mu.Lock()
270 defer s.mu.Unlock()
271 for i := range s.Messages {
272 if fresh, ok := p.renames[s.Messages[i].ID]; ok {
273 s.Messages[i].ID = fresh
274 }
275 }
276 }
277
277 lines GO