返回 DeepSeek-Reasonix
reasoning_warn_state_test.go
根目录 / internal / agent / reasoning_warn_state_test.go
1 package agent
2
3 import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "fmt"
9 "os"
10 "path/filepath"
11 "runtime"
12 "strings"
13 "sync"
14 "sync/atomic"
15 "testing"
16 "time"
17
18 "reasonix/internal/filelock"
19 )
20
21 func warningFingerprint(label string) string {
22 digest := sha256.Sum256([]byte(label))
23 return hex.EncodeToString(digest[:])
24 }
25
26 func missingReasoningTestNow() time.Time {
27 return time.Now().Add(-time.Hour).Truncate(time.Millisecond)
28 }
29
30 func TestMissingReasoningWarnStatePersistsCurrentIncidentAcrossInstances(t *testing.T) {
31 dir := t.TempDir()
32 fingerprint := warningFingerprint("openai\x00deepseek\x00v4-pro")
33 observedAt := missingReasoningTestNow()
34 if !newMissingReasoningWarnState(dir).claimAt(fingerprint, observedAt) {
35 t.Fatal("fresh configuration must claim its first incident notice")
36 }
37 if newMissingReasoningWarnState(dir).claimAt(fingerprint, observedAt.Add(time.Minute)) {
38 t.Fatal("fresh instance must suppress the same current incident")
39 }
40
41 b, err := os.ReadFile(filepath.Join(dir, missingReasoningWarnStateFilename))
42 if err != nil {
43 t.Fatalf("state file missing after claim: %v", err)
44 }
45 latestObservedAt := observedAt.Add(time.Minute)
46 want := fmt.Sprintf(`{"version":2,"incidents":[{"fingerprint":"%s","warnedAtUnixMs":%d,"lastMissingAtUnixMs":%d,"lastMissingAtUnixNano":%d}]}`,
47 fingerprint, observedAt.UnixMilli(), latestObservedAt.UnixMilli(), latestObservedAt.UnixNano())
48 if got := string(b); got != want {
49 t.Fatalf("state file = %s, want %s", got, want)
50 }
51 if strings.Contains(string(b), "deepseek") || strings.Contains(string(b), "v4-pro") {
52 t.Fatalf("state file exposed raw provider configuration: %s", b)
53 }
54 }
55
56 func TestMissingReasoningWarnStateSeparatesConfigurationFingerprints(t *testing.T) {
57 dir := t.TempDir()
58 s := newMissingReasoningWarnState(dir)
59 now := missingReasoningTestNow()
60 if !s.claimAt(warningFingerprint("endpoint-a\x00model-a"), now) {
61 t.Fatal("first configuration must warn")
62 }
63 if !s.claimAt(warningFingerprint("endpoint-a\x00model-b"), now) {
64 t.Fatal("model change must re-arm the warning")
65 }
66 if !s.claimAt(warningFingerprint("endpoint-b\x00model-a"), now) {
67 t.Fatal("endpoint change must re-arm the warning")
68 }
69 }
70
71 func TestMissingReasoningWarnStateExpiresCooldown(t *testing.T) {
72 s := newMissingReasoningWarnState(t.TempDir())
73 fingerprint := warningFingerprint("config")
74 now := missingReasoningTestNow()
75 if !s.claimAt(fingerprint, now) {
76 t.Fatal("fresh incident must warn")
77 }
78 if s.claimAt(fingerprint, now.Add(missingReasoningWarnStateCooldown-time.Second)) {
79 t.Fatal("incident inside cooldown must stay silent")
80 }
81 if !s.claimAt(fingerprint, now.Add(missingReasoningWarnStateCooldown)) {
82 t.Fatal("incident at cooldown boundary must warn again")
83 }
84 }
85
86 func TestMissingReasoningWarnStateHealthyTurnRearmsRegression(t *testing.T) {
87 s := newMissingReasoningWarnState(t.TempDir())
88 fingerprint := warningFingerprint("config")
89 now := missingReasoningTestNow()
90 if !s.claimAt(fingerprint, now) {
91 t.Fatal("fresh incident must warn")
92 }
93 for healthy := 1; healthy <= missingReasoningHealthyResolveStreak; healthy++ {
94 result := s.resolveAt(fingerprint, now.Add(time.Duration(healthy)*time.Minute))
95 if !result.Recorded {
96 t.Fatalf("healthy observation %d was not recorded", healthy)
97 }
98 if got, want := result.Resolved, healthy == missingReasoningHealthyResolveStreak; got != want {
99 t.Fatalf("healthy observation %d resolved = %v, want %v", healthy, got, want)
100 }
101 }
102 if !s.claimAt(fingerprint, now.Add(4*time.Minute)) {
103 t.Fatal("regression after three healthy turns must warn again")
104 }
105 }
106
107 func TestMissingReasoningWarnStateMissingTurnResetsHealthyStreak(t *testing.T) {
108 s := newMissingReasoningWarnState(t.TempDir())
109 fingerprint := warningFingerprint("config")
110 now := missingReasoningTestNow()
111 if !s.claimAt(fingerprint, now) {
112 t.Fatal("fresh incident must warn")
113 }
114 for healthy := 1; healthy < missingReasoningHealthyResolveStreak; healthy++ {
115 if result := s.resolveAt(fingerprint, now.Add(time.Duration(healthy)*time.Minute)); !result.Recorded || result.Resolved {
116 t.Fatalf("pre-reset healthy observation %d = %+v", healthy, result)
117 }
118 }
119 if s.claimAt(fingerprint, now.Add(3*time.Minute)) {
120 t.Fatal("missing turn inside the active incident must stay suppressed")
121 }
122 for healthy := 1; healthy < missingReasoningHealthyResolveStreak; healthy++ {
123 result := s.resolveAt(fingerprint, now.Add(time.Duration(3+healthy)*time.Minute))
124 if !result.Recorded || result.Resolved {
125 t.Fatalf("post-reset healthy observation %d = %+v", healthy, result)
126 }
127 }
128 if s.claimAt(fingerprint, now.Add(6*time.Minute)) {
129 t.Fatal("two healthy turns after a reset must not re-arm recovery")
130 }
131 }
132
133 func TestMissingReasoningWarnStateStaleHealthCannotClearNewerFailure(t *testing.T) {
134 s := newMissingReasoningWarnState(t.TempDir())
135 fingerprint := warningFingerprint("config")
136 now := missingReasoningTestNow()
137 if !s.claimAt(fingerprint, now) {
138 t.Fatal("fresh incident must warn")
139 }
140 if s.claimAt(fingerprint, now.Add(2*time.Millisecond)) {
141 t.Fatal("newer observation inside cooldown must stay silent")
142 }
143 // Simulate an older healthy observation acquiring the lock after the newer
144 // missing observation. It must not erase the newer incident.
145 s.resolveAt(fingerprint, now.Add(time.Millisecond))
146 if s.claimAt(fingerprint, now.Add(3*time.Millisecond)) {
147 t.Fatal("stale healthy observation erased a newer incident")
148 }
149 }
150
151 func TestMissingReasoningWarnStateDuplicateHealthAndDelayedFailureDoNotChangeStreak(t *testing.T) {
152 s := newMissingReasoningWarnState(t.TempDir())
153 fingerprint := warningFingerprint("config")
154 now := missingReasoningTestNow()
155 if !s.persistClaimAt(fingerprint, now) {
156 t.Fatal("fresh incident must warn")
157 }
158 firstHealthyAt := now.Add(2 * time.Millisecond)
159 if result := s.resolveAt(fingerprint, firstHealthyAt); !result.Recorded || result.Resolved {
160 t.Fatalf("first healthy observation = %+v", result)
161 }
162 if result := s.resolveAt(fingerprint, firstHealthyAt); !result.Recorded || result.Resolved {
163 t.Fatalf("duplicate healthy observation = %+v", result)
164 }
165 if s.persistClaimAt(fingerprint, now.Add(time.Millisecond)) {
166 t.Fatal("delayed failure older than healthy progress revived the incident")
167 }
168 if result := s.resolveAt(fingerprint, now.Add(3*time.Millisecond)); !result.Recorded || result.Resolved {
169 t.Fatalf("second unique healthy observation = %+v", result)
170 }
171 if result := s.resolveAt(fingerprint, now.Add(4*time.Millisecond)); !result.Recorded || !result.Resolved {
172 t.Fatalf("third unique healthy observation = %+v", result)
173 }
174 }
175
176 func TestMissingReasoningWarnStateDelayedFailureCannotReviveResolvedIncident(t *testing.T) {
177 s := newMissingReasoningWarnState(t.TempDir())
178 fingerprint := warningFingerprint("config")
179 now := time.Now()
180 firstMissingAt := now.Add(-10 * time.Millisecond)
181 delayedMissingAt := now.Add(-8 * time.Millisecond)
182 healthyAt := []time.Time{
183 now.Add(-6 * time.Millisecond),
184 now.Add(-4 * time.Millisecond),
185 now.Add(-2 * time.Millisecond),
186 }
187
188 if !s.persistClaimAt(fingerprint, firstMissingAt) {
189 t.Fatal("fresh incident must warn")
190 }
191 for i, observedAt := range healthyAt {
192 result := s.resolveAt(fingerprint, observedAt)
193 if !result.Recorded || result.Resolved != (i == len(healthyAt)-1) {
194 t.Fatalf("healthy observation %d = %+v", i+1, result)
195 }
196 }
197 // Simulate a missing observation that happened before the healthy result but
198 // completed its cross-process transaction afterward.
199 if s.persistClaimAt(fingerprint, delayedMissingAt) {
200 t.Fatal("delayed pre-recovery failure revived a resolved incident")
201 }
202 if !s.claimAt(fingerprint, now) {
203 t.Fatal("healthy result did not re-arm a later regression")
204 }
205 }
206
207 func TestMissingReasoningWarnStateV2OptionalStreakFieldsResume(t *testing.T) {
208 dir := t.TempDir()
209 path := filepath.Join(dir, missingReasoningWarnStateFilename)
210 fingerprint := warningFingerprint("config")
211 now := missingReasoningTestNow()
212 doc := fmt.Sprintf(`{"version":2,"incidents":[{"fingerprint":"%s","warnedAtUnixMs":%d,"lastMissingAtUnixMs":%d,"lastMissingAtUnixNano":%d,"resolveStreak":2,"lastHealthyAtUnixNano":%d}]}`,
213 fingerprint, now.UnixMilli(), now.UnixMilli(), now.UnixNano(), now.Add(2*time.Minute).UnixNano())
214 if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
215 t.Fatal(err)
216 }
217
218 s := newMissingReasoningWarnState(dir)
219 result := s.resolveAt(fingerprint, now.Add(3*time.Minute))
220 if !result.Recorded || !result.Resolved {
221 t.Fatalf("resumed third healthy observation = %+v", result)
222 }
223 if !s.claimAt(fingerprint, now.Add(4*time.Minute)) {
224 t.Fatal("resumed v2 streak did not re-arm a later regression")
225 }
226 b, err := os.ReadFile(path)
227 if err != nil {
228 t.Fatal(err)
229 }
230 if !strings.Contains(string(b), `"version":2`) {
231 t.Fatalf("optional fields changed the v2 document contract: %s", b)
232 }
233 }
234
235 func TestMissingReasoningWarnStateFutureLastMissingSelfHeals(t *testing.T) {
236 dir := t.TempDir()
237 path := filepath.Join(dir, missingReasoningWarnStateFilename)
238 fingerprint := warningFingerprint("config")
239 now := time.Now().Truncate(time.Millisecond)
240 doc := missingReasoningWarnDocument{
241 Version: missingReasoningWarnStateVersion,
242 Incidents: []missingReasoningIncident{{
243 Fingerprint: fingerprint,
244 WarnedAtUnixMs: now.UnixMilli(),
245 LastMissingUnixMs: now.Add(time.Hour).UnixMilli(),
246 }},
247 }
248 b, err := json.Marshal(doc)
249 if err != nil {
250 t.Fatal(err)
251 }
252 if err := os.WriteFile(path, b, 0o600); err != nil {
253 t.Fatal(err)
254 }
255
256 s := newMissingReasoningWarnState(dir)
257 s.resolveAt(fingerprint, now.Add(time.Minute))
258 if !s.claimAt(fingerprint, now.Add(2*time.Minute)) {
259 t.Fatal("future last-missing timestamp suppressed a re-armed regression")
260 }
261 }
262
263 func TestMissingReasoningWarnStateLegacyPreviewRearmsAndMigrates(t *testing.T) {
264 dir := t.TempDir()
265 path := filepath.Join(dir, missingReasoningWarnStateFilename)
266 if err := os.WriteFile(path, []byte(`{"providers":["deepseek"]}`), 0o600); err != nil {
267 t.Fatalf("seed legacy state: %v", err)
268 }
269 s := newMissingReasoningWarnState(dir)
270 if !s.claimAt(warningFingerprint("deepseek-current-config"), missingReasoningTestNow()) {
271 t.Fatal("legacy provider-name marker must not suppress a configuration-scoped incident")
272 }
273 b, err := os.ReadFile(path)
274 if err != nil {
275 t.Fatal(err)
276 }
277 if strings.Contains(string(b), `"providers"`) || !strings.Contains(string(b), `"version":2`) {
278 t.Fatalf("legacy state was not migrated to v2: %s", b)
279 }
280 }
281
282 func TestMissingReasoningWarnStateLoadsV2IncidentWithoutNanosecondField(t *testing.T) {
283 dir := t.TempDir()
284 path := filepath.Join(dir, missingReasoningWarnStateFilename)
285 fingerprint := warningFingerprint("config")
286 now := missingReasoningTestNow()
287 doc := fmt.Sprintf(`{"version":2,"incidents":[{"fingerprint":"%s","warnedAtUnixMs":%d,"lastMissingAtUnixMs":%d}]}`,
288 fingerprint, now.UnixMilli(), now.UnixMilli())
289 if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
290 t.Fatal(err)
291 }
292
293 s := newMissingReasoningWarnState(dir)
294 if s.claimAt(fingerprint, now.Add(time.Minute)) {
295 t.Fatal("v2 incident without nanosecond fields did not retain its active warning")
296 }
297 }
298
299 func TestMissingReasoningWarnStateCorruptFileSelfHeals(t *testing.T) {
300 dir := t.TempDir()
301 path := filepath.Join(dir, missingReasoningWarnStateFilename)
302 if err := os.WriteFile(path, []byte("{not json"), 0o644); err != nil {
303 t.Fatalf("seed corrupt file: %v", err)
304 }
305 fingerprint := warningFingerprint("config")
306 s := newMissingReasoningWarnState(dir)
307 now := missingReasoningTestNow()
308 if !s.claimAt(fingerprint, now) {
309 t.Fatal("corrupt state must re-arm the incident")
310 }
311 if s.claimAt(fingerprint, now.Add(time.Minute)) {
312 t.Fatal("rewritten state did not retain the incident")
313 }
314 }
315
316 func TestMissingReasoningWarnStateUsesOwnerOnlyPermissions(t *testing.T) {
317 dir := filepath.Join(t.TempDir(), "state")
318 s := newMissingReasoningWarnState(dir)
319 if !s.claimAt(warningFingerprint("config"), missingReasoningTestNow()) {
320 t.Fatal("fresh incident must warn")
321 }
322 dirInfo, err := os.Stat(dir)
323 if err != nil {
324 t.Fatal(err)
325 }
326 if got := dirInfo.Mode().Perm(); runtime.GOOS != "windows" && got != 0o700 {
327 t.Fatalf("state directory mode = %o, want 700", got)
328 }
329 fileInfo, err := os.Stat(filepath.Join(dir, missingReasoningWarnStateFilename))
330 if err != nil {
331 t.Fatal(err)
332 }
333 if got := fileInfo.Mode().Perm(); runtime.GOOS != "windows" && got != 0o600 {
334 t.Fatalf("state file mode = %o, want 600", got)
335 }
336 }
337
338 func TestMissingReasoningWarnStateIOFailureFallsBackVisible(t *testing.T) {
339 path := filepath.Join(t.TempDir(), "not-a-directory")
340 if err := os.WriteFile(path, []byte("occupied"), 0o600); err != nil {
341 t.Fatal(err)
342 }
343 if !newMissingReasoningWarnState(path).claimAt(warningFingerprint("config"), missingReasoningTestNow()) {
344 t.Fatal("state I/O failure must keep the diagnostic visible")
345 }
346 }
347
348 func TestMissingReasoningWarnStateReadFailureDoesNotOverwriteExistingIncidents(t *testing.T) {
349 if runtime.GOOS == "windows" {
350 t.Skip("chmod permissions are not portable to Windows")
351 }
352 dir := t.TempDir()
353 path := filepath.Join(dir, missingReasoningWarnStateFilename)
354 s := newMissingReasoningWarnState(dir)
355 now := missingReasoningTestNow()
356 existingFingerprint := warningFingerprint("existing")
357 newFingerprint := warningFingerprint("new")
358 if !s.claimAt(existingFingerprint, now) {
359 t.Fatal("fresh existing incident must warn")
360 }
361 if err := os.Chmod(path, 0); err != nil {
362 t.Fatal(err)
363 }
364 permissionsRestored := false
365 defer func() {
366 if !permissionsRestored {
367 _ = os.Chmod(path, 0o600)
368 }
369 }()
370 if !s.claimAt(newFingerprint, now.Add(time.Minute)) {
371 t.Fatal("state read failure must keep the new diagnostic visible")
372 }
373 if err := os.Chmod(path, 0o600); err != nil {
374 t.Fatal(err)
375 }
376 permissionsRestored = true
377
378 incidents, err := s.load(now.Add(2 * time.Minute))
379 if err != nil {
380 t.Fatal(err)
381 }
382 if _, ok := incidents[existingFingerprint]; !ok {
383 t.Fatal("state read failure overwrote the existing incident")
384 }
385 if _, ok := incidents[newFingerprint]; ok {
386 t.Fatal("new incident was unexpectedly persisted from a partial read")
387 }
388 }
389
390 func TestMissingReasoningWarnStateEmptyDirFallsBackVisible(t *testing.T) {
391 s := newMissingReasoningWarnState("")
392 fingerprint := warningFingerprint("config")
393 if !s.claim(fingerprint) {
394 t.Fatal("first empty-dir claim must stay visible")
395 }
396 if !s.claim(fingerprint) {
397 t.Fatal("repeated empty-dir claim must stay visible")
398 }
399 }
400
401 func TestMissingReasoningWarnStateConcurrentSameIncidentWarnsOnce(t *testing.T) {
402 dir := t.TempDir()
403 fingerprint := warningFingerprint("shared-config")
404 now := missingReasoningTestNow()
405 start := make(chan struct{})
406 var warned atomic.Int64
407 var wg sync.WaitGroup
408 for range 8 {
409 wg.Add(1)
410 go func() {
411 defer wg.Done()
412 <-start
413 if newMissingReasoningWarnState(dir).claimAt(fingerprint, now) {
414 warned.Add(1)
415 }
416 }()
417 }
418 close(start)
419 wg.Wait()
420 if got := warned.Load(); got != 1 {
421 t.Fatalf("concurrent first warnings = %d, want 1", got)
422 }
423 }
424
425 func TestMissingReasoningWarnStateConcurrentFollowerPersistsLatestObservation(t *testing.T) {
426 dir := t.TempDir()
427 s := newMissingReasoningWarnState(dir)
428 fingerprint := warningFingerprint("shared-config")
429 firstObservedAt := missingReasoningTestNow()
430 latestObservedAt := firstObservedAt.Add(2 * time.Millisecond)
431
432 releaseLock, err := filelock.Acquire(context.Background(), s.lockPath())
433 if err != nil {
434 t.Fatalf("hold state lock: %v", err)
435 }
436 released := false
437 defer func() {
438 if !released {
439 releaseLock()
440 }
441 }()
442
443 leaderResult := make(chan bool, 1)
444 go func() {
445 leaderResult <- s.claimAt(fingerprint, firstObservedAt)
446 }()
447
448 key := s.claimFlightKey(fingerprint)
449 deadline := time.Now().Add(missingReasoningWarnStateLockTimeout / 2)
450 for {
451 missingReasoningWarnClaimFlights.Lock()
452 flightPresent := missingReasoningWarnClaimFlights.flights[key] != nil
453 missingReasoningWarnClaimFlights.Unlock()
454 if flightPresent {
455 break
456 }
457 if time.Now().After(deadline) {
458 t.Fatal("leader did not register its claim flight")
459 }
460 time.Sleep(time.Millisecond)
461 }
462
463 if s.claimAt(fingerprint, latestObservedAt) {
464 t.Fatal("concurrent follower must not emit a duplicate warning")
465 }
466 releaseLock()
467 released = true
468 if !<-leaderResult {
469 t.Fatal("leader must keep the first incident warning visible")
470 }
471
472 incidents, err := s.load(latestObservedAt)
473 if err != nil {
474 t.Fatal(err)
475 }
476 incident, ok := incidents[fingerprint]
477 if !ok || len(incidents) != 1 {
478 t.Fatalf("persisted incidents = %#v, want only %q", incidents, fingerprint)
479 }
480 if got, want := incident.LastMissingUnixMs, latestObservedAt.UnixMilli(); got != want {
481 t.Fatalf("last missing timestamp = %d, want %d", got, want)
482 }
483 }
484
485 func TestMissingReasoningWarnStateConcurrentClaimsKeepEveryConfiguration(t *testing.T) {
486 dir := t.TempDir()
487 now := missingReasoningTestNow()
488 labels := []string{"alpha", "bravo", "charlie", "delta"}
489 start := make(chan struct{})
490 var wg sync.WaitGroup
491 for _, label := range labels {
492 fingerprint := warningFingerprint(label)
493 wg.Add(1)
494 go func() {
495 defer wg.Done()
496 <-start
497 if !newMissingReasoningWarnState(dir).claimAt(fingerprint, now) {
498 t.Errorf("fresh configuration %q did not claim its notice", label)
499 }
500 }()
501 }
502 close(start)
503 wg.Wait()
504
505 fresh := newMissingReasoningWarnState(dir)
506 for _, label := range labels {
507 if fresh.claimAt(warningFingerprint(label), now.Add(time.Minute)) {
508 t.Errorf("configuration %q was lost after concurrent claims", label)
509 }
510 }
511 }
512
512 lines GO