Batteries-included agent-harness base, extracted from mort's agent layer. This first cut establishes the module + the zero-coupling core primitives: - lane, dispatchguard, pendingattach, run/progress.go: moved verbatim from mort - config: host config Source seam + env-var default (nil-safe helpers) - deliver: output-egress seam + Discard/Stdout defaults - identity: AdminPolicy + MemberResolver seams (nil-safe) - fanout: programmatic N×M swarm (bounded global + per-key concurrency) - README/CLAUDE.md with the vibe-coded banner; CI with Go gates + the "core stays majordomo+stdlib only" invariant Core builds with stdlib only today; majordomo enters at P1 (model/structured). go build/vet/test -race all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
package dispatchguard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnter_RootHasEmptyChainAndStampsItself(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if got := Chain(ctx); got != nil {
|
||||
t.Fatalf("root chain should be nil, got %v", got)
|
||||
}
|
||||
out, rej := Enter(ctx, AncestorRef{Kind: "agent", ID: "A", RunID: "r1"}, 5, 64)
|
||||
if rej != nil {
|
||||
t.Fatalf("root run must not be rejected: %+v", rej)
|
||||
}
|
||||
chain := Chain(out)
|
||||
if len(chain) != 1 || chain[0].ID != "A" {
|
||||
t.Fatalf("child ctx chain = %v, want [A]", chain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnter_DirectSelfInvocationIsLoop(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ctx, rej := Enter(ctx, AncestorRef{Kind: "agent", ID: "A", RunID: "r1"}, 5, 64)
|
||||
if rej != nil {
|
||||
t.Fatal("first enter should pass")
|
||||
}
|
||||
_, rej = Enter(ctx, AncestorRef{Kind: "agent", ID: "A", RunID: "r2"}, 5, 64)
|
||||
if rej == nil || rej.Kind != "loop" {
|
||||
t.Fatalf("re-entering A should be a loop rejection, got %+v", rej)
|
||||
}
|
||||
if rej.Status() != "rejected_loop" {
|
||||
t.Fatalf("status = %q", rej.Status())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnter_IndirectLoopAcrossNouns(t *testing.T) {
|
||||
// A -> B -> A must be caught even though B is a different noun, and
|
||||
// even though no parent_run_id DB row exists (this is the chatbot-tool
|
||||
// parentless-run hole the in-memory chain closes).
|
||||
ctx := context.Background()
|
||||
ctx, _ = Enter(ctx, AncestorRef{Kind: "agent", ID: "A", RunID: "r1"}, 5, 64)
|
||||
ctx, _ = Enter(ctx, AncestorRef{Kind: "skill", ID: "B", RunID: "r2"}, 5, 64)
|
||||
_, rej := Enter(ctx, AncestorRef{Kind: "agent", ID: "A", RunID: "r3"}, 5, 64)
|
||||
if rej == nil || rej.Kind != "loop" {
|
||||
t.Fatalf("A->B->A should be a loop, got %+v", rej)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnter_DifferentNounSameIDIsNotLoop(t *testing.T) {
|
||||
// A skill and an agent can legitimately share an ID space; the guard
|
||||
// keys on (Kind, ID), so skill "X" inside agent "X" is allowed.
|
||||
ctx := context.Background()
|
||||
ctx, _ = Enter(ctx, AncestorRef{Kind: "agent", ID: "X", RunID: "r1"}, 5, 64)
|
||||
_, rej := Enter(ctx, AncestorRef{Kind: "skill", ID: "X", RunID: "r2"}, 5, 64)
|
||||
if rej != nil {
|
||||
t.Fatalf("skill X under agent X should be allowed, got %+v", rej)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnter_DepthCap(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// maxDepth=3: chains of length 0,1,2 may enter; length 3 is rejected.
|
||||
var rej *Rejection
|
||||
for i, id := range []string{"A", "B", "C", "D"} {
|
||||
ctx, rej = Enter(ctx, AncestorRef{Kind: "agent", ID: id}, 3, 64)
|
||||
if i < 3 && rej != nil {
|
||||
t.Fatalf("enter %d (%s) should pass, got %+v", i, id, rej)
|
||||
}
|
||||
if i == 3 {
|
||||
if rej == nil || rej.Kind != "depth" {
|
||||
t.Fatalf("4th enter at maxDepth=3 should be depth rejection, got %+v", rej)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnter_DescendantBudgetSharedAcrossTree(t *testing.T) {
|
||||
// Root installs a budget of 2 descendants. The root itself doesn't
|
||||
// count; the first two children pass, the third is rejected.
|
||||
root := context.Background()
|
||||
root, rej := Enter(root, AncestorRef{Kind: "agent", ID: "root", RunID: "r0"}, 5, 2)
|
||||
if rej != nil {
|
||||
t.Fatal("root must pass")
|
||||
}
|
||||
_, rej = Enter(root, AncestorRef{Kind: "agent", ID: "c1"}, 5, 2)
|
||||
if rej != nil {
|
||||
t.Fatalf("child 1 should pass, got %+v", rej)
|
||||
}
|
||||
_, rej = Enter(root, AncestorRef{Kind: "agent", ID: "c2"}, 5, 2)
|
||||
if rej != nil {
|
||||
t.Fatalf("child 2 should pass, got %+v", rej)
|
||||
}
|
||||
_, rej = Enter(root, AncestorRef{Kind: "agent", ID: "c3"}, 5, 2)
|
||||
if rej == nil || rej.Kind != "budget" {
|
||||
t.Fatalf("child 3 should exhaust the descendant budget, got %+v", rej)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnter_SiblingChainsAreIndependent(t *testing.T) {
|
||||
// Appending to a parent chain must not mutate a sibling's slice.
|
||||
root := context.Background()
|
||||
root, _ = Enter(root, AncestorRef{Kind: "agent", ID: "root"}, 5, 64)
|
||||
branch1, _ := Enter(root, AncestorRef{Kind: "agent", ID: "b1"}, 5, 64)
|
||||
branch2, _ := Enter(root, AncestorRef{Kind: "agent", ID: "b2"}, 5, 64)
|
||||
if c1, c2 := Chain(branch1), Chain(branch2); c1[len(c1)-1].ID == c2[len(c2)-1].ID {
|
||||
t.Fatalf("sibling branches share a tail: %v / %v", c1, c2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user