7f12c7fc37
The v3 stack, previously an uncommitted working tree, plus this session's two units and the burn-in instrument. This commit is the burn-in build identity: coordinator and worker must both report this revision before a task is created. Workflow (earlier sessions, uncommitted until now): human decision events and reduction, source cursors and reconcile-before-launch, turn-boundary reconciliation, internal/agentctx as the single renderer, ace-fca phases with sealed artifacts, the trajectory gate, bounded grilling, independent review, task pr enforcement, and human review reflection. Capability restrictions at the agent boundary: an authz.Agent surface at GatedWrite may ask and may not act. It also fixes two bugs the unit exposed -- gated surfaces could not reach the two endpoints written for them, and RequestHumanDecision would block an unowned task while rejecting a question from the session that did own it. Turn-boundary reconcile-failure escalation: a streak of consecutive failures asks the session to hand off, fenced on the lease epoch, with reconcile_failure as a real handoff reason. The worker was dropping the coordinator's verdict on the floor; it now acts on it. Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to <worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md is the runbook. deploy/build.sh stamps both binaries from one commit. go build, go vet and go test ./... pass, 20 packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
373 lines
12 KiB
Go
373 lines
12 KiB
Go
// Package registry contains the static project, machine, and herdr topology.
|
|
package registry
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"orchestra/internal/domain"
|
|
)
|
|
|
|
var (
|
|
ErrUnknownProject = errors.New("unknown project")
|
|
ErrUnknownMachine = errors.New("unknown machine")
|
|
ErrNoAffinity = errors.New("project has no machine affinity")
|
|
)
|
|
|
|
type Project struct {
|
|
ID string `json:"id"`
|
|
MachineAffinity []string `json:"machine_affinity"`
|
|
// Repo and WorktreeRoot let each project resolve its own git checkout
|
|
// (spec §2.2 — a project is first-class; nothing about the model implies
|
|
// a single shared repo across all projects). Both optional: a project
|
|
// that omits them falls back to whatever global default the deployment
|
|
// wires (single-repo deployments keep working unchanged).
|
|
Repo string `json:"repo,omitempty"`
|
|
WorktreeRoot string `json:"worktree_root,omitempty"`
|
|
QualityGate string `json:"quality_gate,omitempty"`
|
|
// SafeOperations is an audited, deliberately small allow-list for work
|
|
// inside this project's task worktree. It documents what workers may
|
|
// perform without an operator grant; network, secrets, destructive Git,
|
|
// and paths outside the worktree are never represented here.
|
|
SafeOperations []string `json:"safe_operations,omitempty"`
|
|
// WorkPhases is the phase path this project's tasks follow. Empty means
|
|
// the default path. A phase not listed here is skipped, which is how a
|
|
// trivial project runs frame, implement, review with no research or plan.
|
|
WorkPhases []domain.WorkPhase `json:"work_phases,omitempty"`
|
|
// TrajectoryGate names the phase transitions the human must confirm
|
|
// before work continues, keyed "<from>_to_<to>" with value "required".
|
|
// Anything else, including an absent key, is automatic. Explicit policy
|
|
// beats a complexity classifier until there is evidence one is needed.
|
|
TrajectoryGate map[string]string `json:"trajectory_gate,omitempty"`
|
|
// HumanDecisions bounds how often one task may stop to ask. Zero uses the
|
|
// default.
|
|
HumanDecisions struct {
|
|
MaxRequestsPerTask int `json:"max_requests_per_task,omitempty"`
|
|
} `json:"human_decisions,omitempty"`
|
|
}
|
|
|
|
// MaxDecisionRequests is the per-task question budget.
|
|
func (p Project) MaxDecisionRequests() int {
|
|
if p.HumanDecisions.MaxRequestsPerTask > 0 {
|
|
return p.HumanDecisions.MaxRequestsPerTask
|
|
}
|
|
return defaultMaxDecisionRequests
|
|
}
|
|
|
|
// GateRequired reports whether this transition needs human confirmation.
|
|
func (p Project) GateRequired(from, to domain.WorkPhase) bool {
|
|
if from == "" {
|
|
from = domain.WorkPhaseFrame
|
|
}
|
|
return strings.EqualFold(p.TrajectoryGate[string(from)+"_to_"+string(to)], "required")
|
|
}
|
|
|
|
// defaultMaxDecisionRequests mirrors operations.DefaultMaxDecisionRequests,
|
|
// duplicated to keep registry free of a dependency on operations.
|
|
const defaultMaxDecisionRequests = 6
|
|
|
|
// DefaultWorkPhases is the path a project takes when it declares none.
|
|
var DefaultWorkPhases = []domain.WorkPhase{domain.WorkPhaseFrame, domain.WorkPhaseResearch, domain.WorkPhasePlan, domain.WorkPhaseImplement, domain.WorkPhaseReview}
|
|
|
|
// Phases returns the declared path, or the default.
|
|
func (p Project) Phases() []domain.WorkPhase {
|
|
if len(p.WorkPhases) == 0 {
|
|
return DefaultWorkPhases
|
|
}
|
|
return p.WorkPhases
|
|
}
|
|
|
|
// NextPhase returns the phase that follows current on this project's path,
|
|
// skipping any phase the project does not declare. It reports false at the
|
|
// end of the path. The result is always a legal transition, so a project
|
|
// cannot declare a path that moves backwards.
|
|
func (p Project) NextPhase(current domain.WorkPhase) (domain.WorkPhase, bool) {
|
|
if current == "" {
|
|
current = domain.WorkPhaseFrame
|
|
}
|
|
phases := p.Phases()
|
|
// Review's only legal move is back to implement, the one backwards edge
|
|
// in the model. Review passing is not a phase change: it is completion,
|
|
// which belongs to the task lifecycle.
|
|
if current == domain.WorkPhaseReview {
|
|
for _, phase := range phases {
|
|
if phase == domain.WorkPhaseImplement {
|
|
return domain.WorkPhaseImplement, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
for i, phase := range phases {
|
|
if phase != current {
|
|
continue
|
|
}
|
|
for _, candidate := range phases[i+1:] {
|
|
if domain.CanTransitionPhase(current, candidate) {
|
|
return candidate, true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
type Machine struct {
|
|
ID string `json:"id"`
|
|
Address string `json:"address"`
|
|
}
|
|
type Herdr struct {
|
|
ID string `json:"id"`
|
|
MachineID string `json:"machine_id"`
|
|
// Backend selects the machine-local pane implementation. The empty value
|
|
// preserves the existing herdr default. tmux is currently Claude-only.
|
|
Backend string `json:"backend,omitempty"`
|
|
Address string `json:"address,omitempty"`
|
|
Harness string `json:"harness,omitempty"`
|
|
Protocol string `json:"protocol,omitempty"`
|
|
Capabilities []string `json:"capabilities"`
|
|
Concurrency int `json:"concurrency"`
|
|
// QuotaLimit is deprecated in favor of QuotaLimit5h/QuotaLimitWeekly; if
|
|
// set and QuotaLimit5h is not, it is treated as the weekly limit only
|
|
// (its historical meaning), to avoid silently inventing a 5h cap for
|
|
// existing configuration.
|
|
QuotaLimit float64 `json:"quota_limit,omitempty"`
|
|
QuotaLimit5h float64 `json:"quota_limit_5h,omitempty"`
|
|
QuotaLimitWeekly float64 `json:"quota_limit_weekly,omitempty"`
|
|
}
|
|
|
|
const defaultHerdrPort = "9245"
|
|
|
|
type Config struct {
|
|
Projects []Project `json:"projects"`
|
|
Machines []Machine `json:"machines"`
|
|
Herdrs []Herdr `json:"herdrs"`
|
|
}
|
|
type Registry struct {
|
|
projects map[string]Project
|
|
machines map[string]Machine
|
|
herdrs map[string]Herdr
|
|
}
|
|
|
|
func Load(path string) (Registry, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return Registry{}, err
|
|
}
|
|
var c Config
|
|
if err = json.Unmarshal(stripJSONComments(b), &c); err != nil {
|
|
return Registry{}, fmt.Errorf("registry config: %w", err)
|
|
}
|
|
return New(c)
|
|
}
|
|
|
|
// stripJSONComments removes // line comments and /* */ block comments from
|
|
// JSONC input, leaving valid JSON. Comment markers inside string literals
|
|
// (respecting backslash escapes) are left untouched. This lets deployments
|
|
// annotate config.json in place instead of keeping a separate undocumented
|
|
// copy (see deploy/config.example.jsonc).
|
|
func stripJSONComments(b []byte) []byte {
|
|
out := make([]byte, 0, len(b))
|
|
inString, escaped, inLineComment, inBlockComment := false, false, false, false
|
|
for i := 0; i < len(b); i++ {
|
|
c := b[i]
|
|
switch {
|
|
case inLineComment:
|
|
if c == '\n' {
|
|
inLineComment = false
|
|
out = append(out, c)
|
|
}
|
|
case inBlockComment:
|
|
if c == '*' && i+1 < len(b) && b[i+1] == '/' {
|
|
inBlockComment = false
|
|
i++
|
|
}
|
|
case inString:
|
|
out = append(out, c)
|
|
if escaped {
|
|
escaped = false
|
|
} else if c == '\\' {
|
|
escaped = true
|
|
} else if c == '"' {
|
|
inString = false
|
|
}
|
|
case c == '"':
|
|
inString = true
|
|
out = append(out, c)
|
|
case c == '/' && i+1 < len(b) && b[i+1] == '/':
|
|
inLineComment = true
|
|
i++
|
|
case c == '/' && i+1 < len(b) && b[i+1] == '*':
|
|
inBlockComment = true
|
|
i++
|
|
default:
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func New(c Config) (Registry, error) {
|
|
r := Registry{map[string]Project{}, map[string]Machine{}, map[string]Herdr{}}
|
|
for _, p := range c.Projects {
|
|
if err := putID(r.projects, p.ID, "project"); err != nil {
|
|
return Registry{}, err
|
|
}
|
|
if len(p.MachineAffinity) == 0 {
|
|
return Registry{}, fmt.Errorf("project %q: %w", p.ID, ErrNoAffinity)
|
|
}
|
|
for _, op := range p.SafeOperations {
|
|
switch op {
|
|
case "read", "edit", "test", "git":
|
|
default:
|
|
return Registry{}, fmt.Errorf("project %q: unsafe operation %q is not policy-configurable", p.ID, op)
|
|
}
|
|
}
|
|
r.projects[p.ID] = p
|
|
}
|
|
for _, m := range c.Machines {
|
|
if err := putID(r.machines, m.ID, "machine"); err != nil {
|
|
return Registry{}, err
|
|
}
|
|
if strings.TrimSpace(m.Address) == "" {
|
|
return Registry{}, fmt.Errorf("machine %q: address required", m.ID)
|
|
}
|
|
r.machines[m.ID] = m
|
|
}
|
|
for _, h := range c.Herdrs {
|
|
if err := putID(r.herdrs, h.ID, "herdr"); err != nil {
|
|
return Registry{}, err
|
|
}
|
|
if _, ok := r.machines[h.MachineID]; !ok {
|
|
return Registry{}, fmt.Errorf("herdr %q: %w %q", h.ID, ErrUnknownMachine, h.MachineID)
|
|
}
|
|
if h.Concurrency < 0 {
|
|
return Registry{}, fmt.Errorf("herdr %q: negative concurrency", h.ID)
|
|
}
|
|
switch h.Backend {
|
|
case "", "herdr":
|
|
case "tmux":
|
|
if h.Harness != "claude" {
|
|
return Registry{}, fmt.Errorf("herdr %q: tmux backend currently supports only claude, got %q", h.ID, h.Harness)
|
|
}
|
|
default:
|
|
return Registry{}, fmt.Errorf("herdr %q: unsupported backend %q", h.ID, h.Backend)
|
|
}
|
|
r.herdrs[h.ID] = h
|
|
}
|
|
for _, p := range r.projects {
|
|
for _, m := range p.MachineAffinity {
|
|
if _, ok := r.machines[m]; !ok {
|
|
return Registry{}, fmt.Errorf("project %q: %w %q", p.ID, ErrUnknownMachine, m)
|
|
}
|
|
}
|
|
}
|
|
return r, nil
|
|
}
|
|
func putID[T any](m map[string]T, id, kind string) error {
|
|
if strings.TrimSpace(id) == "" {
|
|
return fmt.Errorf("%s id required", kind)
|
|
}
|
|
if _, ok := m[id]; ok {
|
|
return fmt.Errorf("duplicate %s %q", kind, id)
|
|
}
|
|
return nil
|
|
}
|
|
func (r Registry) Project(id string) (Project, bool) { p, ok := r.projects[id]; return p, ok }
|
|
func (r Registry) Machine(id string) (Machine, bool) { m, ok := r.machines[id]; return m, ok }
|
|
func (r Registry) Herdr(id string) (Herdr, bool) { h, ok := r.herdrs[id]; return h, ok }
|
|
func (r Registry) Machines() []Machine {
|
|
out := make([]Machine, 0, len(r.machines))
|
|
for _, m := range r.machines {
|
|
out = append(out, m)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
func (r Registry) Herdrs() []Herdr {
|
|
out := make([]Herdr, 0, len(r.herdrs))
|
|
for _, h := range r.herdrs {
|
|
out = append(out, h)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
func (r Registry) Projects() []Project { return projects(r.projects) }
|
|
func projects(m map[string]Project) []Project {
|
|
out := make([]Project, 0, len(m))
|
|
for _, v := range m {
|
|
out = append(out, v)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out
|
|
}
|
|
|
|
type Reachability interface {
|
|
Reachable(address string, timeout time.Duration) bool
|
|
}
|
|
type TCPReachability struct{}
|
|
|
|
func (TCPReachability) Reachable(address string, timeout time.Duration) bool {
|
|
c, err := net.DialTimeout("tcp", address, timeout)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
_ = c.Close()
|
|
return true
|
|
}
|
|
func (r Registry) Candidates(project string, check Reachability, timeout time.Duration) ([]Herdr, error) {
|
|
return r.candidates(project, func(h Herdr) bool {
|
|
address := r.Endpoint(h)
|
|
return check == nil || check.Reachable(address, timeout)
|
|
})
|
|
}
|
|
|
|
// CandidatesWithHealth filters candidates using a health snapshot gathered by
|
|
// the router. Keeping probe execution outside this method lets a scheduling
|
|
// pass probe each herdr once, in parallel, instead of once per queued task.
|
|
func (r Registry) CandidatesWithHealth(project string, healthy map[string]bool) ([]Herdr, error) {
|
|
return r.candidates(project, func(h Herdr) bool { return healthy[h.ID] })
|
|
}
|
|
|
|
func (r Registry) candidates(project string, include func(Herdr) bool) ([]Herdr, error) {
|
|
p, ok := r.projects[project]
|
|
if !ok {
|
|
return nil, ErrUnknownProject
|
|
}
|
|
allowed := map[string]bool{}
|
|
for _, m := range p.MachineAffinity {
|
|
allowed[m] = true
|
|
}
|
|
out := []Herdr{}
|
|
for _, h := range r.herdrs {
|
|
if !allowed[h.MachineID] || !include(h) {
|
|
continue
|
|
}
|
|
out = append(out, h)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
|
return out, nil
|
|
}
|
|
|
|
// Endpoint resolves the backend-specific health key. Worker-owned tmux
|
|
// backends use an identity-only pseudo endpoint so bypassing their legacy TCP
|
|
// probe cannot accidentally bypass another local herdr sharing port 9245.
|
|
func (r Registry) Endpoint(h Herdr) string {
|
|
if h.Backend == "tmux" {
|
|
return "tmux:" + h.ID
|
|
}
|
|
if h.Address != "" {
|
|
return h.Address
|
|
}
|
|
address := r.machines[h.MachineID].Address
|
|
if host, _, err := net.SplitHostPort(address); err == nil {
|
|
return net.JoinHostPort(host, defaultHerdrPort)
|
|
}
|
|
return address
|
|
}
|