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,124 @@
|
||||
// Package fanout is executus's programmatic swarm primitive: run a function over
|
||||
// many items concurrently with bounded global and per-key concurrency, returning
|
||||
// one result per item in input order.
|
||||
//
|
||||
// This is distinct from the LLM-callable agent_spawn_parallel tool. fanout is a
|
||||
// plain Go API a host drives directly — it is what Gadfly uses to run an
|
||||
// N-models × M-lenses review fleet (flatten the matrix into items, key each by
|
||||
// its provider, cap per-provider concurrency) and what any host uses to scatter
|
||||
// bounded agent runs and gather structured results for consolidation.
|
||||
//
|
||||
// fanout has no dependency beyond the stdlib; a caller wires per-provider caps
|
||||
// from config (Mort: convar; Gadfly: GADFLY_PROVIDER_CONCURRENCY).
|
||||
package fanout
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Result pairs a task's output with its error and original index. fn errors are
|
||||
// captured here, not propagated — one failing task never aborts the batch.
|
||||
type Result[T any] struct {
|
||||
Index int
|
||||
Value T
|
||||
Err error
|
||||
}
|
||||
|
||||
// Options bound a fan-out.
|
||||
//
|
||||
// MaxConcurrent — cap on total in-flight tasks (0 = unbounded).
|
||||
// PerKey — cap on in-flight tasks sharing a key bucket; a key absent
|
||||
// from the map (or mapped to <=0) is uncapped beyond
|
||||
// MaxConcurrent. Used for per-provider concurrency.
|
||||
// Key — maps an item to its bucket; nil means all items are unkeyed.
|
||||
type Options[A any] struct {
|
||||
MaxConcurrent int
|
||||
PerKey map[string]int
|
||||
Key func(A) string
|
||||
}
|
||||
|
||||
// Run executes fn over items concurrently under opts and returns one Result per
|
||||
// item, in input order. Context cancellation stops un-started tasks (their
|
||||
// Result carries ctx.Err()); already-running tasks observe ctx through fn.
|
||||
func Run[A any, T any](ctx context.Context, items []A, opts Options[A], fn func(ctx context.Context, item A) (T, error)) []Result[T] {
|
||||
results := make([]Result[T], len(items))
|
||||
|
||||
var global chan struct{}
|
||||
if opts.MaxConcurrent > 0 {
|
||||
global = make(chan struct{}, opts.MaxConcurrent)
|
||||
}
|
||||
// Build per-key semaphores up front; the map is read-only during the run so
|
||||
// concurrent reads are safe.
|
||||
keySems := make(map[string]chan struct{}, len(opts.PerKey))
|
||||
for k, n := range opts.PerKey {
|
||||
if n > 0 {
|
||||
keySems[k] = make(chan struct{}, n)
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i, it := range items {
|
||||
wg.Add(1)
|
||||
go func(i int, it A) {
|
||||
defer wg.Done()
|
||||
results[i].Index = i
|
||||
|
||||
if err := ctx.Err(); err != nil {
|
||||
results[i].Err = err
|
||||
return
|
||||
}
|
||||
|
||||
// Acquire global then key (consistent order avoids deadlock).
|
||||
if global != nil {
|
||||
select {
|
||||
case global <- struct{}{}:
|
||||
defer func() { <-global }()
|
||||
case <-ctx.Done():
|
||||
results[i].Err = ctx.Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
if opts.Key != nil {
|
||||
if ks := keySems[opts.Key(it)]; ks != nil {
|
||||
select {
|
||||
case ks <- struct{}{}:
|
||||
defer func() { <-ks }()
|
||||
case <-ctx.Done():
|
||||
results[i].Err = ctx.Err()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
v, err := fn(ctx, it)
|
||||
results[i].Value = v
|
||||
results[i].Err = err
|
||||
}(i, it)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// Values returns the successful values (Err == nil) from a result slice, in
|
||||
// order. Convenience for consolidation steps that ignore failures.
|
||||
func Values[T any](rs []Result[T]) []T {
|
||||
out := make([]T, 0, len(rs))
|
||||
for _, r := range rs {
|
||||
if r.Err == nil {
|
||||
out = append(out, r.Value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Errors returns the non-nil errors from a result slice, in order.
|
||||
func Errors[T any](rs []Result[T]) []error {
|
||||
var out []error
|
||||
for _, r := range rs {
|
||||
if r.Err != nil {
|
||||
out = append(out, r.Err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package fanout
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunPreservesOrderAndCapturesErrors(t *testing.T) {
|
||||
items := []int{0, 1, 2, 3, 4}
|
||||
got := Run(context.Background(), items, Options[int]{MaxConcurrent: 2},
|
||||
func(_ context.Context, n int) (int, error) {
|
||||
if n == 2 {
|
||||
return 0, errors.New("boom")
|
||||
}
|
||||
return n * 10, nil
|
||||
})
|
||||
|
||||
if len(got) != len(items) {
|
||||
t.Fatalf("len = %d", len(got))
|
||||
}
|
||||
for i, r := range got {
|
||||
if r.Index != i {
|
||||
t.Errorf("result[%d].Index = %d", i, r.Index)
|
||||
}
|
||||
if i == 2 {
|
||||
if r.Err == nil {
|
||||
t.Errorf("expected error at index 2")
|
||||
}
|
||||
} else if r.Value != i*10 {
|
||||
t.Errorf("result[%d].Value = %d, want %d", i, r.Value, i*10)
|
||||
}
|
||||
}
|
||||
if vals := Values(got); len(vals) != 4 {
|
||||
t.Errorf("Values len = %d, want 4", len(vals))
|
||||
}
|
||||
if errs := Errors(got); len(errs) != 1 {
|
||||
t.Errorf("Errors len = %d, want 1", len(errs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaxConcurrentBound(t *testing.T) {
|
||||
const max = 3
|
||||
var inflight, peak int32
|
||||
items := make([]int, 30)
|
||||
Run(context.Background(), items, Options[int]{MaxConcurrent: max},
|
||||
func(_ context.Context, _ int) (int, error) {
|
||||
n := atomic.AddInt32(&inflight, 1)
|
||||
for {
|
||||
p := atomic.LoadInt32(&peak)
|
||||
if n <= p || atomic.CompareAndSwapInt32(&peak, p, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
atomic.AddInt32(&inflight, -1)
|
||||
return 0, nil
|
||||
})
|
||||
if peak > max {
|
||||
t.Errorf("peak concurrency %d exceeded MaxConcurrent %d", peak, max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerKeyCap(t *testing.T) {
|
||||
// Two providers; provider "slow" capped at 1, so its peak must be 1 even
|
||||
// though MaxConcurrent allows more.
|
||||
var slowInflight, slowPeak int32
|
||||
type job struct{ provider string }
|
||||
items := make([]job, 12)
|
||||
for i := range items {
|
||||
items[i] = job{provider: "slow"}
|
||||
}
|
||||
Run(context.Background(), items, Options[job]{
|
||||
MaxConcurrent: 8,
|
||||
PerKey: map[string]int{"slow": 1},
|
||||
Key: func(j job) string { return j.provider },
|
||||
}, func(_ context.Context, _ job) (int, error) {
|
||||
n := atomic.AddInt32(&slowInflight, 1)
|
||||
for {
|
||||
p := atomic.LoadInt32(&slowPeak)
|
||||
if n <= p || atomic.CompareAndSwapInt32(&slowPeak, p, n) {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
atomic.AddInt32(&slowInflight, -1)
|
||||
return 0, nil
|
||||
})
|
||||
if slowPeak != 1 {
|
||||
t.Errorf("per-key cap not honored: slow peak = %d, want 1", slowPeak)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContextCancellation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
got := Run(ctx, make([]int, 5), Options[int]{MaxConcurrent: 2},
|
||||
func(ctx context.Context, _ int) (int, error) { return 1, nil })
|
||||
for i, r := range got {
|
||||
if r.Err == nil {
|
||||
t.Errorf("result[%d] expected ctx error after cancel", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user