返回 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 terminals := make(chan event.Event, 4)
225 c, _, _ := exclusiveTestController(t, event.FuncSink(func(e event.Event) {
226 if e.Kind == event.TurnDone {
227 terminals <- e
228 }
229 }))
230 c.testCancelGrace = time.Nanosecond
231 started, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{})
232 var once sync.Once
233 t.Cleanup(func() { once.Do(func() { close(release) }) })
234 c.runGuarded(func(context.Context) error {
235 close(started)
236 <-release
237 defer close(returned)
238 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"}}}})
239 return nil
240 })
241 awaitPromptLedgerTest(t, started, "uncooperative turn start")
242 c.mu.Lock()
243 idleDone := c.turns.finishingBound.idleDone
244 c.mu.Unlock()
245 c.CancelSession()
246 // Terminal publication follows the durable commit and runtime publication.
247 // Wait for that barrier: a five-second observer deadline also measures host
248 // fsync latency, which is not this test's late-worker exclusion contract.
249 terminal := <-terminals
250 if state := c.PublishedRuntimeStateSnapshot(); state.Phase != "recovery_required" || terminal.Status != event.TurnRecoveryRequired {
251 t.Fatalf("watchdog terminal did not publish recovery: terminal=%+v state=%+v", terminal, state)
252 }
253 before := terminationCommitHistory(t, c)
254 var turnID string
255 for _, commit := range before {
256 for _, e := range commit.Events {
257 if e.Kind == "turn/end" {
258 turnID = commit.TurnID
259 }
260 }
261 }
262 if turnID == "" {
263 t.Fatal("watchdog published recovery before terminal commit")
264 }
265 c.CancelSession()
266 once.Do(func() { close(release) })
267 awaitPromptLedgerTest(t, returned, "late worker body return")
268 awaitPromptLedgerTest(t, idleDone, "sealed worker finalization")
269 assertSingleTerminationCommit(t, terminationCommitHistory(t, c), turnID, false)
270 if p := c.sessionEventStore().Snapshot().Projection; p.TodoWritten || len(p.Todos) > 0 {
271 b, _ := json.Marshal(p.Todos)
272 t.Fatalf("late worker changed todos: %s", b)
273 }
274 if got := c.runGuarded(func(context.Context) error { return nil }); got != turnDroppedWriteAuthority {
275 t.Fatalf("sealed runtime admitted another turn: %v", got)
276 }
277 }
278
279 func TestWatchdogPlanPreservesAcceptedCompactionSummary(t *testing.T) {
280 c, _, _ := exclusiveTestController(t, event.Discard)
281 input := provider.Message{ID: "watchdog-input", Role: provider.RoleUser, Content: "real input"}
282 c.noteTerminationBoundary(input, true)
283 summary := provider.Message{ID: "watchdog-summary", Role: provider.RoleUser, Content: "<compaction-summary>\nearlier work\n</compaction-summary>"}
284 if err := c.replaceSessionModelContext(t.Context(), []provider.Message{summary, input}, "test-compaction"); err != nil {
285 t.Fatal(err)
286 }
287 c.turnEvents.commitMu.Lock()
288 p, err := c.watchdogTerminationPlanLocked(c.sessionEventStore(), "watchdog-turn")
289 c.turnEvents.commitMu.Unlock()
290 if err != nil {
291 t.Fatal(err)
292 }
293 if len(p.Messages) != 2 || p.Messages[0].ID != summary.ID || p.Messages[1].ID != input.ID {
294 t.Fatalf("watchdog lost accepted context: %+v", p.Messages)
295 }
296 }
297
298 func TestTerminationCommitStorageFailureRequiresRecovery(t *testing.T) {
299 states := make(chan event.RuntimeStateSnapshot, 32)
300 c, _, _ := exclusiveTestController(t, &runtimeStateTestSink{Sink: event.Discard, states: states})
301 started := make(chan struct{})
302 c.runGuarded(func(ctx context.Context) error {
303 close(started)
304 <-ctx.Done()
305 c.stripCancelledVisibleTurnMessagesAfterWithFallback(c.executor.Session().Len(), provider.Message{ID: "input", Role: provider.RoleUser, Content: "keep input"})
306 return ctx.Err()
307 })
308 awaitPromptLedgerTest(t, started, "turn start")
309 blockPromptTestLedger(t, c, "")
310 c.CancelSession()
311 runtimeStateAwait(t, states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "recovery_required" })
312 if got := c.runGuarded(func(context.Context) error { return nil }); got != turnDroppedWriteAuthority {
313 t.Fatalf("failed terminal allowed send: %v", got)
314 }
315 }
316
317 type terminationSyncFailurePersistence struct {
318 *session.FilesystemPersistence
319 armed atomic.Bool
320 store *session.Session
321 failed chan session.Snapshot
322 }
323
324 func (p *terminationSyncFailurePersistence) Create(options session.CreateOptions) (*session.Session, error) {
325 store, err := session.CreateWithOptions(filepath.Join(p.Root, options.SessionID), options.SessionID, session.OpenOptions{Sync: func(file *os.File) error {
326 if p.armed.Load() {
327 snapshot := p.store.StateSnapshot()
328 // Fail only after the terminal was accepted into the execution
329 // projection; earlier status/autosave flushes must remain healthy.
330 if snapshot.Projection.TurnID == "" {
331 select {
332 case p.failed <- snapshot:
333 default:
334 }
335 return errors.New("injected accepted-terminal fsync failure")
336 }
337 }
338 return file.Sync()
339 }})
340 if err == nil {
341 p.store = store
342 }
343 return store, err
344 }
345
346 func TestTerminationCommitAcceptedFlushFailureRequiresRecovery(t *testing.T) {
347 persistence := &terminationSyncFailurePersistence{FilesystemPersistence: session.NewFilesystemPersistence(filepath.Join(t.TempDir(), "sessions")), failed: make(chan session.Snapshot, 4)}
348 service, err := session.NewService("desktop", persistence)
349 if err != nil {
350 t.Fatal(err)
351 }
352 t.Cleanup(func() { persistence.armed.Store(false); _ = service.CloseAll(context.Background()) })
353 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: "flush-failure"})
354 if err != nil {
355 t.Fatal(err)
356 }
357 states := make(chan event.RuntimeStateSnapshot, 32)
358 sink := &runtimeStateTestSink{Sink: event.Discard, states: states}
359 exec := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, sink)
360 c := newOwnedTestController(t, Options{Executor: exec, Sink: sink, SessionService: service, SessionRuntime: runtime, ExclusiveSession: true})
361 t.Cleanup(func() { persistence.armed.Store(false) })
362 started := make(chan struct{})
363 c.runGuarded(func(ctx context.Context) error {
364 close(started)
365 <-ctx.Done()
366 c.stripCancelledVisibleTurnMessagesAfterWithFallback(c.executor.Session().Len(), provider.Message{ID: "accepted-input", Role: provider.RoleUser, Content: "accepted input"})
367 persistence.armed.Store(true)
368 return ctx.Err()
369 })
370 awaitPromptLedgerTest(t, started, "turn start")
371 _, terminatingTurnID, _ := c.currentTurnToken()
372 c.CancelSession()
373 accepted := awaitPromptLedgerTest(t, persistence.failed, "terminal accepted before fsync failure")
374 if accepted.EventSequence <= accepted.DurableSequence {
375 t.Fatalf("injection did not cover accepted-only tail: %+v", accepted)
376 }
377 runtimeStateAwait(t, states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "recovery_required" })
378 if got := c.runGuarded(func(context.Context) error { return nil }); got != turnDroppedWriteAuthority {
379 t.Fatalf("failed durability admitted another turn: %v", got)
380 }
381 // Retry only the persistence barrier, never synthesize a second terminal.
382 persistence.armed.Store(false)
383 assertSingleTerminationCommit(t, terminationCommitHistory(t, c), terminatingTurnID, true)
384 }
385
386 func TestTerminationCommitSyntheticCompactedOutOfModelStillRetracts(t *testing.T) {
387 done := make(chan event.Event, 8)
388 c, _, _ := exclusiveTestController(t, event.FuncSink(func(e event.Event) {
389 if e.Kind == event.TurnDone {
390 done <- e
391 }
392 }))
393 prefix := []provider.Message{
394 {ID: "prefix-system", Role: provider.RoleSystem, Content: "system"},
395 {ID: "old-user", Role: provider.RoleUser, Content: "old real question"},
396 {ID: "old-answer", Role: provider.RoleAssistant, Content: "old real answer"},
397 }
398 if err := c.RecordSessionMessages(t.Context(), "old-history", prefix); err != nil {
399 t.Fatal(err)
400 }
401 c.restoreExecutorFromSessionEvents()
402 started := make(chan struct{})
403 c.runGuarded(func(ctx context.Context) error {
404 start := c.executor.Session().Len()
405 input := provider.Message{ID: "synthetic-input", Role: provider.RoleUser, Content: "internal generated task"}
406 c.noteTerminationBoundary(input, false)
407 current := []provider.Message{input, {ID: "synthetic-answer", Role: provider.RoleAssistant, Content: "unfinished generated answer"}}
408 for _, m := range current {
409 c.executor.Session().Add(m)
410 }
411 if err := c.RecordSessionMessages(ctx, "synthetic-before-compaction", current); err != nil {
412 return err
413 }
414 // Compaction removes both old history and this turn's records from the
415 // model workset; neither absence alone authorizes deleting old history.
416 compacted := []provider.Message{prefix[0], {ID: "summary", Role: provider.RoleUser, Content: "<compaction-summary>\nearlier work\n</compaction-summary>"}}
417 if err := c.replaceSessionModelContext(ctx, compacted, "test-auto-compaction"); err != nil {
418 return err
419 }
420 c.executor.Session().Replace(compacted)
421 close(started)
422 <-ctx.Done()
423 c.stripInterruptedSyntheticTurnMessagesAfter(start)
424 return ctx.Err()
425 })
426 awaitPromptLedgerTest(t, started, "compacted synthetic turn")
427 c.CancelSession()
428 terminal := waitTurnDoneEvent(t, done)
429 waitIdleAdmission(t, c)
430 commits := terminationCommitHistory(t, c)
431 // The earlier context replacement is compaction, not terminal cleanup.
432 var ends int
433 for _, commit := range commits {
434 for _, e := range commit.Events {
435 if e.Kind == "history/replace" {
436 t.Fatal("synthetic cancellation rewrote history")
437 }
438 if e.Kind == "turn/end" && commit.TurnID == terminal.TurnID {
439 ends++
440 }
441 }
442 }
443 if ends != 1 {
444 t.Fatalf("terminal count=%d", ends)
445 }
446 projection := c.sessionEventStore().Snapshot().Projection
447 retained := map[string]bool{}
448 for _, m := range projection.Messages {
449 retained[m.ID] = true
450 if m.ID == "synthetic-input" || m.ID == "synthetic-answer" {
451 t.Fatalf("compacted current-turn record survived: %s", m.ID)
452 }
453 }
454 if !retained["old-user"] || !retained["old-answer"] {
455 t.Fatalf("compaction prefix was incorrectly retracted: %+v", retained)
456 }
457 summaryKept := false
458 for _, m := range projection.ModelMessages {
459 summaryKept = summaryKept || m.ID == "summary"
460 }
461 if !summaryKept {
462 t.Fatal("compaction summary was removed from model context")
463 }
464 }
465
466 func TestTerminationCommitRetainsUnresolvedSideEffectOnce(t *testing.T) {
467 done := make(chan event.Event, 8)
468 c, _, _ := exclusiveTestController(t, event.FuncSink(func(e event.Event) {
469 if e.Kind == event.TurnDone {
470 done <- e
471 }
472 }))
473 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"}
474 started := make(chan struct{})
475 c.runGuarded(func(ctx context.Context) error {
476 start := c.executor.Session().Len()
477 input := provider.Message{ID: "synthetic-input", Role: provider.RoleUser, Content: "internal task"}
478 c.noteTerminationBoundary(input, false)
479 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}}}}
480 for _, message := range messages {
481 c.executor.Session().Add(message)
482 }
483 if err := c.RecordSessionMessages(ctx, "side-effect-record", messages); err != nil {
484 return err
485 }
486 close(started)
487 <-ctx.Done()
488 c.stripTurnMessagesAfter(start)
489 return ctx.Err()
490 })
491 awaitPromptLedgerTest(t, started, "side-effect record")
492 c.CancelSession()
493 c.CancelSession()
494 terminal := waitTurnDoneEvent(t, done)
495 waitIdleAdmission(t, c)
496 assertSingleTerminationCommit(t, terminationCommitHistory(t, c), terminal.TurnID, true)
497 findRecord := func(messages []provider.Message) string {
498 t.Helper()
499 count := 0
500 id := ""
501 for _, message := range messages {
502 if message.ID == "synthetic-input" || message.ID == "side-effect-call" {
503 t.Fatalf("synthetic source survived: %s", message.ID)
504 }
505 for _, call := range message.ToolCalls {
506 if call.Recovery != nil && call.Recovery.Identity.AttemptID == "attempt-write" {
507 count++
508 id = message.ID
509 if !message.LocalOnly || id == "" || call.Recovery.State != provider.ToolRunUnknown || string(call.Recovery.Arguments) != string(record.Arguments) {
510 t.Fatalf("retained record changed: %+v", message)
511 }
512 }
513 }
514 }
515 if count != 1 {
516 t.Fatalf("retained side effect count=%d want=1; messages=%+v", count, messages)
517 }
518 return id
519 }
520 durableID := findRecord(c.sessionEventStore().Snapshot().Projection.Messages)
521 if executorID := findRecord(c.executor.Session().Snapshot()); executorID != durableID {
522 t.Fatalf("executor invented ghost ID %q, durable=%q", executorID, durableID)
523 }
524 if pending := c.executor.PendingToolRecovery(); len(pending) != 1 || pending[0].Identity.AttemptID != "attempt-write" {
525 t.Fatalf("pending side-effect fact=%+v", pending)
526 }
527 // Reinstalling the accepted context must not invoke Replace's retention
528 // fallback a second time and generate an uncommitted LocalOnly ghost.
529 c.executor.Session().Replace(c.executor.Session().Snapshot())
530 if id := findRecord(c.executor.Session().Snapshot()); id != durableID {
531 t.Fatalf("second Replace invented ghost ID %q", id)
532 }
533 if err := c.Snapshot(); err != nil {
534 t.Fatal(err)
535 }
536 if id := findRecord(c.sessionEventStore().Snapshot().Projection.Messages); id != durableID {
537 t.Fatalf("autosave changed retained identity %q", id)
538 }
539 }
540
540 lines GO