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