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>
330 lines
10 KiB
Go
330 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|