返回 DeepSeek-Reasonix
repair_fencing_test.go
根目录 / internal / sessioncatalog / repair_fencing_test.go
1 package sessioncatalog
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "testing"
8 "time"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/provider"
12 )
13
14 func seedRepairFenceRow(t *testing.T, catalog *Catalog, path string) {
15 t.Helper()
16 if _, err := os.Stat(path); os.IsNotExist(err) {
17 if err := os.WriteFile(path, []byte("{\"role\":\"user\",\"content\":\"old\"}\n"), 0o600); err != nil {
18 t.Fatal(err)
19 }
20 } else if err != nil {
21 t.Fatal(err)
22 }
23 contentFingerprint := sessionContentFingerprint(path)
24 metaFingerprint := fileFingerprint(agent.BranchMetaPath(path))
25 record := SessionRecord{
26 Path: path, Directory: filepath.Dir(path), Scope: "global", TurnsState: TurnsUnknown, Health: HealthOK,
27 ContentFingerprint: contentFingerprint, MetaFingerprint: metaFingerprint,
28 }
29 if _, err := catalog.upsertSessionsWithNotification(context.Background(), []SessionRecord{record}, nil, "seed", false, upsertDirectoryProjection); err != nil {
30 t.Fatal(err)
31 }
32 }
33
34 func TestRepairBatchRejectsStaleClaimAfterSourceAdvance(t *testing.T) {
35 ctx := context.Background()
36 now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
37 catalog, err := Open(ctx, Options{
38 Path: filepath.Join(t.TempDir(), "catalog.sqlite"), DisableRepair: true, Now: func() time.Time { return now },
39 })
40 if err != nil {
41 t.Fatal(err)
42 }
43 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
44 path := filepath.Join(t.TempDir(), "stale-source.jsonl")
45 saveLineageSession(t, path, "old", "answer")
46 seedRepairFenceRow(t, catalog, path)
47 items, err := catalog.claimDueRepairs(ctx, 1)
48 if err != nil || len(items) != 1 {
49 t.Fatalf("claimDueRepairs items=%d err=%v", len(items), err)
50 }
51 result := agent.SessionListingRepairResult{
52 Status: agent.SessionListingRepairApplied, Preview: "old", Turns: 1,
53 ContentFingerprint: sessionContentFingerprint(path), MetaFingerprint: fileFingerprint(agent.BranchMetaPath(path)),
54 }
55 beforeRevision := catalog.revision.Load()
56 foreground, err := agent.LoadSession(path)
57 if err != nil {
58 t.Fatal(err)
59 }
60 foreground.Add(provider.Message{Role: provider.RoleUser, Content: "new generation is longer"})
61 if err := foreground.SaveSnapshot(path); err != nil {
62 t.Fatal(err)
63 }
64 if err := catalog.applyRepairBatch(ctx, []repairOutcome{{item: items[0], result: result}}, map[string]DirectoryTarget{}); err != nil {
65 t.Fatal(err)
66 }
67 var state string
68 var turnsState TurnsState
69 var preview, sourceFingerprint string
70 if err := catalog.db.QueryRowContext(ctx, `SELECT repair_state,turns_state,preview,repair_source_fingerprint
71 FROM catalog_sessions WHERE path_key=?`, catalog.pathKey(path)).Scan(&state, &turnsState, &preview, &sourceFingerprint); err != nil {
72 t.Fatal(err)
73 }
74 if state != "pending" || turnsState != TurnsUnknown || preview == "old" {
75 t.Fatalf("stale result published: state=%s turns=%s preview=%q", state, turnsState, preview)
76 }
77 wantSource := sessionContentFingerprint(path) + "\x00" + fileFingerprint(agent.BranchMetaPath(path))
78 if sourceFingerprint != wantSource {
79 t.Fatalf("pending source = %q, want %q", sourceFingerprint, wantSource)
80 }
81 if catalog.revision.Load() != beforeRevision+1 {
82 t.Fatalf("source reset revision = %d, want %d", catalog.revision.Load(), beforeRevision+1)
83 }
84 }
85
86 func TestRepairBatchRejectsExpiredClaimOwner(t *testing.T) {
87 ctx := context.Background()
88 now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
89 dbPath := filepath.Join(t.TempDir(), "catalog.sqlite")
90 openCatalog := func() *Catalog {
91 catalog, err := Open(ctx, Options{Path: dbPath, DisableRepair: true, Now: func() time.Time { return now }})
92 if err != nil {
93 t.Fatal(err)
94 }
95 t.Cleanup(func() { _ = catalog.Close(context.Background()) })
96 return catalog
97 }
98 first := openCatalog()
99 path := filepath.Join(t.TempDir(), "expired-owner.jsonl")
100 seedRepairFenceRow(t, first, path)
101 oldItems, err := first.claimDueRepairs(ctx, 1)
102 if err != nil || len(oldItems) != 1 {
103 t.Fatalf("old claim items=%d err=%v", len(oldItems), err)
104 }
105 now = now.Add(30 * time.Second)
106 second := openCatalog()
107 newItems, err := second.claimDueRepairs(ctx, 1)
108 if err != nil || len(newItems) != 1 {
109 t.Fatalf("new claim items=%d err=%v", len(newItems), err)
110 }
111 fingerprint := sessionContentFingerprint(path)
112 oldResult := agent.SessionListingRepairResult{
113 Status: agent.SessionListingRepairApplied, Preview: "old owner", Turns: 1,
114 ContentFingerprint: fingerprint, MetaFingerprint: fileFingerprint(agent.BranchMetaPath(path)),
115 }
116 beforeRevision := first.revision.Load()
117 if err := first.applyRepairBatch(ctx, []repairOutcome{{item: oldItems[0], result: oldResult}}, map[string]DirectoryTarget{}); err != nil {
118 t.Fatal(err)
119 }
120 var state, preview string
121 var attempts int
122 var retryAt int64
123 if err := first.db.QueryRowContext(ctx, `SELECT repair_state,repair_attempts,repair_retry_at,preview
124 FROM catalog_sessions WHERE path_key=?`, first.pathKey(path)).Scan(&state, &attempts, &retryAt, &preview); err != nil {
125 t.Fatal(err)
126 }
127 if state != "active" || attempts != newItems[0].attempts || retryAt != newItems[0].retryAt || preview == "old owner" {
128 t.Fatalf("old owner overwrote new claim: state=%s attempts=%d retry=%d preview=%q", state, attempts, retryAt, preview)
129 }
130 if first.revision.Load() != beforeRevision {
131 t.Fatal("all-stale repair batch published an empty revision")
132 }
133 newResult := oldResult
134 newResult.Preview = "new owner"
135 if err := second.applyRepairBatch(ctx, []repairOutcome{{item: newItems[0], result: newResult}}, map[string]DirectoryTarget{}); err != nil {
136 t.Fatal(err)
137 }
138 var turnsState TurnsState
139 if err := second.db.QueryRowContext(ctx, `SELECT repair_state,turns_state,preview FROM catalog_sessions WHERE path_key=?`,
140 second.pathKey(path)).Scan(&state, &turnsState, &preview); err != nil {
141 t.Fatal(err)
142 }
143 if state != "complete" || turnsState != TurnsValid || preview != "new owner" {
144 t.Fatalf("new owner did not complete: state=%s turns=%s preview=%q", state, turnsState, preview)
145 }
146 }
147
147 lines GO