Files
orchestra/internal/operations/human_decision_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

185 lines
7.0 KiB
Go

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