P0: stand up executus harness module above majordomo
executus CI / test (push) Failing after 24s

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:
2026-06-26 19:18:37 -04:00
parent 25feb63c00
commit ca243a2d50
31 changed files with 5042 additions and 18 deletions
+159
View File
@@ -0,0 +1,159 @@
// Package dispatchguard is the single chokepoint that bounds agent/skill
// composition: it stops a run from invoking one of its own ancestors
// (loop), from nesting past a depth cap, and from spawning more than a
// budget of descendant runs under one root.
//
// Why a standalone package: "invoke a skill/agent from inside a run" has
// three dispatch surfaces — the agent_invoke/skill_invoke TOOLS, the
// palette skill__/agent__ wrappers, and the agent-as-chatbot tool. The
// guards historically lived only in the TOOLS, so the other two paths
// recursed unbounded (the 2026-06-09 general-agent self-recursion
// incident was exactly this) and the DB-walk guard the tools used failed
// OPEN when the audit store was nil or a run's parent_run_id was empty
// (the chatbot-tool path produces parentless runs). Every dispatch path
// ultimately funnels through Executor.Run -> runInner, so enforcing the
// guard there — against an in-memory ancestor chain carried on the
// context — covers all three paths at once, synchronously, with no
// dependency on the audit table.
//
// The chain + descendant budget ride on context.Context, so propagation
// is automatic: a run stamps itself onto the ctx it hands to its agent
// loop, every tool handler inherits that ctx, and any sub-invocation's
// Executor.Run receives it — so the child sees its full ancestry without
// anyone threading it explicitly. The budget counter is a shared pointer
// created at the root and seen by every descendant; it is garbage
// collected with the context, so there is no global map to clean up.
//
// This package deliberately imports nothing from skillexec/agentexec/
// agents (only the standard library) so it can be called from both
// executors — and the future merged engine — without an import cycle.
package dispatchguard
import (
"context"
"fmt"
"slices"
"sync/atomic"
)
// Default limits, used when Enter is called with a non-positive value.
// Chosen to allow real fan-out (general -> researcher -> sub-task) while
// still capping a runaway recursion or fan-out tree well before it can
// exhaust a lane or the model budget.
const (
DefaultMaxDepth = 5
DefaultMaxDescendant = 64
)
// AncestorRef identifies one run in the current dispatch chain.
type AncestorRef struct {
Kind string // "agent" | "skill"
ID string // the noun's stable UUID
RunID string // this run's audit id (for diagnostics)
}
type chainKeyT struct{}
type budgetKeyT struct{}
var chainKey chainKeyT
var budgetKey budgetKeyT
type descendantBudget struct {
count atomic.Int64
cap int64
}
// Chain returns the ancestor refs carried on ctx, oldest-first (the root
// run is index 0). Never nil-panics; returns nil when ctx carries none.
func Chain(ctx context.Context) []AncestorRef {
if v, ok := ctx.Value(chainKey).([]AncestorRef); ok {
return v
}
return nil
}
// Rejection describes why a run must not proceed. It is intentionally a
// soft outcome: the caller records an audit row and returns the Message
// as the run's output so a delegating parent agent sees a clear,
// actionable refusal rather than a hard error.
type Rejection struct {
// Kind is one of "loop", "depth", "budget" — used for the audit
// status (status_for) and for tests.
Kind string
Detail string
}
// Status maps a rejection to the audit row status.
func (r *Rejection) Status() string { return "rejected_" + r.Kind }
// Message is the human/LLM-facing refusal text returned as the run's
// output.
func (r *Rejection) Message() string {
return "⚠️ delegation refused (" + r.Kind + "): " + r.Detail +
". Synthesize an answer from what you already have instead of re-delegating."
}
// Enter is called once at the top of every run, BEFORE the agent loop
// starts. It checks the loop / depth / descendant-budget guards against
// the ancestor chain on ctx and returns:
//
// - a child context with `ref` appended to the chain (and, at the root,
// a fresh descendant budget) — use this as the base context for the
// run's agent loop so sub-invocations inherit the ancestry; and
// - a non-nil *Rejection when the run must NOT proceed (in which case
// the returned context equals the input and should be ignored).
//
// maxDepth / maxDescendant <= 0 fall back to the package defaults.
func Enter(ctx context.Context, ref AncestorRef, maxDepth, maxDescendant int) (context.Context, *Rejection) {
if maxDepth <= 0 {
maxDepth = DefaultMaxDepth
}
if maxDescendant <= 0 {
maxDescendant = DefaultMaxDescendant
}
chain := Chain(ctx)
// 1) Loop: refuse to invoke a noun already executing in this chain.
for _, a := range chain {
if a.Kind == ref.Kind && a.ID == ref.ID {
return ctx, &Rejection{
Kind: "loop",
Detail: fmt.Sprintf("%s %q is already running higher in this dispatch chain (depth %d)",
ref.Kind, ref.ID, len(chain)),
}
}
}
// 2) Depth: refuse to nest past the cap.
if len(chain) >= maxDepth {
return ctx, &Rejection{
Kind: "depth",
Detail: fmt.Sprintf("dispatch chain depth %d reached the cap of %d", len(chain), maxDepth),
}
}
// 3) Descendant budget: only descendants (non-root) count against the
// per-root budget. The root creates the shared counter below.
if len(chain) > 0 {
if b, ok := ctx.Value(budgetKey).(*descendantBudget); ok && b != nil && b.cap > 0 {
if b.count.Add(1) > b.cap {
return ctx, &Rejection{
Kind: "budget",
Detail: fmt.Sprintf("this run tree already spawned its budget of %d descendant runs", b.cap),
}
}
}
}
// Stamp the child context. slices.Clone keeps each branch's chain
// independent so sibling sub-invocations don't see each other.
newChain := append(slices.Clone(chain), ref)
out := context.WithValue(ctx, chainKey, newChain)
if len(chain) == 0 {
// Root: install the shared descendant budget every descendant
// will increment.
out = context.WithValue(out, budgetKey, &descendantBudget{cap: int64(maxDescendant)})
}
return out, nil
}
+110
View File
@@ -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)
}
}