返回 DeepSeek-Reasonix
rewind.go
根目录 / internal / control / rewind.go
1 package control
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "log/slog"
8 "sync/atomic"
9
10 "reasonix/internal/checkpoint"
11 "reasonix/internal/diff"
12 "reasonix/internal/event"
13 "reasonix/internal/provider"
14 )
15
16 // ErrRewindCoverageConfirmationRequired is returned by the compatibility
17 // Rewind path when restoring files from a partially covered checkpoint. New
18 // callers should preview with PrepareRewind, show the coverage warning, and
19 // commit only after the user explicitly confirms it.
20 var ErrRewindCoverageConfirmationRequired = errors.New("partial checkpoint coverage requires explicit confirmation")
21
22 // RewindPlanRequiresConfirmation reports whether a prepared plan can restore
23 // files but cannot guarantee that every workspace mutation was captured.
24 func RewindPlanRequiresConfirmation(plan checkpoint.RewindPlan) bool {
25 wantsFiles := plan.Scope == checkpoint.RewindCode || plan.Scope == checkpoint.RewindBoth
26 return wantsFiles && plan.CanFiles && (plan.Coverage == checkpoint.CoveragePartial || len(plan.CoverageGaps) > 0)
27 }
28
29 // conversationApplier bridges checkpoint transactions to controller session state.
30 type conversationApplier struct {
31 c *Controller
32 }
33
34 func (a conversationApplier) ApplyConversationTruncate(boundary int, forward []byte) error {
35 c := a.c
36 if c.executor == nil {
37 return fmt.Errorf("executor unavailable")
38 }
39 s := c.executor.Session()
40 msgs := s.Snapshot()
41 if boundary > len(msgs) {
42 return fmt.Errorf("conversation rewind unavailable: the conversation was compacted past this point")
43 }
44 if len(forward) == 0 {
45 var err error
46 forward, err = json.Marshal(msgs)
47 if err != nil {
48 return err
49 }
50 }
51 s.Rewrite(msgs[:boundary], "rewind_truncate")
52 if err := c.SnapshotRewrite(); err != nil {
53 _ = a.RestoreConversation(forward)
54 return fmt.Errorf("persist conversation after rewind: %w", err)
55 }
56 return nil
57 }
58
59 func (a conversationApplier) RestoreConversation(forward []byte) error {
60 c := a.c
61 if c.executor == nil {
62 return fmt.Errorf("executor unavailable")
63 }
64 var msgs []provider.Message
65 if err := json.Unmarshal(forward, &msgs); err != nil {
66 return err
67 }
68 c.executor.Session().Rewrite(msgs, "rewind_restore")
69 if err := c.SnapshotRewrite(); err != nil {
70 return fmt.Errorf("restore conversation: %w", err)
71 }
72 return nil
73 }
74
75 func (a conversationApplier) TruncateCheckpoints(fromTurn int) error {
76 return a.c.checkpoints.truncateFrom(fromTurn)
77 }
78
79 func (a conversationApplier) RestoreCheckpoints(backup []byte) error {
80 store := a.c.checkpoints.storeRef()
81 if store == nil {
82 return fmt.Errorf("checkpoints unavailable")
83 }
84 if err := store.RestoreCheckpointBackupPublic(backup); err != nil {
85 return err
86 }
87 bounds := store.Bounds()
88 a.c.checkpoints.mu.Lock()
89 a.c.checkpoints.bound = bounds
90 a.c.checkpoints.turn = store.NextTurn()
91 a.c.checkpoints.mu.Unlock()
92 return nil
93 }
94
95 // PrepareRewind validates that a rewind can proceed without mutating state.
96 func (c *Controller) PrepareRewind(turn int, scope RewindScope) (checkpoint.RewindPlan, error) {
97 if !c.checkpoints.enabled() || c.executor == nil {
98 return checkpoint.RewindPlan{}, c.rewindFail(fmt.Errorf("checkpoints unavailable"))
99 }
100 if err := c.beginRotation(); err != nil {
101 if errors.Is(err, errTurnRunningRotation) {
102 return checkpoint.RewindPlan{}, c.rewindFail(fmt.Errorf("cannot rewind while a turn is running"))
103 }
104 return checkpoint.RewindPlan{}, c.rewindFail(err)
105 }
106 // Release rotation before file precheck I/O.
107 c.endRotation()
108
109 boundary, hasBound := c.checkpoints.boundary(turn)
110 store := c.checkpoints.storeRef()
111 if store == nil {
112 return checkpoint.RewindPlan{}, c.rewindFail(fmt.Errorf("checkpoints unavailable"))
113 }
114 if obs := c.mutationObserver; obs != nil {
115 store.SetActiveWriters(obs.ActiveWriters())
116 }
117 rev := atomic.LoadInt64(&c.sessionRevision)
118 plan, err := store.PrepareRewind(turn, checkpoint.RewindScope(scope), rev, boundary, hasBound)
119 if err != nil {
120 return plan, c.rewindFail(err)
121 }
122 if scope == RewindBoth && !plan.CanConversation {
123 plan.CanFiles = false
124 if plan.DisabledReason == "" {
125 plan.DisabledReason = "conversation boundary unavailable"
126 }
127 }
128 return plan, nil
129 }
130
131 // CommitRewind executes a prepared plan under rotation gate + mutation barrier.
132 func (c *Controller) CommitRewind(planID string) (checkpoint.RewindResult, error) {
133 if !c.checkpoints.enabled() || c.executor == nil {
134 return checkpoint.RewindResult{}, c.rewindFail(fmt.Errorf("checkpoints unavailable"))
135 }
136 if err := c.beginRotation(); err != nil {
137 if errors.Is(err, errTurnRunningRotation) {
138 return checkpoint.RewindResult{}, c.rewindFail(fmt.Errorf("cannot rewind while a turn is running"))
139 }
140 return checkpoint.RewindResult{}, c.rewindFail(err)
141 }
142 defer c.endRotation()
143
144 store := c.checkpoints.storeRef()
145 if store == nil {
146 return checkpoint.RewindResult{}, c.rewindFail(fmt.Errorf("checkpoints unavailable"))
147 }
148 if err := store.ValidatePlanSessionRevision(planID, atomic.LoadInt64(&c.sessionRevision)); err != nil {
149 conflict := checkpoint.RewindConflict{Reason: checkpoint.ConflictStalePlan}
150 return checkpoint.RewindResult{OK: false, Error: err.Error(), Conflicts: []checkpoint.RewindConflict{conflict}}, c.rewindFail(err)
151 }
152
153 forward, err := json.Marshal(c.executor.Session().Snapshot())
154 if err != nil {
155 return checkpoint.RewindResult{}, c.rewindFail(err)
156 }
157
158 result, err := store.CommitRewindWithForward(planID, forward, conversationApplier{c: c}, nil)
159 if err != nil {
160 return result, c.rewindFail(err)
161 }
162 if result.OK {
163 if len(result.Written) > 0 || len(result.Deleted) > 0 {
164 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
165 Text: fmt.Sprintf("rewound code — %d file(s) restored, %d removed", len(result.Written), len(result.Deleted))})
166 }
167 if result.ConversationOK {
168 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
169 Text: "rewound conversation"})
170 }
171 atomic.AddInt64(&c.sessionRevision, 1)
172 }
173 return result, nil
174 }
175
176 // UndoRewind reverses the last committed rewind transaction when still available.
177 func (c *Controller) UndoRewind(transactionID string) (checkpoint.RewindResult, error) {
178 if !c.checkpoints.enabled() || c.executor == nil {
179 return checkpoint.RewindResult{}, c.rewindFail(fmt.Errorf("checkpoints unavailable"))
180 }
181 if err := c.beginRotation(); err != nil {
182 if errors.Is(err, errTurnRunningRotation) {
183 return checkpoint.RewindResult{}, c.rewindFail(fmt.Errorf("cannot undo rewind while a turn is running"))
184 }
185 return checkpoint.RewindResult{}, c.rewindFail(err)
186 }
187 defer c.endRotation()
188
189 store := c.checkpoints.storeRef()
190 if store == nil {
191 return checkpoint.RewindResult{}, c.rewindFail(fmt.Errorf("checkpoints unavailable"))
192 }
193 result, err := store.UndoRewind(transactionID, conversationApplier{c: c})
194 if err != nil {
195 return result, c.rewindFail(err)
196 }
197 if result.OK {
198 atomic.AddInt64(&c.sessionRevision, 1)
199 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: "undid last rewind"})
200 }
201 return result, nil
202 }
203
204 // PrepareFileRevert prepares a single-file restore to the session's first-touch preimage.
205 func (c *Controller) PrepareFileRevert(path string) (checkpoint.RewindPlan, error) {
206 if !c.checkpoints.enabled() || c.executor == nil {
207 return checkpoint.RewindPlan{}, c.rewindFail(fmt.Errorf("checkpoints unavailable"))
208 }
209 store := c.checkpoints.storeRef()
210 if store == nil {
211 return checkpoint.RewindPlan{}, c.rewindFail(fmt.Errorf("checkpoints unavailable"))
212 }
213 state, ok := store.FileState(path)
214 if !ok {
215 return checkpoint.RewindPlan{
216 Path: path, CanFiles: false, DisabledReason: "file is not session-owned",
217 }, nil
218 }
219 _ = state
220 return store.PrepareFileRevert(path, atomic.LoadInt64(&c.sessionRevision))
221 }
222
223 // CommitFileRevert commits a single-file restore with optional conflict resolution.
224 func (c *Controller) CommitFileRevert(planID string, resolution checkpoint.ConflictResolution) (checkpoint.RewindResult, error) {
225 if !c.checkpoints.enabled() || c.executor == nil {
226 return checkpoint.RewindResult{}, c.rewindFail(fmt.Errorf("checkpoints unavailable"))
227 }
228 if err := c.beginRotation(); err != nil {
229 if errors.Is(err, errTurnRunningRotation) {
230 return checkpoint.RewindResult{}, c.rewindFail(fmt.Errorf("cannot revert file while a turn is running"))
231 }
232 return checkpoint.RewindResult{}, c.rewindFail(err)
233 }
234 defer c.endRotation()
235
236 store := c.checkpoints.storeRef()
237 if store == nil {
238 return checkpoint.RewindResult{}, c.rewindFail(fmt.Errorf("checkpoints unavailable"))
239 }
240 if err := store.ValidatePlanSessionRevision(planID, atomic.LoadInt64(&c.sessionRevision)); err != nil {
241 conflict := checkpoint.RewindConflict{Reason: checkpoint.ConflictStalePlan}
242 return checkpoint.RewindResult{OK: false, Error: err.Error(), Conflicts: []checkpoint.RewindConflict{conflict}}, c.rewindFail(err)
243 }
244 result, err := store.CommitFileRevert(planID, resolution)
245 if err != nil {
246 return result, c.rewindFail(err)
247 }
248 if result.OK {
249 atomic.AddInt64(&c.sessionRevision, 1)
250 }
251 return result, nil
252 }
253
254 // Rewind is the compatibility wrapper used by CLI and existing desktop paths.
255 // Conversation failures never leave files half-applied for both-scope: files are
256 // captured first, restored second, and conversation is persisted last with full
257 // compensation on failure.
258 func (c *Controller) Rewind(turn int, scope RewindScope) error {
259 if !c.checkpoints.enabled() || c.executor == nil {
260 return c.rewindFail(fmt.Errorf("checkpoints unavailable"))
261 }
262 if err := c.beginRotation(); err != nil {
263 if errors.Is(err, errTurnRunningRotation) {
264 return c.rewindFail(fmt.Errorf("cannot rewind while a turn is running"))
265 }
266 return c.rewindFail(err)
267 }
268 defer c.endRotation()
269
270 boundary, hasBound := c.checkpoints.boundary(turn)
271 var forward []byte
272 if scope == RewindConversation || scope == RewindBoth {
273 if !hasBound {
274 return c.rewindFail(fmt.Errorf("conversation rewind unavailable for turn %d (resumed session)", turn))
275 }
276 msgs := c.executor.Session().Snapshot()
277 if boundary > len(msgs) {
278 return c.rewindFail(fmt.Errorf("conversation rewind unavailable for turn %d: the conversation was compacted past this point", turn))
279 }
280 var err error
281 forward, err = json.Marshal(msgs)
282 if err != nil {
283 return c.rewindFail(err)
284 }
285 }
286 store := c.checkpoints.storeRef()
287 if store == nil {
288 return c.rewindFail(fmt.Errorf("checkpoints unavailable"))
289 }
290 if obs := c.mutationObserver; obs != nil {
291 store.SetActiveWriters(obs.ActiveWriters())
292 }
293 rev := atomic.LoadInt64(&c.sessionRevision)
294 plan, err := store.PrepareRewind(turn, checkpoint.RewindScope(scope), rev, boundary, hasBound)
295 if err != nil {
296 return c.rewindFail(err)
297 }
298 if (scope == RewindCode || scope == RewindBoth) && !plan.CanFiles {
299 return c.rewindFail(fmt.Errorf("%s", plan.DisabledReason))
300 }
301 if (scope == RewindConversation || scope == RewindBoth) && !plan.CanConversation {
302 return c.rewindFail(fmt.Errorf("%s", plan.DisabledReason))
303 }
304 if RewindPlanRequiresConfirmation(plan) {
305 return c.rewindFail(fmt.Errorf("%w (%d coverage gap(s))", ErrRewindCoverageConfirmationRequired, len(plan.CoverageGaps)))
306 }
307 if forward == nil {
308 forward, err = json.Marshal(c.executor.Session().Snapshot())
309 if err != nil {
310 return c.rewindFail(err)
311 }
312 }
313 result, err := store.CommitRewindWithForward(plan.PlanID, forward, conversationApplier{c: c}, nil)
314 if err != nil {
315 return c.rewindFail(err)
316 }
317 if len(result.Written) > 0 || len(result.Deleted) > 0 {
318 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
319 Text: fmt.Sprintf("rewound code to turn %d — %d file(s) restored, %d removed", turn, len(result.Written), len(result.Deleted))})
320 }
321 if result.ConversationOK {
322 c.sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelInfo,
323 Text: fmt.Sprintf("rewound conversation to turn %d", turn)})
324 }
325 atomic.AddInt64(&c.sessionRevision, 1)
326 return nil
327 }
328
329 func (c *Controller) recoverCheckpointTransactions() {
330 store := c.checkpoints.storeRef()
331 if store == nil || c.executor == nil {
332 return
333 }
334 for _, note := range store.RecoverTransactionsWithApplier(conversationApplier{c: c}) {
335 slog.Info("controller: checkpoint transaction recovery", "result", note)
336 }
337 }
338
339 // wireMutationObserver installs the v2 observer on the executor.
340 func (c *Controller) wireMutationObserver() {
341 store := c.checkpoints.storeRef()
342 if store == nil || c.executor == nil {
343 return
344 }
345 obs := checkpoint.NewMutationObserver(checkpoint.ObserverOptions{
346 Store: store,
347 WriterID: "root",
348 })
349 c.mutationObserver = obs
350 c.executor.SetMutationObserver(obs)
351 // Keep legacy pre-edit hook as a secondary path when observer is absent on
352 // a cloned agent; with observer set, BeforeMutation is preferred.
353 c.executor.SetPreEditHook(func(ch diff.Change) {
354 if c.mutationObserver != nil {
355 c.mutationObserver.BeforeMutationFromChange(ch, "legacy_hook")
356 return
357 }
358 c.checkpoints.snapshot(ch)
359 })
360 }
361
361 lines GO