1package swarm
2
3import (
4 "context"
5 "errors"
6 "path/filepath"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11)
12
13// newTestSwarm builds a Swarm rooted in t.TempDir with the in-memory
14// worktree and a Runner factory the test controls. Returns the swarm
15// plus a slice of runners keyed by spawn order so tests can assert
16// they were actually invoked.
17func newTestSwarm(t *testing.T, mk func(a *Agent) Runner) *Swarm {
18 t.Helper()
19 root := t.TempDir()
20 return New(Config{
21 Root: root,
22 RepoRoot: root,
23 Worktree: MemWorktree(filepath.Join(root, "worktrees")),
24 NewRunner: mk,
25 })
26}
27
28func TestSpawnRunsAndCompletes(t *testing.T) {
29 ran := make(chan string, 1)
30 f := newTestSwarm(t, func(a *Agent) Runner {
31 return RunnerFunc(func(ctx context.Context, sink Sink) error {
32 ran <- a.Task
33 sink.Activity("hello")
34 sink.Transcript("line one")
35 sink.Transcript("line two")
36 return nil
37 })
38 })
39 a, err := f.Spawn(context.Background(), "do a thing")
40 if err != nil {
41 t.Fatal(err)
42 }
43 select {
44 case got := <-ran:
45 if got != "do a thing" {
46 t.Fatalf("runner got task %q; want %q", got, "do a thing")
47 }
48 case <-time.After(time.Second):
49 t.Fatal("runner did not start")
50 }
51 a.Wait()
52 if a.Status() != StatusDone {
53 t.Fatalf("status %s; want done", a.Status())
54 }
55 if got := a.Transcript(); len(got) != 2 || got[0] != "line one" || got[1] != "line two" {
56 t.Fatalf("transcript = %q", got)
57 }
58 if !strings.Contains(a.ID, "do-a-thing") {
59 t.Fatalf("id %q missing slug", a.ID)
60 }
61 if a.Branch != "swarm/"+a.ID {
62 t.Fatalf("branch = %q", a.Branch)
63 }
64}
65
66func TestSpawnEmptyTaskFails(t *testing.T) {
67 f := newTestSwarm(t, func(a *Agent) Runner {
68 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
69 })
70 if _, err := f.Spawn(context.Background(), " "); err == nil {
71 t.Fatal("expected error on empty task")
72 }
73}
74
75func TestRunnerErrorMarksFailed(t *testing.T) {
76 wantErr := errors.New("boom")
77 f := newTestSwarm(t, func(a *Agent) Runner {
78 return RunnerFunc(func(ctx context.Context, sink Sink) error { return wantErr })
79 })
80 a, _ := f.Spawn(context.Background(), "explode")
81 a.Wait()
82 if a.Status() != StatusFailed {
83 t.Fatalf("status %s; want failed", a.Status())
84 }
85 if !errors.Is(a.Err(), wantErr) {
86 t.Fatalf("err = %v", a.Err())
87 }
88 if !strings.Contains(a.Activity(), "boom") {
89 t.Fatalf("activity = %q; want it to mention the error", a.Activity())
90 }
91}
92
93func TestStopCancelsRunningAgent(t *testing.T) {
94 started := make(chan struct{})
95 f := newTestSwarm(t, func(a *Agent) Runner {
96 return RunnerFunc(func(ctx context.Context, sink Sink) error {
97 close(started)
98 <-ctx.Done()
99 return ctx.Err()
100 })
101 })
102 a, _ := f.Spawn(context.Background(), "long")
103 <-started
104 if err := f.Stop(a.ID); err != nil {
105 t.Fatal(err)
106 }
107 a.Wait()
108 if a.Status() != StatusKilled {
109 t.Fatalf("status = %s; want killed", a.Status())
110 }
111}
112
113func TestStopAfterDoneIsNoop(t *testing.T) {
114 f := newTestSwarm(t, func(a *Agent) Runner {
115 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
116 })
117 a, _ := f.Spawn(context.Background(), "quick")
118 a.Wait()
119 if err := f.Stop(a.ID); err != nil {
120 t.Fatalf("stop after done: %v", err)
121 }
122 if a.Status() != StatusDone {
123 t.Fatalf("status flipped to %s", a.Status())
124 }
125}
126
127func TestGetPrefixMatch(t *testing.T) {
128 f := newTestSwarm(t, func(a *Agent) Runner {
129 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
130 })
131 a, _ := f.Spawn(context.Background(), "alpha task")
132 a.Wait()
133 // Full id works.
134 if got := f.Get(a.ID); got != a {
135 t.Fatal("get by full id failed")
136 }
137 // Slug prefix works as long as it's unique.
138 if got := f.Get("alpha"); got != a {
139 t.Fatal("get by prefix failed")
140 }
141 // Bogus id returns nil.
142 if got := f.Get("zzz-nope"); got != nil {
143 t.Fatalf("expected nil; got %#v", got)
144 }
145}
146
147func TestRemoveRequiresTerminalState(t *testing.T) {
148 hold := make(chan struct{})
149 f := newTestSwarm(t, func(a *Agent) Runner {
150 return RunnerFunc(func(ctx context.Context, sink Sink) error {
151 <-hold
152 return nil
153 })
154 })
155 a, _ := f.Spawn(context.Background(), "still going")
156 // Wait for run goroutine to flip to running.
157 for i := 0; i < 100 && a.Status() != StatusRunning; i++ {
158 time.Sleep(time.Millisecond)
159 }
160 if err := f.Remove(a.ID); err == nil {
161 t.Fatal("remove of running agent should fail")
162 }
163 close(hold)
164 a.Wait()
165 if err := f.Remove(a.ID); err != nil {
166 t.Fatalf("remove after done: %v", err)
167 }
168 if got := f.Get(a.ID); got != nil {
169 t.Fatal("agent still present after remove")
170 }
171}
172
173func TestSnapshotIsStableAcrossAccess(t *testing.T) {
174 var wg sync.WaitGroup
175 wg.Add(1)
176 f := newTestSwarm(t, func(a *Agent) Runner {
177 return RunnerFunc(func(ctx context.Context, sink Sink) error {
178 defer wg.Done()
179 for i := 0; i < 200; i++ {
180 sink.Transcript("chunk")
181 sink.Activity("step")
182 }
183 return nil
184 })
185 })
186 a, _ := f.Spawn(context.Background(), "race")
187 // Hammer Snapshot while the runner is writing; the -race detector
188 // is the real assertion here.
189 stop := make(chan struct{})
190 go func() {
191 for {
192 select {
193 case <-stop:
194 return
195 default:
196 _ = a.Snapshot()
197 }
198 }
199 }()
200 wg.Wait()
201 a.Wait()
202 close(stop)
203 if a.Status() != StatusDone {
204 t.Fatalf("status = %s", a.Status())
205 }
206}
207
208func TestTaskSlug(t *testing.T) {
209 cases := map[string]string{
210 "fix the login form": "fix-the-login-form",
211 " weird --- spaces!! ": "weird-spaces",
212 "": "agent",
213 "a-very-long-task-name-that-overflows": "a-very-long-task-name-th",
214 }
215 for in, want := range cases {
216 if got := taskSlug(in); got != want {
217 t.Errorf("taskSlug(%q) = %q; want %q", in, got, want)
218 }
219 }
220}
221
222func TestSnapshotAllSorted(t *testing.T) {
223 f := newTestSwarm(t, func(a *Agent) Runner {
224 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
225 })
226 a1, _ := f.Spawn(context.Background(), "first")
227 // Force second spawn into a later nanosecond bucket.
228 time.Sleep(2 * time.Millisecond)
229 a2, _ := f.Spawn(context.Background(), "second")
230 a1.Wait()
231 a2.Wait()
232 snaps := f.SnapshotAll()
233 if len(snaps) != 2 {
234 t.Fatalf("want 2 snapshots; got %d", len(snaps))
235 }
236 if !snaps[0].Started.Before(snaps[1].Started) && !snaps[0].Started.Equal(snaps[1].Started) {
237 t.Fatal("snapshots not in spawn order")
238 }
239}