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:
+371
-11
@@ -17,10 +17,12 @@ import (
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/federation"
|
||||
"orchestra/internal/herdr"
|
||||
"orchestra/internal/human"
|
||||
"orchestra/internal/operations"
|
||||
"orchestra/internal/orchestrator"
|
||||
"orchestra/internal/provider"
|
||||
"orchestra/internal/registry"
|
||||
"orchestra/internal/review"
|
||||
"orchestra/internal/router"
|
||||
"orchestra/internal/store"
|
||||
"orchestra/internal/ui"
|
||||
@@ -37,6 +39,9 @@ func id() string { return domain.NewID() }
|
||||
const defaultHerdrPort = "9245"
|
||||
|
||||
func herdrAddress(rr registry.Registry, h registry.Herdr) string {
|
||||
if h.Backend == "tmux" {
|
||||
return rr.Endpoint(h)
|
||||
}
|
||||
if h.Address != "" {
|
||||
return h.Address
|
||||
}
|
||||
@@ -57,9 +62,9 @@ type federatedAvailability struct {
|
||||
localMachine string
|
||||
}
|
||||
|
||||
// federatedReachability keeps the legacy TCP probe for local herdrs, while
|
||||
// avoiding a coordinator-side probe of a remote worker's herdr socket. A
|
||||
// remote harness is reachable precisely when its worker is registered (as
|
||||
// federatedReachability keeps the legacy TCP probe for coordinator-owned
|
||||
// herdrs, while avoiding a coordinator-side probe of a worker-owned backend.
|
||||
// A worker-owned harness is reachable precisely when its worker is registered (as
|
||||
// enforced by federatedAvailability); probing its raw herdr endpoint here
|
||||
// would reintroduce the cross-machine Design A dependency.
|
||||
type federatedReachability struct {
|
||||
@@ -77,7 +82,7 @@ func (r federatedReachability) Reachable(address string, timeout time.Duration)
|
||||
func remoteHerdrAddresses(rr registry.Registry, localMachine string) map[string]bool {
|
||||
remote := map[string]bool{}
|
||||
for _, h := range rr.Herdrs() {
|
||||
if h.MachineID != localMachine {
|
||||
if !coordinatorOwnsHerdr(h, localMachine) {
|
||||
remote[herdrAddress(rr, h)] = true
|
||||
}
|
||||
}
|
||||
@@ -89,6 +94,9 @@ func remoteHerdrAddresses(rr registry.Registry, localMachine string) map[string]
|
||||
// reaching into that machine would turn a worker-owned health signal back
|
||||
// into a misleading coordinator TCP result.
|
||||
func coordinatorOwnsHerdr(h registry.Herdr, localMachine string) bool {
|
||||
if h.Backend == "tmux" {
|
||||
return false
|
||||
}
|
||||
return localMachine == "" || h.MachineID == localMachine
|
||||
}
|
||||
|
||||
@@ -96,7 +104,7 @@ func (a federatedAvailability) Available(h registry.Herdr) bool {
|
||||
if a.base != nil && !a.base.Available(h) {
|
||||
return false
|
||||
}
|
||||
if a.localMachine == "" || h.MachineID == a.localMachine {
|
||||
if coordinatorOwnsHerdr(h, a.localMachine) {
|
||||
return true
|
||||
}
|
||||
return a.workers.Available(h.ID)
|
||||
@@ -106,7 +114,7 @@ func (a federatedAvailability) Supports(h registry.Herdr, project string) bool {
|
||||
// Locally-owned herdrs keep their static registry/project affinity. A
|
||||
// remote worker must additionally prove it has a local checkout for the
|
||||
// project before the router can offer it a lease.
|
||||
if a.localMachine == "" || h.MachineID == a.localMachine {
|
||||
if coordinatorOwnsHerdr(h, a.localMachine) {
|
||||
return true
|
||||
}
|
||||
return a.workers.Supports(h.ID, project)
|
||||
@@ -114,11 +122,18 @@ func (a federatedAvailability) Supports(h registry.Herdr, project string) bool {
|
||||
|
||||
func validateLocalMachine(rr registry.Registry, localMachine string) error {
|
||||
machines := rr.Machines()
|
||||
if len(machines) <= 1 {
|
||||
requiresWorkerOwnership := false
|
||||
for _, h := range rr.Herdrs() {
|
||||
if h.Backend == "tmux" {
|
||||
requiresWorkerOwnership = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(machines) <= 1 && !requiresWorkerOwnership {
|
||||
return nil
|
||||
}
|
||||
if localMachine == "" {
|
||||
return fmt.Errorf("ORCHESTRA_MACHINE_ID is required for a multi-machine registry; refusing unsafe remote-herdr coordination")
|
||||
return fmt.Errorf("ORCHESTRA_MACHINE_ID is required when the registry is multi-machine or has worker-owned backends")
|
||||
}
|
||||
if _, ok := rr.Machine(localMachine); !ok {
|
||||
return fmt.Errorf("ORCHESTRA_MACHINE_ID %q is not in the registry", localMachine)
|
||||
@@ -138,6 +153,15 @@ func main() {
|
||||
var rr registry.Registry
|
||||
var rt *router.Router
|
||||
var coordinator *orchestrator.Coordinator
|
||||
// submissionPublisher is nil until a forge is configured. Submission then
|
||||
// returns the verified plan instead of performing it, which keeps `task
|
||||
// pr` the only path without inventing a fake success.
|
||||
var submissionPublisher func(operations.SubmissionPlan) operations.Publisher
|
||||
// Worktree root per project, so a submission can find the checkout that
|
||||
// holds the commit it is publishing.
|
||||
projectRoots := map[string]string{}
|
||||
// Pull-request readers by source name, for reflecting submitted work.
|
||||
pullRequests := map[string]human.PullRequestSource{}
|
||||
localMachine := os.Getenv("ORCHESTRA_MACHINE_ID")
|
||||
workers := &federation.Registry{AdmitToken: os.Getenv("ORCHESTRA_FEDERATION_ADMIT_TOKEN"), StatePath: filepath.Join(dir, "federation-state.json")}
|
||||
if err := workers.Load(); err != nil {
|
||||
@@ -208,6 +232,7 @@ func main() {
|
||||
for _, p := range rr.Projects() {
|
||||
if p.Repo != "" && p.WorktreeRoot != "" {
|
||||
projectRepos[p.ID] = orchestrator.ProjectRepo{Repo: p.Repo, WorktreeRoot: p.WorktreeRoot}
|
||||
projectRoots[p.ID] = p.WorktreeRoot
|
||||
}
|
||||
}
|
||||
worktrees := orchestrator.PerProjectGitWorktrees{
|
||||
@@ -219,15 +244,15 @@ func main() {
|
||||
return ok && coordinatorOwnsHerdr(h, localMachine)
|
||||
}}
|
||||
rt.OnLease = func(e domain.Event) error {
|
||||
// In federated mode the coordinator must never inspect a remote
|
||||
// In federated mode the coordinator must never inspect a worker-owned
|
||||
// checkout. Its worker consumes the router-issued lease event and
|
||||
// performs all Git/herdr operations on that machine (§2.1).
|
||||
// performs all Git/backend operations on that machine (§2.1).
|
||||
if localMachine != "" {
|
||||
var p struct {
|
||||
HarnessID string `json:"harness_id"`
|
||||
}
|
||||
if json.Unmarshal(e.Payload, &p) == nil {
|
||||
if h, ok := rr.Herdr(p.HarnessID); ok && h.MachineID != localMachine {
|
||||
if h, ok := rr.Herdr(p.HarnessID); ok && !coordinatorOwnsHerdr(h, localMachine) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -628,6 +653,18 @@ func main() {
|
||||
mux.HandleFunc("/v1/admin/diagnostics", adminServer.Diagnostics)
|
||||
mux.HandleFunc("/v1/tasks/", func(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
if r.Method == http.MethodGet && len(parts) == 4 && parts[3] == "intent" {
|
||||
// The reduced authority for one task. Federation workers read this
|
||||
// and render their own launch instruction with agentctx, so there
|
||||
// is one renderer in the codebase rather than one per machine.
|
||||
intent, err := s.EffectiveIntent(parts[2])
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 404)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(intent)
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet && coordinator != nil && len(parts) == 4 {
|
||||
taskID, view := parts[2], parts[3]
|
||||
if view == "health" {
|
||||
@@ -653,6 +690,199 @@ func main() {
|
||||
return
|
||||
}
|
||||
taskID, action := parts[2], parts[3]
|
||||
if (action == "decision-request" || action == "deferred") && len(parts) == 4 {
|
||||
// An agent surface may state a bounded question or a deferred
|
||||
// finding. Neither moves the lifecycle: Orchestra decides what a
|
||||
// question does to the task.
|
||||
if err := authz.AuthorizeEvent(surface(r), "ApprovalRequested"); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
t, ok := s.Task(taskID)
|
||||
if !ok {
|
||||
http.Error(w, "task not found", 404)
|
||||
return
|
||||
}
|
||||
project, _ := rr.Project(t.Project)
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 64<<10)
|
||||
if action == "deferred" {
|
||||
var f domain.DeferredFinding
|
||||
if json.NewDecoder(r.Body).Decode(&f) != nil {
|
||||
http.Error(w, "invalid deferred finding", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
e, err := operations.RecordDeferredFinding(s, taskID, f)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(e)
|
||||
return
|
||||
}
|
||||
var req domain.DecisionRequest
|
||||
if json.NewDecoder(r.Body).Decode(&req) != nil {
|
||||
http.Error(w, "invalid decision request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
e, err := operations.RequestHumanDecision(s, project, taskID, req)
|
||||
if errors.Is(err, operations.ErrDecisionBudgetSpent) {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "operator_required", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
json.NewEncoder(w).Encode(e)
|
||||
return
|
||||
}
|
||||
if action == "submission" && len(parts) == 4 {
|
||||
// The only path from a reviewed implementation to the human. It
|
||||
// verifies first and returns the plan, or performs the submission
|
||||
// when the caller supplies a publisher-backed request.
|
||||
if err := authz.AuthorizeEvent(surface(r), domain.EventTaskSubmitted); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
t, ok := s.Task(taskID)
|
||||
if !ok {
|
||||
http.Error(w, "task not found", 404)
|
||||
return
|
||||
}
|
||||
project, ok := rr.Project(t.Project)
|
||||
if !ok {
|
||||
http.Error(w, "unknown project "+t.Project, 409)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
var body struct {
|
||||
HeadSHA string `json:"head_sha"`
|
||||
Gate domain.GateResult `json:"gate"`
|
||||
Notes operations.Notes `json:"notes"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
}
|
||||
if json.NewDecoder(r.Body).Decode(&body) != nil || body.HeadSHA == "" {
|
||||
http.Error(w, "head_sha and gate are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
check := domain.CheckSubmission(t, body.HeadSHA, body.Gate)
|
||||
check.Reasons = append(check.Reasons, t.RequirePhaseArtifacts(project.Phases())...)
|
||||
check.Eligible = len(check.Reasons) == 0
|
||||
if body.DryRun || !check.Eligible {
|
||||
code := http.StatusOK
|
||||
if !check.Eligible {
|
||||
code = http.StatusConflict
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(check)
|
||||
return
|
||||
}
|
||||
plan, err := operations.PrepareSubmission(s, project, taskID, body.HeadSHA, body.Gate, body.Notes)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if submissionPublisher == nil {
|
||||
// No forge configured: hand back the verified plan so an
|
||||
// operator can perform the submission themselves.
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
json.NewEncoder(w).Encode(map[string]any{"status": "no_publisher", "plan": plan})
|
||||
return
|
||||
}
|
||||
e, err := operations.ExecuteSubmission(r.Context(), s, plan, submissionPublisher(plan), nil)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(e)
|
||||
return
|
||||
}
|
||||
if action == "review" && len(parts) == 4 {
|
||||
// Entering review and sealing a review are both Orchestra's, not
|
||||
// the reviewing session's. The session only supplies findings.
|
||||
if err := authz.AuthorizeEvent(surface(r), domain.EventReviewRecorded); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
t, ok := s.Task(taskID)
|
||||
if !ok {
|
||||
http.Error(w, "task not found", 404)
|
||||
return
|
||||
}
|
||||
project, ok := rr.Project(t.Project)
|
||||
if !ok {
|
||||
http.Error(w, "unknown project "+t.Project, 409)
|
||||
return
|
||||
}
|
||||
r.Body = http.MaxBytesReader(w, r.Body, int64(review.MaxDiffBytes)+(1<<20))
|
||||
var body struct {
|
||||
Evidence *review.Evidence `json:"evidence"`
|
||||
Result *review.Result `json:"result"`
|
||||
}
|
||||
if json.NewDecoder(r.Body).Decode(&body) != nil || (body.Evidence == nil) == (body.Result == nil) {
|
||||
http.Error(w, "send either evidence (to enter review) or result (to seal one)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var e domain.Event
|
||||
var err error
|
||||
if body.Evidence != nil {
|
||||
e, err = operations.EnterReview(s, project, taskID, *body.Evidence)
|
||||
} else {
|
||||
e, err = operations.RecordReview(s, project, taskID, *body.Result)
|
||||
}
|
||||
if errors.Is(err, operations.ErrReviewNotEligible) || errors.Is(err, domain.ErrInvalid) {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(e)
|
||||
return
|
||||
}
|
||||
if action == "phase" && len(parts) == 4 {
|
||||
// Orchestra owns the phase. An agent asks for a change through the
|
||||
// approval surface; this endpoint is how the decision is applied.
|
||||
if err := authz.AuthorizeEvent(surface(r), domain.EventWorkPhaseChanged); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
t, ok := s.Task(taskID)
|
||||
if !ok {
|
||||
http.Error(w, "task not found", 404)
|
||||
return
|
||||
}
|
||||
project, ok := rr.Project(t.Project)
|
||||
if !ok {
|
||||
http.Error(w, "unknown project "+t.Project, 409)
|
||||
return
|
||||
}
|
||||
// The body, when present, is the artifact the finished phase
|
||||
// produced. Bounded like every other artifact upload.
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
artifact, readErr := io.ReadAll(r.Body)
|
||||
if readErr != nil {
|
||||
http.Error(w, "artifact unreadable", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
e, err := operations.AdvanceWorkPhase(s, project, taskID, artifact)
|
||||
if errors.Is(err, operations.ErrTrajectoryGate) {
|
||||
// Not a failure: the task is blocked on the human, and the
|
||||
// packet is on the block event the notification surfaces read.
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "trajectory_gate", "detail": err.Error()})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(e)
|
||||
return
|
||||
}
|
||||
if action == "approval" {
|
||||
if err := authz.AuthorizeEvent(surface(r), map[bool]string{true: "ApprovalRequested", false: "ApprovalGranted"}[len(parts) == 4]); err != nil && len(parts) == 4 {
|
||||
http.Error(w, err.Error(), http.StatusForbidden)
|
||||
@@ -791,6 +1021,12 @@ func main() {
|
||||
// past their lease TTL). Calling ExpireLeases here too would
|
||||
// just resurrect that race, so leave reclaim to the
|
||||
// coordinator and only keep retrying pending assignment.
|
||||
// A blocked task is invisible to the router, so answered
|
||||
// blockers are returned to the queue here, immediately
|
||||
// upstream of assignment.
|
||||
if _, err := operations.ResumeAnsweredBlockers(s); err != nil {
|
||||
log.Printf("resume answered blockers: %v", err)
|
||||
}
|
||||
if coordinator != nil {
|
||||
if _, err := rt.AssignPending(); err != nil {
|
||||
log.Printf("route expired task: %v", err)
|
||||
@@ -805,6 +1041,45 @@ func main() {
|
||||
}
|
||||
}()
|
||||
}
|
||||
if len(pullRequests) > 0 {
|
||||
// Submitted work is reconciled on its own loop, not behind
|
||||
// Store.PreLease: an in-review task cannot be leased, so a pre-lease
|
||||
// hook could never see the feedback that should make it leasable.
|
||||
trust := human.Trust{
|
||||
Accepted: splitList(os.Getenv("ORCHESTRA_REVIEW_ACTORS")),
|
||||
Ignored: splitList(os.Getenv("ORCHESTRA_REVIEW_IGNORE_ACTORS")),
|
||||
}
|
||||
go func() {
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
for _, t := range s.Tasks() {
|
||||
if t.Submission == nil || (t.State != domain.StateInReview && t.State != domain.StateQueued) {
|
||||
continue
|
||||
}
|
||||
source, ok := pullRequests[t.Submission.PR.Provider]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
project, ok := rr.Project(t.Project)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
state, err := source.PullRequest(ctx, t)
|
||||
cancel()
|
||||
if err != nil {
|
||||
// Observable, and the task stays exactly where it was.
|
||||
log.Printf("reflect submission %s: %v", t.ID, err)
|
||||
continue
|
||||
}
|
||||
if _, err := operations.ReflectSubmission(s, project, t.ID, state, trust); err != nil {
|
||||
log.Printf("reflect submission %s: %v", t.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("ok\n")) })
|
||||
mux.HandleFunc("/readyz", adminServer.Readiness)
|
||||
mux.HandleFunc("/v1/providers/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -869,6 +1144,42 @@ func main() {
|
||||
}
|
||||
return wid, nil
|
||||
}
|
||||
mux.HandleFunc("/v1/federation/turn", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := workerAuth(r); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", 405)
|
||||
return
|
||||
}
|
||||
if coordinator == nil {
|
||||
http.Error(w, "coordinator not configured", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
TaskID string `json:"task_id"`
|
||||
LeaseEpoch string `json:"lease_epoch"`
|
||||
Verdict string `json:"verdict"`
|
||||
Delivered []string `json:"delivered_decisions"`
|
||||
}
|
||||
if json.NewDecoder(r.Body).Decode(&body) != nil || body.TaskID == "" || body.LeaseEpoch == "" {
|
||||
http.Error(w, "task_id and lease_epoch are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch body.Verdict {
|
||||
case orchestrator.TurnContinue, orchestrator.TurnPrepareHandoff, orchestrator.TurnRotateNow, orchestrator.TurnRefuse:
|
||||
default:
|
||||
http.Error(w, "unknown verdict "+body.Verdict, http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
verdict, decisions, err := coordinator.RemoteTurn(r.Context(), body.TaskID, body.LeaseEpoch, body.Verdict, body.Delivered)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(federation.TurnDecision{Verdict: verdict, Decisions: decisions})
|
||||
})
|
||||
mux.HandleFunc("/v1/federation/commands", func(w http.ResponseWriter, r *http.Request) {
|
||||
wid, err := workerAuth(r)
|
||||
if err != nil {
|
||||
@@ -1218,6 +1529,7 @@ func main() {
|
||||
Token: os.Getenv("ORCHESTRA_GITEA_TOKEN"), WebhookSecret: os.Getenv("ORCHESTRA_GITEA_WEBHOOK_SECRET"),
|
||||
}}
|
||||
}
|
||||
humanSources := map[string]human.Source{}
|
||||
if len(giteaSources) > 0 {
|
||||
reflectors := map[string]provider.Gitea{}
|
||||
for _, c := range giteaSources {
|
||||
@@ -1252,6 +1564,38 @@ func main() {
|
||||
}}
|
||||
sup.Start(context.Background())
|
||||
providerHealth[name] = sup
|
||||
humanSources[g.SourceName()] = provider.GiteaComments{Gitea: g}
|
||||
if publisher := (provider.GiteaPublisher{Gitea: g, Base: os.Getenv("ORCHESTRA_PR_BASE")}); publisher.BaseURL != "" {
|
||||
root := projectRoots[c.Project]
|
||||
submissionPublisher = func(plan operations.SubmissionPlan) operations.Publisher {
|
||||
p := publisher
|
||||
p.Root = filepath.Join(root, plan.TaskID)
|
||||
return p
|
||||
}
|
||||
pullRequests[g.SourceName()] = publisher
|
||||
}
|
||||
}
|
||||
}
|
||||
// Reconciliation runs immediately before every lease, which is where
|
||||
// ownership of a task begins. A configured source that cannot be read
|
||||
// refuses the lease rather than letting a successor resume from an older
|
||||
// intent. Set ORCHESTRA_HUMAN_RECONCILE=off to disable it for an
|
||||
// operator who needs to run while a source is down.
|
||||
if len(humanSources) > 0 && !strings.EqualFold(os.Getenv("ORCHESTRA_HUMAN_RECONCILE"), "off") {
|
||||
reconciler := &human.Reconciler{Store: s, Sources: humanSources, Timeout: 30 * time.Second}
|
||||
s.PreLease = func(taskID string) error {
|
||||
return reconciler.Reconcile(context.Background(), taskID)
|
||||
}
|
||||
if coordinator != nil {
|
||||
// The second reconciliation point: a verified turn boundary on a
|
||||
// lease that is already running. Nothing here preempts the agent.
|
||||
coordinator.ReconcileHumanInput = reconciler.Reconcile
|
||||
// After this many consecutive failures at that boundary, the
|
||||
// session is asked to hand off rather than keep running on intent
|
||||
// Orchestra can no longer refresh.
|
||||
if v, parseErr := strconv.Atoi(os.Getenv("ORCHESTRA_RECONCILE_FAILURE_HANDOFF")); parseErr == nil && v > 0 {
|
||||
coordinator.ReconcileFailureHandoff = v
|
||||
}
|
||||
}
|
||||
}
|
||||
if path := os.Getenv("ORCHESTRA_JSONL"); path != "" {
|
||||
@@ -1321,6 +1665,11 @@ func main() {
|
||||
tokens := map[authz.Surface]string{
|
||||
authz.TUI: os.Getenv("ORCHESTRA_TUI_TOKEN"),
|
||||
authz.MCP: os.Getenv("ORCHESTRA_MCP_TOKEN"), authz.Maven: os.Getenv("ORCHESTRA_MAVEN_TOKEN"),
|
||||
// The credential an in-pane coding session presents. It buys the two
|
||||
// request endpoints and read access — never a lifecycle mutation. The
|
||||
// forge, Vikunja and operator tokens must never reach an agent pane;
|
||||
// this one is the only Orchestra credential an agent may hold.
|
||||
authz.Agent: os.Getenv("ORCHESTRA_AGENT_TOKEN"),
|
||||
// S12: this is the credential callers present *to* Orchestra on the
|
||||
// ntfy surface. ORCHESTRA_NTFY_TOKEN is a different secret entirely —
|
||||
// it is handed out to the third-party ntfy server (see the sender
|
||||
@@ -1329,3 +1678,14 @@ func main() {
|
||||
}
|
||||
log.Fatal(http.ListenAndServe(":"+port, authz.HTTPWithSessions(tokens, sessions, mux)))
|
||||
}
|
||||
|
||||
// splitList reads a comma-separated env list, ignoring blanks.
|
||||
func splitList(v string) []string {
|
||||
var out []string
|
||||
for _, item := range strings.Split(v, ",") {
|
||||
if s := strings.TrimSpace(item); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestFederatedReachabilityDefersRemoteHerdrToWorkerHeartbeat(t *testing.T) {
|
||||
|
||||
func TestCoordinatorOwnsOnlyLocalHerdrInFederationMode(t *testing.T) {
|
||||
local := registry.Herdr{ID: "homesrv-opencode", MachineID: "homesrv"}
|
||||
localTmux := registry.Herdr{ID: "homesrv-claude", MachineID: "homesrv", Backend: "tmux", Harness: "claude"}
|
||||
remote := registry.Herdr{ID: "workpc-opencode", MachineID: "workpc"}
|
||||
if !coordinatorOwnsHerdr(local, "homesrv") {
|
||||
t.Fatal("coordinator does not own its local herdr")
|
||||
@@ -43,6 +44,9 @@ func TestCoordinatorOwnsOnlyLocalHerdrInFederationMode(t *testing.T) {
|
||||
if coordinatorOwnsHerdr(remote, "homesrv") {
|
||||
t.Fatal("coordinator claimed a worker-owned remote herdr")
|
||||
}
|
||||
if coordinatorOwnsHerdr(localTmux, "homesrv") {
|
||||
t.Fatal("coordinator claimed a local worker-owned tmux backend")
|
||||
}
|
||||
if !coordinatorOwnsHerdr(remote, "") {
|
||||
t.Fatal("single-machine mode should retain legacy local ownership")
|
||||
}
|
||||
@@ -69,3 +73,19 @@ func TestMultiMachineRegistryRequiresKnownLocalMachine(t *testing.T) {
|
||||
t.Fatalf("known local machine rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTmuxRegistryRequiresMachineIdentityEvenOnOneMachine(t *testing.T) {
|
||||
r, err := registry.New(registry.Config{
|
||||
Machines: []registry.Machine{{ID: "homesrv", Address: "homesrv:9145"}},
|
||||
Herdrs: []registry.Herdr{{ID: "homesrv-claude", MachineID: "homesrv", Backend: "tmux", Harness: "claude"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := validateLocalMachine(r, ""); err == nil {
|
||||
t.Fatal("worker-owned tmux backend accepted without machine identity")
|
||||
}
|
||||
if err := validateLocalMachine(r, "homesrv"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user