返回 DeepSeek-Reasonix
session_display_pager_checkpoint_progress_test.go
根目录 / internal / agent / session_display_pager_checkpoint_progress_test.go
1 package agent
2
3 import (
4 "bytes"
5 "context"
6 "database/sql"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "strings"
14 "testing"
15
16 "reasonix/internal/fileops"
17 "reasonix/internal/historywork"
18 "reasonix/internal/projectiondb"
19 "reasonix/internal/provider"
20 "reasonix/internal/store"
21 )
22
23 func TestCheckpointPagerResumesDurableBatchAfterReopen(t *testing.T) {
24 for _, mode := range []string{"resume", "process-restart", "changed-source", "damaged-progress", "missing-progress"} {
25 t.Run(mode, func(t *testing.T) { checkCheckpointPagerResume(t, mode) })
26 }
27 }
28
29 func TestCheckpointPagerRejectsSourceChangeBeforePublication(t *testing.T) {
30 for _, mode := range []string{"rewrite", "replace", "event-log"} {
31 t.Run(mode, func(t *testing.T) {
32 dir := t.TempDir()
33 source := filepath.Join(dir, "history.jsonl")
34 body := []byte("{\"role\":\"user\",\"content\":\"original\"}\n")
35 if err := os.WriteFile(source, body, 0600); err != nil {
36 t.Fatal(err)
37 }
38 info, err := os.Stat(source)
39 if err != nil {
40 t.Fatal(err)
41 }
42 target, version := fileops.DiskSnapshot(source, info)
43 fingerprint := fmt.Sprintf("%s:%s:checkpoint", target.Key, version)
44 opts := projectiondb.OpenOptions{Path: filepath.Join(dir, "display.sqlite"), Migrations: displayPagerMigrations, RequireDisk: true, ResumeKey: fingerprint}
45 err = projectiondb.Rebuild(t.Context(), opts, func(ctx context.Context, db *sql.DB) error {
46 return buildCheckpointDisplayPagerObserved(ctx, db, source, fingerprint, func(int) {
47 switch mode {
48 case "replace":
49 if err := os.Rename(source, source+".previous"); err != nil {
50 t.Fatal(err)
51 }
52 case "event-log":
53 if err := os.WriteFile(store.SessionEventLog(source), []byte("{}\n"), 0600); err != nil {
54 t.Fatal(err)
55 }
56 return
57 }
58 if err := os.WriteFile(source, bytes.ReplaceAll(body, []byte("original"), []byte("replaced")), 0600); err != nil {
59 t.Fatal(err)
60 }
61 if err := os.Chtimes(source, info.ModTime(), info.ModTime()); err != nil {
62 t.Fatal(err)
63 }
64 })
65 })
66 if !errors.Is(err, ErrDisplaySourceChanged) && !(mode == "event-log" && errors.Is(err, ErrDisplayFormatUnsupported)) {
67 t.Fatalf("source change was published: %v", err)
68 }
69 if _, err := os.Stat(opts.Path); !os.IsNotExist(err) {
70 t.Fatalf("invalid index published: %v", err)
71 }
72 })
73 }
74 }
75
76 func checkCheckpointPagerResume(t *testing.T, mode string) {
77 t.Helper()
78 source := filepath.Join(t.TempDir(), "history.jsonl")
79 cache := filepath.Join(t.TempDir(), "display.sqlite")
80 var body bytes.Buffer
81 messages := make([]provider.Message, 2*historywork.BatchEntries)
82 for i := range messages {
83 messages[i] = provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("message-%d %s", i, strings.Repeat("a", 4096))}
84 if err := json.NewEncoder(&body).Encode(messages[i]); err != nil {
85 t.Fatal(err)
86 }
87 }
88 if err := os.WriteFile(source, body.Bytes(), 0600); err != nil {
89 t.Fatal(err)
90 }
91 info, err := os.Stat(source)
92 if err != nil {
93 t.Fatal(err)
94 }
95 target, version := fileops.DiskSnapshot(source, info)
96 fingerprint := fmt.Sprintf("%s:%s:checkpoint", target.Key, version)
97 opts := projectiondb.OpenOptions{Path: cache, Migrations: displayPagerMigrations, RequireDisk: true, MaxOpenConns: 1, ResumeKey: "checkpoint-v1:" + fingerprint}
98 if mode == "process-restart" {
99 executable, err := os.Executable()
100 if err != nil {
101 t.Fatal(err)
102 }
103 child := exec.CommandContext(t.Context(), executable, "-test.run=^TestCheckpointPagerCrashHelper$")
104 child.Env = append(os.Environ(), "REASONIX_CHECKPOINT_CRASH_SOURCE="+source, "REASONIX_CHECKPOINT_CRASH_CACHE="+cache)
105 if output, err := child.CombinedOutput(); err != nil {
106 t.Fatalf("interrupted child: %v\n%s", err, output)
107 }
108 } else {
109 ctx, cancel := context.WithCancel(t.Context())
110 err = projectiondb.Rebuild(ctx, opts, func(ctx context.Context, db *sql.DB) error {
111 return buildCheckpointDisplayPagerObserved(ctx, db, source, fingerprint, func(count int) {
112 if count != historywork.BatchEntries {
113 t.Fatalf("checkpoint was not a complete batch: %d", count)
114 }
115 cancel()
116 })
117 })
118 cancel()
119 if !errors.Is(err, context.Canceled) {
120 t.Fatalf("expected retained interrupted preparation: %v", err)
121 }
122 }
123 switch mode {
124 case "changed-source":
125 body.Reset()
126 for i := range messages {
127 messages[i].Content = strings.ReplaceAll(messages[i].Content, "a", "b")
128 if err := json.NewEncoder(&body).Encode(messages[i]); err != nil {
129 t.Fatal(err)
130 }
131 }
132 if err := os.WriteFile(source, body.Bytes(), 0600); err != nil {
133 t.Fatal(err)
134 }
135 if err := os.Chtimes(source, info.ModTime(), info.ModTime()); err != nil {
136 t.Fatal(err)
137 }
138 case "damaged-progress", "missing-progress":
139 pending := opts
140 pending.Path += ".rebuild-pending"
141 handle, err := projectiondb.Open(t.Context(), pending)
142 if err != nil {
143 t.Fatal(err)
144 }
145 query := `UPDATE metadata SET value='broken' WHERE key='checkpoint_progress'`
146 if mode == "missing-progress" {
147 query = `DELETE FROM metadata WHERE key='checkpoint_progress'`
148 }
149 _, err = handle.DB.Exec(query)
150 _ = handle.DB.Close()
151 if err != nil {
152 t.Fatal(err)
153 }
154 }
155 // A new Open must read only the suffix, restore the semantic digest state,
156 // and publish the same complete view as uninterrupted native replay.
157 meter := &historywork.Coordinator{}
158 pager, err := OpenDisplayPager(meter.Context(t.Context()), source, cache)
159 if err != nil {
160 t.Fatal(err)
161 }
162 defer pager.Close()
163 if got := meter.Diagnostics().InstrumentedReadBytes; (mode == "resume" || mode == "process-restart") && got >= int64(body.Len())*3/4 {
164 t.Fatalf("reopen reread the completed prefix: %d of %d bytes", got, body.Len())
165 }
166 digest, err := ContentDigestForMessages(messages)
167 if err != nil || pager.Header.ContentDigest != digest || pager.Header.MessageCount != len(messages) {
168 t.Fatalf("resumed semantic view differs: count=%d digest=%s err=%v", pager.Header.MessageCount, pager.Header.ContentDigest, err)
169 }
170 last, err := pager.Entry(len(messages) - 1)
171 if err != nil || last.AuthoredTurn != len(messages) {
172 t.Fatalf("turn state was not restored: %+v %v", last, err)
173 }
174 after, err := os.ReadFile(source)
175 if err != nil || !bytes.Equal(after, body.Bytes()) {
176 t.Fatalf("preparation changed the source: %v", err)
177 }
178 }
179
180 // A separate process exits after SQLite commits, without unwinding the file,
181 // database or lock owners. Reopening must recover its WAL and committed offset.
182 func TestCheckpointPagerCrashHelper(t *testing.T) {
183 source, cache := os.Getenv("REASONIX_CHECKPOINT_CRASH_SOURCE"), os.Getenv("REASONIX_CHECKPOINT_CRASH_CACHE")
184 if source == "" || cache == "" {
185 return
186 }
187 info, err := os.Stat(source)
188 if err != nil {
189 t.Fatal(err)
190 }
191 target, version := fileops.DiskSnapshot(source, info)
192 fingerprint := fmt.Sprintf("%s:%s:checkpoint", target.Key, version)
193 opts := projectiondb.OpenOptions{Path: cache, Migrations: displayPagerMigrations, RequireDisk: true, MaxOpenConns: 1, ResumeKey: "checkpoint-v1:" + fingerprint}
194 err = projectiondb.Rebuild(t.Context(), opts, func(ctx context.Context, db *sql.DB) error {
195 return buildCheckpointDisplayPagerObserved(ctx, db, source, fingerprint, func(count int) {
196 if count != historywork.BatchEntries {
197 t.Fatalf("unexpected crash position: %d", count)
198 }
199 os.Exit(0)
200 })
201 })
202 t.Fatalf("child did not stop at the committed batch: %v", err)
203 }
204
204 lines GO