返回 DeepSeek-Reasonix
service_lock_test.go
根目录 / internal / acp / service_lock_test.go
1 package acp
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "reasonix/internal/control"
13 )
14
15 type snapshotLockProbeController struct {
16 *control.Controller
17 onSnapshot func()
18 }
19
20 func TestACPRebuildSerializesCollaborationAndApprovalChanges(t *testing.T) {
21 buildStarted := make(chan struct{})
22 releaseBuild := make(chan struct{})
23 factory := &configurableFactory{
24 onBuild: func(index int, _ SessionParams) {
25 if index != 0 {
26 return
27 }
28 close(buildStarted)
29 <-releaseBuild
30 },
31 }
32 sink := newUpdateSink(&fakeNotifier{}, "sess-axis-race")
33 sess := &acpSession{
34 id: "sess-axis-race",
35 ctrl: control.New(control.Options{}),
36 sink: sink,
37 cwd: t.TempDir(),
38 model: "fast",
39 runtimeProfile: "balanced",
40 toolApprovalMode: control.ToolApprovalAsk,
41 modeID: sessionModeNormal,
42 }
43 svc := &service{factory: factory, sessions: map[string]*acpSession{sess.id: sess}}
44
45 rebuildErr := make(chan error, 1)
46 go func() {
47 rebuildErr <- svc.rebuildSession(context.Background(), sess, SessionConfigState{
48 Model: "pro",
49 }, []sessionConfigDelta{{axis: "model", model: "pro"}})
50 }()
51 select {
52 case <-buildStarted:
53 case <-time.After(time.Second):
54 t.Fatal("controller rebuild did not reach blocked build")
55 }
56
57 modeRaw, err := json.Marshal(SessionSetModeParams{SessionID: sess.id, ModeID: sessionModePlan})
58 if err != nil {
59 t.Fatal(err)
60 }
61 modeDone := make(chan error, 1)
62 approvalDone := make(chan error, 1)
63 go func() {
64 _, err := svc.sessionSetMode(context.Background(), modeRaw)
65 modeDone <- err
66 }()
67 go func() {
68 _, err := svc.switchSessionToolApproval(context.Background(), sess, control.ToolApprovalAuto)
69 approvalDone <- err
70 }()
71 select {
72 case err := <-modeDone:
73 t.Fatalf("mode change completed before controller swap: %v", err)
74 case err := <-approvalDone:
75 t.Fatalf("approval change completed before controller swap: %v", err)
76 case <-time.After(50 * time.Millisecond):
77 }
78 close(releaseBuild)
79
80 for name, ch := range map[string]<-chan error{
81 "rebuild": rebuildErr,
82 "mode": modeDone,
83 "approval": approvalDone,
84 } {
85 select {
86 case err := <-ch:
87 if err != nil {
88 t.Fatalf("%s: %v", name, err)
89 }
90 case <-time.After(time.Second):
91 t.Fatalf("%s did not finish", name)
92 }
93 }
94 ctrl := sess.currentCtrl()
95 if !ctrl.PlanMode() || ctrl.ToolApprovalMode() != control.ToolApprovalAuto {
96 t.Fatalf("post-rebuild axes = plan:%v approval:%q, want plan + auto", ctrl.PlanMode(), ctrl.ToolApprovalMode())
97 }
98 if sess.model != "pro" || sess.currentModeID() != sessionModePlan {
99 t.Fatalf("post-rebuild session = model:%q mode:%q, want pro + plan", sess.model, sess.currentModeID())
100 }
101 }
102
103 func (c *snapshotLockProbeController) Snapshot() error {
104 if c.onSnapshot != nil {
105 c.onSnapshot()
106 }
107 return nil
108 }
109
110 func expectACPSessionMutexAvailableDuringSnapshot(t *testing.T, sess *acpSession, checks chan<- struct{}) func() {
111 t.Helper()
112 return func() {
113 acquired := make(chan struct{})
114 go func() {
115 sess.mu.Lock()
116 sess.mu.Unlock() //nolint:staticcheck // probe: lock must be immediately acquirable
117 close(acquired)
118 }()
119 select {
120 case <-acquired:
121 case <-time.After(500 * time.Millisecond):
122 t.Error("Snapshot ran while holding ACP session mutex")
123 }
124 if checks == nil {
125 return
126 }
127 select {
128 case checks <- struct{}{}:
129 default:
130 }
131 }
132 }
133
134 func TestACPPersistAfterTurnSnapshotsWithoutSessionLock(t *testing.T) {
135 sess := &acpSession{id: "sess-lock"}
136 checks := make(chan struct{}, 1)
137 sess.ctrl = &snapshotLockProbeController{
138 Controller: control.New(control.Options{}),
139 onSnapshot: expectACPSessionMutexAvailableDuringSnapshot(t, sess, checks),
140 }
141
142 sess.persistAfterTurn("hello from acp")
143
144 select {
145 case <-checks:
146 case <-time.After(time.Second):
147 t.Fatal("session was not snapshotted after turn")
148 }
149 if sess.title == "" {
150 t.Fatal("session title was not updated after turn")
151 }
152 }
153
154 func TestACPRebuildSessionSnapshotsWithoutSessionLock(t *testing.T) {
155 sink := newUpdateSink(&fakeNotifier{}, "sess-lock")
156 sess := &acpSession{
157 id: "sess-lock",
158 sink: sink,
159 cwd: t.TempDir(),
160 model: "fast",
161 }
162 checks := make(chan struct{}, 1)
163 oldCtrl := &snapshotLockProbeController{
164 Controller: control.New(control.Options{}),
165 onSnapshot: expectACPSessionMutexAvailableDuringSnapshot(t, sess, checks),
166 }
167 sess.ctrl = oldCtrl
168 svc := &service{
169 factory: &configurableFactory{},
170 sessions: map[string]*acpSession{sess.id: sess},
171 }
172
173 if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}, []sessionConfigDelta{{axis: "model", model: "pro"}}); err != nil {
174 t.Fatalf("rebuildSession: %v", err)
175 }
176 select {
177 case <-checks:
178 case <-time.After(time.Second):
179 t.Fatal("session was not snapshotted before rebuild")
180 }
181 if sess.ctrl == oldCtrl {
182 t.Fatal("session controller was not replaced")
183 }
184 if sess.model != "pro" {
185 t.Fatalf("session model = %q, want pro", sess.model)
186 }
187 }
188
189 type blockingConfigFactory struct {
190 configurableFactory
191 started chan string
192 releaseFirst chan struct{}
193 }
194
195 type blockingResolveFactory struct {
196 configurableFactory
197 proReached chan struct{}
198 releasePro chan struct{}
199 fastResolved chan struct{}
200 proOnce sync.Once
201 fastOnce sync.Once
202 }
203
204 func (f *blockingResolveFactory) SessionConfigState(ctx context.Context, p SessionConfigStateParams) (SessionConfigState, error) {
205 switch p.Model {
206 case "pro":
207 f.proOnce.Do(func() { close(f.proReached) })
208 select {
209 case <-f.releasePro:
210 case <-ctx.Done():
211 return SessionConfigState{}, ctx.Err()
212 }
213 case "fast":
214 f.fastOnce.Do(func() { close(f.fastResolved) })
215 }
216 return f.configurableFactory.SessionConfigState(ctx, p)
217 }
218
219 type failFirstBuildFactory struct {
220 configurableFactory
221 started chan struct{}
222 release chan struct{}
223 mu sync.Mutex
224 attempts int
225 }
226
227 func (f *failFirstBuildFactory) NewSession(ctx context.Context, p SessionParams) (*control.Controller, error) {
228 f.mu.Lock()
229 f.attempts++
230 attempt := f.attempts
231 f.mu.Unlock()
232 if attempt == 1 {
233 close(f.started)
234 select {
235 case <-f.release:
236 case <-ctx.Done():
237 return nil, ctx.Err()
238 }
239 return nil, errors.New("first build failed")
240 }
241 return f.configurableFactory.NewSession(ctx, p)
242 }
243
244 func (f *blockingConfigFactory) NewSession(ctx context.Context, p SessionParams) (*control.Controller, error) {
245 select {
246 case f.started <- p.Model:
247 default:
248 }
249 f.mu.Lock()
250 buildNumber := len(f.builds) + 1
251 f.mu.Unlock()
252 if buildNumber == 1 {
253 select {
254 case <-f.releaseFirst:
255 case <-ctx.Done():
256 return nil, ctx.Err()
257 }
258 }
259 return f.configurableFactory.NewSession(ctx, p)
260 }
261
262 func TestACPRebuildSessionAppliesPendingConfigAfterMaintenance(t *testing.T) {
263 sink := newUpdateSink(&fakeNotifier{}, "sess-lock")
264 sess := &acpSession{
265 id: "sess-lock",
266 sink: sink,
267 cwd: t.TempDir(),
268 model: "fast",
269 ctrl: control.New(control.Options{}),
270 }
271 factory := &blockingConfigFactory{
272 started: make(chan string, 2),
273 releaseFirst: make(chan struct{}),
274 }
275 svc := &service{
276 factory: factory,
277 sessions: map[string]*acpSession{sess.id: sess},
278 }
279
280 errs := make(chan error, 1)
281 go func() {
282 errs <- svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}, []sessionConfigDelta{{axis: "model", model: "pro"}})
283 }()
284 select {
285 case got := <-factory.started:
286 if got != "pro" {
287 t.Fatalf("first rebuild model = %q, want pro", got)
288 }
289 case <-time.After(time.Second):
290 t.Fatal("first rebuild did not start")
291 }
292
293 if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "fast"}, []sessionConfigDelta{{axis: "model", model: "fast"}}); err != nil {
294 t.Fatalf("queue pending rebuild: %v", err)
295 }
296 close(factory.releaseFirst)
297 select {
298 case err := <-errs:
299 if err != nil {
300 t.Fatalf("first rebuild: %v", err)
301 }
302 case <-time.After(time.Second):
303 t.Fatal("first rebuild did not finish")
304 }
305 if sess.model != "fast" {
306 t.Fatalf("session model = %q, want pending fast", sess.model)
307 }
308 if got := factory.buildCount(); got != 2 {
309 t.Fatalf("factory builds = %d, want 2", got)
310 }
311 }
312
313 // TestACPRebuildSessionQueuedCrossAxisChangeDoesNotRollbackCompletedAxis pins
314 // the fix for a race where a queued config change resolved its full
315 // SessionConfigState snapshot at enqueue time from sess.model/effortOverride —
316 // fields that only update once an in-flight rebuild for a *different* axis
317 // lands. Queuing an effort switch while a model switch was still rebuilding
318 // used to restore the pre-switch model as soon as the queued effort drained.
319 func TestACPRebuildSessionQueuedCrossAxisChangeDoesNotRollbackCompletedAxis(t *testing.T) {
320 sink := newUpdateSink(&fakeNotifier{}, "sess-cross-axis")
321 sess := &acpSession{
322 id: "sess-cross-axis",
323 sink: sink,
324 cwd: t.TempDir(),
325 model: "fast",
326 runtimeProfile: "balanced",
327 ctrl: control.New(control.Options{}),
328 }
329 factory := &blockingConfigFactory{
330 started: make(chan string, 2),
331 releaseFirst: make(chan struct{}),
332 }
333 svc := &service{
334 factory: factory,
335 sessions: map[string]*acpSession{sess.id: sess},
336 }
337
338 type switchResult struct {
339 state SessionConfigState
340 err error
341 }
342 results := make(chan switchResult, 1)
343 go func() {
344 state, err := svc.switchSessionModel(context.Background(), sess, "pro")
345 results <- switchResult{state: state, err: err}
346 }()
347 select {
348 case got := <-factory.started:
349 if got != "pro" {
350 t.Fatalf("first rebuild model = %q, want pro", got)
351 }
352 case <-time.After(time.Second):
353 t.Fatal("first rebuild did not start")
354 }
355
356 if _, err := svc.switchSessionEffort(context.Background(), sess, "high"); err != nil {
357 t.Fatalf("queue effort during model rebuild: %v", err)
358 }
359
360 close(factory.releaseFirst)
361 select {
362 case result := <-results:
363 if result.err != nil {
364 t.Fatalf("model switch: %v", result.err)
365 }
366 if result.state.Model != "pro" {
367 t.Fatalf("model switch response model = %q, want pro", result.state.Model)
368 }
369 case <-time.After(time.Second):
370 t.Fatal("model switch did not finish")
371 }
372
373 if got, want := factory.buildCount(), 2; got != want {
374 t.Fatalf("factory builds = %d, want %d", got, want)
375 }
376 if sess.model != "pro" {
377 t.Fatalf("session model = %q, want pro", sess.model)
378 }
379 if got := stringPtrValue(sess.effortOverride); got != "high" {
380 t.Fatalf("session effort = %q, want high", got)
381 }
382 }
383
384 // TestACPCtrlReadPathsDoNotRaceWithRebuild drives the lock-free read surfaces
385 // that used to read sess.ctrl outside sess.mu — info(), service.sessionDir(),
386 // sendAvailableCommands, and resolveSlashPrompt — while a rebuild goroutine
387 // keeps swapping the controller. Under -race this fails without currentCtrl().
388 func TestACPCtrlReadPathsDoNotRaceWithRebuild(t *testing.T) {
389 sink := newUpdateSink(&fakeNotifier{}, "sess-race")
390 sess := &acpSession{
391 id: "sess-race",
392 sink: sink,
393 cwd: t.TempDir(),
394 model: "fast",
395 ctrl: control.New(control.Options{}),
396 }
397 factory := &configurableFactory{}
398 svc := &service{
399 factory: factory,
400 sessions: map[string]*acpSession{sess.id: sess},
401 }
402
403 const rebuilds = 50
404 models := [...]string{"pro", "fast"}
405 done := make(chan struct{})
406 go func() {
407 defer close(done)
408 for i := range rebuilds {
409 if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: models[i%len(models)]}, []sessionConfigDelta{{axis: "model", model: models[i%len(models)]}}); err != nil {
410 t.Errorf("rebuildSession %d: %v", i, err)
411 return
412 }
413 }
414 }()
415
416 for rebuilding := true; rebuilding; {
417 select {
418 case <-done:
419 rebuilding = false
420 default:
421 }
422 if got := sess.info().SessionID; got != sess.id {
423 t.Fatalf("info().SessionID = %q, want %q", got, sess.id)
424 }
425 _ = svc.sessionDir()
426 svc.sendAvailableCommands(sess)
427 if got := svc.resolveSlashPrompt(context.Background(), sess, "/no-such-command args"); got != "/no-such-command args" {
428 t.Fatalf("resolveSlashPrompt rewrote unknown command to %q", got)
429 }
430 }
431
432 if sess.currentCtrl() == nil {
433 t.Fatal("session controller is nil after rebuilds")
434 }
435 if got := factory.buildCount(); got != rebuilds {
436 t.Fatalf("factory builds = %d, want %d", got, rebuilds)
437 }
438 }
439
440 // TestACPBeginRefusesWhilePendingConfigQueued pins the invariant begin relies
441 // on: a session with a queued (not yet applied) config switch must not start a
442 // new turn, or the prompt would run on the outgoing config.
443 func TestACPBeginRefusesWhilePendingConfigQueued(t *testing.T) {
444 sess := &acpSession{id: "sess-pending", ctrl: control.New(control.Options{})}
445 sess.mu.Lock()
446 sess.pendingConfig = []sessionConfigDelta{{axis: "model", model: "pro"}}
447 sess.mu.Unlock()
448
449 if _, _, ok := sess.begin(context.Background()); ok {
450 t.Fatal("begin succeeded while a pending config switch was queued")
451 }
452
453 sess.mu.Lock()
454 sess.pendingConfig = nil
455 sess.mu.Unlock()
456 _, cancel, ok := sess.begin(context.Background())
457 if !ok {
458 t.Fatal("begin failed on an idle session with no pending config")
459 }
460 cancel()
461 sess.finish()
462 }
463
464 // TestACPBeginRefusesDuringPendingConfigApplyWindow drives the exact
465 // interleaving begin used to lose: rebuildSession's defer first finishes
466 // maintenance (maintenanceDone back to nil) and only then applies the queued
467 // pendingConfig. Holding service.mu parks applyPendingSessionConfig on its
468 // initial s.session lookup, so the session sits in that window with the queue
469 // still set; begin must keep refusing until the pending config has landed.
470 func TestACPBeginRefusesDuringPendingConfigApplyWindow(t *testing.T) {
471 sink := newUpdateSink(&fakeNotifier{}, "sess-window")
472 sess := &acpSession{
473 id: "sess-window",
474 sink: sink,
475 cwd: t.TempDir(),
476 model: "fast",
477 ctrl: control.New(control.Options{}),
478 }
479 factory := &blockingConfigFactory{
480 started: make(chan string, 2),
481 releaseFirst: make(chan struct{}),
482 }
483 svc := &service{
484 factory: factory,
485 sessions: map[string]*acpSession{sess.id: sess},
486 }
487
488 errs := make(chan error, 1)
489 go func() {
490 errs <- svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "pro"}, []sessionConfigDelta{{axis: "model", model: "pro"}})
491 }()
492 select {
493 case <-factory.started:
494 case <-time.After(time.Second):
495 t.Fatal("first rebuild did not start")
496 }
497
498 // Queue a second switch while the first build is blocked in maintenance.
499 if err := svc.rebuildSession(context.Background(), sess, SessionConfigState{Model: "fast"}, []sessionConfigDelta{{axis: "model", model: "fast"}}); err != nil {
500 t.Fatalf("queue pending rebuild: %v", err)
501 }
502 sess.mu.Lock()
503 maintenanceDone := sess.maintenanceDone
504 queued := len(sess.pendingConfig) > 0
505 sess.mu.Unlock()
506 if maintenanceDone == nil || !queued {
507 t.Fatalf("maintenance in flight = %v, pending queued = %v, want both", maintenanceDone != nil, queued)
508 }
509
510 svc.mu.Lock()
511 close(factory.releaseFirst)
512 select {
513 case <-maintenanceDone: // closed after maintenanceDone is reset to nil
514 case <-time.After(time.Second):
515 svc.mu.Unlock()
516 t.Fatal("maintenance did not finish")
517 }
518 if _, _, ok := sess.begin(context.Background()); ok {
519 svc.mu.Unlock()
520 t.Fatal("begin succeeded between maintenance end and pending config apply; the turn would run on the outgoing config")
521 }
522 svc.mu.Unlock()
523
524 select {
525 case err := <-errs:
526 if err != nil {
527 t.Fatalf("first rebuild: %v", err)
528 }
529 case <-time.After(time.Second):
530 t.Fatal("first rebuild did not finish")
531 }
532
533 _, cancel, ok := sess.begin(context.Background())
534 if !ok {
535 t.Fatal("begin failed after the pending config was applied")
536 }
537 cancel()
538 sess.finish()
539 if sess.model != "fast" {
540 t.Fatalf("session model = %q, want pending fast", sess.model)
541 }
542 if got := factory.buildCount(); got != 2 {
543 t.Fatalf("factory builds = %d, want 2", got)
544 }
545 }
546
547 // planModeDriftProbeController lets a test pause emitModeDrift's read of
548 // PlanMode() at the exact point a concurrent config switch could otherwise
549 // race in: after finish() would have exposed the session as idle but before
550 // the drift correction lands on sess.modeID.
551 type planModeDriftProbeController struct {
552 *control.Controller
553 onPlanMode func()
554 }
555
556 func (c *planModeDriftProbeController) PlanMode() bool {
557 if c.onPlanMode != nil {
558 c.onPlanMode()
559 }
560 return c.Controller.PlanMode()
561 }
562
563 // TestACPFinishTurnReconcilesModeDriftBeforeExposingIdle pins the fix for the
564 // race where finish() exposed the session as idle before emitModeDrift
565 // corrected a controller-side Plan auto-exit. A concurrent model switch
566 // landing in that window used to see sess.running already false, rebuild
567 // immediately from the stale "plan" modeID, and resurrect Plan mode on the
568 // replacement controller even though the controller had already exited it.
569 func TestACPFinishTurnReconcilesModeDriftBeforeExposingIdle(t *testing.T) {
570 reachedDrift := make(chan struct{})
571 releaseDrift := make(chan struct{})
572 var once sync.Once
573 realCtrl := control.New(control.Options{})
574 realCtrl.SetPlanMode(false) // the turn already auto-exited Plan mode
575 probe := &planModeDriftProbeController{
576 Controller: realCtrl,
577 onPlanMode: func() {
578 once.Do(func() {
579 close(reachedDrift)
580 <-releaseDrift
581 })
582 },
583 }
584
585 sink := newUpdateSink(&fakeNotifier{}, "sess-drift-race")
586 sess := &acpSession{
587 id: "sess-drift-race",
588 ctrl: probe,
589 sink: sink,
590 cwd: t.TempDir(),
591 model: "fast",
592 modeID: sessionModePlan, // stale: not yet reconciled to the controller's actual state
593 }
594 svc := &service{factory: &configurableFactory{}, sessions: map[string]*acpSession{sess.id: sess}}
595
596 if _, _, ok := sess.begin(context.Background()); !ok {
597 t.Fatal("begin failed")
598 }
599
600 finished := make(chan struct{})
601 go func() {
602 defer close(finished)
603 svc.finishTurn(context.Background(), sess)
604 }()
605
606 select {
607 case <-reachedDrift:
608 case <-time.After(time.Second):
609 t.Fatal("mode drift check did not run")
610 }
611
612 // Concurrent model switch must not rebuild from a stale modeID.
613 switchDone := make(chan error, 1)
614 go func() {
615 _, err := svc.switchSessionModel(context.Background(), sess, "pro")
616 if err != nil {
617 <-finished
618 _, err = svc.switchSessionModel(context.Background(), sess, "pro")
619 }
620 switchDone <- err
621 }()
622
623 close(releaseDrift)
624 select {
625 case <-finished:
626 case <-time.After(time.Second):
627 t.Fatal("finishTurn did not complete")
628 }
629 select {
630 case err := <-switchDone:
631 if err != nil {
632 t.Fatalf("switchSessionModel: %v", err)
633 }
634 case <-time.After(time.Second):
635 t.Fatal("model switch did not complete")
636 }
637
638 if sess.currentCtrl().PlanMode() {
639 t.Fatal("concurrent model switch resurrected Plan mode after it had already exited")
640 }
641 if got := sess.currentModeID(); got != sessionModeNormal {
642 t.Fatalf("session modeID = %q, want normal", got)
643 }
644 if sess.model != "pro" {
645 t.Fatalf("session model = %q, want pro", sess.model)
646 }
647 }
648
649 // TestACPPendingConfigMergesAxesQueuedDuringActiveTurn pins the per-axis
650 // pending-config queue: a model change and an effort change both requested
651 // during one active turn must both apply when the turn ends and the queue
652 // drains. With the old single-slot queue the second request silently
653 // overwrote the first even though both RPCs had already reported success and
654 // announced their config_option_update to the client.
655 func TestACPPendingConfigMergesAxesQueuedDuringActiveTurn(t *testing.T) {
656 factory := &configurableFactory{}
657 sink := newUpdateSink(&fakeNotifier{}, "sess-pending-merge")
658 sess := &acpSession{
659 id: "sess-pending-merge",
660 ctrl: control.New(control.Options{}),
661 sink: sink,
662 cwd: t.TempDir(),
663 model: "pro",
664 runtimeProfile: "balanced",
665 }
666 svc := &service{factory: factory, sessions: map[string]*acpSession{sess.id: sess}}
667
668 if _, _, ok := sess.begin(context.Background()); !ok {
669 t.Fatal("begin failed")
670 }
671
672 if _, err := svc.switchSessionModel(context.Background(), sess, "fast"); err != nil {
673 t.Fatalf("switchSessionModel during turn: %v", err)
674 }
675 if _, err := svc.switchSessionEffort(context.Background(), sess, "high"); err != nil {
676 t.Fatalf("switchSessionEffort during turn: %v", err)
677 }
678 sess.mu.Lock()
679 queued := len(sess.pendingConfig)
680 sess.mu.Unlock()
681 if queued != 2 {
682 t.Fatalf("pending deltas = %d, want model + effort (2)", queued)
683 }
684
685 svc.finishTurn(context.Background(), sess)
686
687 sess.mu.Lock()
688 model, effort := sess.model, stringPtrValue(sess.effortOverride)
689 sess.mu.Unlock()
690 if model != "fast" || effort != "high" {
691 t.Fatalf("after drain model = %q, effort = %q; want fast/high", model, effort)
692 }
693 if got := factory.buildCount(); got != 1 {
694 t.Fatalf("factory builds = %d, want a single rebuild applying both queued axes", got)
695 }
696 }
697
698 // TestACPApplyPendingClaimsStateBeforeResolving pins request order for one
699 // axis. The pending drain must own stateChangeMu before it clones/resolves the
700 // old value; otherwise a newer explicit switch can rebuild first and the stale
701 // clone then queues behind it, making the older request win last.
702 func TestACPApplyPendingClaimsStateBeforeResolving(t *testing.T) {
703 factory := &blockingResolveFactory{
704 proReached: make(chan struct{}),
705 releasePro: make(chan struct{}),
706 fastResolved: make(chan struct{}),
707 }
708 high := "high"
709 sess := &acpSession{
710 id: "sess-pending-order",
711 ctrl: control.New(control.Options{}),
712 sink: newUpdateSink(&fakeNotifier{}, "sess-pending-order"),
713 cwd: t.TempDir(),
714 model: "fast",
715 runtimeProfile: "balanced",
716 pendingConfig: []sessionConfigDelta{
717 {axis: "model", model: "pro"},
718 {axis: "thought_level", effortOverride: &high},
719 },
720 }
721 svc := &service{factory: factory, sessions: map[string]*acpSession{sess.id: sess}}
722
723 applyDone := make(chan error, 1)
724 go func() { applyDone <- svc.applyPendingSessionConfig(context.Background(), sess) }()
725 select {
726 case <-factory.proReached:
727 case <-time.After(time.Second):
728 t.Fatal("pending config did not reach blocked resolution")
729 }
730
731 claimed := !sess.stateChangeMu.TryLock()
732 if !claimed {
733 sess.stateChangeMu.Unlock()
734 }
735
736 newerDone := make(chan error, 1)
737 go func() {
738 _, err := svc.switchSessionModel(context.Background(), sess, "fast")
739 newerDone <- err
740 }()
741 select {
742 case <-factory.fastResolved:
743 case <-time.After(time.Second):
744 close(factory.releasePro)
745 t.Fatal("newer model request did not resolve")
746 }
747 close(factory.releasePro)
748 if !claimed {
749 t.Fatal("pending apply resolved without stateChangeMu; a newer same-axis request can overtake it")
750 }
751
752 select {
753 case err := <-applyDone:
754 if err != nil {
755 t.Fatalf("applyPendingSessionConfig: %v", err)
756 }
757 case <-time.After(time.Second):
758 t.Fatal("pending apply did not finish")
759 }
760 select {
761 case err := <-newerDone:
762 if err != nil {
763 t.Fatalf("newer switchSessionModel: %v", err)
764 }
765 case <-time.After(time.Second):
766 t.Fatal("newer model request did not finish")
767 }
768 if got := sess.model; got != "fast" {
769 t.Fatalf("session model = %q, want latest requested value fast", got)
770 }
771 if got := stringPtrValue(sess.effortOverride); got != "" {
772 t.Fatalf("effort = %q, want prior model's pending effort cleared by the newer model switch", got)
773 }
774 }
775
776 // TestACPFailedRebuildStillDrainsNewerPendingConfig covers a failed build with
777 // a newer request queued during maintenance. The newer request already returned
778 // success, so it must still apply and clear the queue even though the older
779 // rebuild reports its own failure.
780 func TestACPFailedRebuildStillDrainsNewerPendingConfig(t *testing.T) {
781 factory := &failFirstBuildFactory{
782 started: make(chan struct{}),
783 release: make(chan struct{}),
784 }
785 sess := &acpSession{
786 id: "sess-failed-drain",
787 ctrl: control.New(control.Options{}),
788 sink: newUpdateSink(&fakeNotifier{}, "sess-failed-drain"),
789 cwd: t.TempDir(),
790 model: "fast",
791 runtimeProfile: "balanced",
792 }
793 svc := &service{factory: factory, sessions: map[string]*acpSession{sess.id: sess}}
794
795 firstDone := make(chan error, 1)
796 go func() {
797 _, err := svc.switchSessionModel(context.Background(), sess, "pro")
798 firstDone <- err
799 }()
800 select {
801 case <-factory.started:
802 case <-time.After(time.Second):
803 t.Fatal("first rebuild did not start")
804 }
805
806 if _, err := svc.switchSessionModel(context.Background(), sess, "fast"); err != nil {
807 t.Fatalf("queue newer model request: %v", err)
808 }
809 close(factory.release)
810 select {
811 case err := <-firstDone:
812 if err == nil || !strings.Contains(err.Error(), "first build failed") {
813 t.Fatalf("first rebuild error = %v, want first build failed", err)
814 }
815 case <-time.After(time.Second):
816 t.Fatal("first rebuild did not finish")
817 }
818
819 if got := sess.model; got != "fast" {
820 t.Fatalf("session model = %q, want newer pending value fast", got)
821 }
822 sess.mu.Lock()
823 queued := len(sess.pendingConfig)
824 sess.mu.Unlock()
825 if queued != 0 {
826 t.Fatalf("pending config entries = %d, want drained after failed maintenance", queued)
827 }
828 if got := factory.buildCount(); got != 1 {
829 t.Fatalf("successful replacement builds = %d, want one pending rebuild", got)
830 }
831 _, cancel, ok := sess.begin(context.Background())
832 if !ok {
833 t.Fatal("session stayed blocked after failed rebuild drained its pending request")
834 }
835 cancel()
836 sess.finish()
837 }
838
839 func TestACPReportPendingConfigFailureRestoresClientState(t *testing.T) {
840 notifier := &fakeNotifier{}
841 sess := &acpSession{
842 id: "sess-pending-failure-update",
843 ctrl: control.New(control.Options{}),
844 sink: newUpdateSink(notifier, "sess-pending-failure-update"),
845 cwd: t.TempDir(),
846 model: "fast",
847 runtimeProfile: "balanced",
848 toolApprovalMode: control.ToolApprovalAsk,
849 }
850 svc := &service{factory: &configurableFactory{}, sessions: map[string]*acpSession{sess.id: sess}}
851
852 svc.reportPendingSessionConfigError(context.Background(), sess, errors.New("replacement build failed"), "after maintenance")
853
854 notifier.mu.Lock()
855 notifs := append([]capturedNotif(nil), notifier.notifs...)
856 notifier.mu.Unlock()
857 found := false
858 for _, notif := range notifs {
859 raw, err := json.Marshal(notif.params)
860 if err != nil {
861 t.Fatalf("marshal notification: %v", err)
862 }
863 var payload struct {
864 Update struct {
865 SessionUpdate string `json:"sessionUpdate"`
866 ConfigOptions []SessionConfigOption `json:"configOptions"`
867 } `json:"update"`
868 }
869 if err := json.Unmarshal(raw, &payload); err != nil {
870 t.Fatalf("decode notification: %v", err)
871 }
872 if payload.Update.SessionUpdate != "config_option_update" {
873 continue
874 }
875 model, ok := findConfigOption(payload.Update.ConfigOptions, "model")
876 if !ok {
877 t.Fatal("rollback config update omitted model option")
878 }
879 if model.CurrentValue != "fast" {
880 t.Fatalf("rollback model = %q, want live value fast", model.CurrentValue)
881 }
882 found = true
883 }
884 if !found {
885 t.Fatal("pending config failure did not restore the client's live config state")
886 }
887 }
888
889 // staleModeReadController reads PlanMode before pausing, modelling the drift
890 // emitter capturing controller state that a concurrent session/set_mode then
891 // changes before the emitter swaps it into the session.
892 type staleModeReadController struct {
893 *control.Controller
894 onPlanMode func()
895 }
896
897 func (c *staleModeReadController) PlanMode() bool {
898 v := c.Controller.PlanMode()
899 if c.onPlanMode != nil {
900 c.onPlanMode()
901 }
902 return v
903 }
904
905 // TestACPFinishTurnModeDriftDoesNotRevertConcurrentSetMode pins the fix for
906 // the drift emitters racing explicit user selections: emitModeDrift reads the
907 // controller without stateChangeMu, so a session/set_mode completing between
908 // that read and the modeID swap was read back as drift, rolled the session
909 // metadata back to the pre-selection mode, and the pending-config rebuild
910 // riding the same finishTurn re-applied the stale mode to the replacement
911 // controller — silently undoing the user's choice.
912 func TestACPFinishTurnModeDriftDoesNotRevertConcurrentSetMode(t *testing.T) {
913 reachedDrift := make(chan struct{})
914 releaseDrift := make(chan struct{})
915 var once sync.Once
916 realCtrl := control.New(control.Options{})
917 probe := &staleModeReadController{
918 Controller: realCtrl,
919 onPlanMode: func() {
920 once.Do(func() {
921 close(reachedDrift)
922 <-releaseDrift
923 })
924 },
925 }
926
927 sink := newUpdateSink(&fakeNotifier{}, "sess-setmode-race")
928 sess := &acpSession{
929 id: "sess-setmode-race",
930 ctrl: probe,
931 sink: sink,
932 cwd: t.TempDir(),
933 model: "fast",
934 runtimeProfile: "balanced",
935 modeID: sessionModeNormal,
936 }
937 svc := &service{factory: &configurableFactory{}, sessions: map[string]*acpSession{sess.id: sess}}
938
939 if _, _, ok := sess.begin(context.Background()); !ok {
940 t.Fatal("begin failed")
941 }
942 // A model change queued during the turn makes finishTurn rebuild the
943 // controller, which re-applies the session's modeID — the step that turned
944 // the stale drift write-back into a durable loss of the user's selection.
945 sess.mu.Lock()
946 sess.pendingConfig = []sessionConfigDelta{{axis: "model", model: "pro"}}
947 sess.mu.Unlock()
948
949 finished := make(chan struct{})
950 go func() {
951 defer close(finished)
952 svc.finishTurn(context.Background(), sess)
953 }()
954
955 select {
956 case <-reachedDrift:
957 case <-time.After(time.Second):
958 t.Fatal("mode drift check did not run")
959 }
960
961 // The user picks Plan mode while the drift pass is between its controller
962 // read and its swap. With stateChangeMu held by the drift pass this blocks
963 // until the pass completes; without it, it lands here and gets reverted.
964 setModeDone := make(chan error, 1)
965 go func() {
966 raw, err := json.Marshal(SessionSetModeParams{SessionID: sess.id, ModeID: sessionModePlan})
967 if err != nil {
968 setModeDone <- err
969 return
970 }
971 _, err = svc.sessionSetMode(context.Background(), raw)
972 setModeDone <- err
973 }()
974 // Bias the pre-fix interleaving: give set_mode time to complete inside the
975 // paused window. Post-fix it is blocked on stateChangeMu regardless, so
976 // this sleep cannot make the fixed behavior flaky.
977 time.Sleep(50 * time.Millisecond)
978
979 close(releaseDrift)
980 select {
981 case <-finished:
982 case <-time.After(time.Second):
983 t.Fatal("finishTurn did not complete")
984 }
985 select {
986 case err := <-setModeDone:
987 if err != nil {
988 t.Fatalf("sessionSetMode: %v", err)
989 }
990 case <-time.After(time.Second):
991 t.Fatal("session/set_mode did not complete")
992 }
993
994 if got := sess.currentModeID(); got != sessionModePlan {
995 t.Fatalf("session modeID = %q, want plan (drift pass reverted the user's set_mode)", got)
996 }
997 if !sess.currentCtrl().PlanMode() {
998 t.Fatal("rebuilt controller lost Plan mode after concurrent set_mode")
999 }
1000 if sess.model != "pro" {
1001 t.Fatalf("session model = %q, want queued pro after finishTurn rebuild", sess.model)
1002 }
1003 }
1004
1005 func stringPtrValue(p *string) string {
1006 if p == nil {
1007 return ""
1008 }
1009 return *p
1010 }
1011
1012 // TestACPDriftEmittersSerializeWithStateChanges pins the lock contract behind
1013 // the fix above: both drift emitters must hold stateChangeMu, or they can race
1014 // every other holder (session/set_mode, tool-approval switches, controller
1015 // rebuilds) between their controller read and session-state swap.
1016 func TestACPDriftEmittersSerializeWithStateChanges(t *testing.T) {
1017 sess := &acpSession{
1018 id: "sess-drift-lock",
1019 ctrl: control.New(control.Options{}),
1020 sink: newUpdateSink(&fakeNotifier{}, "sess-drift-lock"),
1021 cwd: t.TempDir(),
1022 model: "fast",
1023 modeID: sessionModeNormal,
1024 }
1025 svc := &service{factory: &configurableFactory{}, sessions: map[string]*acpSession{sess.id: sess}}
1026
1027 sess.stateChangeMu.Lock()
1028 done := make(chan struct{})
1029 go func() {
1030 svc.emitModeDrift(sess)
1031 svc.emitToolApprovalDrift(context.Background(), sess)
1032 close(done)
1033 }()
1034 select {
1035 case <-done:
1036 t.Fatal("drift emitters completed while stateChangeMu was held; they can race set_mode/tool-approval swaps")
1037 case <-time.After(100 * time.Millisecond):
1038 }
1039 sess.stateChangeMu.Unlock()
1040 select {
1041 case <-done:
1042 case <-time.After(time.Second):
1043 t.Fatal("drift emitters did not finish after stateChangeMu was released")
1044 }
1045 }
1046
1046 lines GO