ca243a2d50
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>
197 lines
6.3 KiB
Go
197 lines
6.3 KiB
Go
package lane
|
|
|
|
import (
|
|
"context"
|
|
"sync"
|
|
)
|
|
|
|
// ConvarReader is the narrow surface the registry uses to read
|
|
// per-lane concurrency caps from convars at startup and on Reload.
|
|
//
|
|
// Why an interface (not pkg/convar directly): registry is a generic
|
|
// primitive and shouldn't import the application convar package.
|
|
// Production wires a thin adapter; tests pass a fake.
|
|
type ConvarReader interface {
|
|
Int(ctx context.Context, name string, def int) int
|
|
}
|
|
|
|
// ConvarReaderFunc adapts a closure into a ConvarReader.
|
|
type ConvarReaderFunc func(ctx context.Context, name string, def int) int
|
|
|
|
// Int satisfies ConvarReader.
|
|
func (f ConvarReaderFunc) Int(ctx context.Context, name string, def int) int {
|
|
if f == nil {
|
|
return def
|
|
}
|
|
return f(ctx, name, def)
|
|
}
|
|
|
|
// Registry is a manager of named lanes. The default policy is
|
|
// fair-share; lanes are created lazily on first GetOrCreate, with
|
|
// concurrency read from convar `lanes.<name>.max_concurrent` (default
|
|
// 1). Reload re-reads convars and updates each lane's MaxConcurrent
|
|
// in place — useful for runtime tuning without losing in-flight work.
|
|
//
|
|
// Why a singleton-ish manager vs constructing lanes ad-hoc: the
|
|
// registry is the integration point where mort.go wires lanes once
|
|
// and every subsystem (LLM transport, skill runner) looks them up by
|
|
// name. Lazy creation lets the registry stay schema-free — adding a
|
|
// new lane is just "ask for it by name".
|
|
//
|
|
// Test: registry_test.go covers GetOrCreate identity, convar read,
|
|
// and Reload.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
lanes map[string]Lane
|
|
convars ConvarReader
|
|
// policyFactory is the queue policy constructor used for new
|
|
// lanes. Defaults to NewFairSharePolicy. Tests substitute FIFO
|
|
// when they want deterministic ordering.
|
|
policyFactory func() queuePolicy
|
|
}
|
|
|
|
// NewRegistry constructs a registry. convars may be nil — lanes
|
|
// fall back to the registry's default concurrency (1).
|
|
func NewRegistry(convars ConvarReader) *Registry {
|
|
return &Registry{
|
|
lanes: make(map[string]Lane),
|
|
convars: convars,
|
|
policyFactory: NewFairSharePolicy,
|
|
}
|
|
}
|
|
|
|
// SetPolicyFactory overrides the default policy used for new lanes.
|
|
// Existing lanes are unchanged. Used by tests; production keeps the
|
|
// fair-share default.
|
|
func (r *Registry) SetPolicyFactory(f func() queuePolicy) {
|
|
if f == nil {
|
|
f = NewFairSharePolicy
|
|
}
|
|
r.mu.Lock()
|
|
r.policyFactory = f
|
|
r.mu.Unlock()
|
|
}
|
|
|
|
// Get returns the named lane or nil if it has not been created.
|
|
// Useful in admin/UI code that wants to show only existing lanes
|
|
// without creating new ones as a side effect.
|
|
func (r *Registry) Get(name string) Lane {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.lanes[name]
|
|
}
|
|
|
|
// StatsReader is the read-only stats surface exposed to admin / user
|
|
// dashboards (Discord queue commands, /skills/admin/queues web view).
|
|
// *Registry satisfies it; tests substitute a fake.
|
|
//
|
|
// Why a narrow interface (vs passing *Registry around): the consumers
|
|
// only need stats and lane lookup — no creation or mutation surface.
|
|
// Keeping the dep narrow makes mocks trivial in webui + skills tests.
|
|
type StatsReader interface {
|
|
// List returns a snapshot of every registered lane.
|
|
List() []Lane
|
|
|
|
// Lookup returns the lane by name, or nil. Mirrors Registry.Get
|
|
// (named differently to avoid the "Get" verb confusion in
|
|
// dashboards that primarily call Stats).
|
|
Lookup(name string) Lane
|
|
}
|
|
|
|
// Lookup satisfies the StatsReader surface alongside Registry.Get. We
|
|
// expose both verbs so the dashboard code reads naturally without
|
|
// forcing existing call sites that use Get() to migrate.
|
|
func (r *Registry) Lookup(name string) Lane { return r.Get(name) }
|
|
|
|
// GetOrCreate returns the named lane, creating it lazily on first
|
|
// call. Concurrency is read from convar `lanes.<name>.max_concurrent`
|
|
// (default 1). The policy is the registry's policy factory (default
|
|
// fair-share).
|
|
//
|
|
// Why convar name `lanes.<name>.max_concurrent` (not
|
|
// `skills.lane.<name>.max_concurrent`): pkg/lane is generic — the
|
|
// skills system happens to be the first caller, but the LLM transport
|
|
// wrapper (Phase 3) and other future runners will use the same
|
|
// registry. The convar namespace `lanes.*` keeps lane configuration
|
|
// in one place. The skills system can adopt different convar names
|
|
// if it prefers; in that case, mort.go reads them and calls
|
|
// SetMaxConcurrent on the resulting lanes after creation.
|
|
func (r *Registry) GetOrCreate(ctx context.Context, name string) Lane {
|
|
r.mu.RLock()
|
|
if l, ok := r.lanes[name]; ok {
|
|
r.mu.RUnlock()
|
|
return l
|
|
}
|
|
r.mu.RUnlock()
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
// Double-check after upgrading the lock.
|
|
if l, ok := r.lanes[name]; ok {
|
|
return l
|
|
}
|
|
maxConcurrent := r.readConcurrency(ctx, name)
|
|
policy := r.policyFactory()
|
|
if policy == nil {
|
|
policy = NewFairSharePolicy()
|
|
}
|
|
l := NewWithPolicy(name, maxConcurrent, policy)
|
|
r.lanes[name] = l
|
|
return l
|
|
}
|
|
|
|
// List returns a snapshot of all registered lanes. Iteration order is
|
|
// not guaranteed (Go map randomization).
|
|
func (r *Registry) List() []Lane {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]Lane, 0, len(r.lanes))
|
|
for _, l := range r.lanes {
|
|
out = append(out, l)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Names returns the registered lane names. Used for the admin
|
|
// "list all lanes" surface.
|
|
func (r *Registry) Names() []string {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]string, 0, len(r.lanes))
|
|
for n := range r.lanes {
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Reload re-reads convars for every registered lane and calls
|
|
// SetMaxConcurrent on each. Existing running jobs continue to run;
|
|
// new dispatches respect the updated cap.
|
|
//
|
|
// Why a manual Reload instead of reading convars at every dispatch:
|
|
// dispatch is on the hot path; reading a convar there for every
|
|
// queued job is wasteful. A periodic Reload (every minute, say) is
|
|
// cheap and good enough for human-driven config changes.
|
|
func (r *Registry) Reload(ctx context.Context) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
for name, l := range r.lanes {
|
|
n := r.readConcurrency(ctx, name)
|
|
l.SetMaxConcurrent(n)
|
|
}
|
|
}
|
|
|
|
// readConcurrency reads `lanes.<name>.max_concurrent` with a default
|
|
// of 1. Defensive against a nil ConvarReader and against negative
|
|
// values (clamped to 1).
|
|
func (r *Registry) readConcurrency(ctx context.Context, name string) int {
|
|
if r.convars == nil {
|
|
return 1
|
|
}
|
|
n := r.convars.Int(ctx, "lanes."+name+".max_concurrent", 1)
|
|
if n <= 0 {
|
|
return 1
|
|
}
|
|
return n
|
|
}
|