c76112a309
An implementer that finds the plan contradicted by the code had two options,
both bad: work around it silently, or improvise a different plan inside the
phase meant to execute one. PlanMismatch is the third.
The report carries an observation and nothing else. It may not propose a
replacement plan, because writing the next plan is the planning phase's work.
requested_action stays advisory: replan, research, or human_decision is a
recommendation, and Orchestra decides.
Staleness is checked before anything is recorded. A report names the plan ref
and the commit it was written against, both filled by the worker from what it
can verify rather than from what the agent asserted. A report against an older
plan says nothing about the current one, and one against an older tree may
already be fixed. Neither is replayed.
The reducer keeps two things apart that are easy to conflate:
mismatch recorded != plan superseded
A plan stops being accepted only when a replacement is actually sealed, so an
abandoned replan leaves the accepted plan and its verified progress intact. On
a real re-seal the old ref moves to PlanHistory and its progress stops counting,
while the verification events stay in the log as provenance.
human_decision never reopens. It blocks with a packet stating what was observed
and what it contradicts, and a human answer can resolve the contradiction
without resealing anything: the plan, its progress and the phase all survive,
and the answer outranks the plan where they differ. Turning every ambiguity
into a replan would put the planner above the person who set the goal.
The backward edge is Orchestra's alone. CanReopenPhase is separate from
CanTransitionPhase, which every path validating an agent's request uses, so
phase-request.json still refuses a move back. An agent asks by reporting a
mismatch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
422 lines
15 KiB
Go
422 lines
15 KiB
Go
package federation
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/review"
|
|
"strings"
|
|
)
|
|
|
|
// Client is the worker-side protocol client. It carries no task state: the
|
|
// homesrv event log remains authoritative and workers only persist their
|
|
// local execution session/checkouts.
|
|
type Client struct {
|
|
BaseURL string
|
|
WorkerID string
|
|
Token string
|
|
AdmitToken string
|
|
HTTP *http.Client
|
|
}
|
|
|
|
func (c Client) Register(ctx context.Context, w Worker) error {
|
|
b, err := json.Marshal(map[string]any{"id": w.ID, "address": w.Address, "capacity": w.Capacity, "supported_projects": w.SupportedProjects, "build": w.Build, "token": c.Token})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/v1/federation/workers", bytes.NewReader(b))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if c.AdmitToken != "" {
|
|
req.Header.Set("Authorization", "Bearer "+c.AdmitToken)
|
|
}
|
|
h := c.HTTP
|
|
if h == nil {
|
|
h = http.DefaultClient
|
|
}
|
|
resp, err := h.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
msg, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("federation register: %s: %s", resp.Status, strings.TrimSpace(string(msg)))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c Client) request(ctx context.Context, method, path string, body any) (*http.Response, error) {
|
|
var r io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, strings.TrimRight(c.BaseURL, "/")+path, r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("X-Orchestra-Worker", c.WorkerID)
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
h := c.HTTP
|
|
if h == nil {
|
|
h = http.DefaultClient
|
|
}
|
|
resp, err := h.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode/100 != 2 {
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
return nil, &StatusError{Code: resp.StatusCode, Status: resp.Status, Body: strings.TrimSpace(string(b))}
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
// StatusError is a coordinator answer the caller can classify. A refusal is
|
|
// the agent's mistake and has to reach the agent; a transport failure is not,
|
|
// and must not be reported to it as one. The message keeps the previous
|
|
// wording so callers that match on it still work.
|
|
type StatusError struct {
|
|
Code int
|
|
Status string
|
|
Body string
|
|
}
|
|
|
|
func (e *StatusError) Error() string { return fmt.Sprintf("federation: %s: %s", e.Status, e.Body) }
|
|
|
|
func (c Client) Events(ctx context.Context, since uint64) ([]domain.Event, uint64, error) {
|
|
resp, err := c.request(ctx, http.MethodGet, "/v1/federation/events?since="+fmt.Sprint(since), nil)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
Cursor uint64 `json:"cursor"`
|
|
Events []domain.Event `json:"events"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
return out.Events, out.Cursor, nil
|
|
}
|
|
|
|
// Tasks hydrates the worker's cache when its local state predates the
|
|
// coordinator's event-retention window. The coordinator remains authoritative
|
|
// for the task projection.
|
|
func (c Client) Tasks(ctx context.Context) ([]domain.Task, error) {
|
|
resp, err := c.request(ctx, http.MethodGet, "/v1/tasks", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var tasks []domain.Task
|
|
if err := json.NewDecoder(resp.Body).Decode(&tasks); err != nil {
|
|
return nil, err
|
|
}
|
|
return tasks, nil
|
|
}
|
|
|
|
// TurnDecision is the coordinator's answer at a worker's turn boundary: the
|
|
// verdict the worker reported, plus the human decisions this session has not
|
|
// been shown. Decisions are present only when the verdict is continue.
|
|
type TurnDecision struct {
|
|
Verdict string `json:"verdict"`
|
|
Decisions []domain.HumanDecision `json:"decisions,omitempty"`
|
|
}
|
|
|
|
// Turn reports a verified turn boundary and collects any newer human
|
|
// decisions. The worker evaluates rotation locally, because only it can see
|
|
// the pane; authority stays with the coordinator.
|
|
func (c Client) Turn(ctx context.Context, taskID, epoch, verdict string, delivered []string) (TurnDecision, error) {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/turn", map[string]any{
|
|
"task_id": taskID, "lease_epoch": epoch, "verdict": verdict, "delivered_decisions": delivered,
|
|
})
|
|
if err != nil {
|
|
return TurnDecision{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out TurnDecision
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return TurnDecision{}, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// AdvancePhase carries an agent's bounded phase-change request to the
|
|
// coordinator, which decides. The accepted phase comes back so the worker
|
|
// knows the session it owns has been superseded and must rotate.
|
|
//
|
|
// artifact is the sealed output of the phase being left, and is empty for a
|
|
// phase that produces none.
|
|
func (c Client) AdvancePhase(ctx context.Context, taskID, epoch, operationID string, from, to domain.WorkPhase, artifact []byte) (domain.WorkPhase, error) {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/phase", map[string]any{
|
|
"task_id": taskID, "lease_epoch": epoch, "operation_id": operationID,
|
|
"from": string(from), "to": string(to), "artifact": artifact,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
Phase domain.WorkPhase `json:"phase"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Phase, nil
|
|
}
|
|
|
|
// Intent fetches the reduced authority for one task: its contract plus the
|
|
// human decisions still standing. A worker renders its launch instruction
|
|
// from this, never from handoff prose.
|
|
func (c Client) Intent(ctx context.Context, taskID string) (domain.EffectiveIntent, error) {
|
|
resp, err := c.request(ctx, http.MethodGet, "/v1/tasks/"+url.PathEscape(taskID)+"/intent", nil)
|
|
if err != nil {
|
|
return domain.EffectiveIntent{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var intent domain.EffectiveIntent
|
|
if err := json.NewDecoder(resp.Body).Decode(&intent); err != nil {
|
|
return domain.EffectiveIntent{}, err
|
|
}
|
|
return intent, nil
|
|
}
|
|
|
|
func (c Client) Ack(ctx context.Context, cursor uint64) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/events/ack", map[string]uint64{"cursor": cursor})
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
func (c Client) Heartbeat(ctx context.Context, health WorkerHealth) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/heartbeat", health)
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
func (c Client) Renew(ctx context.Context, taskID, epoch string, expectedVersion, ttlSeconds int) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/renew", map[string]any{"task_id": taskID, "lease_epoch": epoch, "expected_version": expectedVersion, "ttl_seconds": ttlSeconds})
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
func (c Client) Start(ctx context.Context, taskID, epoch string, expectedVersion int, evidence domain.SessionEvidence) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/start", map[string]any{"task_id": taskID, "lease_epoch": epoch, "expected_version": expectedVersion, "session_evidence": evidence})
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
func (c Client) NackStart(ctx context.Context, taskID, epoch string, expectedVersion int, failureClass, detail string, evidence domain.SessionEvidence) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/nack", map[string]any{"task_id": taskID, "lease_epoch": epoch, "expected_version": expectedVersion, "failure_class": failureClass, "last_error": detail, "session_evidence": evidence})
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
func (c Client) Artifact(ctx context.Context, ref string) ([]byte, error) {
|
|
resp, err := c.request(ctx, http.MethodGet, "/v1/artifacts/"+url.PathEscape(ref), nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
return io.ReadAll(resp.Body)
|
|
}
|
|
|
|
func (c Client) PutArtifact(ctx context.Context, b []byte) (string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.BaseURL, "/")+"/v1/artifacts", bytes.NewReader(b))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/octet-stream")
|
|
req.Header.Set("X-Orchestra-Worker", c.WorkerID)
|
|
req.Header.Set("Authorization", "Bearer "+c.Token)
|
|
h := c.HTTP
|
|
if h == nil {
|
|
h = http.DefaultClient
|
|
}
|
|
resp, err := h.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode/100 != 2 {
|
|
msg, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("artifact upload: %s: %s", resp.Status, strings.TrimSpace(string(msg)))
|
|
}
|
|
var out struct {
|
|
Ref string `json:"ref"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Ref, nil
|
|
}
|
|
func (c Client) Release(ctx context.Context, taskID, ref, anchor, transactionID, epoch string, expectedVersion int, evidence domain.SessionEvidence) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/handoff", map[string]any{"task_id": taskID, "handoff_ref": ref, "anchor_sha": anchor, "transaction_id": transactionID, "lease_epoch": epoch, "expected_version": expectedVersion, "session_evidence": evidence})
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
func (c Client) Pickup(ctx context.Context, taskID, ref, anchor, transactionID, epoch string, leaseVersion int, evidence domain.SessionEvidence) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/pickup", map[string]any{"task_id": taskID, "handoff_ref": ref, "anchor_sha": anchor, "transaction_id": transactionID, "lease_epoch": epoch, "lease_version": leaseVersion, "session_evidence": evidence})
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
func (c Client) Complete(ctx context.Context, taskID, reportRef, resultSHA, branch, remote, epoch string, expectedVersion int, receipt map[string]any, evidence domain.SessionEvidence) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/complete", map[string]any{"task_id": taskID, "handoff_ref": reportRef, "result_sha": resultSHA, "branch": branch, "remote": remote, "lease_epoch": epoch, "expected_version": expectedVersion, "receipt": receipt, "session_evidence": evidence})
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Submit outcomes. Sealing a review and submitting are one call because a
|
|
// review that blocks has no submission to make, and splitting them would let a
|
|
// lost response leave a sealed review with nothing acting on it.
|
|
const (
|
|
// SubmitSubmitted: the pull request is open and TaskSubmitted is recorded.
|
|
SubmitSubmitted = "submitted"
|
|
// SubmitChangesRequested: the review blocked and the task is back in
|
|
// implementation.
|
|
SubmitChangesRequested = "changes_requested"
|
|
// SubmitNoPublisher: the project has no forge, so the caller completes the
|
|
// task directly instead.
|
|
SubmitNoPublisher = "no_publisher"
|
|
)
|
|
|
|
// Submit seals the review against resultSHA and submits that commit for human
|
|
// review. The coordinator owns both events; the worker supplies evidence.
|
|
func (c Client) Submit(ctx context.Context, taskID, epoch string, expectedVersion int, resultSHA, remote string, result review.Result, gate domain.GateResult) (string, error) {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/submit", map[string]any{
|
|
"task_id": taskID, "lease_epoch": epoch, "expected_version": expectedVersion,
|
|
"result_sha": resultSHA, "remote": remote, "review": result, "gate": gate,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Status, nil
|
|
}
|
|
|
|
// VerificationRun is one plan command the worker executed.
|
|
type VerificationRun struct {
|
|
Command []string `json:"command"`
|
|
ExitCode int `json:"exit_code"`
|
|
Output string `json:"output,omitempty"`
|
|
}
|
|
|
|
// PlanPhaseCommands asks the coordinator which commands a phase's verification
|
|
// runs. The worker never takes a command from the agent's request: the plan
|
|
// settled that at seal time, and the project's policy authorises it here.
|
|
func (c Client) PlanPhaseCommands(ctx context.Context, taskID, phaseID, epoch string) ([][]string, error) {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/plan-phase", map[string]any{
|
|
"task_id": taskID, "phase_id": phaseID, "lease_epoch": epoch,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
Commands [][]string `json:"commands"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return nil, err
|
|
}
|
|
return out.Commands, nil
|
|
}
|
|
|
|
// RecordPlanPhase reports what the commands exited and returns the status
|
|
// Orchestra established. The worker supplies evidence; it does not conclude.
|
|
func (c Client) RecordPlanPhase(ctx context.Context, taskID, phaseID, atSHA, epoch string, runs []VerificationRun) (string, error) {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/plan-phase-result", map[string]any{
|
|
"task_id": taskID, "phase_id": phaseID, "at_sha": atSHA, "lease_epoch": epoch, "runs": runs,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Status, nil
|
|
}
|
|
|
|
// ReportPlanMismatch carries the implementer's contradiction report. It
|
|
// returns the phase Orchestra reopened, or an empty phase when the task
|
|
// stopped for the human instead.
|
|
func (c Client) ReportPlanMismatch(ctx context.Context, taskID, epoch string, m domain.PlanMismatch) (string, error) {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/plan-mismatch", map[string]any{
|
|
"task_id": taskID, "lease_epoch": epoch, "mismatch": m,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out struct {
|
|
Phase string `json:"phase"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
return "", err
|
|
}
|
|
return out.Phase, nil
|
|
}
|
|
|
|
func (c Client) PublishCapture(ctx context.Context, capture Capture) (Capture, error) {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/captures", capture)
|
|
if err != nil {
|
|
return Capture{}, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out Capture
|
|
err = json.NewDecoder(resp.Body).Decode(&out)
|
|
return out, err
|
|
}
|
|
func (c Client) Commands(ctx context.Context) ([]Command, error) {
|
|
resp, err := c.request(ctx, http.MethodGet, "/v1/federation/commands", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
var out []Command
|
|
err = json.NewDecoder(resp.Body).Decode(&out)
|
|
return out, err
|
|
}
|
|
func (c Client) ResolveCommand(ctx context.Context, id, status, message string) error {
|
|
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/commands/"+url.PathEscape(id), map[string]string{"status": status, "message": message})
|
|
if resp != nil {
|
|
resp.Body.Close()
|
|
}
|
|
return err
|
|
}
|