a221502356
A detailed plan that nothing enforces is a document. This makes the phases
executable: the implementer may write exactly one status, and every other
status is a conclusion Orchestra reaches by running the plan's own commands.
agent may request: ready_for_verification
agent may not assert: verified, awaiting_manual_verification, failed, skipped
The worker resolves commands from the coordinator, never from the request, so a
request cannot smuggle in a command the planner did not write. They run as argv
through exec with Dir set to the worktree, which is the quality gate's existing
envelope and not a weaker one. There is no shell, so a pipe is a literal
argument.
Project policy decides executable reach. registry.Project.Verification matches
argv positionally, and an absent policy refuses everything: a plan command is
agent-authored, so inheriting the operator-authored gate's reach by default
would be the wrong direction to fail in. A refused command is refused before
anything runs, and the refusal names the project and the command so the planner
learns its real reach.
Two bindings make the record mean something later. PlanRef, so progress earned
under plan A cannot survive into plan B. AtSHA, so "verified" does not outlive
the code that made it true: a record whose commit has moved is retained as
provenance and rendered as stale, never as a claim about the current tree.
Both are the same failure this codebase already fixed for reviews, which bind
to the commit they examined.
Manual steps hold a phase at awaiting_manual_verification. The sign-off is an
ordinary human decision whose subject carries the plan ref and the phase id, so
a later "looks good" on an unrelated thread cannot satisfy a gate nobody was
discussing.
A plan sealed before plan.md declares no executable unit, and says so: the
implement context states that phase progress is unavailable and the work
continues under the old semantics. Inventing phases it never had would be worse
than admitting it has none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
376 lines
12 KiB
Go
376 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"`
|
|
// Verification bounds what a plan's automated checks may execute. Absent
|
|
// policy refuses every plan command; see VerificationPolicy.
|
|
Verification VerificationPolicy `json:"verification,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
|
|
}
|