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,86 @@
|
||||
package run
|
||||
|
||||
import "context"
|
||||
|
||||
// ProgressSink reports a one-line progress note for the current run upward to
|
||||
// any ancestor run that is being watched by a run-critic. It exists to solve a
|
||||
// specific false-positive: when an agent calls a long-running skill/agent as a
|
||||
// single tool, the parent's agent loop is BLOCKED on that one tool call for the
|
||||
// whole child run, so the parent's progress recorder sees "zero iterations,
|
||||
// zero new tokens, no activity" and its critic concludes the tool "hung
|
||||
// indefinitely" — even though the child is iterating happily. Forwarding the
|
||||
// child's per-step activity up the chain keeps every blocked ancestor's
|
||||
// last-activity fresh, so a healthy-but-slow child is no longer mistaken for a
|
||||
// hang. A nil ProgressSink is safe to ignore (there is no ancestor to notify).
|
||||
type ProgressSink func(note string)
|
||||
|
||||
type progressSinkKey struct{}
|
||||
|
||||
// WithProgressSink returns a context carrying sink for descendant runs to find
|
||||
// via ProgressSinkFrom. A nil sink is stored as-is (ProgressSinkFrom returns
|
||||
// nil), which callers treat as "no ancestor watching".
|
||||
func WithProgressSink(ctx context.Context, sink ProgressSink) context.Context {
|
||||
return context.WithValue(ctx, progressSinkKey{}, sink)
|
||||
}
|
||||
|
||||
// ProgressSinkFrom returns the ancestor progress sink carried on ctx, or nil
|
||||
// if none is wired. The returned sink, when non-nil, forwards a note to the
|
||||
// immediate parent run's recorder AND (transitively) to every further
|
||||
// ancestor, because each level installs a sink that forwards upward.
|
||||
func ProgressSinkFrom(ctx context.Context) ProgressSink {
|
||||
if v := ctx.Value(progressSinkKey{}); v != nil {
|
||||
if s, ok := v.(ProgressSink); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// InstallProgressBridge wires the current run into the ancestor progress chain.
|
||||
//
|
||||
// report — this run's own recorder hook (e.g. recorder.OnStatus). nil when
|
||||
// the run has no critic recorder of its own (the common skill case);
|
||||
// the bridge then purely forwards descendants' progress upward.
|
||||
//
|
||||
// It returns:
|
||||
//
|
||||
// childCtx — pass this to the agent loop / toolbox so descendant runs
|
||||
// (invoked as tools) forward their progress into this chain.
|
||||
// notifyAncestors — call this on each of THIS run's own loop steps to keep
|
||||
// every ancestor critic's last-activity fresh. nil when this
|
||||
// run has no ancestors (it is a top-level run); nil-safe to
|
||||
// call only via the returned value being checked, so callers
|
||||
// should guard `if notifyAncestors != nil`.
|
||||
//
|
||||
// The chain is built so that a note from any descendant bumps the recorders of
|
||||
// ALL of its blocked ancestors, not just its immediate parent.
|
||||
func InstallProgressBridge(ctx context.Context, report ProgressSink) (childCtx context.Context, notifyAncestors ProgressSink) {
|
||||
parent := ProgressSinkFrom(ctx)
|
||||
|
||||
// The sink descendants will call. It must bump this run's own recorder
|
||||
// (report) AND forward to all ancestors (parent). Collapse to the minimal
|
||||
// closure so we don't stack a no-op wrapper for recorder-less runs.
|
||||
var child ProgressSink
|
||||
switch {
|
||||
case report == nil:
|
||||
// No recorder of our own: descendants forward straight to ancestors.
|
||||
child = parent
|
||||
case parent == nil:
|
||||
// Top-level run with a recorder: descendants feed only our recorder.
|
||||
child = report
|
||||
default:
|
||||
child = func(note string) {
|
||||
report(note)
|
||||
parent(note)
|
||||
}
|
||||
}
|
||||
|
||||
childCtx = ctx
|
||||
if child != nil {
|
||||
childCtx = WithProgressSink(ctx, child)
|
||||
}
|
||||
// This run's own steps notify ancestors directly (its own recorder is fed
|
||||
// separately by its step observer, so we deliberately do not call report
|
||||
// here — only the ancestors need waking).
|
||||
return childCtx, parent
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ProgressSinkFrom on a bare context returns nil (nothing wired).
|
||||
func TestProgressSinkFrom_Empty(t *testing.T) {
|
||||
if got := ProgressSinkFrom(context.Background()); got != nil {
|
||||
t.Fatalf("expected nil sink on bare context, got non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
// WithProgressSink round-trips a sink through the context.
|
||||
func TestWithProgressSink_RoundTrip(t *testing.T) {
|
||||
var got string
|
||||
ctx := WithProgressSink(context.Background(), func(n string) { got = n })
|
||||
sink := ProgressSinkFrom(ctx)
|
||||
if sink == nil {
|
||||
t.Fatal("expected non-nil sink")
|
||||
}
|
||||
sink("hello")
|
||||
if got != "hello" {
|
||||
t.Fatalf("sink did not deliver note; got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// InstallProgressBridge with no parent and a report only feeds the report,
|
||||
// and notifyAncestors is nil (there are no ancestors to notify).
|
||||
func TestInstallProgressBridge_NoParent(t *testing.T) {
|
||||
var reported []string
|
||||
childCtx, notify := InstallProgressBridge(context.Background(), func(n string) {
|
||||
reported = append(reported, n)
|
||||
})
|
||||
if notify != nil {
|
||||
t.Fatal("expected nil notifyAncestors when there is no parent")
|
||||
}
|
||||
// A child installed under childCtx should reach our report.
|
||||
child := ProgressSinkFrom(childCtx)
|
||||
if child == nil {
|
||||
t.Fatal("expected a child sink installed")
|
||||
}
|
||||
child("from-child")
|
||||
if len(reported) != 1 || reported[0] != "from-child" {
|
||||
t.Fatalf("report did not receive child note; got %v", reported)
|
||||
}
|
||||
}
|
||||
|
||||
// InstallProgressBridge with a nil report and an existing parent must pass the
|
||||
// parent through unchanged (no needless wrapper layer) — this is the skill
|
||||
// case: a skill run has no recorder of its own but must forward its progress
|
||||
// to the ancestor agent's critic.
|
||||
func TestInstallProgressBridge_NilReportPassesParentThrough(t *testing.T) {
|
||||
var ancestor []string
|
||||
base := WithProgressSink(context.Background(), func(n string) { ancestor = append(ancestor, n) })
|
||||
|
||||
childCtx, notify := InstallProgressBridge(base, nil)
|
||||
// This run's own steps must notify the ancestor.
|
||||
if notify == nil {
|
||||
t.Fatal("expected non-nil notifyAncestors when a parent exists")
|
||||
}
|
||||
notify("my-step")
|
||||
|
||||
// And a descendant under childCtx must also reach the ancestor.
|
||||
ProgressSinkFrom(childCtx)("grandchild-step")
|
||||
|
||||
if len(ancestor) != 2 || ancestor[0] != "my-step" || ancestor[1] != "grandchild-step" {
|
||||
t.Fatalf("ancestor did not receive both notes; got %v", ancestor)
|
||||
}
|
||||
}
|
||||
|
||||
// The full three-level chain: grandchild progress must bump BOTH the child's
|
||||
// own report and the root ancestor — this is the depth>=2 case (agent ->
|
||||
// sub-agent -> sub-sub-agent) where every blocked ancestor must stay alive.
|
||||
func TestInstallProgressBridge_ThreeLevelChain(t *testing.T) {
|
||||
var root, mid []string
|
||||
|
||||
// Level 0 (root agent): has a recorder (report), no parent.
|
||||
rootCtx, rootNotify := InstallProgressBridge(context.Background(),
|
||||
func(n string) { root = append(root, n) })
|
||||
if rootNotify != nil {
|
||||
t.Fatal("root should have no ancestors")
|
||||
}
|
||||
|
||||
// Level 1 (child agent): has its own recorder, parent = root.
|
||||
midCtx, midNotify := InstallProgressBridge(rootCtx,
|
||||
func(n string) { mid = append(mid, n) })
|
||||
if midNotify == nil {
|
||||
t.Fatal("mid should notify root")
|
||||
}
|
||||
|
||||
// Level 1's own step notifies root only (its own recorder is fed by its
|
||||
// own step observer, not via notifyAncestors).
|
||||
midNotify("mid-step")
|
||||
if len(root) != 1 || root[0] != "mid-step" {
|
||||
t.Fatalf("root missed mid-step; root=%v", root)
|
||||
}
|
||||
|
||||
// Level 2 (grandchild): no recorder, parent = mid.
|
||||
gcCtx, gcNotify := InstallProgressBridge(midCtx, nil)
|
||||
if gcNotify == nil {
|
||||
t.Fatal("grandchild should notify its ancestors")
|
||||
}
|
||||
// Grandchild's own step must bump BOTH mid (its parent's recorder) and
|
||||
// root (mid forwards upward).
|
||||
gcNotify("gc-step")
|
||||
if len(mid) != 1 || mid[0] != "gc-step" {
|
||||
t.Fatalf("mid missed gc-step; mid=%v", mid)
|
||||
}
|
||||
if len(root) != 2 || root[1] != "gc-step" {
|
||||
t.Fatalf("root missed forwarded gc-step; root=%v", root)
|
||||
}
|
||||
|
||||
// A descendant installed under gcCtx still reaches mid + root.
|
||||
ProgressSinkFrom(gcCtx)("ggc-step")
|
||||
if len(mid) != 2 || mid[1] != "ggc-step" {
|
||||
t.Fatalf("mid missed ggc-step; mid=%v", mid)
|
||||
}
|
||||
if len(root) != 3 || root[2] != "ggc-step" {
|
||||
t.Fatalf("root missed ggc-step; root=%v", root)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user