test: add comprehensive cascade stop tests
Coverage for: single run, parent-child chain, mid-chain stop, multiple siblings, cycle protection (A→B→A), non-existent key, StopAll, GetChildren, Go context tree propagation.
This commit is contained in:
parent
03b95932ee
commit
94675e024a
1 changed files with 327 additions and 0 deletions
327
pkg/multiagent/cascade_test.go
Normal file
327
pkg/multiagent/cascade_test.go
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
package multiagent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunRegistry_RegisterAndDeregister(t *testing.T) {
|
||||
reg := NewRunRegistry()
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "run-1",
|
||||
AgentID: "coder",
|
||||
Cancel: cancel,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
|
||||
if reg.ActiveCount() != 1 {
|
||||
t.Fatalf("ActiveCount = %d, want 1", reg.ActiveCount())
|
||||
}
|
||||
|
||||
reg.Deregister("run-1")
|
||||
|
||||
if reg.ActiveCount() != 0 {
|
||||
t.Fatalf("ActiveCount = %d after deregister, want 0", reg.ActiveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistry_CascadeStop_SingleRun(t *testing.T) {
|
||||
reg := NewRunRegistry()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "run-1",
|
||||
AgentID: "coder",
|
||||
Cancel: cancel,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
|
||||
killed := reg.CascadeStop("run-1")
|
||||
if killed != 1 {
|
||||
t.Fatalf("killed = %d, want 1", killed)
|
||||
}
|
||||
|
||||
// Context should be cancelled
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// expected
|
||||
default:
|
||||
t.Fatal("context not cancelled after CascadeStop")
|
||||
}
|
||||
|
||||
if reg.ActiveCount() != 0 {
|
||||
t.Fatalf("ActiveCount = %d after CascadeStop, want 0", reg.ActiveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistry_CascadeStop_ParentChildChain(t *testing.T) {
|
||||
reg := NewRunRegistry()
|
||||
|
||||
// Build: parent → child → grandchild
|
||||
_, cancelParent := context.WithCancel(context.Background())
|
||||
ctxChild, cancelChild := context.WithCancel(context.Background())
|
||||
ctxGrandchild, cancelGrandchild := context.WithCancel(context.Background())
|
||||
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "parent",
|
||||
AgentID: "main",
|
||||
ParentKey: "",
|
||||
Cancel: cancelParent,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "child",
|
||||
AgentID: "coder",
|
||||
ParentKey: "parent",
|
||||
Cancel: cancelChild,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "grandchild",
|
||||
AgentID: "researcher",
|
||||
ParentKey: "child",
|
||||
Cancel: cancelGrandchild,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
|
||||
if reg.ActiveCount() != 3 {
|
||||
t.Fatalf("ActiveCount = %d, want 3", reg.ActiveCount())
|
||||
}
|
||||
|
||||
// Cascade stop from parent should kill all 3
|
||||
killed := reg.CascadeStop("parent")
|
||||
if killed != 3 {
|
||||
t.Fatalf("killed = %d, want 3", killed)
|
||||
}
|
||||
|
||||
// All contexts should be cancelled
|
||||
select {
|
||||
case <-ctxChild.Done():
|
||||
default:
|
||||
t.Fatal("child context not cancelled")
|
||||
}
|
||||
select {
|
||||
case <-ctxGrandchild.Done():
|
||||
default:
|
||||
t.Fatal("grandchild context not cancelled")
|
||||
}
|
||||
|
||||
if reg.ActiveCount() != 0 {
|
||||
t.Fatalf("ActiveCount = %d after cascade, want 0", reg.ActiveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistry_CascadeStop_MidChain(t *testing.T) {
|
||||
reg := NewRunRegistry()
|
||||
|
||||
// Build: parent → child → grandchild
|
||||
_, cancelParent := context.WithCancel(context.Background())
|
||||
_, cancelChild := context.WithCancel(context.Background())
|
||||
ctxGrandchild, cancelGrandchild := context.WithCancel(context.Background())
|
||||
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "parent",
|
||||
AgentID: "main",
|
||||
ParentKey: "",
|
||||
Cancel: cancelParent,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "child",
|
||||
AgentID: "coder",
|
||||
ParentKey: "parent",
|
||||
Cancel: cancelChild,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "grandchild",
|
||||
AgentID: "researcher",
|
||||
ParentKey: "child",
|
||||
Cancel: cancelGrandchild,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
|
||||
// Cascade stop from child should kill child + grandchild, but NOT parent
|
||||
killed := reg.CascadeStop("child")
|
||||
if killed != 2 {
|
||||
t.Fatalf("killed = %d, want 2", killed)
|
||||
}
|
||||
|
||||
// Grandchild context cancelled
|
||||
select {
|
||||
case <-ctxGrandchild.Done():
|
||||
default:
|
||||
t.Fatal("grandchild context not cancelled")
|
||||
}
|
||||
|
||||
// Parent still active
|
||||
if reg.ActiveCount() != 1 {
|
||||
t.Fatalf("ActiveCount = %d, want 1 (parent survives)", reg.ActiveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistry_CascadeStop_MultipleSiblings(t *testing.T) {
|
||||
reg := NewRunRegistry()
|
||||
|
||||
_, cancelParent := context.WithCancel(context.Background())
|
||||
_, cancelSibling1 := context.WithCancel(context.Background())
|
||||
_, cancelSibling2 := context.WithCancel(context.Background())
|
||||
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "parent",
|
||||
AgentID: "main",
|
||||
Cancel: cancelParent,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "sibling-1",
|
||||
AgentID: "coder",
|
||||
ParentKey: "parent",
|
||||
Cancel: cancelSibling1,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "sibling-2",
|
||||
AgentID: "researcher",
|
||||
ParentKey: "parent",
|
||||
Cancel: cancelSibling2,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
|
||||
killed := reg.CascadeStop("parent")
|
||||
if killed != 3 {
|
||||
t.Fatalf("killed = %d, want 3 (parent + 2 siblings)", killed)
|
||||
}
|
||||
if reg.ActiveCount() != 0 {
|
||||
t.Fatalf("ActiveCount = %d, want 0", reg.ActiveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistry_CascadeStop_CycleProtection(t *testing.T) {
|
||||
reg := NewRunRegistry()
|
||||
|
||||
// Manually create a cycle: A→B→A (shouldn't happen in practice, but guard against it)
|
||||
_, cancelA := context.WithCancel(context.Background())
|
||||
_, cancelB := context.WithCancel(context.Background())
|
||||
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "A",
|
||||
AgentID: "agent-a",
|
||||
ParentKey: "B",
|
||||
Cancel: cancelA,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "B",
|
||||
AgentID: "agent-b",
|
||||
ParentKey: "A",
|
||||
Cancel: cancelB,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
|
||||
// Should not infinite-loop, should kill both
|
||||
killed := reg.CascadeStop("A")
|
||||
if killed != 2 {
|
||||
t.Fatalf("killed = %d, want 2", killed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistry_CascadeStop_NonExistent(t *testing.T) {
|
||||
reg := NewRunRegistry()
|
||||
|
||||
killed := reg.CascadeStop("ghost")
|
||||
if killed != 0 {
|
||||
t.Fatalf("killed = %d, want 0 for non-existent key", killed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistry_StopAll(t *testing.T) {
|
||||
reg := NewRunRegistry()
|
||||
|
||||
var cancelled atomic.Int32
|
||||
for i := 0; i < 5; i++ {
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
idx := i
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: fmt.Sprintf("run-%d", idx),
|
||||
AgentID: "agent",
|
||||
Cancel: func() {
|
||||
cancelled.Add(1)
|
||||
cancel()
|
||||
},
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
|
||||
killed := reg.StopAll()
|
||||
if killed != 5 {
|
||||
t.Fatalf("StopAll killed = %d, want 5", killed)
|
||||
}
|
||||
if cancelled.Load() != 5 {
|
||||
t.Fatalf("cancel called %d times, want 5", cancelled.Load())
|
||||
}
|
||||
if reg.ActiveCount() != 0 {
|
||||
t.Fatalf("ActiveCount = %d after StopAll, want 0", reg.ActiveCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistry_GetChildren(t *testing.T) {
|
||||
reg := NewRunRegistry()
|
||||
_, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
reg.Register(&ActiveRun{SessionKey: "parent", Cancel: cancel, StartedAt: time.Now()})
|
||||
reg.Register(&ActiveRun{SessionKey: "child-1", ParentKey: "parent", Cancel: cancel, StartedAt: time.Now()})
|
||||
reg.Register(&ActiveRun{SessionKey: "child-2", ParentKey: "parent", Cancel: cancel, StartedAt: time.Now()})
|
||||
reg.Register(&ActiveRun{SessionKey: "other", ParentKey: "other-parent", Cancel: cancel, StartedAt: time.Now()})
|
||||
|
||||
children := reg.GetChildren("parent")
|
||||
if len(children) != 2 {
|
||||
t.Fatalf("GetChildren = %d, want 2", len(children))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRegistry_ContextCancellationPropagates(t *testing.T) {
|
||||
reg := NewRunRegistry()
|
||||
|
||||
// Simulate Go context tree: parent ctx → child ctx
|
||||
parentCtx, parentCancel := context.WithCancel(context.Background())
|
||||
childCtx, childCancel := context.WithCancel(parentCtx)
|
||||
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "parent",
|
||||
AgentID: "main",
|
||||
Cancel: parentCancel,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
reg.Register(&ActiveRun{
|
||||
SessionKey: "child",
|
||||
AgentID: "coder",
|
||||
ParentKey: "parent",
|
||||
Cancel: childCancel,
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
|
||||
// Cascade stop on parent should cancel parent context
|
||||
reg.CascadeStop("parent")
|
||||
|
||||
// Parent context cancelled
|
||||
select {
|
||||
case <-parentCtx.Done():
|
||||
default:
|
||||
t.Fatal("parent context not cancelled")
|
||||
}
|
||||
|
||||
// Child context should ALSO be cancelled (Go context tree propagation)
|
||||
select {
|
||||
case <-childCtx.Done():
|
||||
default:
|
||||
t.Fatal("child context not cancelled via Go context tree")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue