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>
325 lines
11 KiB
Go
325 lines
11 KiB
Go
package operations
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"orchestra/internal/authz"
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/registry"
|
|
"orchestra/internal/store"
|
|
)
|
|
|
|
// ErrNotSubmittable reports that the eligibility rule refused. The reasons are
|
|
// on the SubmissionCheck the caller passed or can recompute.
|
|
var ErrNotSubmittable = errors.New("not eligible for submission")
|
|
|
|
// ErrRemoteMismatch is a hard refusal: the remote does not hold the commit the
|
|
// plan named. Nothing is recorded, because a submission that points at the
|
|
// wrong tree is worse than no submission.
|
|
var ErrRemoteMismatch = errors.New("remote ref does not resolve to the submitted commit")
|
|
|
|
// SubmissionPlan is what a submission will do, derived from Orchestra state
|
|
// alone. It is computed before any side effect so the verify step and the
|
|
// perform step cannot disagree about what is being submitted.
|
|
type SubmissionPlan struct {
|
|
TaskID string
|
|
HeadSHA string
|
|
Branch string
|
|
Remote string
|
|
GateRef string
|
|
ReviewRef string
|
|
PacketRef string
|
|
PRTitle string
|
|
PRBody string
|
|
// LeaseHarness and LeaseEpoch fence the resulting event when a reviewing
|
|
// session still holds the lease.
|
|
LeaseHarness string
|
|
LeaseEpoch string
|
|
// Existing is the submission already recorded for this exact commit, if
|
|
// any. Its presence is what makes a repeated `task pr` idempotent.
|
|
Existing *domain.SubmissionRef
|
|
}
|
|
|
|
// Notes is the bounded, agent-supplied half of the human packet. It is
|
|
// evidence, not a completion claim: Orchestra derives everything it can from
|
|
// the contract, the decisions, the gate, the review, and git.
|
|
type Notes struct {
|
|
BehaviouralChanges []string `json:"behavioural_changes,omitempty"`
|
|
Deviations []string `json:"deviations,omitempty"`
|
|
Risks []string `json:"risks,omitempty"`
|
|
Hotspots []string `json:"hotspots,omitempty"`
|
|
}
|
|
|
|
const maxNotes = 12
|
|
|
|
func (n Notes) Validate() error {
|
|
for name, list := range map[string][]string{
|
|
"behavioural_changes": n.BehaviouralChanges, "deviations": n.Deviations,
|
|
"risks": n.Risks, "hotspots": n.Hotspots,
|
|
} {
|
|
if len(list) > maxNotes {
|
|
return fmt.Errorf("%w: %s has %d entries, at most %d", domain.ErrInvalid, name, len(list), maxNotes)
|
|
}
|
|
for i, v := range list {
|
|
if strings.TrimSpace(v) == "" {
|
|
return fmt.Errorf("%w: %s[%d] is empty", domain.ErrInvalid, name, i)
|
|
}
|
|
if len(v) > 500 || strings.ContainsAny(v, "\n\r") {
|
|
return fmt.Errorf("%w: %s[%d] must be one line of at most 500 characters", domain.ErrInvalid, name, i)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PrepareSubmission verifies eligibility and derives the plan. It performs no
|
|
// side effect and appends no event, so calling it twice changes nothing.
|
|
func PrepareSubmission(s *store.Store, project registry.Project, taskID, headSHA string, gate domain.GateResult, notes Notes) (SubmissionPlan, error) {
|
|
if err := notes.Validate(); err != nil {
|
|
return SubmissionPlan{}, err
|
|
}
|
|
t, ok := s.Task(taskID)
|
|
if !ok {
|
|
return SubmissionPlan{}, domain.ErrNotFound
|
|
}
|
|
check := domain.CheckSubmission(t, headSHA, gate)
|
|
reasons := append(check.Reasons, t.RequirePhaseArtifacts(project.Phases())...)
|
|
if len(reasons) > 0 {
|
|
// An existing submission for this exact commit is not a failure. It is
|
|
// the same submission, and returning it is what makes a retry safe.
|
|
if t.Submitted(headSHA) && onlyStateReasons(reasons) {
|
|
return planFor(s, t, project, headSHA, gate, notes)
|
|
}
|
|
return SubmissionPlan{}, fmt.Errorf("%w: %s", ErrNotSubmittable, strings.Join(reasons, "; "))
|
|
}
|
|
return planFor(s, t, project, headSHA, gate, notes)
|
|
}
|
|
|
|
// onlyStateReasons reports whether every refusal is a consequence of the task
|
|
// already being submitted, rather than a real defect in eligibility.
|
|
func onlyStateReasons(reasons []string) bool {
|
|
for _, r := range reasons {
|
|
if !strings.Contains(r, "work phase is") && !strings.Contains(r, "already") {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func planFor(s *store.Store, t domain.Task, project registry.Project, headSHA string, gate domain.GateResult, notes Notes) (SubmissionPlan, error) {
|
|
gateRef, err := s.PutArtifact(gateEvidence(gate))
|
|
if err != nil {
|
|
return SubmissionPlan{}, err
|
|
}
|
|
packet, err := SubmissionPacket(s, t, headSHA, gate, notes)
|
|
if err != nil {
|
|
return SubmissionPlan{}, err
|
|
}
|
|
packetRef, err := s.PutArtifact([]byte(packet))
|
|
if err != nil {
|
|
return SubmissionPlan{}, err
|
|
}
|
|
plan := SubmissionPlan{
|
|
TaskID: t.ID, HeadSHA: headSHA, Branch: "orchestra/" + t.ID,
|
|
// The remote name is a worker-side deployment detail; submission names
|
|
// the conventional default and the executor may override it.
|
|
Remote: "origin",
|
|
GateRef: gateRef, PacketRef: packetRef,
|
|
PRTitle: prTitle(t), PRBody: packet, Existing: t.Submission,
|
|
}
|
|
if t.Review != nil {
|
|
plan.ReviewRef = t.Review.ArtifactRef
|
|
}
|
|
if t.Lease != nil {
|
|
plan.LeaseHarness, plan.LeaseEpoch = t.Lease.HarnessID, t.Lease.Epoch
|
|
}
|
|
return plan, nil
|
|
}
|
|
|
|
func prTitle(t domain.Task) string {
|
|
title := oneLine(firstNonEmpty(t.Title, t.Description, "Orchestra task "+t.ID))
|
|
if len(title) > 120 {
|
|
title = title[:120]
|
|
}
|
|
return title
|
|
}
|
|
|
|
func gateEvidence(g domain.GateResult) []byte {
|
|
b, _ := json.Marshal(g)
|
|
return b
|
|
}
|
|
|
|
// Publisher is the side-effecting half. It is an interface so submission can
|
|
// be tested without a forge, and so the git and forge steps stay separable.
|
|
type Publisher interface {
|
|
// Push publishes exactly the named commit and returns what the remote
|
|
// resolves the branch to afterwards.
|
|
Push(ctx context.Context, remote, branch, sha string) (string, error)
|
|
// EnsurePR creates the pull request or updates the existing one for this
|
|
// branch. It must never create a second pull request for the same branch.
|
|
EnsurePR(ctx context.Context, plan SubmissionPlan) (domain.ExternalRef, error)
|
|
}
|
|
|
|
// HeadResolver reads the current commit, so execution can re-check it
|
|
// immediately before pushing and again before recording success.
|
|
type HeadResolver func(ctx context.Context) (string, error)
|
|
|
|
// ExecuteSubmission performs the plan and records it.
|
|
//
|
|
// The commit is re-read immediately before the push and again before the event
|
|
// is appended, so a tree that moved after eligibility was computed cannot be
|
|
// submitted under the old verdict. A transport failure leaves the task
|
|
// review-ready and retryable rather than in a fake terminal state.
|
|
func ExecuteSubmission(ctx context.Context, s *store.Store, plan SubmissionPlan, pub Publisher, head HeadResolver) (domain.Event, error) {
|
|
if head != nil {
|
|
current, err := head(ctx)
|
|
if err != nil {
|
|
return domain.Event{}, fmt.Errorf("re-read head before push: %w", err)
|
|
}
|
|
if current != plan.HeadSHA {
|
|
return domain.Event{}, fmt.Errorf("%w: head moved from %s to %s before push", ErrNotSubmittable, plan.HeadSHA, current)
|
|
}
|
|
}
|
|
remoteSHA, err := pub.Push(ctx, plan.Remote, plan.Branch, plan.HeadSHA)
|
|
if err != nil {
|
|
return domain.Event{}, fmt.Errorf("push %s: %w", plan.Branch, err)
|
|
}
|
|
if remoteSHA != plan.HeadSHA {
|
|
return domain.Event{}, fmt.Errorf("%w: %s holds %s, expected %s", ErrRemoteMismatch, plan.Branch, remoteSHA, plan.HeadSHA)
|
|
}
|
|
pr, err := pub.EnsurePR(ctx, plan)
|
|
if err != nil {
|
|
// The push stands. A retry re-verifies the pushed commit and continues
|
|
// from here rather than starting over.
|
|
return domain.Event{}, fmt.Errorf("pull request for %s: %w", plan.Branch, err)
|
|
}
|
|
if head != nil {
|
|
current, err := head(ctx)
|
|
if err != nil {
|
|
return domain.Event{}, fmt.Errorf("re-read head before recording: %w", err)
|
|
}
|
|
if current != plan.HeadSHA {
|
|
return domain.Event{}, fmt.Errorf("%w: head moved to %s while submitting", ErrNotSubmittable, current)
|
|
}
|
|
}
|
|
t, ok := s.Task(plan.TaskID)
|
|
if !ok {
|
|
return domain.Event{}, domain.ErrNotFound
|
|
}
|
|
if t.Submitted(plan.HeadSHA) {
|
|
// Already recorded for this commit. The push and the pull request were
|
|
// both idempotent, so this is the same submission, not a second one.
|
|
return domain.Event{}, nil
|
|
}
|
|
payload := map[string]any{
|
|
"result_sha": plan.HeadSHA,
|
|
"remote_ref": plan.Remote + "/" + plan.Branch,
|
|
"pr": pr,
|
|
"gate_ref": plan.GateRef,
|
|
"review_ref": plan.ReviewRef,
|
|
"packet_ref": plan.PacketRef,
|
|
}
|
|
if plan.LeaseEpoch != "" {
|
|
payload["harness_id"], payload["lease_epoch"] = plan.LeaseHarness, plan.LeaseEpoch
|
|
}
|
|
b, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return domain.Event{}, err
|
|
}
|
|
e := domain.Event{ID: domain.NewID(), Type: domain.EventTaskSubmitted, TaskID: plan.TaskID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
|
|
return e, s.Append(e)
|
|
}
|
|
|
|
// SubmissionPacket is the human's single review packet. Orchestra derives
|
|
// everything it can; the agent's contribution is bounded and labelled as its
|
|
// own account rather than as verified fact.
|
|
func SubmissionPacket(s *store.Store, t domain.Task, headSHA string, gate domain.GateResult, notes Notes) (string, error) {
|
|
intent, err := s.EffectiveIntent(t.ID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "## Goal\n\n%s\n", oneLine(firstNonEmpty(t.Title, t.Description, "not stated")))
|
|
if t.Description != "" && t.Title != "" {
|
|
fmt.Fprintf(&b, "\n%s\n", oneLine(t.Description))
|
|
}
|
|
|
|
b.WriteString("\n## Acceptance\n\n")
|
|
if len(t.Acceptance) == 0 {
|
|
b.WriteString("- not stated in the task contract\n")
|
|
}
|
|
for _, a := range t.Acceptance {
|
|
fmt.Fprintf(&b, "- %s\n", oneLine(a))
|
|
}
|
|
|
|
if len(intent.Decisions) > 0 {
|
|
b.WriteString("\n## Human decisions\n\n")
|
|
for _, d := range intent.Decisions {
|
|
fmt.Fprintf(&b, "- %s (%s): %s\n", d.Kind, d.Subject, oneLine(d.Value))
|
|
}
|
|
}
|
|
|
|
b.WriteString("\n## Verification\n\n")
|
|
if gate.Command != "" {
|
|
fmt.Fprintf(&b, "- `%s` exited %d\n", oneLine(gate.Command), gate.ExitCode)
|
|
}
|
|
fmt.Fprintf(&b, "- commit: %s\n", headSHA)
|
|
if t.Review != nil {
|
|
verdict := "pass"
|
|
if t.Review.Blocking > 0 {
|
|
verdict = fmt.Sprintf("%d unresolved blocking findings", t.Review.Blocking)
|
|
}
|
|
fmt.Fprintf(&b, "- independent review of %s: %s\n", t.Review.ResultSHA, verdict)
|
|
if r, err := TaskReview(s, t); err == nil && r != nil {
|
|
minor := len(r.Findings) - len(r.Blocking())
|
|
if minor > 0 {
|
|
fmt.Fprintf(&b, "- minor findings, not fixed: %d\n", minor)
|
|
}
|
|
}
|
|
}
|
|
if t.PlanRef != "" {
|
|
fmt.Fprintf(&b, "- accepted plan: %s\n", t.PlanRef)
|
|
}
|
|
|
|
writeNotes(&b, "Behavioural changes", notes.BehaviouralChanges, "none reported")
|
|
writeNotes(&b, "Deviations from plan", notes.Deviations, "none reported")
|
|
writeNotes(&b, "Remaining risks", notes.Risks, "none reported")
|
|
writeNotes(&b, "Review hotspots", notes.Hotspots, "none reported")
|
|
|
|
if r, err := TaskReview(s, t); err == nil && r != nil && len(r.Findings) > 0 {
|
|
b.WriteString("\n## Reviewer findings\n\n")
|
|
for _, f := range r.Findings {
|
|
where := oneLine(f.File)
|
|
if f.Line > 0 {
|
|
where = fmt.Sprintf("%s:%d", where, f.Line)
|
|
}
|
|
fmt.Fprintf(&b, "- %s: `%s` %s\n", f.Severity, where, oneLine(f.Claim))
|
|
}
|
|
}
|
|
if found := DeferredFindings(s, t.ID); len(found) > 0 {
|
|
b.WriteString("\n## Deferred, not done here\n\n")
|
|
for _, f := range found {
|
|
fmt.Fprintf(&b, "- %s (%s)\n", oneLine(f.Summary), oneLine(f.Why))
|
|
}
|
|
}
|
|
b.WriteString("\nThe sections above are derived from Orchestra state. The reported\n")
|
|
b.WriteString("changes, deviations, risks, and hotspots are the implementing agent's\n")
|
|
b.WriteString("own account and are not verified.\n")
|
|
return b.String(), nil
|
|
}
|
|
|
|
func writeNotes(b *strings.Builder, heading string, items []string, empty string) {
|
|
fmt.Fprintf(b, "\n## %s\n\n", heading)
|
|
if len(items) == 0 {
|
|
fmt.Fprintf(b, "- %s\n", empty)
|
|
return
|
|
}
|
|
for _, item := range items {
|
|
fmt.Fprintf(b, "- %s\n", oneLine(item))
|
|
}
|
|
}
|