7f12c7fc37
The v3 stack, previously an uncommitted working tree, plus this session's two units and the burn-in instrument. This commit is the burn-in build identity: coordinator and worker must both report this revision before a task is created. Workflow (earlier sessions, uncommitted until now): human decision events and reduction, source cursors and reconcile-before-launch, turn-boundary reconciliation, internal/agentctx as the single renderer, ace-fca phases with sealed artifacts, the trajectory gate, bounded grilling, independent review, task pr enforcement, and human review reflection. Capability restrictions at the agent boundary: an authz.Agent surface at GatedWrite may ask and may not act. It also fixes two bugs the unit exposed -- gated surfaces could not reach the two endpoints written for them, and RequestHumanDecision would block an unowned task while rejecting a question from the session that did own it. Turn-boundary reconcile-failure escalation: a streak of consecutive failures asks the session to hand off, fenced on the lease epoch, with reconcile_failure as a real handoff reason. The worker was dropping the coordinator's verdict on the floor; it now acts on it. Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to <worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md is the runbook. deploy/build.sh stamps both binaries from one commit. go build, go vet and go test ./... pass, 20 packages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
295 lines
11 KiB
Go
295 lines
11 KiB
Go
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)
|
|
}
|
|
}
|