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