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