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:
2026-08-26 18:31:20 +04:00
parent 97a9c65302
commit 7f12c7fc37
78 changed files with 16417 additions and 352 deletions
+166
View File
@@ -0,0 +1,166 @@
package operations
import (
"encoding/json"
"errors"
"fmt"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/registry"
"orchestra/internal/store"
)
// DefaultMaxDecisionRequests bounds how many times one task may stop for a
// human question. The bound is per task, not per round: rounds are
// conversation machinery, and one blocker with one question needs none.
const DefaultMaxDecisionRequests = 6
// ErrDecisionBudgetSpent reports that a task has asked its last question. The
// task stays blocked, but for an operator rather than for another answer, so
// an agent cannot turn a task into an interview.
var ErrDecisionBudgetSpent = errors.New("decision request budget spent: operator required")
// RequestHumanDecision records a bounded question and blocks the task on it.
//
// Admission is the agent's judgement, stated in the phase brief: ask only when
// the answer materially changes the implementation, the repository cannot
// answer it, and no useful safe work can continue without guessing. Orchestra
// owns what happens next, which is this function.
func RequestHumanDecision(s *store.Store, project registry.Project, taskID string, req domain.DecisionRequest) (domain.Event, error) {
if err := req.Validate(); err != nil {
return domain.Event{}, err
}
t, ok := s.Task(taskID)
if !ok {
return domain.Event{}, domain.ErrNotFound
}
if t.State != domain.StateLeased && t.State != domain.StateNeedsAttention {
// Only a session that currently owns the task may stop it for a
// question. Without this, an agent credential is a way to block any
// task in the queue, including one no agent is working on.
return domain.Event{}, fmt.Errorf("%w: task %s is not owned by a session (state %s)", domain.ErrConflict, taskID, t.State)
}
if t.State == domain.StateBlocked && t.BlockReason == domain.BlockReasonHumanDecision {
// Already waiting. Re-asking would spam the human and move the
// position the answered check depends on.
return domain.Event{}, fmt.Errorf("%w: task %s is already waiting on a decision", domain.ErrConflict, taskID)
}
spent := countDecisionRequests(s, taskID)
if spent >= project.MaxDecisionRequests() {
e, err := blockTask(s, t, domain.BlockReasonOperatorRequired,
fmt.Sprintf("This task has asked %d questions, its budget. An operator should look at it rather than answer another.\n\nLast question: %s", spent, req.Question), nil)
if err != nil {
return domain.Event{}, err
}
return e, fmt.Errorf("%w (task %s, %d requests)", ErrDecisionBudgetSpent, taskID, spent)
}
return blockTask(s, t, domain.BlockReasonHumanDecision, req.Render(), &req)
}
func blockTask(s *store.Store, t domain.Task, reason domain.BlockReason, blocker string, req *domain.DecisionRequest) (domain.Event, error) {
payload := map[string]any{
"blocker": blocker, "block_reason": string(reason),
"lifecycle_phase": "awaiting_human",
}
if t.Lease != nil {
// Store.Append fences every lifecycle event on a leased task against
// the current owner and epoch. A question from a session that no
// longer owns the task is a conflict, not a block.
payload["harness_id"] = t.Lease.HarnessID
payload["lease_epoch"] = t.Lease.Epoch
}
if req != nil {
payload["decision_request"] = req
}
b, err := json.Marshal(payload)
if err != nil {
return domain.Event{}, err
}
e := domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
return e, s.Append(e)
}
func countDecisionRequests(s *store.Store, taskID string) int {
n := 0
for _, e := range s.Events(0) {
if e.TaskID != taskID || e.Type != "TaskBlocked" {
continue
}
var p struct {
BlockReason string `json:"block_reason"`
}
if json.Unmarshal(e.Payload, &p) == nil && p.BlockReason == string(domain.BlockReasonHumanDecision) {
n++
}
}
return n
}
// ResumeAnsweredBlockers returns every task whose human blocker has been
// answered to the queue. Run it wherever pending assignment runs: the router
// cannot see a blocked task, so something has to unblock it, and that
// something must be Orchestra rather than the agent that asked.
func ResumeAnsweredBlockers(s *store.Store) ([]domain.Event, error) {
var out []domain.Event
for _, t := range s.Tasks() {
if t.State != domain.StateBlocked {
continue
}
switch t.BlockReason {
case domain.BlockReasonHumanDecision, domain.BlockReasonTrajectoryGate:
default:
// operator_required is deliberately not resumed by a reply. An
// operator decides when a task that spent its budget continues.
continue
}
if !blockerAnswered(s, t.ID, t.BlockReason) {
continue
}
before := t.Version
updated, err := clearBlocker(s, t, t.BlockReason, "resumed")
if err != nil {
return out, err
}
if updated.Version != before {
out = append(out, domain.Event{ID: t.ID, Type: "TaskCorrected", TaskID: t.ID, Version: updated.Version})
}
}
return out, nil
}
// RecordDeferredFinding keeps a real but out-of-scope discovery without
// derailing the task. It is appended to the log and projected onto nothing,
// so it never enters agent context. Turning these into follow-up tasks is a
// separate, deliberate step.
func RecordDeferredFinding(s *store.Store, taskID string, f domain.DeferredFinding) (domain.Event, error) {
if err := f.Validate(); err != nil {
return domain.Event{}, err
}
t, ok := s.Task(taskID)
if !ok {
return domain.Event{}, domain.ErrNotFound
}
b, err := json.Marshal(map[string]any{"summary": f.Summary, "why": f.Why})
if err != nil {
return domain.Event{}, err
}
e := domain.Event{ID: domain.NewID(), Type: domain.EventDeferredFindingRecorded, TaskID: taskID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
return e, s.Append(e)
}
// DeferredFindings lists what a task chose not to do, for follow-up creation
// at completion time.
func DeferredFindings(s *store.Store, taskID string) []domain.DeferredFinding {
var out []domain.DeferredFinding
for _, e := range s.Events(0) {
if e.TaskID != taskID || e.Type != domain.EventDeferredFindingRecorded {
continue
}
var f domain.DeferredFinding
if json.Unmarshal(e.Payload, &f) == nil {
out = append(out, f)
}
}
return out
}
+184
View File
@@ -0,0 +1,184 @@
package operations
import (
"errors"
"strings"
"testing"
"orchestra/internal/domain"
"orchestra/internal/registry"
)
func request(q string) domain.DecisionRequest {
return domain.DecisionRequest{
Question: q,
Why: "both behaviours are valid and two external callers depend on the answer",
Options: []domain.DecisionOption{
{ID: "preserve", Description: "keep the old contract", Tradeoff: "larger implementation"},
{ID: "break", Description: "change the contract", Tradeoff: "consumers must migrate"},
},
Evidence: []string{"internal/cache/cache.go:44 documents neither behaviour"},
}
}
// Real ambiguity blocks with one bounded question, the human answers through
// the ordinary decision path, and the task resumes.
func TestDecisionRequestBlocksAndResumes(t *testing.T) {
s, id := phaseStore(t)
lease(t, s, id)
project := registry.Project{ID: "p"}
if _, err := RequestHumanDecision(s, project, id, request("should the old cache contract stay compatible?")); err != nil {
t.Fatal(err)
}
blocked, _ := s.Task(id)
if blocked.State != domain.StateBlocked || blocked.BlockReason != domain.BlockReasonHumanDecision {
t.Fatalf("task = %+v", blocked)
}
if blocked.DecisionRequest == nil || blocked.DecisionRequest.Question == "" {
t.Fatal("the question is not projected onto the task")
}
for _, want := range []string{"Human decision required", "stay compatible", "preserve", "consumers must migrate"} {
if !strings.Contains(blocked.Blocker, want) {
t.Fatalf("blocker missing %q:\n%s", want, blocked.Blocker)
}
}
// Asking again while waiting is refused.
if _, err := RequestHumanDecision(s, project, id, request("another question?")); !errors.Is(err, domain.ErrConflict) {
t.Fatalf("want ErrConflict, got %v", err)
}
// Nothing resumes before the human replies.
if events, err := ResumeAnsweredBlockers(s); err != nil || len(events) != 0 {
t.Fatalf("resumed early: %v %v", events, err)
}
humanReply(t, s, id, "d1", "break compatibility and update the two callers")
events, err := ResumeAnsweredBlockers(s)
if err != nil || len(events) != 1 {
t.Fatalf("events=%v err=%v", events, err)
}
resumed, _ := s.Task(id)
if resumed.State != domain.StateQueued {
t.Fatalf("state = %s", resumed.State)
}
// The question is gone from the projection. The log still has it, so a
// resolved question never reappears in a later context.
if resumed.DecisionRequest != nil {
t.Fatalf("resolved question still on the task: %+v", resumed.DecisionRequest)
}
// And a second sweep is idempotent.
if events, err := ResumeAnsweredBlockers(s); err != nil || len(events) != 0 {
t.Fatalf("resumed twice: %v %v", events, err)
}
}
// A rotation between the question and the answer must not re-ask it.
func TestResolvedQuestionIsNotRepeatedAfterRotation(t *testing.T) {
s, id := phaseStore(t)
lease(t, s, id)
project := registry.Project{ID: "p"}
if _, err := RequestHumanDecision(s, project, id, request("preserve compatibility?")); err != nil {
t.Fatal(err)
}
humanReply(t, s, id, "d1", "break it")
if _, err := ResumeAnsweredBlockers(s); err != nil {
t.Fatal(err)
}
// A later session sees no pending question, and the budget records that
// one was spent.
got, _ := s.Task(id)
if got.DecisionRequest != nil {
t.Fatal("question repeated after resume")
}
if n := countDecisionRequests(s, id); n != 1 {
t.Fatalf("requests counted = %d", n)
}
}
// The budget stops a task turning into an interview.
func TestDecisionBudgetBecomesOperatorRequired(t *testing.T) {
s, id := phaseStore(t)
project := registry.Project{ID: "p"}
project.HumanDecisions.MaxRequestsPerTask = 2
for i, q := range []string{"first?", "second?"} {
// An answered question returns the task to the queue, so the next
// session leases it again before it can ask.
lease(t, s, id)
if _, err := RequestHumanDecision(s, project, id, request(q)); err != nil {
t.Fatalf("request %d: %v", i, err)
}
humanReply(t, s, id, "d"+q, "answered")
if _, err := ResumeAnsweredBlockers(s); err != nil {
t.Fatal(err)
}
}
lease(t, s, id)
_, err := RequestHumanDecision(s, project, id, request("third?"))
if !errors.Is(err, ErrDecisionBudgetSpent) {
t.Fatalf("want ErrDecisionBudgetSpent, got %v", err)
}
got, _ := s.Task(id)
if got.BlockReason != domain.BlockReasonOperatorRequired {
t.Fatalf("block reason = %q", got.BlockReason)
}
// A reply must not resume a task that spent its budget. An operator does.
humanReply(t, s, id, "d-late", "just keep going")
if events, err := ResumeAnsweredBlockers(s); err != nil || len(events) != 0 {
t.Fatalf("operator_required resumed on a reply: %v %v", events, err)
}
}
// Bounds are the whole defence against an interview arriving as one request.
func TestDecisionRequestBoundsRejectInterviews(t *testing.T) {
s, id := phaseStore(t)
project := registry.Project{ID: "p"}
cases := map[string]domain.DecisionRequest{
"no question": {Why: "w"},
"no why": {Question: "q"},
"multiline": {Question: "line one\nline two", Why: "w"},
"long": {Question: strings.Repeat("x", 501), Why: "w"},
"five options": {Question: "q", Why: "w", Options: []domain.DecisionOption{
{ID: "a", Description: "d"}, {ID: "b", Description: "d"}, {ID: "c", Description: "d"},
{ID: "d", Description: "d"}, {ID: "e", Description: "d"},
}},
"duplicate options": {Question: "q", Why: "w", Options: []domain.DecisionOption{
{ID: "a", Description: "d"}, {ID: "a", Description: "d"},
}},
"nine evidence lines": {Question: "q", Why: "w", Evidence: []string{"1", "2", "3", "4", "5", "6", "7", "8", "9"}},
}
for name, req := range cases {
if _, err := RequestHumanDecision(s, project, id, req); !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("%s: want ErrInvalid, got %v", name, err)
}
}
if got, _ := s.Task(id); got.State == domain.StateBlocked {
t.Fatal("a rejected request must not block the task")
}
}
// An out-of-scope discovery is recorded and does not block anything, and never
// reaches agent context.
func TestDeferredFindingDoesNotBlock(t *testing.T) {
s, id := phaseStore(t)
before, _ := s.Task(id)
if _, err := RecordDeferredFinding(s, id, domain.DeferredFinding{
Summary: "identity clustering could be redesigned",
Why: "unrelated to speaker attribution and out of this task's scope",
}); err != nil {
t.Fatal(err)
}
after, _ := s.Task(id)
if after.State != before.State || after.DecisionRequest != nil {
t.Fatalf("deferred finding changed task state: %+v", after)
}
found := DeferredFindings(s, id)
if len(found) != 1 || found[0].Summary != "identity clustering could be redesigned" {
t.Fatalf("findings = %+v", found)
}
if _, err := RecordDeferredFinding(s, id, domain.DeferredFinding{Summary: " "}); !errors.Is(err, domain.ErrInvalid) {
t.Fatal("an empty finding must be rejected")
}
}
+5
View File
@@ -0,0 +1,5 @@
package operations
import "encoding/json"
func unmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
+195
View File
@@ -0,0 +1,195 @@
package operations
import (
"encoding/json"
"errors"
"fmt"
"time"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/human"
"orchestra/internal/registry"
"orchestra/internal/store"
)
// ErrForeignPullRequest rejects an observation that does not belong to the
// task's own submission. Only the pull request bound in TaskSubmitted may move
// that task, or one task's forge traffic could complete another.
var ErrForeignPullRequest = errors.New("observation is for a different pull request")
// ReflectSubmission reconciles one submitted pull request.
//
// It runs on its own, not behind Store.PreLease. An in-review task cannot be
// leased, so a pre-lease hook could never observe the feedback that should make
// it leasable again. That is the same shape of bug as reconciling only at
// launch, one lifecycle stage later.
func ReflectSubmission(s *store.Store, project registry.Project, taskID string, state human.PullRequestState, trust human.Trust) ([]domain.Event, error) {
t, ok := s.Task(taskID)
if !ok {
return nil, domain.ErrNotFound
}
if t.Submission == nil {
return nil, nil
}
if state.ID != t.Submission.PR.ID {
return nil, fmt.Errorf("%w: %s is not %s", ErrForeignPullRequest, state.ID, t.Submission.PR.ID)
}
submittedAt, submissionEvent, ok := submissionRecord(s, t)
if !ok {
return nil, fmt.Errorf("%w: no submission event for task %s", domain.ErrInvalid, taskID)
}
switch state.State {
case "merged":
// Merge strategy decides what MergeSHA is, so completion rests on the
// bound pull request having merged while carrying the submitted commit.
if state.HeadSHA != t.Submission.ResultSHA {
return nil, fmt.Errorf("%w: pull request %s carries %s, but %s was submitted", ErrForeignPullRequest, state.ID, state.HeadSHA, t.Submission.ResultSHA)
}
if t.State == domain.StateCompleted {
return nil, nil
}
e, err := complete(s, t, submissionEvent, state)
if err != nil {
return nil, err
}
return []domain.Event{e}, nil
case "closed":
// Closed without a merge could mean abandoned, rejected, superseded,
// or a misclick. Guessing would be worse than surfacing it.
if t.State == domain.StateNeedsAttention || t.State != domain.StateInReview {
return nil, nil
}
e, err := blockTask(s, t, domain.BlockReasonOperator,
fmt.Sprintf("Pull request %s was closed without merging the submitted commit %s. Decide whether this task is abandoned, superseded, or should be resubmitted.", state.ID, t.Submission.ResultSHA), nil)
if err != nil {
return nil, err
}
return []domain.Event{e}, nil
}
if t.State != domain.StateInReview {
// Already reopened, or never submitted into review. Nothing to do.
return nil, nil
}
feedback := state.FeedbackAfter(t.Submission.PR.Provider, submittedAt, trust)
if len(feedback) == 0 {
return nil, nil
}
var recorded []domain.Event
var decisionIDs []string
for _, in := range feedback {
if id, exists := s.DecisionForSource(in.Provider, in.ExternalID); exists {
// Already imported. A repeated poll must not reopen the task twice
// for the same comment.
decisionIDs = append(decisionIDs, id)
continue
}
e, id, err := recordDecision(s, t.ID, in)
if err != nil {
return recorded, err
}
recorded = append(recorded, e)
decisionIDs = append(decisionIDs, id)
t, _ = s.Task(t.ID)
}
if len(recorded) == 0 {
// Every comment was already imported, so this poll changed nothing.
return nil, nil
}
e, err := requestChanges(s, t, submissionEvent, decisionIDs)
if err != nil {
return recorded, err
}
recorded = append(recorded, e)
// The work goes back to implementation, where a fresh gate and a fresh
// review will be required because the commit will change.
if _, err := AdvanceWorkPhase(s, project, t.ID, nil); err != nil {
return recorded, err
}
return recorded, nil
}
// submissionRecord finds when the current submission happened, which is the
// cutoff for "feedback on this submission".
func submissionRecord(s *store.Store, t domain.Task) (time.Time, string, bool) {
for i := len(s.Events(0)) - 1; i >= 0; i-- {
e := s.Events(0)[i]
if e.TaskID != t.ID || e.Type != domain.EventTaskSubmitted {
continue
}
var p struct {
ResultSHA string `json:"result_sha"`
}
if json.Unmarshal(e.Payload, &p) == nil && p.ResultSHA == t.Submission.ResultSHA {
return e.At, e.ID, true
}
}
return time.Time{}, "", false
}
func recordDecision(s *store.Store, taskID string, in human.Input) (domain.Event, string, error) {
current, ok := s.Task(taskID)
if !ok {
return domain.Event{}, "", domain.ErrNotFound
}
id := domain.NewID()
b, err := json.Marshal(map[string]any{
"decision_id": id, "kind": string(domain.HumanDecisionCorrection),
"subject": "operator_instruction", "value": in.Body, "author": in.Author,
"source": map[string]any{"provider": in.Provider, "external_id": in.ExternalID},
})
if err != nil {
return domain.Event{}, "", err
}
at := in.At
if at.IsZero() {
at = time.Now().UTC()
}
e := domain.Event{ID: domain.NewID(), Type: domain.EventHumanDecisionRecorded, TaskID: taskID, Version: current.Version + 1, At: at, Payload: b, Surface: string(authz.System)}
return e, id, s.Append(e)
}
func requestChanges(s *store.Store, t domain.Task, submissionEvent string, decisionIDs []string) (domain.Event, error) {
current, _ := s.Task(t.ID)
b, err := json.Marshal(map[string]any{
"submission_event": submissionEvent,
"submitted_sha": t.Submission.ResultSHA,
"decision_ids": decisionIDs,
})
if err != nil {
return domain.Event{}, err
}
e := domain.Event{ID: domain.NewID(), Type: domain.EventTaskChangesRequested, TaskID: t.ID, Version: current.Version + 1, Payload: b, Surface: string(authz.System)}
return e, s.Append(e)
}
func complete(s *store.Store, t domain.Task, submissionEvent string, state human.PullRequestState) (domain.Event, error) {
receipt := domain.CompletionReceipt{
SubmissionRef: submissionEvent, PR: t.Submission.PR,
SubmittedSHA: t.Submission.ResultSHA, MergeSHA: state.MergeSHA, MergedAt: state.MergedAt,
}
if receipt.MergedAt.IsZero() {
receipt.MergedAt = time.Now().UTC()
}
sealed, err := json.Marshal(receipt)
if err != nil {
return domain.Event{}, err
}
ref, err := s.PutArtifact(sealed)
if err != nil {
return domain.Event{}, err
}
var asMap map[string]any
if err := json.Unmarshal(sealed, &asMap); err != nil {
return domain.Event{}, err
}
current, _ := s.Task(t.ID)
b, err := json.Marshal(map[string]any{"report_ref": ref, "receipt": asMap})
if err != nil {
return domain.Event{}, err
}
e := domain.Event{ID: domain.NewID(), Type: "TaskCompleted", TaskID: t.ID, Version: current.Version + 1, Payload: b, Surface: string(authz.System)}
return e, s.Append(e)
}
+294
View File
@@ -0,0 +1,294 @@
package operations
import (
"context"
"errors"
"strings"
"testing"
"time"
"orchestra/internal/domain"
"orchestra/internal/human"
"orchestra/internal/registry"
"orchestra/internal/review"
"orchestra/internal/store"
)
var operatorTrust = human.Trust{Accepted: []string{"kami"}, Ignored: []string{"orchestra-bot", "gitea-actions"}}
// submitted walks a task all the way to in_review at shaA.
func submitted(t *testing.T) (*store.Store, string, registry.Project) {
t.Helper()
s, id, project := reviewed(t)
plan, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{})
if err != nil {
t.Fatal(err)
}
if _, err := ExecuteSubmission(context.Background(), s, plan, &fakePublisher{}, head(shaA)); err != nil {
t.Fatal(err)
}
if got, _ := s.Task(id); got.State != domain.StateInReview {
t.Fatalf("state = %s", got.State)
}
return s, id, project
}
func submittedAt(t *testing.T, s *store.Store, id string) time.Time {
t.Helper()
got, _ := s.Task(id)
at, _, ok := submissionRecord(s, got)
if !ok {
t.Fatal("no submission event")
}
return at
}
func prState(id, headSHA, state string, comments ...human.Input) human.PullRequestState {
return human.PullRequestState{ID: id, HeadSHA: headSHA, State: state, Comments: comments}
}
// Trusted feedback after submission reopens the task without any lease being
// involved, and the old submission stays as history.
func TestTrustedFeedbackReopensTheTask(t *testing.T) {
s, id, project := submitted(t)
after := submittedAt(t, s, id).Add(time.Minute)
events, err := ReflectSubmission(s, project, id, prState("142", shaA, "open",
human.Input{Provider: "gitea:p", ExternalID: "c9", Author: "kami", At: after, Body: "change x to y"},
), operatorTrust)
if err != nil {
t.Fatal(err)
}
if len(events) != 2 {
t.Fatalf("events = %d, want a decision and a changes-requested", len(events))
}
got, _ := s.Task(id)
if got.State != domain.StateQueued {
t.Fatalf("state = %s, want queued so the router can lease it", got.State)
}
if got.WorkPhase != domain.WorkPhaseImplement {
t.Fatalf("phase = %q, want implement", got.WorkPhase)
}
if got.Submission == nil || got.Submission.ResultSHA != shaA {
t.Fatalf("the submission must remain as history: %+v", got.Submission)
}
// The feedback is standing authority.
intent, err := s.EffectiveIntent(id)
if err != nil {
t.Fatal(err)
}
if len(intent.Decisions) != 1 || intent.Decisions[0].Value != "change x to y" {
t.Fatalf("decisions = %+v", intent.Decisions)
}
// The old review and submission satisfy nothing at a new commit.
if domain.CheckSubmission(got, shaB, gate(shaB)).Eligible {
t.Fatal("a new commit inherited the old review")
}
// Polling again with the same comment changes nothing.
before := len(s.Events(0))
if events, err := ReflectSubmission(s, project, id, prState("142", shaA, "open",
human.Input{Provider: "gitea:p", ExternalID: "c9", Author: "kami", At: after, Body: "change x to y"},
), operatorTrust); err != nil || len(events) != 0 {
t.Fatalf("duplicate poll: events=%d err=%v", len(events), err)
}
if len(s.Events(0)) != before {
t.Fatal("a duplicate comment appended events")
}
}
// Bots, Orchestra itself, and comments from before the submission cannot
// reopen finished work.
func TestUntrustedAndStaleCommentsDoNotReopen(t *testing.T) {
s, id, project := submitted(t)
at := submittedAt(t, s, id)
cases := map[string]human.Input{
"bot": {Provider: "gitea:p", ExternalID: "b1", Author: "gitea-actions", At: at.Add(time.Minute), Body: "build passed"},
"orchestra itself": {Provider: "gitea:p", ExternalID: "b2", Author: "orchestra-bot", At: at.Add(time.Minute), Body: "submitted"},
"unknown actor": {Provider: "gitea:p", ExternalID: "b3", Author: "passer-by", At: at.Add(time.Minute), Body: "nice"},
"before submission": {Provider: "gitea:p", ExternalID: "b4", Author: "kami", At: at.Add(-time.Hour), Body: "looks good so far"},
"at submission": {Provider: "gitea:p", ExternalID: "b5", Author: "kami", At: at, Body: "same instant"},
"empty": {Provider: "gitea:p", ExternalID: "b6", Author: "kami", At: at.Add(time.Minute), Body: " "},
}
for name, in := range cases {
events, err := ReflectSubmission(s, project, id, prState("142", shaA, "open", in), operatorTrust)
if err != nil || len(events) != 0 {
t.Fatalf("%s: events=%d err=%v", name, len(events), err)
}
if got, _ := s.Task(id); got.State != domain.StateInReview {
t.Fatalf("%s: reopened the task", name)
}
}
}
// A merged pull request completes the task, with the receipt bound to the exact
// submission. Merge strategy is not assumed.
func TestMergedPullRequestCompletes(t *testing.T) {
s, id, project := submitted(t)
state := human.PullRequestState{
ID: "142", HeadSHA: shaA, State: "merged",
MergeSHA: "9999999999999999999999999999999999999999", MergedAt: time.Unix(1700000000, 0).UTC(),
}
events, err := ReflectSubmission(s, project, id, state, operatorTrust)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Type != "TaskCompleted" {
t.Fatalf("events = %+v", events)
}
got, _ := s.Task(id)
if got.State != domain.StateCompleted {
t.Fatalf("state = %s", got.State)
}
// The receipt names the submission, the pull request, both commits, and
// when it merged.
var payload struct {
ReportRef string `json:"report_ref"`
Receipt domain.CompletionReceipt `json:"receipt"`
}
if err := unmarshal(events[0].Payload, &payload); err != nil {
t.Fatal(err)
}
r := payload.Receipt
if r.SubmittedSHA != shaA || r.MergeSHA != "9999999999999999999999999999999999999999" {
t.Fatalf("receipt = %+v", r)
}
if r.PR.ID != "142" || r.SubmissionRef == "" || r.MergedAt.IsZero() {
t.Fatalf("receipt = %+v", r)
}
if _, err := s.Artifact(payload.ReportRef); err != nil {
t.Fatalf("receipt artifact missing: %v", err)
}
// Reflecting again is idempotent.
if events, err := ReflectSubmission(s, project, id, state, operatorTrust); err != nil || len(events) != 0 {
t.Fatalf("second merge reflection: events=%d err=%v", len(events), err)
}
}
// A stale observation cannot complete a task, and neither can another task's
// pull request.
func TestForeignOrStaleObservationCannotComplete(t *testing.T) {
s, id, project := submitted(t)
// Another pull request entirely.
if _, err := ReflectSubmission(s, project, id, human.PullRequestState{ID: "999", HeadSHA: shaA, State: "merged"}, operatorTrust); !errors.Is(err, ErrForeignPullRequest) {
t.Fatalf("want ErrForeignPullRequest, got %v", err)
}
// The right pull request, but carrying a commit that was never submitted.
if _, err := ReflectSubmission(s, project, id, human.PullRequestState{ID: "142", HeadSHA: shaB, State: "merged"}, operatorTrust); !errors.Is(err, ErrForeignPullRequest) {
t.Fatalf("want ErrForeignPullRequest, got %v", err)
}
if got, _ := s.Task(id); got.State != domain.StateInReview {
t.Fatalf("state = %s, a rejected observation must change nothing", got.State)
}
}
// Closed without merging is an operator question, not a failure.
func TestClosedWithoutMergeAsksTheOperator(t *testing.T) {
s, id, project := submitted(t)
events, err := ReflectSubmission(s, project, id, human.PullRequestState{ID: "142", HeadSHA: shaA, State: "closed"}, operatorTrust)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 {
t.Fatalf("events = %+v", events)
}
got, _ := s.Task(id)
if got.State == domain.StateFailed || got.State == domain.StateCompleted {
t.Fatalf("state = %s, a closed pull request must not decide the task", got.State)
}
if got.BlockReason != domain.BlockReasonOperator {
t.Fatalf("block reason = %q", got.BlockReason)
}
if !strings.Contains(got.Blocker, "closed without merging") {
t.Fatalf("blocker = %q", got.Blocker)
}
// And it does not repeat on the next poll.
if events, err := ReflectSubmission(s, project, id, human.PullRequestState{ID: "142", HeadSHA: shaA, State: "closed"}, operatorTrust); err != nil || len(events) != 0 {
t.Fatalf("repeated: events=%d err=%v", len(events), err)
}
}
// A review with changes_requested and no body still reopens the task.
func TestChangesRequestedReviewWithNoBodyReopens(t *testing.T) {
s, id, project := submitted(t)
after := submittedAt(t, s, id).Add(time.Minute)
state := human.PullRequestState{ID: "142", HeadSHA: shaA, State: "open", Reviews: []human.ReviewObservation{
{Actor: "kami", State: "changes_requested", At: after},
}}
events, err := ReflectSubmission(s, project, id, state, operatorTrust)
if err != nil {
t.Fatal(err)
}
if len(events) != 2 {
t.Fatalf("events = %d", len(events))
}
if got, _ := s.Task(id); got.State != domain.StateQueued {
t.Fatalf("state = %s", got.State)
}
}
// A reflector outage leaves the task exactly as it was.
func TestReflectorOutageChangesNothing(t *testing.T) {
s, id, project := submitted(t)
before, _ := s.Task(id)
beforeEvents := len(s.Events(0))
// A poll that never happened is simply a poll with no observation. The
// caller records its own error; the task must not move.
if _, err := ReflectSubmission(s, project, id, prState("142", shaA, "open"), operatorTrust); err != nil {
t.Fatal(err)
}
after, _ := s.Task(id)
if after.State != before.State || after.Version != before.Version || len(s.Events(0)) != beforeEvents {
t.Fatalf("an empty observation changed state: %s -> %s", before.State, after.State)
}
}
// The full loop: rejected at A, fixed at B, reviewed again, resubmitted to the
// same pull request, then merged.
func TestFullHumanLoopFromRejectionToMerge(t *testing.T) {
s, id, project := submitted(t)
after := submittedAt(t, s, id).Add(time.Minute)
if _, err := ReflectSubmission(s, project, id, prState("142", shaA, "open",
human.Input{Provider: "gitea:p", ExternalID: "c9", Author: "kami", At: after, Body: "rename the variable"},
), operatorTrust); err != nil {
t.Fatal(err)
}
// A fresh gate and a fresh review at the new commit.
if _, err := EnterReview(s, project, id, evidence(shaB)); err != nil {
t.Fatal(err)
}
if _, err := RecordReview(s, project, id, review.Result{ResultSHA: shaB, Findings: []review.Finding{finding("f1", review.Minor)}}); err != nil {
t.Fatal(err)
}
plan, err := PrepareSubmission(s, project, id, shaB, gate(shaB), Notes{})
if err != nil {
t.Fatal(err)
}
pub := &fakePublisher{pr: domain.ExternalRef{Provider: "gitea:p", ID: "142", URL: "https://git/pulls/142"}}
if _, err := ExecuteSubmission(context.Background(), s, plan, pub, head(shaB)); err != nil {
t.Fatal(err)
}
resubmitted, _ := s.Task(id)
if resubmitted.Submission.ResultSHA != shaB || resubmitted.Submission.PR.ID != "142" {
t.Fatalf("submission = %+v, the same pull request must be refreshed", resubmitted.Submission)
}
// Feedback on the old submission cannot reopen the new one.
if _, err := ReflectSubmission(s, project, id, prState("142", shaA, "merged"), operatorTrust); !errors.Is(err, ErrForeignPullRequest) {
t.Fatalf("a stale observation completed a newer submission: %v", err)
}
// The human merges what they reviewed.
if _, err := ReflectSubmission(s, project, id, human.PullRequestState{
ID: "142", HeadSHA: shaB, State: "merged", MergeSHA: "8888888888888888888888888888888888888888", MergedAt: time.Unix(1700009999, 0).UTC(),
}, operatorTrust); err != nil {
t.Fatal(err)
}
final, _ := s.Task(id)
if final.State != domain.StateCompleted {
t.Fatalf("state = %s", final.State)
}
}
+122
View File
@@ -0,0 +1,122 @@
package operations
import (
"encoding/json"
"errors"
"fmt"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/registry"
"orchestra/internal/review"
"orchestra/internal/store"
)
// ErrReviewNotEligible reports that the entry conditions for review are not
// met. It names which one, because "not eligible" alone sends an operator
// reading code.
var ErrReviewNotEligible = errors.New("not eligible for review")
// EnterReview checks the entry conditions and moves the task to the review
// phase, which is what makes the next session a reviewing session.
//
// The conditions exist so a reviewer is never handed an unfinished or
// unanchored change: reviewing a tree that nobody can reproduce produces
// findings nobody can act on.
func EnterReview(s *store.Store, project registry.Project, taskID string, ev review.Evidence) (domain.Event, error) {
t, ok := s.Task(taskID)
if !ok {
return domain.Event{}, domain.ErrNotFound
}
if current(t) != domain.WorkPhaseImplement {
return domain.Event{}, fmt.Errorf("%w: work phase is %s, not implement", ErrReviewNotEligible, current(t))
}
if t.State == domain.StateBlocked || t.State == domain.StateNeedsAttention {
return domain.Event{}, fmt.Errorf("%w: task is %s (%s)", ErrReviewNotEligible, t.State, t.BlockReason)
}
if t.DecisionRequest != nil {
return domain.Event{}, fmt.Errorf("%w: an unresolved human decision is outstanding", ErrReviewNotEligible)
}
if len(ev.ResultSHA) != 40 || len(ev.BaseSHA) != 40 {
return domain.Event{}, fmt.Errorf("%w: base and result commits must both be anchored", ErrReviewNotEligible)
}
if ev.Diff == "" {
return domain.Event{}, fmt.Errorf("%w: there is no diff to review", ErrReviewNotEligible)
}
if ev.GateCommand != "" && ev.GateExit != 0 {
return domain.Event{}, fmt.Errorf("%w: quality gate %q exited %d", ErrReviewNotEligible, ev.GateCommand, ev.GateExit)
}
if project.QualityGate != "" && ev.GateCommand == "" {
return domain.Event{}, fmt.Errorf("%w: project requires the quality gate to have run", ErrReviewNotEligible)
}
return advanceWorkPhase(s, project, taskID, nil, map[string]any{"result_sha": ev.ResultSHA})
}
// RecordReview seals a review against the exact commit it examined, then acts
// on it. Blocking findings return the task to implementation with the findings
// in hand. Minor findings are recorded and left alone.
//
// The reviewing session supplies findings and nothing else. It does not decide
// the phase, and it never edits code.
func RecordReview(s *store.Store, project registry.Project, taskID string, result review.Result) (domain.Event, error) {
if err := result.Validate(); err != nil {
return domain.Event{}, fmt.Errorf("%w: %s", domain.ErrInvalid, err)
}
t, ok := s.Task(taskID)
if !ok {
return domain.Event{}, domain.ErrNotFound
}
if current(t) != domain.WorkPhaseReview {
return domain.Event{}, fmt.Errorf("%w: work phase is %s, not review", domain.ErrInvalid, current(t))
}
// A review of a different commit is not a review of this work. Catching it
// here beats discovering it at completion, when the reviewing session is
// already gone.
if t.ReviewTargetSHA != "" && result.ResultSHA != t.ReviewTargetSHA {
return domain.Event{}, fmt.Errorf("%w: review is for %s but this phase was entered against %s", domain.ErrInvalid, result.ResultSHA, t.ReviewTargetSHA)
}
sealed, err := review.Encode(result)
if err != nil {
return domain.Event{}, err
}
ref, err := s.PutArtifact(sealed)
if err != nil {
return domain.Event{}, err
}
blocking := len(result.Blocking())
b, err := json.Marshal(map[string]any{
"artifact_ref": ref, "result_sha": result.ResultSHA, "blocking": blocking,
})
if err != nil {
return domain.Event{}, err
}
e := domain.Event{ID: domain.NewID(), Type: domain.EventReviewRecorded, TaskID: taskID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
if err := s.Append(e); err != nil {
return domain.Event{}, err
}
if blocking == 0 {
return e, nil
}
// Back to implementation, with the findings as the reason.
if _, err := AdvanceWorkPhase(s, project, taskID, nil); err != nil {
return e, err
}
return e, nil
}
// TaskReview loads the sealed findings for a task, for the implementation
// context that has to act on them.
func TaskReview(s *store.Store, t domain.Task) (*review.Result, error) {
if t.Review == nil {
return nil, nil
}
b, err := s.Artifact(t.Review.ArtifactRef)
if err != nil {
return nil, err
}
r, err := review.Decode(b)
if err != nil {
return nil, err
}
return &r, nil
}
+218
View File
@@ -0,0 +1,218 @@
package operations
import (
"errors"
"strings"
"testing"
"orchestra/internal/domain"
"orchestra/internal/registry"
"orchestra/internal/review"
"orchestra/internal/store"
)
const shaA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
const shaB = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
const shaBase = "0000000000000000000000000000000000000000"
func evidence(result string) review.Evidence {
return review.Evidence{
BaseSHA: shaBase, ResultSHA: result,
Diff: "--- a/internal/attr/attr.go\n+++ b/internal/attr/attr.go\n+index lookup\n",
GateCommand: "go test ./...", GateExit: 0,
}
}
// atImplement walks a task to the implementation phase with both artifacts
// sealed, which is where review becomes possible.
func atImplement(t *testing.T, project registry.Project) (*store.Store, string) {
t.Helper()
s, id := phaseStore(t)
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
t.Fatal(err)
}
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
t.Fatal(err)
}
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, plan)); err != nil {
t.Fatal(err)
}
return s, id
}
func finding(id string, sev review.Severity) review.Finding {
return review.Finding{
ID: id, Severity: sev, File: "internal/attr/attr.go", Line: 81,
Claim: "retry path acknowledges success before the durable append", Evidence: "line 81 returns before Append",
}
}
// Minor findings are reported and the task stays eligible. The review is bound
// to the commit it examined.
func TestMinorOnlyReviewIsAccepted(t *testing.T) {
project := registry.Project{ID: "p", QualityGate: "go test ./..."}
s, id := atImplement(t, project)
if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil {
t.Fatal(err)
}
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseReview {
t.Fatalf("phase = %q", got.WorkPhase)
}
if _, err := RecordReview(s, project, id, review.Result{ResultSHA: shaA, Findings: []review.Finding{finding("f1", review.Minor)}}); err != nil {
t.Fatal(err)
}
got, _ := s.Task(id)
if got.WorkPhase != domain.WorkPhaseReview {
t.Fatalf("a minor-only review must not send work back: %q", got.WorkPhase)
}
if !got.ReviewSatisfied(shaA) {
t.Fatalf("review not satisfied for its own commit: %+v", got.Review)
}
// The same review says nothing about a different tree.
if got.ReviewSatisfied(shaB) {
t.Fatal("a review of one commit must not satisfy another")
}
}
// A blocking finding returns the task to implementation, and the old review
// cannot satisfy the new commit.
func TestBlockingReviewReturnsWorkAndGoesStale(t *testing.T) {
project := registry.Project{ID: "p", QualityGate: "go test ./..."}
s, id := atImplement(t, project)
if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil {
t.Fatal(err)
}
if _, err := RecordReview(s, project, id, review.Result{ResultSHA: shaA, Findings: []review.Finding{
finding("f1", review.Important), finding("f2", review.Minor),
}}); err != nil {
t.Fatal(err)
}
got, _ := s.Task(id)
if got.WorkPhase != domain.WorkPhaseImplement {
t.Fatalf("phase = %q, want implement", got.WorkPhase)
}
if got.ReviewSatisfied(shaA) {
t.Fatal("a review with an important finding must not satisfy completion")
}
// The findings are readable for the implementation context.
r, err := TaskReview(s, got)
if err != nil || r == nil || len(r.Findings) != 2 {
t.Fatalf("findings = %+v err=%v", r, err)
}
// Fixed at a new commit: a fresh review passes, and it is bound to B.
if _, err := EnterReview(s, project, id, evidence(shaB)); err != nil {
t.Fatal(err)
}
if _, err := RecordReview(s, project, id, review.Result{ResultSHA: shaB}); err != nil {
t.Fatal(err)
}
got, _ = s.Task(id)
if !got.ReviewSatisfied(shaB) {
t.Fatalf("fresh review not satisfied: %+v", got.Review)
}
if got.ReviewSatisfied(shaA) {
t.Fatal("the superseded commit must not look reviewed")
}
}
// A review sealed against a commit other than the one under review is
// rejected while the reviewing session still exists to redo it.
func TestReviewForTheWrongCommitIsRejected(t *testing.T) {
project := registry.Project{ID: "p", QualityGate: "go test ./..."}
s, id := atImplement(t, project)
if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil {
t.Fatal(err)
}
if got, _ := s.Task(id); got.ReviewTargetSHA != shaA {
t.Fatalf("review target = %q", got.ReviewTargetSHA)
}
if _, err := RecordReview(s, project, id, review.Result{ResultSHA: shaB}); !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("want ErrInvalid, got %v", err)
}
if got, _ := s.Task(id); got.Review != nil {
t.Fatalf("a mismatched review was recorded: %+v", got.Review)
}
if _, err := RecordReview(s, project, id, review.Result{ResultSHA: shaA}); err != nil {
t.Fatal(err)
}
}
func TestReviewEntryConditions(t *testing.T) {
project := registry.Project{ID: "p", QualityGate: "go test ./..."}
// Wrong phase.
s, id := phaseStore(t)
if _, err := EnterReview(s, project, id, evidence(shaA)); !errors.Is(err, ErrReviewNotEligible) {
t.Fatalf("frame phase: want ErrReviewNotEligible, got %v", err)
}
// Failing gate, unanchored commits, empty diff, and a gate that never ran.
s, id = atImplement(t, project)
bad := map[string]review.Evidence{
"failing gate": func() review.Evidence { e := evidence(shaA); e.GateExit = 1; return e }(),
"no result": func() review.Evidence { e := evidence(shaA); e.ResultSHA = "short"; return e }(),
"no base": func() review.Evidence { e := evidence(shaA); e.BaseSHA = ""; return e }(),
"no diff": func() review.Evidence { e := evidence(shaA); e.Diff = ""; return e }(),
"gate skipped": func() review.Evidence { e := evidence(shaA); e.GateCommand = ""; return e }(),
}
for name, ev := range bad {
if _, err := EnterReview(s, project, id, ev); !errors.Is(err, ErrReviewNotEligible) {
t.Fatalf("%s: want ErrReviewNotEligible, got %v", name, err)
}
}
// An unresolved question blocks entry.
lease(t, s, id)
if _, err := RequestHumanDecision(s, project, id, request("which behaviour is intended?")); err != nil {
t.Fatal(err)
}
if _, err := EnterReview(s, project, id, evidence(shaA)); !errors.Is(err, ErrReviewNotEligible) {
t.Fatalf("blocked task: want ErrReviewNotEligible, got %v", err)
}
}
// A review can only be sealed by a reviewing session, and only in a shape that
// is actually reviewable.
func TestReviewResultRejections(t *testing.T) {
project := registry.Project{ID: "p", QualityGate: "go test ./..."}
s, id := atImplement(t, project)
// Not in the review phase yet.
if _, err := RecordReview(s, project, id, review.Result{ResultSHA: shaA}); !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("want ErrInvalid, got %v", err)
}
if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil {
t.Fatal(err)
}
long := strings.Repeat("x", 501)
bad := map[string]review.Result{
"no sha": {Findings: []review.Finding{finding("f1", review.Minor)}},
"short sha": {ResultSHA: "abc"},
"no id": {ResultSHA: shaA, Findings: []review.Finding{{Severity: review.Minor, File: "a.go", Claim: "c", Evidence: "e"}}},
"duplicate id": {ResultSHA: shaA, Findings: []review.Finding{finding("f1", review.Minor), finding("f1", review.Blocker)}},
"bad severity": {ResultSHA: shaA, Findings: []review.Finding{{ID: "f", Severity: "invalid", File: "a.go", Claim: "c", Evidence: "e"}}},
"absolute path": {ResultSHA: shaA, Findings: []review.Finding{{ID: "f", Severity: review.Minor, File: "/etc/passwd", Claim: "c", Evidence: "e"}}},
"no evidence": {ResultSHA: shaA, Findings: []review.Finding{{ID: "f", Severity: review.Minor, File: "a.go", Claim: "c"}}},
"essay": {ResultSHA: shaA, Findings: []review.Finding{{ID: "f", Severity: review.Minor, File: "a.go", Claim: long, Evidence: "e"}}},
"multiline": {ResultSHA: shaA, Findings: []review.Finding{{ID: "f", Severity: review.Minor, File: "a.go", Claim: "one\ntwo", Evidence: "e"}}},
}
for name, result := range bad {
if _, err := RecordReview(s, project, id, result); !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("%s: want ErrInvalid, got %v", name, err)
}
}
// A rejected review left no trace.
if got, _ := s.Task(id); got.Review != nil {
t.Fatalf("a rejected review was recorded: %+v", got.Review)
}
// Too many findings is also a rejection.
flood := review.Result{ResultSHA: shaA}
for i := 0; i < 41; i++ {
flood.Findings = append(flood.Findings, finding(string(rune('a'+i%26))+strings.Repeat("z", i), review.Minor))
}
if _, err := RecordReview(s, project, id, flood); !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("want ErrInvalid, got %v", err)
}
}
+324
View File
@@ -0,0 +1,324 @@
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))
}
}
+329
View File
@@ -0,0 +1,329 @@
package operations
import (
"context"
"errors"
"strings"
"testing"
"orchestra/internal/domain"
"orchestra/internal/registry"
"orchestra/internal/review"
"orchestra/internal/store"
)
type fakePublisher struct {
pushes int
prCalls int
remoteSHA string
pushErr error
prErr error
pr domain.ExternalRef
lastBody string
}
func (f *fakePublisher) Push(_ context.Context, _, _, sha string) (string, error) {
f.pushes++
if f.pushErr != nil {
return "", f.pushErr
}
if f.remoteSHA != "" {
return f.remoteSHA, nil
}
return sha, nil
}
func (f *fakePublisher) EnsurePR(_ context.Context, plan SubmissionPlan) (domain.ExternalRef, error) {
f.prCalls++
f.lastBody = plan.PRBody
if f.prErr != nil {
return domain.ExternalRef{}, f.prErr
}
if f.pr.ID == "" {
f.pr = domain.ExternalRef{Provider: "gitea:p", ID: "142", URL: "https://git/pulls/142"}
}
return f.pr, nil
}
func gate(sha string) domain.GateResult {
return domain.GateResult{Command: "go test ./...", ExitCode: 0, SHA: sha, Output: "ok"}
}
// reviewed walks a task to a reviewed state at one commit.
func reviewed(t *testing.T, findings ...review.Finding) (*store.Store, string, registry.Project) {
t.Helper()
project := registry.Project{ID: "p", QualityGate: "go test ./..."}
s, id := atImplement(t, project)
if _, err := EnterReview(s, project, id, evidence(shaA)); err != nil {
t.Fatal(err)
}
if _, err := RecordReview(s, project, id, review.Result{ResultSHA: shaA, Findings: findings}); err != nil {
t.Fatal(err)
}
return s, id, project
}
func head(sha string) HeadResolver {
return func(context.Context) (string, error) { return sha, nil }
}
func TestSubmissionEligibility(t *testing.T) {
// Reviewed A, head A, gate A: allowed.
s, id, project := reviewed(t)
task, _ := s.Task(id)
if check := domain.CheckSubmission(task, shaA, gate(shaA)); !check.Eligible {
t.Fatalf("want eligible, got %v", check.Reasons)
}
// The three shas must agree.
cases := map[string]struct {
head string
g domain.GateResult
}{
"head moved past the review": {shaB, gate(shaB)},
"gate ran on another commit": {shaB, gate(shaA)},
"failing gate": {shaA, domain.GateResult{Command: "go test ./...", ExitCode: 1, SHA: shaA}},
"unanchored head": {"short", gate("short")},
}
for name, c := range cases {
if check := domain.CheckSubmission(task, c.head, c.g); check.Eligible {
t.Fatalf("%s: want refusal", name)
}
}
if _, err := PrepareSubmission(s, project, id, shaB, gate(shaB), Notes{}); !errors.Is(err, ErrNotSubmittable) {
t.Fatalf("want ErrNotSubmittable, got %v", err)
}
// Minor findings do not block. Important findings do.
s, id, project = reviewed(t, finding("f1", review.Minor))
task, _ = s.Task(id)
if check := domain.CheckSubmission(task, shaA, gate(shaA)); !check.Eligible {
t.Fatalf("minor-only: want eligible, got %v", check.Reasons)
}
s, id, project = reviewed(t, finding("f1", review.Important))
task, _ = s.Task(id)
check := domain.CheckSubmission(task, shaA, gate(shaA))
if check.Eligible {
t.Fatal("an important finding must refuse submission")
}
if !strings.Contains(strings.Join(check.Reasons, " "), "unresolved blocker or important") {
t.Fatalf("reasons = %v", check.Reasons)
}
// A pending human decision refuses.
s, id, project = reviewed(t)
lease(t, s, id)
if _, err := RequestHumanDecision(s, project, id, request("which behaviour?")); err != nil {
t.Fatal(err)
}
task, _ = s.Task(id)
if domain.CheckSubmission(task, shaA, gate(shaA)).Eligible {
t.Fatal("an outstanding question must refuse submission")
}
// A project whose path includes plan but sealed none refuses.
bare, bareID := phaseStore(t)
if got, _ := bare.Task(bareID); len(got.RequirePhaseArtifacts(project.Phases())) != 2 {
t.Fatal("missing research and plan should both be reported")
}
}
func TestSubmissionRecordsExactIdentityAndIsIdempotent(t *testing.T) {
s, id, project := reviewed(t, finding("f1", review.Minor))
pub := &fakePublisher{}
plan, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{
BehaviouralChanges: []string{"lookups now use the index"},
Risks: []string{"index rebuild on first boot"},
Hotspots: []string{"internal/attr/attr.go:81-140 concurrency semantics changed"},
})
if err != nil {
t.Fatal(err)
}
e, err := ExecuteSubmission(context.Background(), s, plan, pub, head(shaA))
if err != nil {
t.Fatal(err)
}
if e.Type != domain.EventTaskSubmitted {
t.Fatalf("event = %+v", e)
}
got, _ := s.Task(id)
if got.State != domain.StateInReview {
t.Fatalf("state = %s, want in_review", got.State)
}
if got.Submission == nil {
t.Fatal("no submission recorded")
}
if got.Submission.ResultSHA != shaA || got.Submission.PR.ID != "142" || got.Submission.ReviewRef == "" || got.Submission.GateRef == "" {
t.Fatalf("submission = %+v", got.Submission)
}
if got.Submission.RemoteRef != "origin/orchestra/"+id {
t.Fatalf("remote ref = %q", got.Submission.RemoteRef)
}
// Submission is not completion.
if got.State == domain.StateCompleted {
t.Fatal("submission must not complete the task")
}
// The packet is derived, and labels the agent's account as unverified.
packet, err := s.Artifact(got.Submission.PacketRef)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"## Goal", "## Acceptance", "## Verification", "`go test ./...` exited 0",
"independent review of " + shaA, "minor findings, not fixed: 1",
"lookups now use the index", "index rebuild on first boot",
"internal/attr/attr.go:81-140", "are not verified",
} {
if !strings.Contains(string(packet), want) {
t.Fatalf("packet missing %q:\n%s", want, packet)
}
}
// Running it again with unchanged state is the same submission.
plan2, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{})
if err != nil {
t.Fatalf("a repeated submission must not be refused: %v", err)
}
if plan2.Existing == nil || plan2.Existing.PR.ID != "142" {
t.Fatalf("the existing submission was not carried into the plan: %+v", plan2.Existing)
}
e2, err := ExecuteSubmission(context.Background(), s, plan2, pub, head(shaA))
if err != nil {
t.Fatal(err)
}
if e2.ID != "" {
t.Fatal("a second TaskSubmitted was appended for the same commit")
}
if pub.prCalls != 2 || pub.pr.ID != "142" {
t.Fatalf("pr calls=%d id=%s: a retry must refresh one pull request", pub.prCalls, pub.pr.ID)
}
}
// A forge failure after a successful push must stay retryable.
func TestPRFailureLeavesTaskRetryable(t *testing.T) {
s, id, project := reviewed(t)
pub := &fakePublisher{prErr: errors.New("gitea 502")}
plan, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{})
if err != nil {
t.Fatal(err)
}
if _, err := ExecuteSubmission(context.Background(), s, plan, pub, head(shaA)); err == nil {
t.Fatal("expected the forge failure to surface")
}
got, _ := s.Task(id)
if got.State == domain.StateFailed || got.State == domain.StateInReview {
t.Fatalf("a transport failure changed the lifecycle: %s", got.State)
}
if got.Submission != nil {
t.Fatal("a failed submission was recorded")
}
// The retry re-pushes, verifies, and succeeds.
pub.prErr = nil
plan, err = PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{})
if err != nil {
t.Fatal(err)
}
if _, err := ExecuteSubmission(context.Background(), s, plan, pub, head(shaA)); err != nil {
t.Fatal(err)
}
if got, _ := s.Task(id); got.Submission == nil || got.Submission.ResultSHA != shaA {
t.Fatalf("retry did not record the submission")
}
if pub.pushes != 2 {
t.Fatalf("pushes = %d, the retry must re-verify the remote", pub.pushes)
}
}
// The remote holding a different commit is a hard refusal.
func TestRemoteMismatchRecordsNothing(t *testing.T) {
s, id, project := reviewed(t)
pub := &fakePublisher{remoteSHA: shaB}
plan, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{})
if err != nil {
t.Fatal(err)
}
if _, err := ExecuteSubmission(context.Background(), s, plan, pub, head(shaA)); !errors.Is(err, ErrRemoteMismatch) {
t.Fatalf("want ErrRemoteMismatch, got %v", err)
}
if pub.prCalls != 0 {
t.Fatal("a pull request was opened for an unverified push")
}
if got, _ := s.Task(id); got.Submission != nil {
t.Fatal("a mismatched submission was recorded")
}
}
// The commit is re-read immediately before the push and again before the
// event, so a tree that moves mid-submission cannot be submitted.
func TestHeadMovingDuringSubmissionRefuses(t *testing.T) {
s, id, project := reviewed(t)
plan, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{})
if err != nil {
t.Fatal(err)
}
pub := &fakePublisher{}
if _, err := ExecuteSubmission(context.Background(), s, plan, pub, head(shaB)); !errors.Is(err, ErrNotSubmittable) {
t.Fatalf("want refusal before push, got %v", err)
}
if pub.pushes != 0 {
t.Fatal("pushed a commit that was no longer head")
}
// Moves after the push, before the record.
calls := 0
moving := func(context.Context) (string, error) {
calls++
if calls == 1 {
return shaA, nil
}
return shaB, nil
}
if _, err := ExecuteSubmission(context.Background(), s, plan, pub, moving); !errors.Is(err, ErrNotSubmittable) {
t.Fatalf("want refusal before recording, got %v", err)
}
if got, _ := s.Task(id); got.Submission != nil {
t.Fatal("recorded a submission for a stale commit")
}
}
// A change after submission means the recorded submission no longer represents
// the current head.
func TestLaterChangeInvalidatesTheSubmission(t *testing.T) {
s, id, project := reviewed(t)
plan, err := PrepareSubmission(s, project, id, shaA, gate(shaA), Notes{})
if err != nil {
t.Fatal(err)
}
if _, err := ExecuteSubmission(context.Background(), s, plan, &fakePublisher{}, head(shaA)); err != nil {
t.Fatal(err)
}
got, _ := s.Task(id)
if !got.Submitted(shaA) {
t.Fatal("submission missing for its own commit")
}
if got.Submitted(shaB) {
t.Fatal("a submission of one commit must not cover another")
}
if domain.CheckSubmission(got, shaB, gate(shaB)).Eligible {
t.Fatal("a new commit must not inherit the old review and submission")
}
}
func TestNotesAreBounded(t *testing.T) {
s, id, project := reviewed(t)
long := strings.Repeat("x", 501)
bad := []Notes{
{Risks: []string{""}},
{Risks: []string{long}},
{Risks: []string{"one\ntwo"}},
{Hotspots: make([]string, 13)},
}
for i, n := range bad {
if _, err := PrepareSubmission(s, project, id, shaA, gate(shaA), n); !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("notes %d: want ErrInvalid, got %v", i, err)
}
}
}
+220
View File
@@ -0,0 +1,220 @@
package operations
import (
"encoding/json"
"errors"
"fmt"
"strings"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/store"
"orchestra/internal/workphase"
)
// ErrTrajectoryGate reports that a phase change stopped for human
// confirmation. It is not a fault: the work so far is sealed and valid, and
// the human now decides whether the direction is right.
var ErrTrajectoryGate = errors.New("trajectory gate: waiting for the human to confirm the direction")
// maxPacketBytes bounds the gate packet. It travels in the TaskBlocked
// blocker field, which is what notification surfaces already deliver, so the
// human reads the packet where they already read blockers.
const maxPacketBytes = 4000
// trajectoryGateOpen reports whether the human has answered the most recent
// gate for this task.
//
// The rule is positional rather than a flag: a decision recorded after the
// gate was raised is the answer to it. That needs no new task field and
// cannot drift out of sync with the log, and it accepts any wording, which
// matters because an imported comment carries no gate-specific subject.
func trajectoryGateOpen(s *store.Store, taskID string) bool {
return blockerAnswered(s, taskID, domain.BlockReasonTrajectoryGate)
}
// blockerAnswered reports whether the human has replied since the most recent
// block of this reason.
//
// The rule is positional on purpose. Deciding whether a reply semantically
// answers the question would mean parsing intent, and a wrong parse either
// strands a task the human already answered or resumes one they did not. The
// agent receives both the question and the reply and can see for itself.
func blockerAnswered(s *store.Store, taskID string, reason domain.BlockReason) bool {
var blockSeq, decisionSeq uint64
for _, e := range s.Events(0) {
if e.TaskID != taskID {
continue
}
switch e.Type {
case "TaskBlocked":
var p struct {
BlockReason string `json:"block_reason"`
}
if json.Unmarshal(e.Payload, &p) == nil && p.BlockReason == string(reason) {
blockSeq = e.Seq
}
case domain.EventHumanDecisionRecorded:
decisionSeq = e.Seq
}
}
return blockSeq > 0 && decisionSeq > blockSeq
}
// raiseTrajectoryGate blocks the task and hands the human the packet.
func raiseTrajectoryGate(s *store.Store, t domain.Task, from, to domain.WorkPhase, proposal []byte) error {
packet, err := TrajectoryGatePacket(s, t, from, to, proposal)
if err != nil {
return err
}
b, err := json.Marshal(map[string]any{
"blocker": packet,
"block_reason": string(domain.BlockReasonTrajectoryGate),
"lifecycle_phase": "awaiting_human",
})
if err != nil {
return err
}
e := domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
if err := s.Append(e); err != nil {
return err
}
return fmt.Errorf("%w (task %s, %s to %s)", ErrTrajectoryGate, t.ID, from, to)
}
// TrajectoryGatePacket renders the human's decision packet from state that
// already exists. It is human-facing, unlike agentctx, and deliberately
// carries no transcript: the human is confirming a direction, not auditing a
// session.
// proposal, when set, is the artifact the finishing phase produced but has
// not sealed yet, which is exactly what the human is being asked about.
func TrajectoryGatePacket(s *store.Store, t domain.Task, from, to domain.WorkPhase, proposal []byte) (string, error) {
intent, err := s.EffectiveIntent(t.ID)
if err != nil {
return "", err
}
var b strings.Builder
fmt.Fprintf(&b, "Trajectory gate: %s to %s needs your confirmation.\n", from, to)
fmt.Fprintf(&b, "\nGoal: %s\n", oneLine(firstNonEmpty(t.Title, t.Description, "not stated")))
if len(t.Acceptance) > 0 {
b.WriteString("\nAcceptance:\n")
for _, a := range t.Acceptance {
fmt.Fprintf(&b, "- %s\n", oneLine(a))
}
}
if t.ResearchRef != "" {
if raw, err := s.Artifact(t.ResearchRef); err == nil {
if r, err := workphase.DecodeResearch(raw); err == nil {
b.WriteString("\nWhat research established:\n")
for _, f := range r.Findings {
fmt.Fprintf(&b, "- %s (%s)\n", oneLine(f.Claim), oneLine(f.Evidence))
}
for _, u := range r.Unknowns {
fmt.Fprintf(&b, "- still unknown: %s\n", oneLine(u))
}
}
}
}
planned := proposal
if len(planned) == 0 && t.PlanRef != "" {
if raw, err := s.Artifact(t.PlanRef); err == nil {
planned = raw
}
}
if len(planned) > 0 {
{
if p, err := workphase.DecodePlan(planned); err == nil {
b.WriteString("\nProposed changes:\n")
for _, c := range p.Changes {
fmt.Fprintf(&b, "- %s: %s\n", oneLine(c.Target), oneLine(c.Intent))
}
if len(p.Verification) > 0 {
b.WriteString("\nVerification:\n")
for _, v := range p.Verification {
fmt.Fprintf(&b, "- %s\n", oneLine(v))
}
}
if len(p.Risks) > 0 {
b.WriteString("\nRisks:\n")
for _, r := range p.Risks {
fmt.Fprintf(&b, "- %s\n", oneLine(r))
}
}
if len(p.DecisionsNeeded) > 0 {
b.WriteString("\nOpen decisions for you:\n")
for _, d := range p.DecisionsNeeded {
fmt.Fprintf(&b, "- %s\n", oneLine(d))
}
}
}
}
}
if len(intent.Decisions) > 0 {
b.WriteString("\nYour decisions so far:\n")
for _, d := range intent.Decisions {
fmt.Fprintf(&b, "- %s (%s): %s\n", d.Kind, d.Subject, oneLine(d.Value))
}
}
b.WriteString("\nReply to confirm or correct the direction. Your reply becomes a recorded decision and outranks the plan above.\n")
out := b.String()
if len(out) > maxPacketBytes {
out = out[:maxPacketBytes] + "\n(truncated)\n"
}
return out, nil
}
func oneLine(s string) string {
return strings.Join(strings.Fields(strings.ReplaceAll(s, "\n", " ")), " ")
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// clearTrajectoryGate returns a gated task to the queue once the human has
// answered. The compensating TaskCorrected names the block it reverses, which
// is the §3.1 rule: a wrong or superseded event is never edited.
func clearTrajectoryGate(s *store.Store, t domain.Task) (domain.Task, error) {
return clearBlocker(s, t, domain.BlockReasonTrajectoryGate, "gate_cleared")
}
// clearBlocker returns an answered task to the queue. The compensating
// TaskCorrected names the block it reverses, per §3.1: a superseded event is
// never edited.
func clearBlocker(s *store.Store, t domain.Task, reason domain.BlockReason, phase string) (domain.Task, error) {
var gate domain.Event
for _, e := range s.Events(0) {
if e.TaskID != t.ID || e.Type != "TaskBlocked" {
continue
}
var p struct {
BlockReason string `json:"block_reason"`
}
if json.Unmarshal(e.Payload, &p) == nil && p.BlockReason == string(reason) {
gate = e
}
}
if gate.ID == "" {
return t, fmt.Errorf("%w: no %s blocker to clear on task %s", domain.ErrInvalid, reason, t.ID)
}
b, err := json.Marshal(map[string]any{
"corrects": gate.ID, "state": string(domain.StateQueued),
"lifecycle_phase": phase,
})
if err != nil {
return t, err
}
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCorrected", TaskID: t.ID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}); err != nil {
return t, err
}
updated, ok := s.Task(t.ID)
if !ok {
return t, domain.ErrNotFound
}
return updated, nil
}
+147
View File
@@ -0,0 +1,147 @@
package operations
import (
"encoding/json"
"errors"
"strings"
"testing"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/registry"
"orchestra/internal/store"
"orchestra/internal/workphase"
)
func gatedProject() registry.Project {
return registry.Project{ID: "p", TrajectoryGate: map[string]string{"plan_to_implement": "required"}}
}
func humanReply(t *testing.T, s *store.Store, taskID, id, value string) {
t.Helper()
task, _ := s.Task(taskID)
if err := s.Append(domain.Event{
ID: domain.NewID(), Type: domain.EventHumanDecisionRecorded, TaskID: taskID,
Version: task.Version + 1, Surface: string(authz.System),
Payload: mustJSONBytes(t, map[string]any{
"decision_id": id, "kind": "correction", "subject": "operator_instruction", "value": value,
"source": map[string]any{"provider": "gitea", "external_id": "c-" + id},
}),
}); err != nil {
t.Fatal(err)
}
}
func mustJSONBytes(t *testing.T, v any) []byte {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
return b
}
// The gate stops plan to implement, hands the human a packet built from state
// that already exists, and lets the work through once they answer.
func TestTrajectoryGateBlocksThenClears(t *testing.T) {
s, id := phaseStore(t)
project := gatedProject()
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
t.Fatal(err)
}
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
t.Fatal(err)
}
// plan to implement is gated.
proposal := sealed(t, workphase.Plan{
Changes: []workphase.Change{{Target: "internal/attr/attr.go", Intent: "add the cache"}},
Verification: []string{"go test ./internal/attr/"},
Risks: []string{"cache invalidation on rename"},
})
_, err := AdvanceWorkPhase(s, project, id, proposal)
if !errors.Is(err, ErrTrajectoryGate) {
t.Fatalf("want ErrTrajectoryGate, got %v", err)
}
blocked, _ := s.Task(id)
if blocked.State != domain.StateBlocked || blocked.BlockReason != domain.BlockReasonTrajectoryGate {
t.Fatalf("task = %+v", blocked)
}
if blocked.WorkPhase != domain.WorkPhasePlan {
t.Fatalf("phase moved before the human answered: %q", blocked.WorkPhase)
}
// The packet carries the proposal that is not sealed yet, plus the
// research it came from.
for _, want := range []string{
"Trajectory gate: plan to implement",
"add the cache",
"go test ./internal/attr/",
"cache invalidation on rename",
"runs per figure",
} {
if !strings.Contains(blocked.Blocker, want) {
t.Fatalf("packet missing %q:\n%s", want, blocked.Blocker)
}
}
// Asking again while waiting must not re-raise the gate.
before := len(s.Events(0))
if _, err := AdvanceWorkPhase(s, project, id, proposal); !errors.Is(err, ErrTrajectoryGate) {
t.Fatalf("want ErrTrajectoryGate, got %v", err)
}
if len(s.Events(0)) != before {
t.Fatal("a second gate event was appended while waiting")
}
// The human answers. Any wording counts: an imported comment carries no
// gate-specific subject.
humanReply(t, s, id, "d1", "keep the per-person aggregation, but do not add the cache, add the index")
if _, err := AdvanceWorkPhase(s, project, id, proposal); err != nil {
t.Fatal(err)
}
got, _ := s.Task(id)
if got.State != domain.StateLeased && got.State != domain.StateQueued {
t.Fatalf("state = %s, want queued after the gate cleared", got.State)
}
if got.WorkPhase != domain.WorkPhaseImplement {
t.Fatalf("phase = %q", got.WorkPhase)
}
if got.PlanRef == "" {
t.Fatal("the plan was not sealed once the gate cleared")
}
}
// An ungated project never stops.
func TestUngatedProjectAdvances(t *testing.T) {
s, id := phaseStore(t)
project := registry.Project{ID: "p"}
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
t.Fatal(err)
}
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
t.Fatal(err)
}
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, plan)); err != nil {
t.Fatal(err)
}
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseImplement {
t.Fatalf("phase = %q", got.WorkPhase)
}
}
// A decision recorded before the gate was raised is not an answer to it.
func TestOlderDecisionDoesNotOpenTheGate(t *testing.T) {
s, id := phaseStore(t)
project := gatedProject()
humanReply(t, s, id, "d0", "an earlier instruction")
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
t.Fatal(err)
}
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
t.Fatal(err)
}
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, plan)); !errors.Is(err, ErrTrajectoryGate) {
t.Fatalf("want ErrTrajectoryGate, got %v", err)
}
}
+96
View File
@@ -0,0 +1,96 @@
package operations
import (
"encoding/json"
"fmt"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/registry"
"orchestra/internal/store"
"orchestra/internal/workphase"
)
// AdvanceWorkPhase moves a task to the next phase on its project's declared
// path and seals the artifact the phase produced.
//
// Only Orchestra changes phase. An agent that believes the phase should
// change says so through the approval surface, and this is what acts on that
// belief. The artifact is validated before the transition is recorded, so a
// phase can never be left with an artifact the next phase cannot read.
//
// Review is the end of the path. Its only move is back to implement, because
// a review that passes ends the task through the lifecycle, not the phase.
func AdvanceWorkPhase(s *store.Store, project registry.Project, taskID string, artifact []byte) (domain.Event, error) {
return advanceWorkPhase(s, project, taskID, artifact, nil)
}
// advanceWorkPhase carries extra payload fields a specific transition needs,
// such as the commit a review phase is entered against.
func advanceWorkPhase(s *store.Store, project registry.Project, taskID string, artifact []byte, extra map[string]any) (domain.Event, error) {
t, ok := s.Task(taskID)
if !ok {
return domain.Event{}, domain.ErrNotFound
}
next, ok := project.NextPhase(t.WorkPhase)
if !ok {
return domain.Event{}, fmt.Errorf("%w: work phase %q is the end of project %s's path", domain.ErrInvalid, current(t), project.ID)
}
// The gate sits between the sealed artifact and the next phase, so the
// human confirms a direction that is already written down.
if project.GateRequired(current(t), next) {
switch {
case trajectoryGateOpen(s, taskID):
cleared, err := clearTrajectoryGate(s, t)
if err != nil {
return domain.Event{}, err
}
t = cleared
case t.State == domain.StateBlocked && t.BlockReason == domain.BlockReasonTrajectoryGate:
// Already waiting. Re-raising would spam the human and reset the
// position the open check depends on.
return domain.Event{}, fmt.Errorf("%w (task %s, %s to %s)", ErrTrajectoryGate, taskID, current(t), next)
default:
// The artifact is not sealed yet, so the packet reads the proposal
// from the bytes in hand. The caller retries this same advance with
// the same artifact once the human has answered.
return domain.Event{}, raiseTrajectoryGate(s, t, current(t), next, artifact)
}
}
payload := map[string]any{"phase": string(next), "from": string(current(t))}
for k, v := range extra {
payload[k] = v
}
if len(artifact) > 0 {
// Validate against the phase being left, which is the phase that
// produced this artifact.
switch current(t) {
case domain.WorkPhaseResearch:
if _, err := workphase.DecodeResearch(artifact); err != nil {
return domain.Event{}, err
}
case domain.WorkPhasePlan:
if _, err := workphase.DecodePlan(artifact); err != nil {
return domain.Event{}, err
}
}
ref, err := s.PutArtifact(artifact)
if err != nil {
return domain.Event{}, err
}
payload["artifact_ref"] = ref
}
b, err := json.Marshal(payload)
if err != nil {
return domain.Event{}, err
}
e := domain.Event{ID: domain.NewID(), Type: domain.EventWorkPhaseChanged, TaskID: taskID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
return e, s.Append(e)
}
func current(t domain.Task) domain.WorkPhase {
if t.WorkPhase == "" {
return domain.WorkPhaseFrame
}
return t.WorkPhase
}
+165
View File
@@ -0,0 +1,165 @@
package operations
import (
"encoding/json"
"errors"
"testing"
"time"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/registry"
"orchestra/internal/store"
"orchestra/internal/workphase"
)
func phaseStore(t *testing.T) (*store.Store, string) {
t.Helper()
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(map[string]any{"source": "gitea", "external_id": "381", "project": "p"})
id := domain.NewID()
if err := s.Append(domain.Event{ID: domain.NewID(), Type: "TaskCreated", TaskID: id, Version: 1, Payload: b, Surface: string(authz.System)}); err != nil {
t.Fatal(err)
}
return s, id
}
// lease gives the task an owning session. A question or a phase change comes
// from a live session, so a test that skips the lease is exercising a state no
// agent can be in.
func lease(t *testing.T, s *store.Store, id string) {
t.Helper()
if _, err := s.Lease(id, "h1", time.Hour); err != nil {
t.Fatal(err)
}
}
func sealed(t *testing.T, v interface{ Validate() error }) []byte {
t.Helper()
b, err := workphase.Encode(v)
if err != nil {
t.Fatal(err)
}
return b
}
var research = workphase.Research{Findings: []workphase.Finding{{Claim: "runs per figure", Evidence: "attr.go:88"}}}
var plan = workphase.Plan{Changes: []workphase.Change{{Target: "attr.go", Intent: "aggregate per person"}}}
func TestFullPhasePathSealsEachArtifact(t *testing.T) {
s, id := phaseStore(t)
project := registry.Project{ID: "p"}
// frame -> research needs no artifact: framing produces none.
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
t.Fatal(err)
}
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseResearch {
t.Fatalf("phase = %q", got.WorkPhase)
}
// research -> plan must seal the research.
if _, err := AdvanceWorkPhase(s, project, id, nil); !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("leaving research without an artifact must fail, got %v", err)
}
if _, err := AdvanceWorkPhase(s, project, id, []byte(`{"findings":[]}`)); err == nil {
t.Fatal("an invalid research artifact must be rejected")
}
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, research)); err != nil {
t.Fatal(err)
}
got, _ := s.Task(id)
if got.WorkPhase != domain.WorkPhasePlan || got.ResearchRef == "" {
t.Fatalf("task = %+v", got)
}
// plan -> implement must seal the plan, and must not overwrite the
// research ref.
researchRef := got.ResearchRef
if _, err := AdvanceWorkPhase(s, project, id, sealed(t, plan)); err != nil {
t.Fatal(err)
}
got, _ = s.Task(id)
if got.WorkPhase != domain.WorkPhaseImplement || got.PlanRef == "" {
t.Fatalf("task = %+v", got)
}
if got.ResearchRef != researchRef {
t.Fatal("research ref was overwritten by the plan")
}
if got.PlanRef == got.ResearchRef {
t.Fatal("plan and research sealed to the same ref")
}
// implement -> review, then review sends work back to implement.
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
t.Fatal(err)
}
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseReview {
t.Fatalf("phase = %q", got.WorkPhase)
}
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
t.Fatal(err)
}
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseImplement {
t.Fatalf("phase = %q, review must be able to return work", got.WorkPhase)
}
}
// A project that declares a short path skips the phases it omits.
func TestProjectPathSkipsUndeclaredPhases(t *testing.T) {
s, id := phaseStore(t)
project := registry.Project{ID: "p", WorkPhases: []domain.WorkPhase{domain.WorkPhaseFrame, domain.WorkPhaseImplement, domain.WorkPhaseReview}}
if _, err := AdvanceWorkPhase(s, project, id, nil); err != nil {
t.Fatal(err)
}
got, _ := s.Task(id)
if got.WorkPhase != domain.WorkPhaseImplement {
t.Fatalf("phase = %q, want implement", got.WorkPhase)
}
if got.ResearchRef != "" || got.PlanRef != "" {
t.Fatal("a skipped phase must not seal an artifact")
}
}
// The store refuses a phase move that is not legal, whatever a caller asks.
func TestIllegalTransitionRejectedAtTheAppendBoundary(t *testing.T) {
s, id := phaseStore(t)
task, _ := s.Task(id)
b, _ := json.Marshal(map[string]any{"phase": string(domain.WorkPhaseReview)})
err := s.Append(domain.Event{ID: domain.NewID(), Type: domain.EventWorkPhaseChanged, TaskID: id, Version: task.Version + 1, Payload: b, Surface: string(authz.System)})
if !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("frame to review must be rejected, got %v", err)
}
}
func TestEndOfPathIsRefused(t *testing.T) {
s, id := phaseStore(t)
project := registry.Project{ID: "p", WorkPhases: []domain.WorkPhase{domain.WorkPhaseFrame}}
if _, err := AdvanceWorkPhase(s, project, id, nil); !errors.Is(err, domain.ErrInvalid) {
t.Fatalf("want ErrInvalid at the end of the path, got %v", err)
}
}
func TestPhaseChangeDoesNotTouchLifecycle(t *testing.T) {
s, id := phaseStore(t)
if _, err := s.Lease(id, "h1", 60_000_000_000); err != nil {
t.Fatal(err)
}
before, _ := s.Task(id)
if _, err := AdvanceWorkPhase(s, registry.Project{ID: "p"}, id, nil); err != nil {
t.Fatal(err)
}
after, _ := s.Task(id)
if after.State != before.State {
t.Fatalf("state changed %s -> %s", before.State, after.State)
}
if after.Lease == nil || *after.Lease != *before.Lease {
t.Fatal("lease changed")
}
if after.WorkPhase != domain.WorkPhaseResearch {
t.Fatalf("phase = %q", after.WorkPhase)
}
}