Files
orchestra/internal/operations/submission_test.go
T
kami fb7135e1d9 Refuse a plan command outside project policy when the plan seals
The brief tells the planner "a command outside its policy is refused when you
seal, not later". It was not. The only caller of VerificationPolicy.Allows was
PlanPhaseCommands, which runs when the implementer asks to verify: one phase,
one session and one rotation after the planner could have fixed it.

Run 9 sealed ["bash", "scripts/test_healthcheck.sh"] against a policy that
allows neither shape, and the phase request was accepted.

The check now runs beside citation resolution, on the coordinator, where the
project is already in scope. A project with no verification policy can still
seal a plan; it cannot seal one that declares run: lines, which matches what
an absent policy already meant at verification time.

Test fixtures gained a policy for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-28 15:27:39 +04:00

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 ./...", Verification: registry.VerificationPolicy{Allowed: [][]string{{"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)
}
}
}