v3 workflow: intent, phases, review, submission, enforcement, burn-in
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>
This commit is contained in:
@@ -10,6 +10,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -34,14 +36,97 @@ type Project struct {
|
||||
// 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"`
|
||||
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"`
|
||||
@@ -164,6 +249,15 @@ func New(c Config) (Registry, error) {
|
||||
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 {
|
||||
@@ -260,10 +354,13 @@ func (r Registry) candidates(project string, include func(Herdr) bool) ([]Herdr,
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Endpoint resolves the herdr-specific address or its machine's default
|
||||
// herdr endpoint. It is exposed so health checks can be batched independently
|
||||
// from project routing.
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -44,3 +44,22 @@ func TestProjectSafeOperationsAreNarrowAndAudited(t *testing.T) {
|
||||
t.Fatalf("safe policy rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxBackendIsClaudeOnlyAndUsesDistinctHealthKey(t *testing.T) {
|
||||
config := Config{
|
||||
Machines: []Machine{{ID: "m", Address: "host:9145"}},
|
||||
Herdrs: []Herdr{{ID: "claude", MachineID: "m", Backend: "tmux", Harness: "claude"}},
|
||||
}
|
||||
r, err := New(config)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, _ := r.Herdr("claude")
|
||||
if got := r.Endpoint(h); got != "tmux:claude" {
|
||||
t.Fatalf("tmux health key=%q", got)
|
||||
}
|
||||
config.Herdrs[0].Harness = "codex"
|
||||
if _, err := New(config); err == nil {
|
||||
t.Fatal("tmux backend accepted Codex")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user