返回 DeepSeek-Reasonix
turn_kill_durability_test.go
根目录 / internal / control / turn_kill_durability_test.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12 "time"
13
14 "reasonix/internal/agent"
15 "reasonix/internal/event"
16 "reasonix/internal/provider"
17 "reasonix/internal/session"
18 "reasonix/internal/tool"
19 )
20
21 // killMidStreamProvider answers the first request, then streams part of the
22 // second reply and ends the process the way a force-quit does: no deferred
23 // cleanup, no TurnDone, no final save.
24 type killMidStreamProvider struct {
25 calls int
26 exitIn time.Duration
27 }
28
29 func (*killMidStreamProvider) Name() string { return "kill-mid-stream" }
30
31 func (p *killMidStreamProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
32 p.calls++
33 ch := make(chan provider.Chunk, 4)
34 if p.calls == 1 {
35 ch <- provider.Chunk{Type: provider.ChunkText, Text: "first answer"}
36 ch <- provider.Chunk{Type: provider.ChunkDone}
37 close(ch)
38 return ch, nil
39 }
40 ch <- provider.Chunk{Type: provider.ChunkText, Text: "partial reply"}
41 go func() {
42 time.Sleep(p.exitIn)
43 os.Exit(73)
44 }()
45 return ch, nil
46 }
47
48 func runKillFixture(t *testing.T, name, envKey string) string {
49 t.Helper()
50 root := t.TempDir()
51 cmd := exec.Command(os.Args[0], "-test.run=^"+name+"$")
52 cmd.Env = append(os.Environ(), envKey+"="+root)
53 out, err := cmd.CombinedOutput()
54 var exit *exec.ExitError
55 if !errors.As(err, &exit) || exit.ExitCode() != 73 {
56 t.Fatalf("kill fixture: %v %s", err, out)
57 }
58 return root
59 }
60
61 func containsUserText(messages []provider.Message, text string) bool {
62 for _, m := range messages {
63 if m.Role == provider.RoleUser && strings.Contains(m.Content, text) {
64 return true
65 }
66 }
67 return false
68 }
69
70 func TestKilledLegacyTurnKeepsItsPrompt(t *testing.T) {
71 if root := os.Getenv("REASONIX_LEGACY_TURN_KILL_FIXTURE"); root != "" {
72 a := agent.New(&killMidStreamProvider{exitIn: 300 * time.Millisecond}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
73 c := newOwnedTestController(t, Options{Executor: a, Runner: a, SessionPath: filepath.Join(root, "session.jsonl"), SessionDir: root, Sink: event.Discard, NativeLegacySession: true})
74 if err := c.RunTurn(context.Background(), "first question"); err != nil {
75 t.Fatal(err)
76 }
77 _ = c.RunTurn(context.Background(), "second question")
78 t.Fatal("fixture was not killed")
79 }
80 root := runKillFixture(t, "TestKilledLegacyTurnKeepsItsPrompt", "REASONIX_LEGACY_TURN_KILL_FIXTURE")
81 path := filepath.Join(root, "session.jsonl")
82 sess, err := agent.LoadSession(path)
83 if err != nil {
84 t.Fatal(err)
85 }
86 a := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
87 c := newOwnedTestController(t, Options{Executor: a, SessionPath: path, SessionDir: root, Sink: event.Discard, NativeLegacySession: true})
88 c.recoverInterruptedTurn(path)
89 history := c.History()
90 if !containsUserText(history, "first question") {
91 t.Fatalf("completed turn lost after restart:\n%s", requestMessagesText(history))
92 }
93 if !containsUserText(history, "second question") {
94 t.Fatalf("prompt of the killed turn lost after restart:\n%s", requestMessagesText(history))
95 }
96 }
97
98 func openKilledV3Session(t *testing.T, root string) (*session.Service, *session.ClientBinding, session.SessionRef) {
99 t.Helper()
100 service, err := session.NewService("desktop", session.NewFilesystemPersistence(root))
101 if err != nil {
102 t.Fatal(err)
103 }
104 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
105 ref := session.SessionRef{HostID: "desktop", SessionID: "killed"}
106 binding, err := service.Open(t.Context(), ref)
107 if err != nil {
108 t.Fatal(err)
109 }
110 t.Cleanup(func() { _ = binding.Release(context.Background()) })
111 return service, binding, ref
112 }
113
114 func readNewestHistory(t *testing.T, service *session.Service, ref session.SessionRef) session.HistoryWindowPage {
115 t.Helper()
116 deadline := time.Now().Add(5 * time.Second)
117 for {
118 page, err := service.Query().ReadHistoryWindow(t.Context(), ref, session.HistoryWindowRequest{Anchor: "newest", Limit: 64})
119 if err != nil {
120 t.Fatal(err)
121 }
122 if page.Status != "preparing" {
123 return page
124 }
125 if time.Now().After(deadline) {
126 t.Fatalf("history window still preparing")
127 }
128 time.Sleep(10 * time.Millisecond)
129 }
130 }
131
132 func historyRowsContaining(page session.HistoryWindowPage, text string) []session.PersistentMessage {
133 var rows []session.PersistentMessage
134 for _, m := range page.Messages {
135 if strings.Contains(string(m.Inline)+m.Preview, text) {
136 rows = append(rows, m)
137 }
138 }
139 return rows
140 }
141
142 func TestKilledTurnKeepsItsPartialOutput(t *testing.T) {
143 if root := os.Getenv("REASONIX_V3_TURN_KILL_FIXTURE"); root != "" {
144 midTurnSnapshotInterval.Store(int64(25 * time.Millisecond))
145 service, err := session.NewService("desktop", session.NewFilesystemPersistence(root))
146 if err != nil {
147 t.Fatal(err)
148 }
149 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "killed"})
150 if err != nil {
151 t.Fatal(err)
152 }
153 a := agent.New(&killMidStreamProvider{exitIn: time.Second}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
154 c := newOwnedTestController(t, Options{Runner: a, Executor: a, Sink: event.Discard, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
155 if err := c.RunTurn(context.Background(), "first question"); err != nil {
156 t.Fatal(err)
157 }
158 c.Send("second question")
159 time.Sleep(10 * time.Second)
160 t.Fatal("fixture was not killed")
161 }
162 root := runKillFixture(t, "TestKilledTurnKeepsItsPartialOutput", "REASONIX_V3_TURN_KILL_FIXTURE")
163 service, binding, ref := openKilledV3Session(t, root)
164 page := readNewestHistory(t, service, ref)
165 if len(historyRowsContaining(page, "second question")) != 1 {
166 t.Fatalf("prompt of the killed turn not durable: %+v", page.Messages)
167 }
168 partial := historyRowsContaining(page, "partial reply")
169 if len(partial) != 1 {
170 t.Fatalf("partial output of the killed turn not durable after reopen: %d rows", len(partial))
171 }
172 var record provider.Message
173 if err := json.Unmarshal(partial[0].Inline, &record); err != nil {
174 t.Fatalf("partial row is not an inline message: %v", err)
175 }
176 if !record.LocalOnly || record.InterruptedTurn == nil {
177 t.Fatalf("partial output must be the local-only interrupted record: %+v", record)
178 }
179 a := agent.New(nil, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
180 c := newOwnedTestController(t, Options{Executor: a, Runner: a, Sink: event.Discard, SessionService: service, SessionRuntime: binding.Runtime(), ExclusiveSession: true})
181 history := c.History()
182 if !containsUserText(history, "second question") {
183 t.Fatalf("prompt of the killed turn missing from model history:\n%s", requestMessagesText(history))
184 }
185 for _, m := range provider.ModelMessages(history) {
186 if strings.Contains(m.Content, "partial reply") {
187 t.Fatalf("partial output reached the provider-visible history: %+v", m)
188 }
189 }
190 }
191
192 // pausingStreamProvider streams the opening of a reply, holds the stream open
193 // long enough for several autosave ticks, then completes it.
194 type pausingStreamProvider struct{ hold time.Duration }
195
196 func (*pausingStreamProvider) Name() string { return "pausing-stream" }
197
198 func (p *pausingStreamProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
199 ch := make(chan provider.Chunk, 4)
200 go func() {
201 defer close(ch)
202 ch <- provider.Chunk{Type: provider.ChunkText, Text: "alpha "}
203 time.Sleep(p.hold)
204 ch <- provider.Chunk{Type: provider.ChunkText, Text: "omega"}
205 ch <- provider.Chunk{Type: provider.ChunkDone}
206 }()
207 return ch, nil
208 }
209
210 func TestCompletedStreamSupersedesItsCheckpoint(t *testing.T) {
211 old := midTurnSnapshotInterval.Load()
212 midTurnSnapshotInterval.Store(int64(20 * time.Millisecond))
213 t.Cleanup(func() { midTurnSnapshotInterval.Store(old) })
214 service, err := session.NewService("desktop", session.NewFilesystemPersistence(t.TempDir()))
215 if err != nil {
216 t.Fatal(err)
217 }
218 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
219 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "completed"})
220 if err != nil {
221 t.Fatal(err)
222 }
223 done := make(chan struct{}, 1)
224 sink := event.FuncSink(func(e event.Event) {
225 if e.Kind == event.TurnDone {
226 done <- struct{}{}
227 }
228 })
229 a := agent.New(&pausingStreamProvider{hold: 300 * time.Millisecond}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
230 c := newOwnedTestController(t, Options{Runner: a, Executor: a, Sink: sink, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
231 c.Send("question")
232 select {
233 case <-done:
234 case <-time.After(10 * time.Second):
235 t.Fatal("turn did not finish")
236 }
237 c.autosaveWG.Wait()
238 page, err := runtime.Session().AcceptedPage(t.Context(), 0, 1000)
239 if err != nil {
240 t.Fatal(err)
241 }
242 checkpoints := 0
243 for _, commit := range page.Commits {
244 for _, e := range commit.Events {
245 if e.Kind == "stream/checkpoint" {
246 checkpoints++
247 }
248 }
249 }
250 if checkpoints == 0 {
251 t.Fatalf("no partial-output checkpoint was written while the stream was open")
252 }
253 history := readNewestHistory(t, service, runtime.Ref())
254 rows := historyRowsContaining(history, "alpha")
255 if len(rows) != 1 || !strings.Contains(string(rows[0].Inline)+rows[0].Preview, "omega") {
256 t.Fatalf("completed reply must appear exactly once, as the completed message: %+v", rows)
257 }
258 }
259
260 // committedThenKilledProvider completes a streamed message that asks for a
261 // tool, after holding the stream open across several autosave ticks.
262 type committedThenKilledProvider struct{ hold time.Duration }
263
264 func (*committedThenKilledProvider) Name() string { return "committed-then-killed" }
265
266 func (p *committedThenKilledProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
267 ch := make(chan provider.Chunk, 4)
268 go func() {
269 defer close(ch)
270 ch <- provider.Chunk{Type: provider.ChunkText, Text: "alpha "}
271 time.Sleep(p.hold)
272 ch <- provider.Chunk{Type: provider.ChunkText, Text: "omega"}
273 ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "hang", Arguments: "{}"}}
274 ch <- provider.Chunk{Type: provider.ChunkDone}
275 }()
276 return ch, nil
277 }
278
279 // killingTool ends the process while the turn that called it is still open.
280 type killingTool struct{}
281
282 func (killingTool) Name() string { return "hang" }
283 func (killingTool) Description() string { return "hangs" }
284 func (killingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
285 func (killingTool) ReadOnly() bool { return true }
286 func (killingTool) Execute(context.Context, json.RawMessage) (string, error) {
287 time.Sleep(300 * time.Millisecond)
288 os.Exit(73)
289 return "", nil
290 }
291
292 func TestKilledAfterCommittedStreamShowsItOnce(t *testing.T) {
293 if root := os.Getenv("REASONIX_V3_COMMITTED_KILL_FIXTURE"); root != "" {
294 midTurnSnapshotInterval.Store(int64(20 * time.Millisecond))
295 service, err := session.NewService("desktop", session.NewFilesystemPersistence(root))
296 if err != nil {
297 t.Fatal(err)
298 }
299 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "killed"})
300 if err != nil {
301 t.Fatal(err)
302 }
303 reg := tool.NewRegistry()
304 reg.Add(killingTool{})
305 a := agent.New(&committedThenKilledProvider{hold: 300 * time.Millisecond}, reg, agent.NewSession("sys"), agent.Options{}, event.Discard)
306 c := newOwnedTestController(t, Options{Runner: a, Executor: a, Sink: event.Discard, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
307 c.Send("question")
308 time.Sleep(10 * time.Second)
309 t.Fatal("fixture was not killed")
310 }
311 root := runKillFixture(t, "TestKilledAfterCommittedStreamShowsItOnce", "REASONIX_V3_COMMITTED_KILL_FIXTURE")
312 service, binding, ref := openKilledV3Session(t, root)
313 events, err := binding.Runtime().Session().AcceptedPage(t.Context(), 0, 1000)
314 if err != nil {
315 t.Fatal(err)
316 }
317 checkpoints := 0
318 for _, commit := range events.Commits {
319 for _, e := range commit.Events {
320 if e.Kind == "stream/checkpoint" {
321 checkpoints++
322 }
323 }
324 }
325 if checkpoints == 0 {
326 t.Fatalf("no partial-output checkpoint was written while the stream was open")
327 }
328 rows := historyRowsContaining(readNewestHistory(t, service, ref), "alpha")
329 if len(rows) != 1 {
330 t.Fatalf("committed reply must appear exactly once after recovery, got %d rows", len(rows))
331 }
332 var message provider.Message
333 if err := json.Unmarshal(rows[0].Inline, &message); err != nil {
334 t.Fatal(err)
335 }
336 if message.LocalOnly || message.Role != provider.RoleAssistant || !strings.Contains(message.Content, "omega") {
337 t.Fatalf("recovery replaced the committed reply with its checkpoint: %+v", message)
338 }
339 }
340
340 lines GO