| 1 | package control |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "io" |
| 6 | "testing" |
| 7 | |
| 8 | "reasonix/internal/event" |
| 9 | "reasonix/internal/jobs" |
| 10 | ) |
| 11 | |
| 12 | func TestModelReplacementFencesCandidateAndRetiredCallbacks(t *testing.T) { |
| 13 | isolateControlConfigHome(t) |
| 14 | m := jobs.NewManager(event.Discard) |
| 15 | scope := jobs.NewSessionBackgroundScope(m, nil) |
| 16 | old := newOwnedTestController(t, Options{Jobs: m, BackgroundScope: scope}) |
| 17 | old.SetToolApprovalMode(ToolApprovalYolo) |
| 18 | process := m.StartSessionProcess("", "bash", "gateway", func(ctx context.Context, _ io.Writer) (string, error) { <-ctx.Done(); return "", ctx.Err() }) |
| 19 | if ModelReplacementBlocked(old) { |
| 20 | t.Fatal("independent process blocked model replacement") |
| 21 | } |
| 22 | _, finish, abort, err := ReserveBackgroundReplacement(old) |
| 23 | if err != nil { |
| 24 | t.Fatal(err) |
| 25 | } |
| 26 | defer abort() |
| 27 | if err := scope.Acquire(); err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | next := newOwnedTestController(t, Options{Jobs: m, BackgroundScope: scope}) |
| 31 | if err := finish(next); err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | // Candidate setup can narrow its initial mode before migration restores the |
| 35 | // old mode. It must not cancel jobs still owned by the outgoing controller. |
| 36 | next.SetToolApprovalMode(ToolApprovalYolo) |
| 37 | next.SetToolApprovalMode(ToolApprovalAsk) |
| 38 | if len(m.Running()) != 1 { |
| 39 | t.Fatal("candidate initialization cancelled outgoing process") |
| 40 | } |
| 41 | if old.receivesBackgroundRuntimeEvents() || next.receivesBackgroundRuntimeEvents() { |
| 42 | t.Fatal("replacement published background callbacks before activation") |
| 43 | } |
| 44 | if err := ActivateControllerReplacement(old, next); err == nil { |
| 45 | t.Fatal("permission-changing replacement kept background process") |
| 46 | } |
| 47 | next.SetToolApprovalMode(old.ToolApprovalMode()) |
| 48 | if err := ActivateControllerReplacement(old, next); err != nil { |
| 49 | t.Fatal(err) |
| 50 | } |
| 51 | if old.receivesBackgroundRuntimeEvents() || !next.receivesBackgroundRuntimeEvents() { |
| 52 | t.Fatal("background callbacks did not transfer to published runtime") |
| 53 | } |
| 54 | if len(m.Running()) != 1 || m.Running()[0].ID != process.ID { |
| 55 | t.Fatal("activation replaced gateway identity") |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | func TestModelApplicationCancelOnlySelectedRuntimeTasks(t *testing.T) { |
| 60 | isolateControlConfigHome(t) |
| 61 | m := jobs.NewManager(event.Discard) |
| 62 | scope := jobs.NewSessionBackgroundScope(m, nil) |
| 63 | c := newOwnedTestController(t, Options{Jobs: m, BackgroundScope: scope}) |
| 64 | start := func(process bool, cancelled chan struct{}) *jobs.Job { |
| 65 | run := func(ctx context.Context, _ io.Writer) (string, error) { |
| 66 | <-ctx.Done() |
| 67 | close(cancelled) |
| 68 | return "", ctx.Err() |
| 69 | } |
| 70 | if process { |
| 71 | return m.StartSessionProcess("", "bash", "gateway", run) |
| 72 | } |
| 73 | return m.StartForSession("", "task", "dependent", run) |
| 74 | } |
| 75 | selectedExit, otherExit, processExit := make(chan struct{}), make(chan struct{}), make(chan struct{}) |
| 76 | selected, other, process := start(false, selectedExit), start(false, otherExit), start(true, processExit) |
| 77 | if !ModelReplacementBlocked(c) { |
| 78 | t.Fatal("runtime task did not block replacement") |
| 79 | } |
| 80 | c.CancelModelApplicationBlockers([]string{selected.ID, process.ID, "missing"}) |
| 81 | <-selectedExit |
| 82 | for _, exit := range []chan struct{}{otherExit, processExit} { |
| 83 | select { |
| 84 | case <-exit: |
| 85 | t.Fatal("unselected or session process cancelled") |
| 86 | default: |
| 87 | } |
| 88 | } |
| 89 | if len(c.ModelReplacementJobs()) == 0 { |
| 90 | t.Fatalf("remaining task %s lost", other.ID) |
| 91 | } |
| 92 | } |
| 93 |