返回 DeepSeek-Reasonix
turn_changes.go
根目录 / internal / checkpoint / turn_changes.go
1 package checkpoint
2
3 import (
4 "fmt"
5 "slices"
6 "strings"
7
8 "reasonix/internal/diff"
9 fileenc "reasonix/internal/fileutil/encoding"
10 )
11
12 // TurnChangesBudget bounds retained patches and the content processed per turn.
13 const TurnChangesBudget = 2 << 20
14
15 // TurnChanges contains only confirmed net changes. Partial and unknown results
16 // must never be presented as an exhaustive inventory of workspace changes.
17 type TurnChanges struct {
18 ID string `json:"id,omitempty"`
19 Turn int `json:"turn"`
20 Coverage string `json:"coverage"`
21 Files []TurnFile `json:"files"`
22 Added int `json:"added"`
23 Removed int `json:"removed"`
24 Reasons []string `json:"reasons"`
25 }
26
27 type TurnFile struct {
28 Path string `json:"path"`
29 Kind diff.Kind `json:"kind"`
30 Added int `json:"added"`
31 Removed int `json:"removed"`
32 Binary bool `json:"binary,omitempty"`
33 ModeOnly bool `json:"modeOnly,omitempty"`
34 Uncounted bool `json:"uncounted,omitempty"`
35 Unavailable string `json:"unavailable,omitempty"`
36 Patch string `json:"patch,omitempty"`
37 }
38
39 func emptyTurnChanges(turn int) *TurnChanges {
40 return &TurnChanges{Turn: turn, Coverage: "unknown", Files: []TurnFile{}, Reasons: []string{}}
41 }
42
43 func cloneTurnChanges(r *TurnChanges) *TurnChanges {
44 if r == nil {
45 return nil
46 }
47 out := *r
48 out.Files = append([]TurnFile{}, r.Files...)
49 out.Reasons = append([]string{}, r.Reasons...)
50 return &out
51 }
52
53 // Summary omits patches from transport events and transcript sidecars.
54 func (r *TurnChanges) Summary() *TurnChanges {
55 out := cloneTurnChanges(r)
56 if out != nil {
57 for i := range out.Files {
58 out.Files[i].Patch = ""
59 }
60 }
61 return out
62 }
63
64 func (r *TurnChanges) gap(reason string) {
65 r.Coverage = "partial"
66 if !slices.Contains(r.Reasons, reason) {
67 r.Reasons = append(r.Reasons, reason)
68 }
69 }
70
71 // TurnChanges returns the frozen result; reading history never touches the
72 // workspace. A returned value is detached from the store's mutable state.
73 func (s *Store) TurnChanges(turn int) *TurnChanges {
74 if s == nil {
75 return emptyTurnChanges(turn)
76 }
77 s.mu.Lock()
78 defer s.mu.Unlock()
79 for _, c := range s.all() {
80 if c.Turn == turn && c.Result != nil {
81 return cloneTurnChanges(c.Result)
82 }
83 }
84 return emptyTurnChanges(turn)
85 }
86
87 // FreezeTurnChanges runs while controller turn admission is closed. The shared
88 // mutation barrier excludes owned writes without waiting on background jobs.
89 // Untracked/external writes are rejected by their after-image fingerprints.
90 func (s *Store) FreezeTurnChanges(turn int) *TurnChanges {
91 if s == nil {
92 return emptyTurnChanges(turn)
93 }
94 owned := s.barrier.TryEnterExclusive()
95 if owned {
96 defer s.barrier.ExitExclusive()
97 }
98 s.mu.Lock()
99 defer s.mu.Unlock()
100 var c *Checkpoint
101 for _, candidate := range s.all() {
102 if candidate.Turn == turn {
103 c = candidate
104 break
105 }
106 }
107 if c == nil {
108 return emptyTurnChanges(turn)
109 }
110 if c.Result != nil {
111 return cloneTurnChanges(c.Result)
112 }
113 r := emptyTurnChanges(turn)
114 r.ID = fmt.Sprintf("%d:%d", turn, c.Time.UnixNano())
115 r.Coverage = "complete"
116 for _, gap := range c.CoverageGaps {
117 r.gap(string(gap.Reason))
118 }
119 if c.Legacy || c.ExpiredFilePayload {
120 r.gap("snapshot_unavailable")
121 }
122 if !owned || len(s.activeWriters) > 0 {
123 r.gap("active_writer")
124 } else {
125 remaining := TurnChangesBudget
126 for _, f := range c.Files {
127 s.addTurnFile(r, f, &remaining)
128 }
129 }
130 c.Result = r
131 if err := s.persist(c); err != nil {
132 r.gap("result_not_saved")
133 }
134 return cloneTurnChanges(r)
135 }
136
137 func (s *Store) addTurnFile(r *TurnChanges, f FileSnap, remaining *int) {
138 if f.PayloadExpired || (f.SHA256 != "" && f.Content == nil) {
139 r.gap("snapshot_unavailable")
140 return
141 }
142 if f.AfterExisted == nil || (*f.AfterExisted && f.AfterSHA256 == "") {
143 r.gap("ownership_unknown")
144 return
145 }
146 if f.Content != nil && (f.SHA256 == "" || Digest(v3PayloadBytes(f)) != f.SHA256) {
147 r.gap("snapshot_unavailable")
148 return
149 }
150 before := ""
151 if f.Content != nil {
152 before = *f.Content
153 }
154 if len(before) >= *remaining {
155 r.gap("size_limit")
156 return
157 }
158 after, gap, err := CapturePath(f.Path, CaptureOptions{WorkspaceRoot: s.root, ReadContent: true, MaxBytes: int64(*remaining - len(before))})
159 if err != nil || gap != nil {
160 r.gap("file_unavailable")
161 return
162 }
163 if CompareIdentity(after, f.AfterSHA256, f.AfterExisted, f.AfterMode) != "" {
164 r.gap("external_change")
165 return
166 }
167 existed := f.Content != nil || f.SHA256 != ""
168 if existed == after.Existed && (!existed || (f.SHA256 == after.SHA256 && f.Mode == after.Mode)) {
169 return
170 }
171 kind := diff.Modify
172 if !existed {
173 kind = diff.Create
174 } else if !after.Existed {
175 kind = diff.Delete
176 }
177 enc, raw := fileenc.Detect(after.Content)
178 text := string(fileenc.Decode(raw, enc))
179 if len(before)+len(text) > *remaining {
180 r.gap("size_limit")
181 return
182 }
183 change := diff.Build(NormalizeRelPath(s.root, f.Path), before, text, kind)
184 entry := TurnFile{Path: change.Path, Kind: kind, Binary: change.Binary, ModeOnly: existed && after.Existed && f.SHA256 == after.SHA256 && f.Mode != after.Mode}
185 if strings.HasPrefix(change.Diff, "(diff omitted:") {
186 entry.Uncounted, entry.Unavailable = true, "size_limit"
187 r.gap("size_limit")
188 } else {
189 entry.Added, entry.Removed = change.Added, change.Removed
190 entry.Patch = change.Diff
191 if len(before)+len(text)+len(entry.Patch) > *remaining {
192 entry.Patch, entry.Unavailable = "", "size_limit"
193 }
194 }
195 *remaining -= len(before) + len(text) + len(entry.Patch)
196 r.Files = append(r.Files, entry)
197 r.Added += entry.Added
198 r.Removed += entry.Removed
199 }
200
200 lines GO