Files
orchestra/internal/operations/submission.go
T
kami 7f12c7fc37 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>
2026-08-26 18:31:20 +04:00

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))
}
}