返回 DeepSeek-Reasonix
termination_commit_test.go
根目录 / internal / control / termination_commit_test.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "sync"
10 "sync/atomic"
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 type terminationBlockingProvider struct{}
22
23 func (terminationBlockingProvider) Name() string { return "termination-test" }
24 func (terminationBlockingProvider) Stream(ctx context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
25 chunks := make(chan provider.Chunk, 1)
26 go func() {
27 defer close(chunks)
28 select {
29 case chunks <- provider.Chunk{Type: provider.ChunkText, Text: "partial answer"}:
30 case <-ctx.Done():
31 return
32 }
33 <-ctx.Done()
34 chunks <- provider.Chunk{Type: provider.ChunkError, Err: ctx.Err()}
35 }()
36 return chunks, nil
37 }
38
39 func terminationCommitHistory(t *testing.T, c *Controller) []session.Commit {
40 t.Helper()
41 store := c.sessionEventStore()
42 if store == nil {
43 t.Fatal("missing session store")
44 }
45 if _, err := store.Flush(t.Context()); err != nil {
46 t.Fatal(err)
47 }
48 var commits []session.Commit
49 var cursor uint64
50 for {
51 page, err := store.Handle().Read(t.Context(), cursor, 100)
52 if err != nil {
53 t.Fatal(err)
54 }
55 commits = append(commits, page.Commits...)
56 if !page.Truncated {
57 break
58 }
59 if page.Next == cursor {
60 t.Fatal("event pagination stalled")
61 }
62 cursor = page.Next
63 }
64 return commits
65 }
66
67 func assertSingleTerminationCommit(t *testing.T, commits []session.Commit, turnID string, wantCleanup bool) {
68 t.Helper()
69 terminals, cleanups := 0, 0
70 for _, commit := range commits {
71 terminal, cleanup := false, false
72 for _, e := range commit.Events {
73 if e.Kind == "history/replace" {
74 t.Fatal("pause emitted history/replace")
75 }
76 if commit.TurnID == turnID {
77 terminal = terminal || e.Kind == "turn/end"
78 cleanup = cleanup || e.Kind == "model/context-replace"
79 }
80 }
81 if terminal {
82 terminals++
83 if commit.OperationID != "turn-finalize:"+turnID {
84 t.Fatalf("unstable terminal operation %q", commit.OperationID)
85 }
86 }
87 if cleanup {
88 cleanups++
89 if !terminal {
90 t.Fatal("cleanup committed separately from turn/end")
91 }
92 }
93 }
94 if terminals != 1 {
95 t.Fatalf("terminal commits=%d, want=1", terminals)
96 }
97 if wantCleanup && cleanups != 1 {
98 t.Fatalf("cleanup commits=%d, want=1", cleanups)
99 }
100 }
101
102 func TestTerminationCommitRealSendPartialCancel(t *testing.T) {
103 done := make(chan event.Event, 8)
104 text := make(chan struct{}, 1)
105 sink := event.FuncSink(func(e event.Event) {
106 if e.Kind == event.TurnDone {
107 done <- e
108 }
109 if e.Kind == event.Text {
110 select {
111 case text <- struct{}{}:
112 default:
113 }
114 }
115 })
116 exec := agent.New(terminationBlockingProvider{}, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, sink)
117 dir := t.TempDir()
118 c := newOwnedTestController(t, Options{Runner: exec, Executor: exec, Sink: sink, SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl")})
119 c.Send("keep my question")
120 awaitPromptLedgerTest(t, text, "partial output")
121 c.CancelSession()
122 c.CancelSession()
123 terminal := waitTurnDoneEvent(t, done)
124 waitIdleAdmission(t, c)
125 assertSingleTerminationCommit(t, terminationCommitHistory(t, c), terminal.TurnID, true)
126 kept, recovery := false, false
127 for _, m := range c.sessionEventStore().Snapshot().Projection.Messages {
128 kept = kept || (m.Role == provider.RoleUser && m.Content == "keep my question")
129 // Existing sampling cancellation discards speculative result.text before
130 // recordInterruptedDisplay (sampling_recovery.go). Preserve that policy:
131 // this path retains a pending marker, not uncommitted stream text.
132 recovery = recovery || (m.LocalOnly && m.InterruptedTurn != nil && m.InterruptedTurn.Pending)
133 }
134 if !kept || !recovery {
135 t.Fatalf("retained user=%v pending recovery=%v", kept, recovery)
136 }
137 }
138
139 func TestTerminationCommitFallbackAndSynthetic(t *testing.T) {
140 for _, name := range []string{"fallback", "synthetic", "persisted-partial"} {
141 t.Run(name, func(t *testing.T) {
142 synthetic := name == "synthetic"
143 done := make(chan event.Event, 8)
144 c, _, _ := exclusiveTestController(t, event.FuncSink(func(e event.Event) {
145 if e.Kind == event.TurnDone {
146 done <- e
147 }
148 }))
149 started := make(chan struct{})
150 question := provider.Message{ID: "current-user", Role: provider.RoleUser, Content: "preserve exact input"}
151 c.runGuarded(func(ctx context.Context) error {
152 start := c.executor.Session().Len()
153 if synthetic {
154 c.executor.Session().Add(provider.Message{ID: "synthetic", Role: provider.RoleUser, Content: "internal generated input"})
155 if err := c.RecordSessionMessages(ctx, "synthetic-input", c.executor.Session().Snapshot()[start:]); err != nil {
156 return err
157 }
158 }
159 if name == "persisted-partial" {
160 c.executor.Session().Add(question)
161 c.executor.Session().Add(provider.Message{ID: "partial", Role: provider.RoleAssistant, Content: "committed fragment", ReasoningContent: "partial reasoning"})
162 if err := c.RecordSessionMessages(ctx, "partial-input", c.executor.Session().Snapshot()[start:]); err != nil {
163 return err
164 }
165 }
166 close(started)
167 <-ctx.Done()
168 if synthetic {
169 c.stripTurnMessagesAfter(start)
170 } else {
171 c.stripCancelledVisibleTurnMessagesAfterWithFallback(start, question)
172 }
173 return ctx.Err()
174 })
175 awaitPromptLedgerTest(t, started, "turn start")
176 c.CancelSession()
177 first := waitTurnDoneEvent(t, done)
178 waitIdleAdmission(t, c)
179 commits := terminationCommitHistory(t, c)
180 assertSingleTerminationCommit(t, commits, first.TurnID, true)
181 found, retracted := false, false
182 for _, m := range c.sessionEventStore().Snapshot().Projection.Messages {
183 if m.ID == question.ID {
184 found = true
185 }
186 if m.ID == "synthetic" {
187 t.Fatal("synthetic message survived cancellation")
188 }
189 }
190 for _, commit := range commits {
191 for _, e := range commit.Events {
192 retracted = retracted || e.Kind == "message/retract"
193 }
194 }
195 if synthetic && !retracted {
196 t.Fatal("synthetic cancellation omitted retraction")
197 }
198 if !synthetic && !found {
199 t.Fatal("pre-executor fallback lost")
200 }
201 if name == "persisted-partial" {
202 local := false
203 for _, m := range c.sessionEventStore().Snapshot().Projection.Messages {
204 local = local || (m.ID == "partial" && m.LocalOnly && m.Content == "committed fragment" && m.ReasoningContent == "partial reasoning")
205 }
206 if !local {
207 t.Fatal("persisted partial fragment lost during cleanup")
208 }
209 }
210 if got := c.runGuarded(func(context.Context) error { return nil }); got != turnStarted {
211 t.Fatalf("next admission=%v", got)
212 }
213 second := waitTurnDoneEvent(t, done)
214 waitIdleAdmission(t, c)
215 if second.TurnID == first.TurnID {
216 t.Fatal("next turn reused terminated ID")
217 }
218 assertSingleTerminationCommit(t, terminationCommitHistory(t, c), first.TurnID, true)
219 })
220 }
221 }
222
223 func TestTerminationCommitWatchdogRejectsLateWorker(t *testing.T) {
224 states := make(chan event.RuntimeStateSnapshot, 32)
225 terminals := make(chan event.Event, 4)
226 c, _, _ := exclusiveTestController(t, &runtimeStateTestSink{Sink: event.FuncSink(func(e event.Event) {
227 if e.Kind == event.TurnDone {
228 terminals <- e
229 }
230 }), states: states})
231 c.testCancelGrace = time.Nanosecond
232 started, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{})
233 var once sync.Once
234 t.Cleanup(func() { once.Do(func() { close(release) }) })
235 c.runGuarded(func(context.Context) error {
236 close(started)
237 <-release
238 defer close(returned)
239 c.sink.Emit(event.Event{Kind: event.ToolResult, Tool: event.Tool{ID: "late", Name: "todo_write", TodoWritten: true, Todos: []event.Todo{{Content: "late mutation", Status: "completed"}}}})
240 return nil
241 })
242 awaitPromptLedgerTest(t, started, "uncooperative turn start")
243 c.mu.Lock()
244 idleDone := c.turns.finishingBound.idleDone
245 c.mu.Unlock()
246 c.CancelSession()
247 runtimeStateAwait(t, states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "recovery_required" })
248 awaitPromptLedgerTest(t, terminals, "watchdog terminal publication")
249 before := terminationCommitHistory(t, c)
250 var turnID string
251 for _, commit := range before {
252 for _, e := range commit.Events {
253 if e.Kind == "turn/end" {
254 turnID = commit.TurnID
255 }
256 }
257 }
258 if turnID == "" {
259 t.Fatal("watchdog published recovery before terminal commit")
260 }
261 c.CancelSession()
262 once.Do(func() { close(release) })
263 awaitPromptLedgerTest(t, returned, "late worker body return")
264 awaitPromptLedgerTest(t, idleDone, "sealed worker finalization")
265 assertSingleTerminationCommit(t, terminationCommitHistory(t, c), turnID, false)
266 if p := c.sessionEventStore().Snapshot().Projection; p.TodoWritten || len(p.Todos) > 0 {
267 b, _ := json.Marshal(p.Todos)
268 t.Fatalf("late worker changed todos: %s", b)
269 }
270 if got := c.runGuarded(func(context.Context) error { return nil }); got != turnDroppedWriteAuthority {
271 t.Fatalf("sealed runtime admitted another turn: %v", got)
272 }
273 }
274
275 func TestWatchdogPlanPreservesAcceptedCompactionSummary(t *testing.T) {
276 c, _, _ := exclusiveTestController(t, event.Discard)
277 input := provider.Message{ID: "watchdog-input", Role: provider.RoleUser, Content: "real input"}
278 c.noteTerminationBoundary(input, true)
279 summary := provider.Message{ID: "watchdog-summary", Role: provider.RoleUser, Content: "<compaction-summary>\nearlier work\n</compaction-summary>"}
280 if err := c.replaceSessionModelContext(t.Context(), []provider.Message{summary, input}, "test-compaction"); err != nil {
281 t.Fatal(err)
282 }
283 c.turnEvents.commitMu.Lock()
284 p, err := c.watchdogTerminationPlanLocked(c.sessionEventStore(), "watchdog-turn")
285 c.turnEvents.commitMu.Unlock()
286 if err != nil {
287 t.Fatal(err)
288 }
289 if len(p.Messages) != 2 || p.Messages[0].ID != summary.ID || p.Messages[1].ID != input.ID {
290 t.Fatalf("watchdog lost accepted context: %+v", p.Messages)
291 }
292 }
293
294 func TestTerminationCommitStorageFailureRequiresRecovery(t *testing.T) {
295 states := make(chan event.RuntimeStateSnapshot, 32)
296 c, _, _ := exclusiveTestController(t, &runtimeStateTestSink{Sink: event.Discard, states: states})
297 started := make(chan struct{})
298 c.runGuarded(func(ctx context.Context) error {
299 close(started)
300 <-ctx.Done()
301 c.stripCancelledVisibleTurnMessagesAfterWithFallback(c.executor.Session().Len(), provider.Message{ID: "input", Role: provider.RoleUser, Content: "keep input"})
302 return ctx.Err()
303 })
304 awaitPromptLedgerTest(t, started, "turn start")
305 blockPromptTestLedger(t, c, "")
306 c.CancelSession()
307 runtimeStateAwait(t, states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "recovery_required" })
308 if got := c.runGuarded(func(context.Context) error { return nil }); got != turnDroppedWriteAuthority {
309 t.Fatalf("failed terminal allowed send: %v", got)
310 }
311 }
312
313 type terminationSyncFailurePersistence struct {
314 *session.FilesystemPersistence
315 armed atomic.Bool
316 store *session.Session
317 failed chan session.Snapshot
318 }
319
320 func (p *terminationSyncFailurePersistence) Create(options session.CreateOptions) (*session.Session, error) {
321 store, err := session.CreateWithOptions(filepath.Join(p.Root, options.SessionID), options.SessionID, session.OpenOptions{Sync: func(file *os.File) error {
322 if p.armed.Load() {
323 snapshot := p.store.StateSnapshot()
324 // Fail only after the terminal was accepted into the execution
325 // projection; earlier status/autosave flushes must remain healthy.
326 if snapshot.Projection.TurnID == "" {
327 select {
328 case p.failed <- snapshot:
329 default:
330 }
331 return errors.New("injected accepted-terminal fsync failure")
332 }
333 }
334 return file.Sync()
335 }})
336 if err == nil {
337 p.store = store
338 }
339 return store, err
340 }
341
342 func TestTerminationCommitAcceptedFlushFailureRequiresRecovery(t *testing.T) {
343 persistence := &terminationSyncFailurePersistence{FilesystemPersistence: session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions")), failed: make(chan session.Snapshot, 4)}
344 service, err := session.NewService("desktop", persistence)
345 if err != nil {
346 t.Fatal(err)
347 }
348 t.Cleanup(func() { persistence.armed.Store(false); _ = service.CloseAll(context.Background()) })
349 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "flush-failure"})
350 if err != nil {
351 t.Fatal(err)
352 }
353 states := make(chan event.RuntimeStateSnapshot, 32)
354 sink := &runtimeStateTestSink{Sink: event.Discard, states: states}
355 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, sink)
356 c := newOwnedTestController(t, Options{Executor: exec, Sink: sink, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
357 t.Cleanup(func() { persistence.armed.Store(false) })
358 started := make(chan struct{})
359 c.runGuarded(func(ctx context.Context) error {
360 close(started)
361 <-ctx.Done()
362 c.stripCancelledVisibleTurnMessagesAfterWithFallback(c.executor.Session().Len(), provider.Message{ID: "accepted-input", Role: provider.RoleUser, Content: "accepted input"})
363 persistence.armed.Store(true)
364 return ctx.Err()
365 })
366 awaitPromptLedgerTest(t, started, "turn start")
367 _, terminatingTurnID, _ := c.currentTurnToken()
368 c.CancelSession()
369 accepted := awaitPromptLedgerTest(t, persistence.failed, "terminal accepted before fsync failure")
370 if accepted.EventSequence <= accepted.DurableSequence {
371 t.Fatalf("injection did not cover accepted-only tail: %+v", accepted)
372 }
373 runtimeStateAwait(t, states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "recovery_required" })
374 if got := c.runGuarded(func(context.Context) error { return nil }); got != turnDroppedWriteAuthority {
375 t.Fatalf("failed durability admitted another turn: %v", got)
376 }
377 // Retry only the persistence barrier, never synthesize a second terminal.
378 persistence.armed.Store(false)
379 assertSingleTerminationCommit(t, terminationCommitHistory(t, c), terminatingTurnID, true)
380 }
381
382 func TestTerminationCommitSyntheticCompactedOutOfModelStillRetracts(t *testing.T) {
383 done := make(chan event.Event, 8)
384 c, _, _ := exclusiveTestController(t, event.FuncSink(func(e event.Event) {
385 if e.Kind == event.TurnDone {
386 done <- e
387 }
388 }))
389 prefix := []provider.Message{
390 {ID: "prefix-system", Role: provider.RoleSystem, Content: "system"},
391 {ID: "old-user", Role: provider.RoleUser, Content: "old real question"},
392 {ID: "old-answer", Role: provider.RoleAssistant, Content: "old real answer"},
393 }
394 if err := c.RecordSessionMessages(t.Context(), "old-history", prefix); err != nil {
395 t.Fatal(err)
396 }
397 c.restoreExecutorFromSessionEvents()
398 started := make(chan struct{})
399 c.runGuarded(func(ctx context.Context) error {
400 start := c.executor.Session().Len()
401 input := provider.Message{ID: "synthetic-input", Role: provider.RoleUser, Content: "internal generated task"}
402 c.noteTerminationBoundary(input, false)
403 current := []provider.Message{input, {ID: "synthetic-answer", Role: provider.RoleAssistant, Content: "unfinished generated answer"}}
404 for _, m := range current {
405 c.executor.Session().Add(m)
406 }
407 if err := c.RecordSessionMessages(ctx, "synthetic-before-compaction", current); err != nil {
408 return err
409 }
410 // Compaction removes both old history and this turn's records from the
411 // model workset; neither absence alone authorizes deleting old history.
412 compacted := []provider.Message{prefix[0], {ID: "summary", Role: provider.RoleUser, Content: "<compaction-summary>\nearlier work\n</compaction-summary>"}}
413 if err := c.replaceSessionModelContext(ctx, compacted, "test-auto-compaction"); err != nil {
414 return err
415 }
416 c.executor.Session().Replace(compacted)
417 close(started)
418 <-ctx.Done()
419 c.stripInterruptedSyntheticTurnMessagesAfter(start)
420 return ctx.Err()
421 })
422 awaitPromptLedgerTest(t, started, "compacted synthetic turn")
423 c.CancelSession()
424 terminal := waitTurnDoneEvent(t, done)
425 waitIdleAdmission(t, c)
426 commits := terminationCommitHistory(t, c)
427 // The earlier context replacement is compaction, not terminal cleanup.
428 var ends int
429 for _, commit := range commits {
430 for _, e := range commit.Events {
431 if e.Kind == "history/replace" {
432 t.Fatal("synthetic cancellation rewrote history")
433 }
434 if e.Kind == "turn/end" && commit.TurnID == terminal.TurnID {
435 ends++
436 }
437 }
438 }
439 if ends != 1 {
440 t.Fatalf("terminal count=%d", ends)
441 }
442 projection := c.sessionEventStore().Snapshot().Projection
443 retained := map[string]bool{}
444 for _, m := range projection.Messages {
445 retained[m.ID] = true
446 if m.ID == "synthetic-input" || m.ID == "synthetic-answer" {
447 t.Fatalf("compacted current-turn record survived: %s", m.ID)
448 }
449 }
450 if !retained["old-user"] || !retained["old-answer"] {
451 t.Fatalf("compaction prefix was incorrectly retracted: %+v", retained)
452 }
453 summaryKept := false
454 for _, m := range projection.ModelMessages {
455 summaryKept = summaryKept || m.ID == "summary"
456 }
457 if !summaryKept {
458 t.Fatal("compaction summary was removed from model context")
459 }
460 }
461
462 func TestTerminationCommitRetainsUnresolvedSideEffectOnce(t *testing.T) {
463 done := make(chan event.Event, 8)
464 c, _, _ := exclusiveTestController(t, event.FuncSink(func(e event.Event) {
465 if e.Kind == event.TurnDone {
466 done <- e
467 }
468 }))
469 record := provider.ToolCallRecord{Identity: provider.ActionIdentity{AttemptID: "attempt-write", CallID: "write-1"}, Arguments: json.RawMessage(`{"path":"output.txt"}`), State: provider.ToolRunUnknown, ReadOnly: false, EffectSummary: "effect_unknown"}
470 started := make(chan struct{})
471 c.runGuarded(func(ctx context.Context) error {
472 start := c.executor.Session().Len()
473 input := provider.Message{ID: "synthetic-input", Role: provider.RoleUser, Content: "internal task"}
474 c.noteTerminationBoundary(input, false)
475 messages := []provider.Message{input, {ID: "side-effect-call", Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "write-1", Name: "write_file", Arguments: `{"path":"output.txt"}`, Recovery: &record}}}}
476 for _, message := range messages {
477 c.executor.Session().Add(message)
478 }
479 if err := c.RecordSessionMessages(ctx, "side-effect-record", messages); err != nil {
480 return err
481 }
482 close(started)
483 <-ctx.Done()
484 c.stripTurnMessagesAfter(start)
485 return ctx.Err()
486 })
487 awaitPromptLedgerTest(t, started, "side-effect record")
488 c.CancelSession()
489 c.CancelSession()
490 terminal := waitTurnDoneEvent(t, done)
491 waitIdleAdmission(t, c)
492 assertSingleTerminationCommit(t, terminationCommitHistory(t, c), terminal.TurnID, true)
493 findRecord := func(messages []provider.Message) string {
494 t.Helper()
495 count := 0
496 id := ""
497 for _, message := range messages {
498 if message.ID == "synthetic-input" || message.ID == "side-effect-call" {
499 t.Fatalf("synthetic source survived: %s", message.ID)
500 }
501 for _, call := range message.ToolCalls {
502 if call.Recovery != nil && call.Recovery.Identity.AttemptID == "attempt-write" {
503 count++
504 id = message.ID
505 if !message.LocalOnly || id == "" || call.Recovery.State != provider.ToolRunUnknown || string(call.Recovery.Arguments) != string(record.Arguments) {
506 t.Fatalf("retained record changed: %+v", message)
507 }
508 }
509 }
510 }
511 if count != 1 {
512 t.Fatalf("retained side effect count=%d want=1; messages=%+v", count, messages)
513 }
514 return id
515 }
516 durableID := findRecord(c.sessionEventStore().Snapshot().Projection.Messages)
517 if executorID := findRecord(c.executor.Session().Snapshot()); executorID != durableID {
518 t.Fatalf("executor invented ghost ID %q, durable=%q", executorID, durableID)
519 }
520 if pending := c.executor.PendingToolRecovery(); len(pending) != 1 || pending[0].Identity.AttemptID != "attempt-write" {
521 t.Fatalf("pending side-effect fact=%+v", pending)
522 }
523 // Reinstalling the accepted context must not invoke Replace's retention
524 // fallback a second time and generate an uncommitted LocalOnly ghost.
525 c.executor.Session().Replace(c.executor.Session().Snapshot())
526 if id := findRecord(c.executor.Session().Snapshot()); id != durableID {
527 t.Fatalf("second Replace invented ghost ID %q", id)
528 }
529 if err := c.Snapshot(); err != nil {
530 t.Fatal(err)
531 }
532 if id := findRecord(c.sessionEventStore().Snapshot().Projection.Messages); id != durableID {
533 t.Fatalf("autosave changed retained identity %q", id)
534 }
535 }
536
536 lines GO