1package swarm
2
3import (
4 "context"
5 "errors"
6 "testing"
7 "time"
8)
9
10func nextSwarmLifecycle(t *testing.T, events <-chan LifecycleEvent) LifecycleEvent {
11 t.Helper()
12 select {
13 case e := <-events:
14 return e
15 case <-time.After(2 * time.Second):
16 t.Fatal("missing lifecycle event")
17 return LifecycleEvent{}
18 }
19}
20
21func TestSubagentLifecycle(t *testing.T) {
22 for _, tc := range []struct {
23 status string
24 err error
25 }{
26 {"completed", nil}, {"failed", errors.New("failed")}, {"cancelled", context.Canceled}, {"timed_out", context.DeadlineExceeded},
27 } {
28 t.Run(tc.status, func(t *testing.T) {
29 root := t.TempDir()
30 events := make(chan LifecycleEvent, 2)
31 f := New(Config{Root: root, RepoRoot: root, OnLifecycle: func(e LifecycleEvent) { events <- e }, NewRunner: func(*Agent) Runner {
32 return RunnerFunc(func(context.Context, Sink) error { return tc.err })
33 }})
34 f.SetActiveSession("parent")
35 a, err := f.Spawn(context.Background(), "review")
36 if err != nil {
37 t.Fatal(err)
38 }
39 done := make(chan struct{})
40 go func() { a.Wait(); close(done) }()
41 select {
42 case <-done:
43 case <-time.After(2 * time.Second):
44 t.Fatal("runner did not finish")
45 }
46 first, last := nextSwarmLifecycle(t, events), nextSwarmLifecycle(t, events)
47 if first.Event != "subagent_start" || last.Event != "subagent_stop" || last.Status != tc.status || first.AgentRunID == "" || first.AgentRunID != last.AgentRunID {
48 t.Fatalf("events: %+v %+v", first, last)
49 }
50 for _, e := range []LifecycleEvent{first, last} {
51 if e.SessionID != "parent" || e.AgentID != a.ID || e.CWD != root {
52 t.Fatalf("identity: %+v", e)
53 }
54 }
55 if err := f.Stop(a.ID); err != nil {
56 t.Fatal(err)
57 }
58 select {
59 case e := <-events:
60 t.Fatalf("duplicate event: %+v", e)
61 default:
62 }
63 })
64 }
65}
66
67func TestShutdownWaitsForSubagentStop(t *testing.T) {
68 root := t.TempDir()
69 events := make(chan LifecycleEvent, 2)
70 started := make(chan struct{})
71 f := New(Config{Root: root, RepoRoot: root, OnLifecycle: func(e LifecycleEvent) { events <- e }, NewRunner: func(*Agent) Runner {
72 return RunnerFunc(func(ctx context.Context, _ Sink) error { close(started); <-ctx.Done(); return ctx.Err() })
73 }})
74 if _, err := f.Spawn(context.Background(), "wait"); err != nil {
75 t.Fatal(err)
76 }
77 select {
78 case <-started:
79 case <-time.After(time.Second):
80 t.Fatal("runner not started")
81 }
82 ctx, cancel := context.WithTimeout(context.Background(), time.Second)
83 defer cancel()
84 f.Shutdown(ctx)
85 if ctx.Err() != nil {
86 t.Fatal("shutdown did not finish")
87 }
88 if nextSwarmLifecycle(t, events).Event != "subagent_start" {
89 t.Fatal("missing start")
90 }
91 if e := nextSwarmLifecycle(t, events); e.Event != "subagent_stop" || e.Status != "cancelled" {
92 t.Fatalf("stop: %+v", e)
93 }
94}
95
96func TestResumedSubagentHasNewLifecycleRun(t *testing.T) {
97 root := t.TempDir()
98 events := make(chan LifecycleEvent, 4)
99 f := New(Config{Root: root, RepoRoot: root, OnLifecycle: func(e LifecycleEvent) { events <- e }, NewRunner: func(*Agent) Runner {
100 return RunnerFunc(func(context.Context, Sink) error { return nil })
101 }})
102 f.SetActiveSession("original")
103 a, err := f.Spawn(context.Background(), "review")
104 if err != nil {
105 t.Fatal(err)
106 }
107 first := nextSwarmLifecycle(t, events)
108 nextSwarmLifecycle(t, events)
109 a.Wait()
110 f.SetActiveSession("other")
111 resumed, err := f.Resume(context.Background(), a.ID)
112 if err != nil {
113 t.Fatal(err)
114 }
115 start, stop := nextSwarmLifecycle(t, events), nextSwarmLifecycle(t, events)
116 resumed.Wait()
117 if start.Event != "subagent_start" || stop.Event != "subagent_stop" || start.AgentRunID == first.AgentRunID || start.AgentRunID != stop.AgentRunID || start.SessionID != "original" || stop.SessionID != "original" {
118 t.Fatalf("resume: %+v %+v", start, stop)
119 }
120}