Files
zero/internal/swarm/lifecycle_test.go
bdbda89f3e fix(swarm): close lifecycle admission on shutdown (#776)
* fix(swarm): close lifecycle admission on shutdown

* fix(swarm): admit lifecycle work without holding the lock across Launch

Addresses the P1 on #776 plus the two documentation P3s.

[P1] beginLifecycleAdmission returned with lifecycleMu.RLock() held for the
caller's entire duration — including the call into the injected
MemberLauncher.Launch. Close needs the write lock before it can call s.cancel(),
so a launcher that blocks in Launch waiting on its context (the ordinary shape of
a launcher that waits for a slot, a daemon connection, or a sandbox handshake)
deadlocked every Close caller: the launch held the read lock while Close waited
for the write lock to reach the very cancellation the launch needed to unblock.

beginLifecycleAdmission is now a counted ticket: it holds lifecycleMu only across
the closed check and a lifecycleWork.Add(1), then returns a release func
(lifecycleWork.Done) the caller defers. Close flips closed and releases the lock
BEFORE calling cancel, then waits out lifecycleWork (admitted-but-unfinished
work) followed by watchers (goroutines admitted work may still be starting) — so
it remains a true completion barrier without holding any lock across external
launcher code. Every call site (Spawn, Handoff, AdoptOrphans, the retry path in
watch, and afterExit) moved to the ticket pattern.

[P3] beginLifecycleAdmission's contract and the Scheduler/Close handoff are now
both documented at the point that matters: the func-returning signature makes
the release contract explicit in the type itself, and Scheduler's comment spells
out why the read/write lock ordering guarantees exactly one of Scheduler or
Close closes whatever scheduler ends up installed.

Tests: TestCloseCancelsLauncherBlockedInLaunch reproduces the exact deadlock
(spawn parked in Launch, Close in a second goroutine, both must complete once
cancellation reaches the launcher) — verified it hangs under the old held-lock
pattern by temporarily restoring it. TestCloseWaitsForAdmittedSpawn is the
converse: Close must not return while an admitted spawn is still running.

Validated with `go test -race ./internal/swarm/... -count=1` under WSL/Linux
(this session's Windows host has no C toolchain for -race directly), plus
`go vet ./...` under WSL and go build/vet/test on Windows.

* fix(swarm): fail late queue admissions and dequeues after shutdown

Addresses jatmn's two #776 review findings:

- Close swept (clearQueue) every team's queue before waiting for
  admitted-but-in-flight lifecycle operations to finish. A Spawn,
  Handoff, or AdoptOrphans call that got its ticket right before
  closed flipped could still append to a team's queue after the
  sweep ran, stranding that spec forever (never launched, since
  shutdown began; never failed, since the sweep won't run again).
  Reorder Close to wait for lifecycleWork before sweeping queues, so
  every queue append from already-admitted work is visible to the
  sweep.

- afterExitAdmitted dequeued a queued spec (t.onExit) and launched it
  unconditionally once its own ticket was granted, even though Close
  can flip closed in the gap between that dequeue (which makes the
  spec invisible to the clearQueue sweep) and the launch. Re-check
  closed after dequeuing so a spec pulled off the queue after
  shutdown began fails with ErrSwarmClosed instead of starting a new
  worker.

Both regression tests are verified to fail against the pre-fix code
(confirmed by temporarily reverting each fix and rerunning).

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(swarm): snapshot teams after lifecycle work stops

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-a4d5-7078-a80b-802bab23607e

Co-authored-by: Pierre Bruno <[email protected]>

* fix(swarm): reject late admitted launches during shutdown

Re-check the closed state at the final launcher boundary so lifecycle work admitted before Close cannot start or retry a member after shutdown has already won the race.

Add deterministic late-dispatch and scheduler-handoff coverage, stress concurrent Spawn and Close under the race detector, and keep the late-team snapshot regression isolated from launcher behavior.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-4f7f-766e-a312-a7db232b9987

Co-authored-by: Pierre Bruno <[email protected]>

* fix(swarm): reject post-close launch commits

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(swarm): gate retry relaunch on the post-close commit

The post-Launch shutdown gate added for spawns and queue drains was missing
from the bounded-retry path, which called the launcher and swapped in the new
handle unguarded. Since FuncLauncher returns a live handle immediately without
consulting its context, a Close landing between the pre-check and Launch's
return left the watcher supervising a member relaunched after shutdown began —
and recording that member's outcome on the task.

Extract the commit gate as commitLaunch and route the relaunch through it via
relaunchAdmitted, so an initial launch and a retry pass the same three gates:
admission, the pre-Launch re-check, and the post-Launch commit. When shutdown
wins, the handle is reaped and the watcher records the terminal failure instead
of adopting it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-07-29 09:46:25 +08:00

1087 lines
32 KiB
Go

package swarm
import (
"context"
"errors"
"strings"
"sync"
"testing"
"time"
)
// controllableLauncher records every launched spec and lets a test control each
// member's outcome and (optionally) gate completion until a channel is closed.
type controllableLauncher struct {
mu sync.Mutex
specs []MemberSpec
attempts map[string]int
gate chan struct{} // nil => members return immediately
result func(spec MemberSpec, attempt int) (MemberResult, error)
launchErr error
}
func newLauncher(result func(MemberSpec, int) (MemberResult, error)) *controllableLauncher {
return &controllableLauncher{attempts: map[string]int{}, result: result}
}
func (l *controllableLauncher) Launch(ctx context.Context, spec MemberSpec) (MemberHandle, error) {
l.mu.Lock()
if l.launchErr != nil {
err := l.launchErr
l.mu.Unlock()
return nil, err
}
l.specs = append(l.specs, spec)
l.attempts[spec.ID]++
attempt := l.attempts[spec.ID]
gate := l.gate
result := l.result
l.mu.Unlock()
h := &funcHandle{id: spec.ID, done: make(chan struct{})}
go func() {
defer close(h.done)
if gate != nil {
select {
case <-gate:
case <-ctx.Done():
h.err = ctx.Err()
return
}
}
if result != nil {
h.res, h.err = result(spec, attempt)
}
}()
return h, nil
}
func (l *controllableLauncher) recorded() []MemberSpec {
l.mu.Lock()
defer l.mu.Unlock()
out := make([]MemberSpec, len(l.specs))
copy(out, l.specs)
return out
}
func (l *controllableLauncher) attemptCount(id string) int {
l.mu.Lock()
defer l.mu.Unlock()
return l.attempts[id]
}
func newSwarmFor(t *testing.T, l MemberLauncher) *Swarm {
t.Helper()
sw, err := New(Options{BaseDir: t.TempDir(), Launcher: l, MaxTeamSize: 2})
if err != nil {
t.Fatalf("New: %v", err)
}
t.Cleanup(sw.Close)
return sw
}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
func okFor(spec MemberSpec, _ int) (MemberResult, error) {
return MemberResult{Result: "ok:" + spec.Task, SessionID: "sess-" + spec.ID}, nil
}
func TestSpawnCompletes(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
id, err := sw.Spawn(Policy{Model: "m"}, "team", "teammate", "build widget", "/work")
if err != nil {
t.Fatalf("Spawn: %v", err)
}
waitFor(t, "task done", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusDone
})
task, _ := sw.Coordinator().Get(id)
if task.Result != "ok:build widget" {
t.Fatalf("result = %q", task.Result)
}
}
func TestSpawnInheritsPolicy(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
_, err := sw.Spawn(Policy{Model: "orch-model", PermissionMode: permissionModeAuto}, "team", "teammate", "task", "/cwd")
if err != nil {
t.Fatalf("Spawn: %v", err)
}
waitFor(t, "spec recorded", func() bool { return len(l.recorded()) == 1 })
spec := l.recorded()[0]
if spec.Model != "orch-model" {
t.Fatalf("member model = %q, want inherited orch-model", spec.Model)
}
if spec.PermissionMode != permissionModeAuto {
t.Fatalf("member permission mode = %q, want inherited auto", spec.PermissionMode)
}
if spec.Cwd != "/cwd" {
t.Fatalf("member cwd = %q, want /cwd", spec.Cwd)
}
if spec.SystemPrompt == "" {
t.Fatal("member should carry a resolved system prompt")
}
}
func TestConcurrencyCapAndQueueDrains(t *testing.T) {
gate := make(chan struct{})
l := newLauncher(okFor)
l.gate = gate
sw := newSwarmFor(t, l) // MaxTeamSize 2
pol := Policy{Model: "m"}
for i := 0; i < 5; i++ {
if _, err := sw.Spawn(pol, "team", "teammate", "task", ""); err != nil {
t.Fatalf("Spawn %d: %v", i, err)
}
}
team := sw.team("team")
if team.Running() != 2 || team.QueueDepth() != 3 {
t.Fatalf("cap not enforced: running=%d queue=%d, want 2/3", team.Running(), team.QueueDepth())
}
// Release everyone; the queue should drain one-per-slot until all are done.
close(gate)
waitFor(t, "all tasks done", func() bool { return sw.Coordinator().Summarize().Done == 5 })
if team.Running() != 0 || team.QueueDepth() != 0 {
t.Fatalf("after drain running=%d queue=%d, want 0/0", team.Running(), team.QueueDepth())
}
if got := len(l.recorded()); got != 5 {
t.Fatalf("launched %d members, want 5", got)
}
}
func TestRetryOnTemporaryError(t *testing.T) {
l := newLauncher(func(spec MemberSpec, attempt int) (MemberResult, error) {
if attempt < 3 {
return MemberResult{}, ErrMemberTemporary
}
return MemberResult{Result: "recovered"}, nil
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
waitFor(t, "task recovered", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusDone
})
if got := l.attemptCount(id); got != 3 {
t.Fatalf("attempts = %d, want 3 (initial + 2 retries)", got)
}
task, _ := sw.Coordinator().Get(id)
if task.Result != "recovered" {
t.Fatalf("result = %q, want recovered", task.Result)
}
}
func TestRetryExhaustionFails(t *testing.T) {
l := newLauncher(func(MemberSpec, int) (MemberResult, error) {
return MemberResult{}, ErrMemberTemporary
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
waitFor(t, "task failed", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusFailed
})
if got := l.attemptCount(id); got != maxMemberRestarts+1 {
t.Fatalf("attempts = %d, want %d", got, maxMemberRestarts+1)
}
}
func TestPermanentErrorNoRetry(t *testing.T) {
l := newLauncher(func(MemberSpec, int) (MemberResult, error) {
return MemberResult{}, errPlain("hard failure")
})
sw := newSwarmFor(t, l)
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
waitFor(t, "task failed", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusFailed
})
if got := l.attemptCount(id); got != 1 {
t.Fatalf("attempts = %d, want 1 (no retry on permanent error)", got)
}
task, _ := sw.Coordinator().Get(id)
if task.Err == "" {
t.Fatal("failed task should record an error message")
}
}
func TestLifecycleAdmissionRejectedAfterClose(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
sw.Close()
if _, err := sw.Spawn(Policy{}, "team", "teammate", "task", ""); !errors.Is(err, ErrSwarmClosed) {
t.Fatalf("Spawn after Close error = %v, want ErrSwarmClosed", err)
}
if _, err := sw.Handoff(Policy{}, "team", "task", "teammate", "note"); !errors.Is(err, ErrSwarmClosed) {
t.Fatalf("Handoff after Close error = %v, want ErrSwarmClosed", err)
}
if _, err := sw.AdoptOrphans(Policy{}, "team", "teammate"); !errors.Is(err, ErrSwarmClosed) {
t.Fatalf("AdoptOrphans after Close error = %v, want ErrSwarmClosed", err)
}
if got := len(l.recorded()); got != 0 {
t.Fatalf("launches after Close = %d, want 0", got)
}
}
func TestCloseDoesNotLaunchQueuedMembers(t *testing.T) {
gate := make(chan struct{})
l := newLauncher(okFor)
l.gate = gate
sw := newSwarmFor(t, l)
var ids []string
for i := 0; i < 3; i++ {
id, err := sw.Spawn(Policy{}, "team", "teammate", "task", "")
if err != nil {
t.Fatalf("Spawn %d: %v", i, err)
}
ids = append(ids, id)
}
if got := len(l.recorded()); got != 2 {
t.Fatalf("initial launches = %d, want 2 with one queued", got)
}
sw.Close()
if got := len(l.recorded()); got != 2 {
t.Fatalf("launches after Close = %d, want queued member not launched", got)
}
for _, id := range ids {
task, ok := sw.Coordinator().Get(id)
if !ok || !task.Status.terminal() {
t.Fatalf("task %s after Close = %+v, want terminal", id, task)
}
}
}
func TestClosePreventsMemberRetry(t *testing.T) {
started := make(chan struct{}, maxMemberRestarts+1)
release := make(chan struct{})
l := FuncLauncher{Run: func(context.Context, MemberSpec) (MemberResult, error) {
started <- struct{}{}
<-release
return MemberResult{}, ErrMemberTemporary
}}
sw := newSwarmFor(t, l)
_, err := sw.Spawn(Policy{}, "team", "teammate", "task", "")
if err != nil {
t.Fatalf("Spawn: %v", err)
}
select {
case <-started:
case <-time.After(3 * time.Second):
t.Fatal("initial member did not start")
}
closed := make(chan struct{})
go func() {
sw.Close()
close(closed)
}()
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
})
close(release)
select {
case <-closed:
case <-time.After(3 * time.Second):
t.Fatal("Close did not return after retryable member exit")
}
select {
case <-started:
t.Fatal("member retried after Close")
default:
}
}
func TestCloseWaitsForMemberWatchers(t *testing.T) {
release := make(chan struct{})
l := FuncLauncher{Run: func(context.Context, MemberSpec) (MemberResult, error) {
<-release
return MemberResult{Result: "done"}, nil
}}
sw := newSwarmFor(t, l)
id, err := sw.Spawn(Policy{}, "team", "teammate", "task", "")
if err != nil {
t.Fatalf("Spawn: %v", err)
}
waitFor(t, "task running", func() bool {
task, ok := sw.Coordinator().Get(id)
return ok && task.Status == StatusRunning
})
const callers = 3
closing := make(chan struct{}, callers)
closed := make(chan struct{}, callers)
for i := 0; i < callers; i++ {
go func() {
closing <- struct{}{}
sw.Close()
closed <- struct{}{}
}()
}
for i := 0; i < callers; i++ {
<-closing
}
select {
case <-closed:
t.Fatal("a Close caller returned before the member watcher exited")
case <-time.After(50 * time.Millisecond):
}
close(release)
for i := 0; i < callers; i++ {
select {
case <-closed:
case <-time.After(3 * time.Second):
t.Fatal("all Close callers did not return after the member watcher exited")
}
}
}
func TestHandoffDeliversNoteAndRetiresOriginal(t *testing.T) {
gate := make(chan struct{})
l := newLauncher(okFor)
l.gate = gate // keep the original member running so it is non-terminal
sw := newSwarmFor(t, l)
pol := Policy{Model: "m"}
origID, _ := sw.Spawn(pol, "team", "teammate", "original task", "/w")
waitFor(t, "original running", func() bool {
task, ok := sw.Coordinator().Get(origID)
return ok && task.Status == StatusRunning
})
newID, err := sw.Handoff(pol, "team", origID, "subagent", "please continue")
if err != nil {
t.Fatalf("Handoff: %v", err)
}
// Original retired.
orig, _ := sw.Coordinator().Get(origID)
if orig.Status != StatusHandedOff {
t.Fatalf("original status = %v, want handed-off", orig.Status)
}
// Note delivered to the new member's inbox.
msgs, err := sw.Mailbox().ReadAndConsume("team", newID)
if err != nil {
t.Fatalf("read new inbox: %v", err)
}
if len(msgs) != 1 || msgs[0].Body != "please continue" || msgs[0].Type != "handoff" {
t.Fatalf("handoff note not delivered: %+v", msgs)
}
// The new member carries the handoff note in its task and preserves cwd.
close(gate)
waitFor(t, "spec for new member", func() bool {
for _, s := range l.recorded() {
if s.ID == newID {
return true
}
}
return false
})
for _, s := range l.recorded() {
if s.ID == newID {
if s.Cwd != "/w" {
t.Fatalf("handoff lost cwd: %q", s.Cwd)
}
}
}
// A handoff of an already-terminal task is rejected.
waitFor(t, "new task done", func() bool {
task, ok := sw.Coordinator().Get(newID)
return ok && task.Status == StatusDone
})
if _, err := sw.Handoff(pol, "team", newID, "teammate", "again"); err == nil {
t.Fatal("handoff of a terminal task must fail")
}
}
func TestAdoptOrphans(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
// Simulate a crashed member: a running task in the coordinator whose owning
// agent has no live member in the team.
if _, err := sw.Coordinator().Register("orphan-1", "ghost", "team", "stranded work"); err != nil {
t.Fatalf("Register: %v", err)
}
_ = sw.Coordinator().SetStatus("orphan-1", StatusRunning)
adopted, err := sw.AdoptOrphans(Policy{Model: "m"}, "team", "subagent")
if err != nil {
t.Fatalf("AdoptOrphans: %v", err)
}
if len(adopted) != 1 || adopted[0] != "orphan-1" {
t.Fatalf("adopted = %v, want [orphan-1]", adopted)
}
// The orphan is relaunched under a fresh agent and completes.
waitFor(t, "orphan completed", func() bool {
task, ok := sw.Coordinator().Get("orphan-1")
return ok && task.Status == StatusDone
})
task, _ := sw.Coordinator().Get("orphan-1")
if task.AgentID == "ghost" {
t.Fatal("orphan should be reassigned to a new agent")
}
// A second adoption finds nothing (the task now has a live/owned outcome).
again, _ := sw.AdoptOrphans(Policy{}, "team", "subagent")
if len(again) != 0 {
t.Fatalf("second adoption = %v, want none", again)
}
}
func TestLiveAgentsIncludesQueuedSpecs(t *testing.T) {
// H1 regression: a spec queued over the concurrency cap is owned and about to
// launch, so it must count as a live agent. Otherwise AdoptOrphans sees its
// task as orphaned and re-dispatches it — double-executing the same task.
team := &Team{Name: "t", members: map[string]*Member{}, maxSize: 1}
if !team.admit(MemberSpec{ID: "a1", TaskID: "t1"}) {
t.Fatal("first spec should take the only slot and launch immediately")
}
team.addMember(&Member{ID: "a1"})
if team.admit(MemberSpec{ID: "a2", TaskID: "t2"}) {
t.Fatal("second spec is over the cap and should queue, not launch")
}
live := team.liveAgents()
if _, ok := live["a1"]; !ok {
t.Error("running member a1 missing from liveAgents")
}
if _, ok := live["a2"]; !ok {
t.Error("queued spec a2 missing from liveAgents — would be double-dispatched by AdoptOrphans")
}
}
func TestCollectScopesToTeam(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
a, _ := sw.Spawn(Policy{}, "alpha", "teammate", "ta", "")
_, _ = sw.Spawn(Policy{}, "beta", "teammate", "tb", "")
waitFor(t, "alpha done", func() bool {
task, ok := sw.Coordinator().Get(a)
return ok && task.Status == StatusDone
})
collected := sw.Collect("alpha")
if len(collected) != 1 || collected[0].Team != "alpha" {
t.Fatalf("Collect(alpha) = %+v, want one alpha task", collected)
}
}
func TestFuncLauncherRecoversPanic(t *testing.T) {
// A panic inside a member's Run must surface as that member's error, never
// escape the goroutine and crash the orchestrator.
l := FuncLauncher{Run: func(context.Context, MemberSpec) (MemberResult, error) {
panic("boom in member")
}}
h, err := l.Launch(context.Background(), MemberSpec{ID: "m1"})
if err != nil {
t.Fatalf("Launch: %v", err)
}
_, err = h.Wait()
if err == nil || !strings.Contains(err.Error(), "panicked") {
t.Fatalf("panic must surface as a member error, got %v", err)
}
}
func TestSpawnUnknownAgentType(t *testing.T) {
sw := newSwarmFor(t, newLauncher(okFor))
if _, err := sw.Spawn(Policy{}, "team", "does-not-exist", "task", ""); err == nil {
t.Fatal("Spawn with unknown agent type must error")
}
}
// blockingLauncher blocks inside Launch until its context is cancelled — the
// shape of a real launcher that waits on a slot, a daemon connection, or a
// sandbox handshake before it can return a handle.
type blockingLauncher struct {
entered chan struct{}
once sync.Once
}
func (l *blockingLauncher) Launch(ctx context.Context, spec MemberSpec) (MemberHandle, error) {
l.once.Do(func() { close(l.entered) })
<-ctx.Done()
return nil, ctx.Err()
}
// TestCloseCancelsLauncherBlockedInLaunch is the regression test for holding
// lifecycle admission across MemberLauncher.Launch. When admission was a read lock
// held for the caller's whole duration, this deadlocked: the spawn sat inside
// Launch waiting for its context, while Close waited for the write lock it needed
// before it could cancel that very context. Neither side could progress, so both
// the spawn and every Close caller hung.
//
// Admission is now a counted ticket, so Close can flip the flag, cancel, and then
// wait the ticket out.
func TestCloseCancelsLauncherBlockedInLaunch(t *testing.T) {
launcher := &blockingLauncher{entered: make(chan struct{})}
sw := newSwarmFor(t, launcher)
spawned := make(chan error, 1)
go func() {
_, err := sw.Spawn(Policy{}, "team", "teammate", "task", "")
spawned <- err
}()
select {
case <-launcher.entered:
case <-time.After(3 * time.Second):
t.Fatal("launcher never entered Launch")
}
closed := make(chan struct{})
go func() {
sw.Close()
close(closed)
}()
select {
case <-closed:
case <-time.After(5 * time.Second):
t.Fatal("Close deadlocked against a launcher blocked in Launch")
}
select {
case <-spawned:
case <-time.After(3 * time.Second):
t.Fatal("Spawn never returned after Close cancelled the launch context")
}
}
// TestCloseWaitsForAdmittedSpawn pins the other half of the contract: Close is a
// barrier, so it must not return while an admitted spawn is still running. Without
// the lifecycleWork wait, Close could finish while this launch was mid-flight.
func TestCloseWaitsForAdmittedSpawn(t *testing.T) {
entered := make(chan struct{})
finish := make(chan struct{})
launcher := &gatedLaunchLauncher{entered: entered, finish: finish}
sw := newSwarmFor(t, launcher)
go func() {
_, _ = sw.Spawn(Policy{}, "team", "teammate", "task", "")
}()
select {
case <-entered:
case <-time.After(3 * time.Second):
t.Fatal("launcher never entered Launch")
}
closed := make(chan struct{})
go func() {
sw.Close()
close(closed)
}()
select {
case <-closed:
t.Fatal("Close returned while an admitted spawn was still in flight")
case <-time.After(200 * time.Millisecond):
}
close(finish)
select {
case <-closed:
case <-time.After(5 * time.Second):
t.Fatal("Close did not return after the admitted spawn finished")
}
}
// TestAdmittedDispatchDoesNotLaunchAfterClose covers an operation that won a
// lifecycle ticket before shutdown but did not reach dispatch until after Close
// set closed. The ticket keeps Close from returning early, but it must not permit
// a new external launch with the already-cancelled swarm context.
func TestAdmittedDispatchDoesNotLaunchAfterClose(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
sw.maxTeamSize = 1
if _, err := sw.coord.Register("late-task", "late-agent", "team", "late work"); err != nil {
t.Fatalf("Register late task: %v", err)
}
release, err := sw.beginLifecycleAdmission()
if err != nil {
t.Fatalf("beginLifecycleAdmission: %v", err)
}
closed := make(chan struct{})
go func() {
sw.Close()
close(closed)
}()
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
})
sw.dispatchAdmitted(MemberSpec{
ID: "late-agent", TaskID: "late-task", AgentType: "teammate", Team: "team",
})
release()
select {
case <-closed:
case <-time.After(3 * time.Second):
t.Fatal("Close did not return after the admitted dispatch finished")
}
if got := len(l.recorded()); got != 0 {
t.Fatalf("launches after closed was set = %d, want 0", got)
}
team := sw.team("team")
if got := team.Running(); got != 0 {
t.Fatalf("team running after rejected launch = %d, want 0", got)
}
task, ok := sw.Coordinator().Get("late-task")
if !ok {
t.Fatal("late task vanished")
}
if task.Status != StatusFailed || !strings.Contains(task.Err, ErrSwarmClosed.Error()) {
t.Fatalf("late task after Close = %+v, want failed with ErrSwarmClosed", task)
}
}
// TestCloseBetweenLaunchPrecheckAndReturn rejects and reaps a handle created by
// a Launch that straddles shutdown. The launch has passed the pre-check when it
// enters the gate; Close then sets closed and cancels its context before the
// launcher is allowed to return successfully.
func TestCloseBetweenLaunchPrecheckAndReturn(t *testing.T) {
launcher := &closeRaceLauncher{
entered: make(chan struct{}),
finish: make(chan struct{}),
waited: make(chan struct{}),
}
sw := newSwarmFor(t, launcher)
spawned := make(chan string, 1)
go func() {
id, _ := sw.Spawn(Policy{}, "team", "teammate", "task", "")
spawned <- id
}()
select {
case <-launcher.entered:
case <-time.After(3 * time.Second):
t.Fatal("launcher never entered Launch")
}
closed := make(chan struct{})
go func() {
sw.Close()
close(closed)
}()
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
})
close(launcher.finish)
var id string
select {
case id = <-spawned:
case <-time.After(3 * time.Second):
t.Fatal("Spawn did not return after Launch")
}
select {
case <-closed:
case <-time.After(3 * time.Second):
t.Fatal("Close did not return after late handle was reaped")
}
select {
case <-launcher.waited:
default:
t.Fatal("late handle was not reaped")
}
team := sw.team("team")
if got := team.Running(); got != 0 {
t.Fatalf("team running after close-raced Launch = %d, want 0", got)
}
if got := len(team.liveAgents()); got != 0 {
t.Fatalf("registered members after close-raced Launch = %d, want 0", got)
}
task, ok := sw.Coordinator().Get(id)
if !ok {
t.Fatal("task vanished")
}
if task.Status != StatusFailed || !strings.Contains(task.Err, ErrSwarmClosed.Error()) {
t.Fatalf("task after close-raced Launch = %+v, want failed with ErrSwarmClosed", task)
}
}
type closeRaceLauncher struct {
entered chan struct{}
finish chan struct{}
waited chan struct{}
once sync.Once
}
func (l *closeRaceLauncher) Launch(ctx context.Context, spec MemberSpec) (MemberHandle, error) {
l.once.Do(func() { close(l.entered) })
<-l.finish
return &closeRaceHandle{id: spec.ID, ctx: ctx, waited: l.waited}, nil
}
type closeRaceHandle struct {
id string
ctx context.Context
waited chan struct{}
once sync.Once
}
func (h *closeRaceHandle) ID() string { return h.id }
func (h *closeRaceHandle) Wait() (MemberResult, error) {
<-h.ctx.Done()
h.once.Do(func() { close(h.waited) })
return MemberResult{}, h.ctx.Err()
}
// TestCloseBetweenRetryLaunchPrecheckAndReturn is the retry counterpart of
// TestCloseBetweenLaunchPrecheckAndReturn: a bounded relaunch wins admission and
// passes the pre-check, then Close lands while the launcher is inside Launch. The
// relaunched handle must be reaped rather than adopted, so the watcher never
// resumes supervising a member started after shutdown began — a relaunch whose
// member goes on to succeed must not be recorded as the task's outcome.
func TestCloseBetweenRetryLaunchPrecheckAndReturn(t *testing.T) {
launcher := &retryCloseRaceLauncher{
entered: make(chan struct{}),
finish: make(chan struct{}),
late: &retryLateHandle{id: "teammate-1"},
}
sw := newSwarmFor(t, launcher)
id, err := sw.Spawn(Policy{}, "team", "teammate", "task", "")
if err != nil {
t.Fatalf("Spawn: %v", err)
}
// The first member exits retryable, so the watcher is now inside the relaunch.
select {
case <-launcher.entered:
case <-time.After(3 * time.Second):
t.Fatal("launcher never entered the relaunch Launch")
}
closed := make(chan struct{})
go func() {
sw.Close()
close(closed)
}()
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
})
close(launcher.finish)
select {
case <-closed:
case <-time.After(3 * time.Second):
t.Fatal("Close did not return after the late relaunch handle was reaped")
}
if got := launcher.launchCount(); got != 2 {
t.Fatalf("launch attempts = %d, want 2 (initial + one relaunch)", got)
}
if got := launcher.late.waitCount(); got != 1 {
t.Fatalf("late relaunch handle waits = %d, want 1 (reaped, not supervised)", got)
}
team := sw.team("team")
if got := team.Running(); got != 0 {
t.Fatalf("team running after close-raced relaunch = %d, want 0", got)
}
if got := len(team.liveAgents()); got != 0 {
t.Fatalf("registered members after close-raced relaunch = %d, want 0", got)
}
task, ok := sw.Coordinator().Get(id)
if !ok {
t.Fatal("task vanished")
}
if task.Status != StatusFailed {
t.Fatalf("task after close-raced relaunch = %+v, want failed, not the late member's outcome", task)
}
if task.Result != "" {
t.Fatalf("task result after close-raced relaunch = %q, want the late member's result discarded", task.Result)
}
}
// retryCloseRaceLauncher fails its first member with a retryable error, then
// blocks inside the relaunch's Launch until the test releases it.
type retryCloseRaceLauncher struct {
mu sync.Mutex
calls int
entered chan struct{}
finish chan struct{}
late *retryLateHandle
once sync.Once
}
func (l *retryCloseRaceLauncher) Launch(_ context.Context, spec MemberSpec) (MemberHandle, error) {
l.mu.Lock()
l.calls++
first := l.calls == 1
l.mu.Unlock()
if first {
return &temporaryHandle{id: spec.ID}, nil
}
l.once.Do(func() { close(l.entered) })
<-l.finish
return l.late, nil
}
func (l *retryCloseRaceLauncher) launchCount() int {
l.mu.Lock()
defer l.mu.Unlock()
return l.calls
}
// temporaryHandle exits immediately with a retryable error, driving the watcher
// into its bounded relaunch path.
type temporaryHandle struct{ id string }
func (h *temporaryHandle) ID() string { return h.id }
func (h *temporaryHandle) Wait() (MemberResult, error) {
return MemberResult{}, ErrMemberTemporary
}
// retryLateHandle models a member whose work does not consult the cancelled
// launch context and reports success. Adopting it after shutdown would record a
// post-close result on the task, so a correct shutdown reaps it exactly once and
// discards what it returns.
type retryLateHandle struct {
id string
mu sync.Mutex
waits int
}
func (h *retryLateHandle) ID() string { return h.id }
func (h *retryLateHandle) Wait() (MemberResult, error) {
h.mu.Lock()
h.waits++
h.mu.Unlock()
return MemberResult{Result: "late result", SessionID: "late-session"}, nil
}
func (h *retryLateHandle) waitCount() int {
h.mu.Lock()
defer h.mu.Unlock()
return h.waits
}
func TestCloseRacesLifecycleAdmission(t *testing.T) {
gate := make(chan struct{})
l := newLauncher(okFor)
l.gate = gate
sw := newSwarmFor(t, l)
const spawns = 64
start := make(chan struct{})
results := make(chan struct {
id string
err error
}, spawns)
var callers sync.WaitGroup
callers.Add(spawns)
for i := 0; i < spawns; i++ {
go func() {
defer callers.Done()
<-start
id, err := sw.Spawn(Policy{}, "team", "teammate", "racing task", "")
results <- struct {
id string
err error
}{id: id, err: err}
}()
}
closed := make(chan struct{})
go func() {
<-start
sw.Close()
close(closed)
}()
close(start)
callers.Wait()
<-closed
close(results)
for result := range results {
if result.err != nil {
if !errors.Is(result.err, ErrSwarmClosed) {
t.Fatalf("Spawn racing Close error = %v, want ErrSwarmClosed", result.err)
}
continue
}
task, ok := sw.Coordinator().Get(result.id)
if !ok || !task.Status.terminal() {
t.Fatalf("admitted task %q after Close = %+v, want terminal", result.id, task)
}
}
for _, task := range sw.Coordinator().List() {
if !task.Status.terminal() {
t.Fatalf("task %q remained %s after Close", task.ID, task.Status)
}
}
}
// TestCloseFailsQueuedSpecOnLateCreatedTeam is the regression test for queue
// sweeping after shutdown begins. Spawn/Handoff/AdoptOrphans calls that obtained
// tickets before closed flipped can still create a team and append to its queue.
// Close must wait for those calls before both snapshotting teams and sweeping
// their queues, or a late-created team's queued task remains pending forever.
//
// This drives that interleaving directly: two operations obtain tickets before
// Close, then the first creates and fills a previously unseen one-slot team and
// the second queues behind it after Close has flipped closed.
func TestCloseFailsQueuedSpecOnLateCreatedTeam(t *testing.T) {
sw := newSwarmFor(t, newLauncher(okFor))
sw.maxTeamSize = 1
if _, err := sw.coord.Register("late-task", "late-agent", "team", "late work"); err != nil {
t.Fatalf("Register late task: %v", err)
}
firstRelease, err := sw.beginLifecycleAdmission()
if err != nil {
t.Fatalf("first beginLifecycleAdmission: %v", err)
}
secondRelease, err := sw.beginLifecycleAdmission()
if err != nil {
firstRelease()
t.Fatalf("second beginLifecycleAdmission: %v", err)
}
runningSpec := MemberSpec{ID: "running-agent", TaskID: "running-task", AgentType: "teammate", Team: "team"}
queuedSpec := MemberSpec{ID: "late-agent", TaskID: "late-task", AgentType: "teammate", Team: "team"}
closed := make(chan struct{})
go func() {
sw.Close()
close(closed)
}()
waitFor(t, "swarm closed state", func() bool {
sw.lifecycleMu.RLock()
defer sw.lifecycleMu.RUnlock()
return sw.closed
})
select {
case <-closed:
t.Fatal("Close returned while an admitted ticket was still open")
default:
}
// Neither operation touched a team before Close took its old, buggy snapshot.
// Finish the team-admission portion of both operations while their tickets are
// still held: the first creates the team and fills its only slot; the second
// appends to that late-created team's queue.
team := sw.team("team")
if !team.admit(runningSpec) {
t.Fatal("first spec should take the late-created team's only slot")
}
firstRelease()
if team.admit(queuedSpec) {
t.Fatal("second spec should queue on the late-created team")
}
secondRelease()
select {
case <-closed:
case <-time.After(3 * time.Second):
t.Fatal("Close did not return after the admitted ticket released")
}
if got := team.QueueDepth(); got != 0 {
t.Fatalf("queue depth after Close = %d, want 0 (late spec swept)", got)
}
task, ok := sw.Coordinator().Get("late-task")
if !ok {
t.Fatal("late-queued task vanished")
}
if task.Status != StatusFailed {
t.Fatalf("late-queued task status = %v, want %v (failed, not stranded)", task.Status, StatusFailed)
}
if !strings.Contains(task.Err, ErrSwarmClosed.Error()) {
t.Fatalf("late-queued task err = %q, want it to mention %q", task.Err, ErrSwarmClosed.Error())
}
}
// TestAfterExitAdmittedFailsDequeuedSpecAfterClose is the regression test for
// jatmn's second #776 finding: afterExitAdmitted used to dequeue a queued spec
// (t.onExit) and launch it unconditionally once its own ticket was granted,
// even though Close can flip closed in the gap between that dequeue — which
// makes the spec invisible to Close's clearQueue sweep — and the launch. A
// launcher that performs work before honoring its cancelled context could then
// start a brand new worker after shutdown had already begun.
func TestAfterExitAdmittedFailsDequeuedSpecAfterClose(t *testing.T) {
l := newLauncher(okFor)
sw := newSwarmFor(t, l)
if _, err := sw.coord.Register("queued-task", "queued-agent", "team", "queued work"); err != nil {
t.Fatalf("Register: %v", err)
}
spec := MemberSpec{ID: "queued-agent", TaskID: "queued-task", AgentType: "teammate", Team: "team"}
// Occupy the only slot, then queue spec behind it.
team := &Team{Name: "team", members: map[string]*Member{}, maxSize: 1}
if !team.admit(MemberSpec{ID: "running-agent", TaskID: "running-task"}) {
t.Fatal("first spec should take the only slot")
}
if team.admit(spec) {
t.Fatal("second spec should queue, not launch, over the cap")
}
// Simulate shutdown racing in between this call's ticket admission (already
// granted, hence no beginLifecycleAdmission call here) and its dequeue+launch,
// exactly like the finding describes: closed is already set by the time
// afterExitAdmitted's post-dequeue check runs.
sw.lifecycleMu.Lock()
sw.closed = true
sw.lifecycleMu.Unlock()
sw.afterExitAdmitted(team) // running-agent "exits" -> dequeues spec
if got := team.Running(); got != 0 {
t.Fatalf("team running after close-raced drain = %d, want 0 (slot released, not held for a launch)", got)
}
task, ok := sw.Coordinator().Get("queued-task")
if !ok {
t.Fatal("dequeued task vanished")
}
if task.Status != StatusFailed {
t.Fatalf("dequeued task status = %v, want %v (failed, not launched)", task.Status, StatusFailed)
}
if !strings.Contains(task.Err, ErrSwarmClosed.Error()) {
t.Fatalf("dequeued task err = %q, want it to mention %q", task.Err, ErrSwarmClosed.Error())
}
for _, s := range l.recorded() {
if s.ID == "queued-agent" {
t.Fatal("a spec dequeued after shutdown began must never launch")
}
}
}
// gatedLaunchLauncher blocks in Launch until the test releases it, ignoring
// context cancellation so the test controls exactly when the admitted work ends.
type gatedLaunchLauncher struct {
entered chan struct{}
finish chan struct{}
once sync.Once
}
func (l *gatedLaunchLauncher) Launch(_ context.Context, spec MemberSpec) (MemberHandle, error) {
l.once.Do(func() { close(l.entered) })
<-l.finish
return &funcHandle{id: spec.ID, done: closedChan()}, nil
}
func closedChan() chan struct{} {
ch := make(chan struct{})
close(ch)
return ch
}