返回 DeepSeek-Reasonix
in_flight_turn.go
根目录 / internal / control / in_flight_turn.go
1 package control
2
3 import (
4 "log/slog"
5 "path/filepath"
6 "strings"
7 "time"
8
9 "reasonix/internal/agent"
10 "reasonix/internal/event"
11 "reasonix/internal/provider"
12 )
13
14 func (c *Controller) markInFlightTurn(startMessageIndex int, preserveUser bool) agent.InFlightTurnMeta {
15 path := c.SessionPath()
16 if path == "" {
17 return agent.InFlightTurnMeta{}
18 }
19 if s := c.loggedTurnSession(); s != nil {
20 id := agent.NewMessageID()
21 if s.QueueTurnBegin(id, preserveUser) {
22 ref, _ := s.Head()
23 return agent.InFlightTurnMeta{ID: id, StartMessageIndex: startMessageIndex, PreserveUser: preserveUser, StartedAt: time.Now().UTC(), HeadID: ref.HeadID}
24 }
25 }
26 marker, err := agent.BeginSessionInFlightTurn(path, startMessageIndex, preserveUser)
27 if err != nil {
28 slog.Warn("controller: mark in-flight turn", "err", err)
29 return agent.InFlightTurnMeta{}
30 }
31 return marker
32 }
33
34 func (c *Controller) clearInFlightTurn(marker agent.InFlightTurnMeta) {
35 path := c.SessionPath()
36 if path == "" || marker.ID == "" {
37 return
38 }
39 if marker.HeadID != "" {
40 if s := c.loggedTurnSession(); s != nil {
41 s.QueueTurnEnd(marker.ID)
42 }
43 return
44 }
45 if _, err := agent.ClearSessionInFlightTurnIfMatch(path, marker); err != nil {
46 slog.Warn("controller: clear in-flight turn", "err", err)
47 }
48 }
49
50 // finishInFlightTurn persists the completed transcript before removing the
51 // crash marker. A crash can therefore leave either a recoverable marker or a
52 // durable completed transcript, never an unmarked in-memory-only suffix.
53 func (c *Controller) finishInFlightTurn(startMessages int, marker agent.InFlightTurnMeta) {
54 if ledger := c.turnEventLedger(); ledger != nil && ledger.CurrentStatus() == event.TurnRecoveryRequired {
55 // The watchdog already sealed this runtime generation while its worker
56 // was still alive. Once that worker finally returns, discard every
57 // assistant/tool message it produced after the seal. Late output is
58 // diagnostic only and must never become model-visible history.
59 c.stripRecoverySealedTurn(startMessages, marker)
60 return
61 }
62 if marker.HeadID != "" {
63 c.finishLoggedTurn(startMessages, marker)
64 return
65 }
66 commitPrepared := marker.ID == ""
67 if marker.ID != "" && c.executor != nil {
68 digest, digestErr := c.executor.Session().ContentDigest()
69 if digestErr != nil {
70 slog.Warn("controller: compute completed turn digest", "err", digestErr)
71 } else if prepared, matched, prepareErr := agent.PrepareSessionInFlightTurnCommit(c.SessionPath(), marker, digest); prepareErr != nil {
72 slog.Warn("controller: prepare in-flight turn commit", "err", prepareErr)
73 } else if matched {
74 marker = prepared
75 commitPrepared = true
76 }
77 }
78 durable, err := c.snapshotActivityIfChanged(startMessages)
79 if err != nil && !durable {
80 // Keep the marker when the transcript did not become durable. Resume can
81 // then retry recovery instead of treating an in-memory-only tail as done.
82 slog.Warn("controller: keeping in-flight marker after failed turn snapshot", "err", err)
83 return
84 }
85 if err != nil {
86 slog.Warn("controller: turn transcript saved before metadata update failed", "err", err)
87 }
88 if !commitPrepared {
89 // Do not clear an unprepared marker: a crash between the snapshot and this
90 // point would otherwise leave recovery without exact commit evidence.
91 slog.Warn("controller: keeping in-flight marker without commit digest", "marker_id", marker.ID)
92 return
93 }
94 c.clearInFlightTurn(marker)
95 }
96
97 func (c *Controller) stripRecoverySealedTurn(startMessages int, marker agent.InFlightTurnMeta) {
98 if c == nil || c.executor == nil {
99 return
100 }
101 msgs := c.executor.Session().Snapshot()
102 start := startMessages
103 if resolved, ok := resolveInterruptedTurnStart(msgs, startMessages, marker.PreserveUser, marker.StartedAt, provider.Message{}); ok {
104 start = resolved
105 }
106 start = max(0, min(start, len(msgs)))
107 next := append([]provider.Message(nil), msgs[:start]...)
108 if marker.PreserveUser || marker.ID == "" {
109 for _, message := range msgs[start:] {
110 if !agent.IsUserAuthoredTurnMessage(message) {
111 continue
112 }
113 message.Content = StripComposePrefixes(message.Content)
114 next = append(next, message)
115 break
116 }
117 }
118 c.replaceSessionAfterCancel(next)
119 if marker.HeadID != "" {
120 if session := c.loggedTurnSession(); session != nil {
121 session.QueueTurnEnd(marker.ID)
122 if err := c.snapshot(false, true, false); err != nil {
123 slog.Warn("controller: persist recovery-sealed transcript", "err", err)
124 }
125 }
126 return
127 }
128 c.clearInFlightTurn(marker)
129 }
130
131 // transplantInFlightTurnMarker moves a pending in-flight-turn marker from the
132 // session path a recovery fork abandoned onto the branch the turn continues
133 // on. Left behind, the stale marker would fire recoverInterruptedTurn on the
134 // next open of the original branch and strip messages from a turn that in
135 // fact kept running on the recovery branch; missing from the recovery branch,
136 // a crash before turn end would leave its partial tail unmarked.
137 func (c *Controller) transplantInFlightTurnMarker(fromPath, toPath string) {
138 if strings.TrimSpace(fromPath) == "" || strings.TrimSpace(toPath) == "" || fromPath == toPath {
139 return
140 }
141 meta, ok, err := agent.LoadBranchMeta(fromPath)
142 if err != nil || !ok || meta.InFlightTurn == nil {
143 if err != nil {
144 slog.Warn("controller: load in-flight turn marker for transplant", "path", fromPath, "err", err)
145 }
146 return
147 }
148 marker := meta.InFlightTurn
149 if err := agent.SetSessionInFlightTurn(toPath, *marker); err != nil {
150 // Keep the original marker: a turn boundary on the wrong branch beats
151 // no boundary anywhere if the runtime dies before the turn completes.
152 slog.Warn("controller: transplant in-flight turn marker", "path", toPath, "err", err)
153 return
154 }
155 if _, err := agent.ClearSessionInFlightTurnIfMatch(fromPath, *marker); err != nil {
156 slog.Warn("controller: clear in-flight turn marker on forked-from branch", "path", fromPath, "err", err)
157 }
158 }
159
160 func (c *Controller) recoverInterruptedTurn(path string) {
161 if c.executor == nil || path == "" {
162 return
163 }
164 c.emitHeadEvents()
165 if s := c.loggedTurnSession(); s != nil {
166 if turn, open := s.OpenTurn(); open {
167 c.recoverLoggedInterruptedTurn(s, turn)
168 return
169 }
170 }
171 meta, ok, err := agent.LoadBranchMeta(path)
172 if err != nil || !ok || meta.InFlightTurn == nil {
173 if err != nil {
174 slog.Warn("controller: load in-flight turn marker", "err", err)
175 }
176 return
177 }
178 marker := meta.InFlightTurn
179 if interruptedTurnContinuedOnRecoveryBranch(path, marker) {
180 // A recovery branch forked after the marker was set: the turn kept
181 // running there, and older runtimes left the marker behind here.
182 // Stripping would truncate a transcript the completed turn superseded.
183 if _, err := agent.ClearSessionInFlightTurnIfMatch(path, *marker); err != nil {
184 slog.Warn("controller: clear fork-orphaned in-flight turn", "err", err)
185 }
186 return
187 }
188 msgs := c.executor.Session().Snapshot()
189 if marker.CommitDigest != "" {
190 if digest, digestErr := c.executor.Session().ContentDigest(); digestErr != nil {
191 slog.Warn("controller: digest resumed in-flight turn", "err", digestErr)
192 } else if digest == marker.CommitDigest {
193 // The exact transcript named before the final snapshot is present. The
194 // process died after commit and before CAS cleanup; preserve everything.
195 if _, err := agent.ClearSessionInFlightTurnIfMatch(path, *marker); err != nil {
196 slog.Warn("controller: clear committed in-flight turn marker", "err", err)
197 }
198 return
199 }
200 }
201 start, found := resolveInterruptedTurnStart(msgs, marker.StartMessageIndex, marker.PreserveUser, marker.StartedAt, provider.Message{})
202 if found && interruptedTurnCrossesLaterTurn(msgs, start) {
203 slog.Warn("controller: preserving WAL transcript after stale in-flight marker",
204 "path", path, "messages", len(msgs), "marker_index", marker.StartMessageIndex, "resolved_index", start,
205 "marker_revision", marker.StartRevision, "current_revision", meta.Revision)
206 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
207 Text: "Session recovery found completed turns after a stale interruption marker; the full WAL history was preserved."})
208 if _, err := agent.ClearSessionInFlightTurnIfMatch(path, *marker); err != nil {
209 slog.Warn("controller: clear stale multi-turn in-flight marker", "err", err)
210 }
211 return
212 }
213 changed := found && len(msgs) > start
214 if changed {
215 if marker.PreserveUser {
216 c.stripCancelledVisibleTurnMessagesAfterWithFallbackAt(start, provider.Message{}, marker.StartedAt)
217 } else {
218 c.stripTurnMessagesAfter(start)
219 }
220 if err := c.snapshot(false, true, false); err != nil {
221 slog.Warn("controller: post-interrupted-turn snapshot", "err", err)
222 }
223 }
224 if _, err := agent.ClearSessionInFlightTurnIfMatch(path, *marker); err != nil {
225 slog.Warn("controller: clear stale in-flight turn", "err", err)
226 }
227 }
228
229 // interruptedTurnCrossesLaterTurn detects the data-loss shape where an old
230 // marker survived while one or more later turns were durably appended. A
231 // compaction summary and mid-turn steer are not new foreground turn boundaries.
232 func interruptedTurnCrossesLaterTurn(msgs []provider.Message, start int) bool {
233 if start < 0 || start >= len(msgs) {
234 return false
235 }
236 turns := 0
237 for _, msg := range msgs[start:] {
238 if !agent.IsUserAuthoredTurnMessage(msg) {
239 continue
240 }
241 turns++
242 if turns > 1 {
243 return true
244 }
245 }
246 return false
247 }
248
249 // interruptedTurnContinuedOnRecoveryBranch reports whether a recovery branch
250 // forked off path after its in-flight-turn marker was set. Markers only exist
251 // while a turn runs and recovery forks happen on saves, so a child recovery
252 // branch younger than the marker means the marked turn itself moved there —
253 // the marker is a leftover from a runtime that switched paths mid-turn, not a
254 // crashed turn whose partial tail needs stripping. A marker without a start
255 // time is treated as continued whenever any recovery child exists: erring
256 // toward keeping messages is the data-safe direction.
257 func interruptedTurnContinuedOnRecoveryBranch(path string, marker *agent.InFlightTurnMeta) bool {
258 if marker == nil {
259 return false
260 }
261 branches, err := agent.ListBranches(filepath.Dir(path))
262 if err != nil {
263 return false
264 }
265 id := agent.BranchID(path)
266 for _, b := range branches {
267 if b.Recovered && b.ParentID == id && b.CreatedAt.After(marker.StartedAt) {
268 return true
269 }
270 }
271 return false
272 }
273
274 // loggedTurnSession returns the executor session when its turn markers live
275 // in a schema-2 log, nil for schema-1 sessions that still use the sidecar.
276 func (c *Controller) loggedTurnSession() *agent.Session {
277 if c.executor == nil {
278 return nil
279 }
280 s := c.executor.Session()
281 if s == nil {
282 return nil
283 }
284 if _, ok := s.Head(); !ok {
285 return nil
286 }
287 return s
288 }
289
290 // finishLoggedTurn queues the end marker before the completed transcript is
291 // saved, so the tail and its turn_end land in one appended batch. A failed
292 // save leaves the marker queued for the next one.
293 func (c *Controller) finishLoggedTurn(startMessages int, marker agent.InFlightTurnMeta) {
294 if s := c.loggedTurnSession(); s != nil {
295 s.QueueTurnEnd(marker.ID)
296 }
297 if durable, err := c.snapshotActivityIfChanged(startMessages); err != nil && !durable {
298 slog.Warn("controller: keeping open turn marker after failed turn snapshot", "err", err)
299 } else if err != nil {
300 slog.Warn("controller: turn transcript saved before metadata update failed", "err", err)
301 }
302 }
303
304 // recoverLoggedInterruptedTurn closes a turn a previous runtime left open in
305 // the schema-2 log. Its tail is dropped through a rewind marker (never a
306 // truncation) unless the turn visibly continued: on another head, or through
307 // later completed turns on this one.
308 func (c *Controller) recoverLoggedInterruptedTurn(s *agent.Session, turn agent.SessionOpenTurn) {
309 finish := func() {
310 s.QueueTurnEnd(turn.TurnID)
311 if err := c.snapshot(false, true, false); err != nil {
312 slog.Warn("controller: post-interrupted-turn snapshot", "err", err)
313 }
314 }
315 if s.TurnContinuedOnOtherHead(turn.LeafID) {
316 finish()
317 return
318 }
319 msgs := s.Snapshot()
320 start := 0
321 if turn.LeafID != "" {
322 if idx := s.IndexOfID(turn.LeafID); idx >= 0 {
323 start = idx + 1
324 } else {
325 start = len(msgs)
326 }
327 }
328 if interruptedTurnCrossesLaterTurn(msgs, start) {
329 slog.Warn("controller: preserving transcript after stale open turn marker",
330 "turn", turn.TurnID, "messages", len(msgs), "resolved_index", start)
331 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn,
332 Text: "Session recovery found completed turns after a stale interruption marker; the full history was preserved."})
333 finish()
334 return
335 }
336 if len(msgs) > start {
337 if turn.PreserveUser {
338 c.stripCancelledVisibleTurnMessagesAfterWithFallbackAt(start, provider.Message{}, turn.StartedAt)
339 } else {
340 c.stripTurnMessagesAfter(start)
341 }
342 }
343 finish()
344 }
345
346 // emitHeadEvents turns the head facts a save or load recorded into notices.
347 func (c *Controller) emitHeadEvents() {
348 if c.executor == nil {
349 return
350 }
351 s := c.executor.Session()
352 if s == nil {
353 return
354 }
355 for _, ev := range s.DrainHeadEvents() {
356 switch ev.Kind {
357 case agent.HeadEventForkedConcurrent:
358 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionConcurrentWriter,
359 "another Reasonix window or process added to this conversation; its content is kept as a separate version"))
360 case agent.HeadEventMultipleRecentHeads:
361 c.sink.Emit(sessionRecoveryNotice(event.NoticeCodeSessionHeadSwitched,
362 "opened the newest version of this conversation; other saved versions are available in View versions"))
363 }
364 }
365 }
366
366 lines GO