返回 DeepSeek-Reasonix
state.go
根目录 / internal / recovery / state.go
1 package recovery
2
3 import (
4 "encoding/json"
5 "strings"
6 "unicode/utf8"
7 )
8
9 // episodeBudget is the host-owned hard-stop budget shared by every TaskID
10 // (root and all sub-agents) inside one Recovery Episode. Exact-operation
11 // counters remain on taskRuntime; totals and global stop live here so spawning
12 // a new sub-agent cannot reset the Episode ceiling.
13 type episodeBudget struct {
14 totalFailures uint8
15 reviewRejects uint8
16 stoppedOpRetries uint8
17 stopped bool
18 stopReason StopReason
19 finalizationOffered bool
20 finalizationConsumed bool
21 }
22
23 func (ep *episodeBudget) clear() {
24 if ep == nil {
25 return
26 }
27 *ep = episodeBudget{}
28 }
29
30 // taskRuntime holds task-local recovery evidence and exact-operation counters.
31 // Episode-level totals, reviewer rejects, and hard stop live on Gate.episode.
32 type taskRuntime struct {
33 episodeID string
34
35 // operationFailures counts qualifying failures per exact fingerprint.
36 operationFailures map[string]uint8
37 // stoppedOps records fingerprints that already hit the per-operation limit.
38 stoppedOps map[string]struct{}
39
40 // lastFailure is reviewer/diagnostic evidence for the most recent failure
41 // on this task. It does not itself act as a task-wide lock.
42 lastFailure *activeFailure
43
44 guidanceSent bool
45 // taskGrants are runtime-only semantic authorizations. Snapshot/Restore never
46 // serializes them, so a restart or session switch always drops the grant.
47 taskGrants map[string]struct{}
48 taskGrantScope string
49 }
50
51 // activeFailure is the latest failure evidence used by the reviewer and UI.
52 // Per-operation counts live on taskRuntime; Episode budgets live on Gate.
53 type activeFailure struct {
54 evidence FailureEvent
55 safeRetryUsed bool
56 diagnosis []string
57 }
58
59 const (
60 maxDiagnosisNotes = 4
61 maxDiagnosisNoteBytes = 400
62 maxDiagnosisTotalBytes = 1600 // 1.6 KiB hard cap across all notes
63 )
64
65 func (st *taskRuntime) empty() bool {
66 if st == nil {
67 return true
68 }
69 if st.lastFailure != nil || st.guidanceSent {
70 return false
71 }
72 if len(st.operationFailures) > 0 || len(st.stoppedOps) > 0 {
73 return false
74 }
75 return true
76 }
77
78 func (st *taskRuntime) hasTaskGrant(key string) bool {
79 if st == nil || key == "" || st.taskGrants == nil {
80 return false
81 }
82 _, ok := st.taskGrants[key]
83 return ok
84 }
85
86 func (st *taskRuntime) addTaskGrant(key string) {
87 if st == nil || key == "" {
88 return
89 }
90 if st.taskGrants == nil {
91 st.taskGrants = map[string]struct{}{}
92 }
93 st.taskGrants[key] = struct{}{}
94 }
95
96 func (st *taskRuntime) useTaskGrantScope(scope string) {
97 if st == nil || scope == "" {
98 return
99 }
100 if st.taskGrantScope != "" && st.taskGrantScope != scope {
101 clear(st.taskGrants)
102 }
103 st.taskGrantScope = scope
104 }
105
106 func (st *taskRuntime) hasTaskGrants() bool {
107 return st != nil && len(st.taskGrants) > 0
108 }
109
110 // clearTaskRecoveryState drops task-local operation counters and evidence.
111 // Episode-level totals are cleared separately on the Gate.
112 func (st *taskRuntime) clearTaskRecoveryState() {
113 if st == nil {
114 return
115 }
116 st.operationFailures = nil
117 st.stoppedOps = nil
118 st.lastFailure = nil
119 st.guidanceSent = false
120 }
121
122 func (st *taskRuntime) ensureMaps() {
123 if st == nil {
124 return
125 }
126 if st.operationFailures == nil {
127 st.operationFailures = map[string]uint8{}
128 }
129 if st.stoppedOps == nil {
130 st.stoppedOps = map[string]struct{}{}
131 }
132 }
133
134 func (st *taskRuntime) operationFailureCount(fp string) uint8 {
135 if st == nil || fp == "" || st.operationFailures == nil {
136 return 0
137 }
138 return st.operationFailures[fp]
139 }
140
141 func (st *taskRuntime) isOperationStopped(fp string) bool {
142 if st == nil || fp == "" {
143 return false
144 }
145 if st.stoppedOps != nil {
146 if _, ok := st.stoppedOps[fp]; ok {
147 return true
148 }
149 }
150 return st.operationFailureCount(fp) >= MaxOperationFailures
151 }
152
153 func (st *taskRuntime) markOperationStopped(fp string) {
154 if st == nil || fp == "" {
155 return
156 }
157 st.ensureMaps()
158 st.stoppedOps[fp] = struct{}{}
159 }
160
161 func (st *taskRuntime) failureCount() uint8 {
162 if st == nil {
163 return 0
164 }
165 if st.lastFailure != nil {
166 fp := strings.TrimSpace(st.lastFailure.evidence.Fingerprint)
167 if fp != "" {
168 if n := st.operationFailureCount(fp); n > 0 {
169 return n
170 }
171 }
172 }
173 return 0
174 }
175
176 func (st *taskRuntime) safeRetryAvailable() bool {
177 if st == nil || st.lastFailure == nil {
178 return false
179 }
180 return !st.lastFailure.safeRetryUsed
181 }
182
183 func (st *taskRuntime) diagnosisNotes() []string {
184 if st == nil || st.lastFailure == nil {
185 return nil
186 }
187 return append([]string(nil), st.lastFailure.diagnosis...)
188 }
189
190 func (st *taskRuntime) evidenceCopy() *FailureEvent {
191 if st == nil || st.lastFailure == nil {
192 return nil
193 }
194 return cloneFailureEvent(&st.lastFailure.evidence, st.lastFailure, st)
195 }
196
197 // cloneFailureEvent builds a wire FailureEvent with compatibility fields
198 // derived from the runtime truth.
199 func cloneFailureEvent(ev *FailureEvent, af *activeFailure, st *taskRuntime) *FailureEvent {
200 if ev == nil {
201 return nil
202 }
203 cp := *ev
204 cp.Args = append(json.RawMessage(nil), ev.Args...)
205 if af != nil {
206 fp := strings.TrimSpace(ev.Fingerprint)
207 if st != nil && fp != "" {
208 cp.RepeatCount = int(st.operationFailureCount(fp))
209 }
210 if af.safeRetryUsed {
211 cp.SafeRetryLeft = 0
212 } else {
213 cp.SafeRetryLeft = 1
214 }
215 cp.DiagnosisNotes = append([]string(nil), af.diagnosis...)
216 } else {
217 cp.DiagnosisNotes = append([]string(nil), ev.DiagnosisNotes...)
218 }
219 return &cp
220 }
221
222 // toTaskState projects live runtime truth for debugging / Snapshot().
223 // Episode-level fields are filled by the gate after this returns.
224 func (st *taskRuntime) toTaskState(phase Phase) *TaskState {
225 if st == nil || st.empty() {
226 return nil
227 }
228 out := &TaskState{
229 Phase: phase,
230 TailInjected: st.guidanceSent,
231 EpisodeID: st.episodeID,
232 }
233 if st.lastFailure != nil {
234 out.Failure = cloneFailureEvent(&st.lastFailure.evidence, st.lastFailure, st)
235 out.LastFailure = cloneFailureEvent(&st.lastFailure.evidence, st.lastFailure, st)
236 out.ConsecutiveFails = int(st.failureCount())
237 if out.Phase == PhaseIdle {
238 out.Phase = PhaseDiagnosing
239 }
240 }
241 // Pending and ApprovalID are intentionally never written: restore must not
242 // revive a transient authorization or waiter across restarts.
243 return out
244 }
245
246 // toPersistenceState projects only historical evidence. Active locks, Episode
247 // counters, generation, and waiters never land on disk.
248 func (st *taskRuntime) toPersistenceState() *TaskState {
249 if st == nil || st.lastFailure == nil {
250 return nil
251 }
252 // Evidence-only: no consecutive_fails / review_blocks as re-armable locks.
253 return &TaskState{
254 Phase: PhaseIdle,
255 LastFailure: cloneFailureEvent(&st.lastFailure.evidence, st.lastFailure, st),
256 }
257 }
258
259 // taskRuntimeFromState migrates old or new snapshots into historical evidence
260 // only. Counters are never re-armed so a restart cannot re-block the user.
261 func taskRuntimeFromState(st *TaskState) *taskRuntime {
262 if st == nil {
263 return nil
264 }
265 src := st.LastFailure
266 if src == nil {
267 src = st.Failure
268 }
269 if src == nil {
270 return nil
271 }
272 af := &activeFailure{
273 evidence: FailureEvent{
274 Tool: src.Tool,
275 ArgsSummary: src.ArgsSummary,
276 Subject: src.Subject,
277 ErrSummary: src.ErrSummary,
278 OutputExcerpt: src.OutputExcerpt,
279 SourceAgent: src.SourceAgent,
280 TaskID: src.TaskID,
281 TaskScopeID: src.TaskScopeID,
282 ReadOnly: src.ReadOnly,
283 Verification: src.Verification,
284 Mutates: src.Mutates,
285 CreatedAt: src.CreatedAt,
286 Args: append(json.RawMessage(nil), src.Args...),
287 Fingerprint: src.Fingerprint,
288 },
289 // Historical evidence only — fail closed for automatic safe retry after
290 // restore so a restart cannot grant a free second attempt.
291 safeRetryUsed: true,
292 diagnosis: append([]string(nil), src.DiagnosisNotes...),
293 }
294 trimDiagnosis(af)
295 // Do not restore consecutive_fails / review_blocks as live locks.
296 return &taskRuntime{
297 lastFailure: af,
298 }
299 }
300
301 func trimDiagnosis(af *activeFailure) {
302 if af == nil {
303 return
304 }
305 notes := af.diagnosis
306 if len(notes) > maxDiagnosisNotes {
307 notes = notes[len(notes)-maxDiagnosisNotes:]
308 }
309 total := 0
310 kept := make([]string, 0, len(notes))
311 // Keep the newest notes within the total budget.
312 for i := len(notes) - 1; i >= 0; i-- {
313 n := clipDiagnosisNote(notes[i])
314 if n == "" {
315 continue
316 }
317 if total+len(n) > maxDiagnosisTotalBytes {
318 if len(kept) == 0 {
319 n = clipBytes(n, maxDiagnosisTotalBytes)
320 if n != "" {
321 kept = append(kept, n)
322 }
323 }
324 break
325 }
326 kept = append(kept, n)
327 total += len(n)
328 }
329 for i, j := 0, len(kept)-1; i < j; i, j = i+1, j-1 {
330 kept[i], kept[j] = kept[j], kept[i]
331 }
332 af.diagnosis = kept
333 af.evidence.DiagnosisNotes = append([]string(nil), kept...)
334 }
335
336 func appendDiagnosisNote(af *activeFailure, note string) bool {
337 if af == nil {
338 return false
339 }
340 note = clipDiagnosisNote(note)
341 if note == "" {
342 return false
343 }
344 for _, existing := range af.diagnosis {
345 if existing == note {
346 return false
347 }
348 }
349 af.diagnosis = append(af.diagnosis, note)
350 trimDiagnosis(af)
351 return true
352 }
353
354 func clipDiagnosisNote(note string) string {
355 return clipBytes(strings.TrimSpace(note), maxDiagnosisNoteBytes)
356 }
357
358 func clipBytes(s string, n int) string {
359 s = strings.TrimSpace(s)
360 if n <= 0 || len(s) <= n {
361 return s
362 }
363 const ellipsis = "…"
364 cut := n - len(ellipsis)
365 if cut <= 0 {
366 return ellipsis
367 }
368 for cut > 0 && !utf8.RuneStart(s[cut]) {
369 cut--
370 }
371 return s[:cut] + ellipsis
372 }
373
374 func normalizeTaskID(id string) string {
375 id = strings.TrimSpace(id)
376 if id == "" {
377 return "root"
378 }
379 return id
380 }
381
382 func clip(s string, n int) string {
383 s = strings.TrimSpace(s)
384 if n <= 0 || len(s) <= n {
385 return s
386 }
387 return clipBytes(s, n)
388 }
389
389 lines GO