Make a contradicted plan a typed report, and the reopen Orchestra's
An implementer that finds the plan contradicted by the code had two options,
both bad: work around it silently, or improvise a different plan inside the
phase meant to execute one. PlanMismatch is the third.
The report carries an observation and nothing else. It may not propose a
replacement plan, because writing the next plan is the planning phase's work.
requested_action stays advisory: replan, research, or human_decision is a
recommendation, and Orchestra decides.
Staleness is checked before anything is recorded. A report names the plan ref
and the commit it was written against, both filled by the worker from what it
can verify rather than from what the agent asserted. A report against an older
plan says nothing about the current one, and one against an older tree may
already be fixed. Neither is replayed.
The reducer keeps two things apart that are easy to conflate:
mismatch recorded != plan superseded
A plan stops being accepted only when a replacement is actually sealed, so an
abandoned replan leaves the accepted plan and its verified progress intact. On
a real re-seal the old ref moves to PlanHistory and its progress stops counting,
while the verification events stay in the log as provenance.
human_decision never reopens. It blocks with a packet stating what was observed
and what it contradicts, and a human answer can resolve the contradiction
without resealing anything: the plan, its progress and the phase all survive,
and the answer outranks the plan where they differ. Turning every ambiguity
into a replan would put the planner above the person who set the goal.
The backward edge is Orchestra's alone. CanReopenPhase is separate from
CanTransitionPhase, which every path validating an agent's request uses, so
phase-request.json still refuses a move back. An agent asks by reporting a
mismatch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"orchestra/internal/authz"
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/registry"
|
||||
"orchestra/internal/store"
|
||||
"orchestra/internal/workphase"
|
||||
)
|
||||
|
||||
// ErrPlanMismatchStale reports a report written against a plan or a tree that
|
||||
// is no longer current. It is refused rather than replayed: a contradiction
|
||||
// observed under plan A says nothing about plan B, and one observed at an
|
||||
// older commit may already be fixed.
|
||||
var ErrPlanMismatchStale = errors.New("plan mismatch report is stale")
|
||||
|
||||
// RecordPlanMismatch records the report, then decides what happens next.
|
||||
//
|
||||
// The order matters. The observation is durable before any phase moves, so a
|
||||
// reopen that fails partway leaves the reason for it in the log rather than a
|
||||
// task that moved backwards with nothing explaining why.
|
||||
//
|
||||
// The requested action is advisory. Orchestra owns the transition, and a
|
||||
// request that asks for a replan may still get a human decision instead.
|
||||
func RecordPlanMismatch(s *store.Store, project registry.Project, taskID string, m domain.PlanMismatch, headSHA string) (domain.Event, error) {
|
||||
if err := m.Validate(); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
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", domain.ErrInvalid, current(t))
|
||||
}
|
||||
if m.PlanRef != t.PlanRef {
|
||||
return domain.Event{}, fmt.Errorf("%w: it names plan %s but this task now works from %s", ErrPlanMismatchStale, short(m.PlanRef), short(t.PlanRef))
|
||||
}
|
||||
if headSHA != "" && m.AtSHA != headSHA {
|
||||
return domain.Event{}, fmt.Errorf("%w: it was written at %s but the worktree is now at %s", ErrPlanMismatchStale, short(m.AtSHA), short(headSHA))
|
||||
}
|
||||
if err := planPhaseExists(s, t, m.PhaseID); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"plan_ref": m.PlanRef, "phase_id": m.PhaseID, "at_sha": m.AtSHA,
|
||||
"observed": m.Observed, "contradicts": m.Contradicts,
|
||||
"evidence": m.Evidence, "requested_action": string(m.RequestedAction),
|
||||
}
|
||||
if t.Lease != nil {
|
||||
payload["harness_id"], payload["lease_epoch"] = t.Lease.HarnessID, t.Lease.Epoch
|
||||
}
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
recorded := domain.Event{ID: domain.NewID(), Type: domain.EventPlanMismatchRecorded, TaskID: taskID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)}
|
||||
if err := s.Append(recorded); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
// A contradiction about intent is not something reading the repository
|
||||
// settles, so it stops for the human rather than reopening. This keeps
|
||||
// human authority above the planner and stops every ambiguity from
|
||||
// becoming a replan.
|
||||
if m.RequestedAction == domain.PlanMismatchHumanDecision {
|
||||
if err := blockForPlanMismatch(s, taskID, m); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
return recorded, nil
|
||||
}
|
||||
to := domain.WorkPhasePlan
|
||||
if m.RequestedAction == domain.PlanMismatchResearch {
|
||||
to = domain.WorkPhaseResearch
|
||||
}
|
||||
// A project whose path omits the phase cannot reopen into it. Planning
|
||||
// again on a project that never plans would strand the task in a phase it
|
||||
// has no brief for.
|
||||
if !projectHasPhase(project, to) {
|
||||
if err := blockForPlanMismatch(s, taskID, m); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
return recorded, nil
|
||||
}
|
||||
if err := reopenPhase(s, taskID, to, m); err != nil {
|
||||
return domain.Event{}, err
|
||||
}
|
||||
return recorded, nil
|
||||
}
|
||||
|
||||
func planPhaseExists(s *store.Store, t domain.Task, phaseID string) error {
|
||||
raw, err := s.Artifact(t.PlanRef)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read accepted plan: %w", err)
|
||||
}
|
||||
doc, err := workphase.DecodeStoredPlan(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read accepted plan: %w", err)
|
||||
}
|
||||
// A legacy plan names no phases, and a mismatch against one is still real
|
||||
// information. Only a plan that does declare phases can contradict the
|
||||
// caller about which one it means.
|
||||
if len(doc.Phases) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, ok := doc.Phase(phaseID); !ok {
|
||||
return fmt.Errorf("%w: the accepted plan has no %s", domain.ErrInvalid, phaseID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func projectHasPhase(p registry.Project, phase domain.WorkPhase) bool {
|
||||
for _, declared := range p.Phases() {
|
||||
if declared == phase {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// reopenPhase performs the one backward move Orchestra may make. The plan is
|
||||
// not superseded here: it stays accepted, with its progress intact, until a
|
||||
// replacement is actually sealed. An abandoned replan therefore costs nothing.
|
||||
func reopenPhase(s *store.Store, taskID string, to domain.WorkPhase, m domain.PlanMismatch) error {
|
||||
t, ok := s.Task(taskID)
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
b, err := json.Marshal(map[string]any{
|
||||
"phase": string(to), "from": string(current(t)),
|
||||
"reopen": string(domain.EventPlanMismatchRecorded), "reopen_phase_id": m.PhaseID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Append(domain.Event{ID: domain.NewID(), Type: domain.EventWorkPhaseChanged, TaskID: taskID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)})
|
||||
}
|
||||
|
||||
// blockForPlanMismatch hands the contradiction to the human. The packet states
|
||||
// what was observed and what it contradicts, so the reply is informed rather
|
||||
// than a guess at what the agent meant.
|
||||
func blockForPlanMismatch(s *store.Store, taskID string, m domain.PlanMismatch) error {
|
||||
t, ok := s.Task(taskID)
|
||||
if !ok {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
packet := fmt.Sprintf(
|
||||
"The accepted plan is contradicted by the code.\n\nPhase: %s\nObserved: %s\nThe plan says: %s\n",
|
||||
m.PhaseID, oneLine(m.Observed), oneLine(m.Contradicts))
|
||||
for _, e := range m.Evidence {
|
||||
packet += "- evidence: " + oneLine(e) + "\n"
|
||||
}
|
||||
packet += "\nReply to say how to proceed. Your reply becomes a recorded decision and outranks the plan. If it resolves the contradiction, the task resumes on the same plan; say so explicitly if you want the plan rewritten instead.\n"
|
||||
b, err := json.Marshal(map[string]any{
|
||||
"blocker": packet,
|
||||
"block_reason": string(domain.BlockReasonPlanMismatch),
|
||||
"lifecycle_phase": "awaiting_human",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.Append(domain.Event{ID: domain.NewID(), Type: "TaskBlocked", TaskID: taskID, Version: t.Version + 1, Payload: b, Surface: string(authz.System)})
|
||||
}
|
||||
|
||||
// PlanMismatchAnswered reports whether the human has replied since the task
|
||||
// stopped on a plan mismatch. The rule is positional, the same one the
|
||||
// trajectory gate uses: deciding whether a reply semantically resolves a
|
||||
// contradiction would mean parsing intent, and a wrong parse either strands a
|
||||
// task the human answered or resumes one they did not.
|
||||
func PlanMismatchAnswered(s *store.Store, taskID string) bool {
|
||||
return blockerAnswered(s, taskID, domain.BlockReasonPlanMismatch)
|
||||
}
|
||||
|
||||
// short renders a ref for a human-readable refusal without dumping 64 hex
|
||||
// characters into a sentence.
|
||||
func short(ref string) string {
|
||||
if len(ref) > 12 {
|
||||
return ref[:12]
|
||||
}
|
||||
if ref == "" {
|
||||
return "none"
|
||||
}
|
||||
return ref
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/store"
|
||||
)
|
||||
|
||||
func mismatch(planRef string) domain.PlanMismatch {
|
||||
return domain.PlanMismatch{
|
||||
PlanRef: planRef, PhaseID: "phase-1", AtSHA: shaOne,
|
||||
Observed: "a.go already caches per person",
|
||||
Contradicts: "the plan says a.go caches per figure",
|
||||
Evidence: []string{"a.go:88"},
|
||||
RequestedAction: domain.PlanMismatchReplan,
|
||||
}
|
||||
}
|
||||
|
||||
// A report written against a plan the task no longer works from says nothing
|
||||
// about the current one. Replaying it would reopen planning over a
|
||||
// contradiction that may not exist any more.
|
||||
func TestStalePlanRefIsRefused(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
_, err := RecordPlanMismatch(s, project, id, mismatch("an-older-plan-ref"), shaOne)
|
||||
if !errors.Is(err, ErrPlanMismatchStale) {
|
||||
t.Fatalf("a stale plan ref was accepted: %v", err)
|
||||
}
|
||||
assertNoMismatchRecorded(t, s, id)
|
||||
assertPhase(t, s, id, domain.WorkPhaseImplement)
|
||||
}
|
||||
|
||||
// A contradiction observed at an older commit may already be fixed.
|
||||
func TestStaleCommitIsRefused(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
task, _ := s.Task(id)
|
||||
_, err := RecordPlanMismatch(s, project, id, mismatch(task.PlanRef), shaTwo)
|
||||
if !errors.Is(err, ErrPlanMismatchStale) {
|
||||
t.Fatalf("a stale commit was accepted: %v", err)
|
||||
}
|
||||
assertNoMismatchRecorded(t, s, id)
|
||||
assertPhase(t, s, id, domain.WorkPhaseImplement)
|
||||
}
|
||||
|
||||
func TestUnknownPhaseIsRefused(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
task, _ := s.Task(id)
|
||||
m := mismatch(task.PlanRef)
|
||||
m.PhaseID = "phase-9"
|
||||
if _, err := RecordPlanMismatch(s, project, id, m, shaOne); err == nil {
|
||||
t.Fatal("a mismatch against a phase the plan does not have was accepted")
|
||||
}
|
||||
assertNoMismatchRecorded(t, s, id)
|
||||
}
|
||||
|
||||
// The observation is durable before anything moves. A reopen that failed
|
||||
// partway would otherwise leave a task in an earlier phase with nothing in the
|
||||
// log explaining why.
|
||||
func TestMismatchIsRecordedBeforeThePhaseMoves(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
task, _ := s.Task(id)
|
||||
if _, err := RecordPlanMismatch(s, project, id, mismatch(task.PlanRef), shaOne); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var mismatchSeq, phaseSeq uint64
|
||||
for _, e := range s.Events(0) {
|
||||
if e.TaskID != id {
|
||||
continue
|
||||
}
|
||||
switch e.Type {
|
||||
case domain.EventPlanMismatchRecorded:
|
||||
mismatchSeq = e.Seq
|
||||
case domain.EventWorkPhaseChanged:
|
||||
phaseSeq = e.Seq
|
||||
}
|
||||
}
|
||||
if mismatchSeq == 0 {
|
||||
t.Fatal("no mismatch was recorded")
|
||||
}
|
||||
if phaseSeq < mismatchSeq {
|
||||
t.Fatalf("the phase moved at %d before the mismatch was durable at %d", phaseSeq, mismatchSeq)
|
||||
}
|
||||
assertPhase(t, s, id, domain.WorkPhasePlan)
|
||||
}
|
||||
|
||||
// Recording a mismatch is not the same as superseding the plan. Until a
|
||||
// replacement is sealed the task still works from the plan it has, with the
|
||||
// progress it earned.
|
||||
func TestReplanKeepsTheOldPlanUntilAReplacementIsSealed(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
if _, err := RecordPlanPhaseVerification(s, project, id, "phase-1", shaOne,
|
||||
[]VerificationRun{{Command: []string{"go", "build", "./..."}, ExitCode: 0}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := s.Task(id)
|
||||
if _, err := RecordPlanMismatch(s, project, id, mismatch(before.PlanRef), shaOne); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
during, _ := s.Task(id)
|
||||
if during.PlanRef != before.PlanRef {
|
||||
t.Fatal("the plan was superseded by the mismatch alone")
|
||||
}
|
||||
if _, ok := during.PlanPhase("phase-1"); !ok {
|
||||
t.Fatal("progress was discarded before a replacement plan existed")
|
||||
}
|
||||
if len(during.PlanHistory) != 0 {
|
||||
t.Fatalf("the plan was moved to history early: %v", during.PlanHistory)
|
||||
}
|
||||
}
|
||||
|
||||
// Sealing the replacement is the moment the old plan is superseded. Progress
|
||||
// goes with it, and the old ref stays queryable as provenance.
|
||||
func TestSealingTheReplacementSupersedesThePlanAndItsProgress(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
if _, err := RecordPlanPhaseVerification(s, project, id, "phase-1", shaOne,
|
||||
[]VerificationRun{{Command: []string{"go", "build", "./..."}, ExitCode: 0}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, _ := s.Task(id)
|
||||
oldRef := before.PlanRef
|
||||
if _, err := RecordPlanMismatch(s, project, id, mismatch(oldRef), shaOne); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
revised := strings.Replace(twoPhasePlan, "# Two phase plan", "# Revised two phase plan", 1)
|
||||
if _, err := AdvanceWorkPhase(s, project, id, []byte(revised)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := s.Task(id)
|
||||
if after.PlanRef == oldRef {
|
||||
t.Fatal("the replacement did not become the accepted plan")
|
||||
}
|
||||
if rec, ok := after.PlanPhase("phase-1"); ok {
|
||||
t.Fatalf("verification from the superseded plan still counts: %+v", rec)
|
||||
}
|
||||
if len(after.PlanHistory) != 1 || after.PlanHistory[0] != oldRef {
|
||||
t.Fatalf("the superseded plan is not queryable: %v", after.PlanHistory)
|
||||
}
|
||||
// Provenance: the old verification is still in the log, and the artifact
|
||||
// it names is still readable.
|
||||
found := false
|
||||
for _, e := range s.Events(0) {
|
||||
if e.TaskID == id && e.Type == domain.EventPlanPhaseVerified && strings.Contains(string(e.Payload), oldRef) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("the old verification was erased from the log")
|
||||
}
|
||||
if _, err := s.Artifact(oldRef); err != nil {
|
||||
t.Fatalf("the superseded plan is unreadable: %v", err)
|
||||
}
|
||||
assertPhase(t, s, id, domain.WorkPhaseImplement)
|
||||
}
|
||||
|
||||
// research is the other reopen: the plan rests on something the repository
|
||||
// does not establish, so planning again would repeat the mistake.
|
||||
func TestResearchActionReopensResearch(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
task, _ := s.Task(id)
|
||||
m := mismatch(task.PlanRef)
|
||||
m.RequestedAction = domain.PlanMismatchResearch
|
||||
if _, err := RecordPlanMismatch(s, project, id, m, shaOne); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertPhase(t, s, id, domain.WorkPhaseResearch)
|
||||
}
|
||||
|
||||
// A contradiction about intent stops for the human instead of reopening.
|
||||
// Otherwise every ambiguity becomes a replan and the planner outranks the
|
||||
// person who set the goal.
|
||||
func TestHumanDecisionBlocksInsteadOfMovingPhases(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
task, _ := s.Task(id)
|
||||
m := mismatch(task.PlanRef)
|
||||
m.RequestedAction = domain.PlanMismatchHumanDecision
|
||||
if _, err := RecordPlanMismatch(s, project, id, m, shaOne); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocked, _ := s.Task(id)
|
||||
if blocked.State != domain.StateBlocked || blocked.BlockReason != domain.BlockReasonPlanMismatch {
|
||||
t.Fatalf("task = %s / %s, want blocked on plan_mismatch", blocked.State, blocked.BlockReason)
|
||||
}
|
||||
if blocked.WorkPhase != domain.WorkPhaseImplement {
|
||||
t.Fatalf("the phase moved to %q without the human", blocked.WorkPhase)
|
||||
}
|
||||
for _, want := range []string{"a.go already caches per person", "the plan says a.go caches per figure", "a.go:88"} {
|
||||
if !strings.Contains(blocked.Blocker, want) {
|
||||
t.Fatalf("the packet omits %q:\n%s", want, blocked.Blocker)
|
||||
}
|
||||
}
|
||||
if PlanMismatchAnswered(s, id) {
|
||||
t.Fatal("an unanswered block reported answered")
|
||||
}
|
||||
}
|
||||
|
||||
// A human answer can resolve the contradiction without a replan. The plan and
|
||||
// its progress survive, and the answer outranks the plan wherever they differ.
|
||||
func TestHumanAnswerResumesTheSamePlanWithoutResealing(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
if _, err := RecordPlanPhaseVerification(s, project, id, "phase-1", shaOne,
|
||||
[]VerificationRun{{Command: []string{"go", "build", "./..."}, ExitCode: 0}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task, _ := s.Task(id)
|
||||
planRef := task.PlanRef
|
||||
m := mismatch(planRef)
|
||||
m.RequestedAction = domain.PlanMismatchHumanDecision
|
||||
if _, err := RecordPlanMismatch(s, project, id, m, shaOne); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
humanReply(t, s, id, "d1", "the per-person cache is correct, keep it and continue phase 2")
|
||||
|
||||
if !PlanMismatchAnswered(s, id) {
|
||||
t.Fatal("the human answered and the task is still waiting")
|
||||
}
|
||||
after, _ := s.Task(id)
|
||||
if after.PlanRef != planRef {
|
||||
t.Fatal("answering the question replaced the plan")
|
||||
}
|
||||
if _, ok := after.PlanPhase("phase-1"); !ok {
|
||||
t.Fatal("answering the question discarded verified progress")
|
||||
}
|
||||
if len(after.PlanHistory) != 0 {
|
||||
t.Fatalf("the plan was superseded by an answer: %v", after.PlanHistory)
|
||||
}
|
||||
if after.WorkPhase != domain.WorkPhaseImplement {
|
||||
t.Fatalf("the phase moved to %q", after.WorkPhase)
|
||||
}
|
||||
// The answer is standing authority, above the plan.
|
||||
intent, err := s.EffectiveIntent(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(intent.Decisions) != 1 {
|
||||
t.Fatalf("the answer is not standing authority: %+v", intent.Decisions)
|
||||
}
|
||||
}
|
||||
|
||||
// A project whose path omits planning cannot be reopened into it, so the
|
||||
// contradiction goes to the human rather than stranding the task in a phase it
|
||||
// has no brief for.
|
||||
func TestReopenIntoAPhaseTheProjectDoesNotDeclareBlocksInstead(t *testing.T) {
|
||||
s, project, id := planWith(t, twoPhasePlan)
|
||||
trimmed := project
|
||||
trimmed.WorkPhases = []domain.WorkPhase{domain.WorkPhaseFrame, domain.WorkPhaseImplement, domain.WorkPhaseReview}
|
||||
task, _ := s.Task(id)
|
||||
if _, err := RecordPlanMismatch(s, trimmed, id, mismatch(task.PlanRef), shaOne); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocked, _ := s.Task(id)
|
||||
if blocked.BlockReason != domain.BlockReasonPlanMismatch {
|
||||
t.Fatalf("block reason = %q", blocked.BlockReason)
|
||||
}
|
||||
if blocked.WorkPhase != domain.WorkPhaseImplement {
|
||||
t.Fatalf("the task was reopened into a phase the project does not declare: %q", blocked.WorkPhase)
|
||||
}
|
||||
}
|
||||
|
||||
// The backward edge is Orchestra's alone. An agent asks by reporting a
|
||||
// mismatch, and phase-request.json still refuses a move back.
|
||||
func TestAgentCannotAskForABackwardPhaseMove(t *testing.T) {
|
||||
if domain.CanTransitionPhase(domain.WorkPhaseImplement, domain.WorkPhasePlan) {
|
||||
t.Fatal("the agent-facing phase graph allows implement to plan")
|
||||
}
|
||||
if domain.CanTransitionPhase(domain.WorkPhaseImplement, domain.WorkPhaseResearch) {
|
||||
t.Fatal("the agent-facing phase graph allows implement to research")
|
||||
}
|
||||
if !domain.CanReopenPhase(domain.WorkPhaseImplement, domain.WorkPhasePlan) {
|
||||
t.Fatal("Orchestra cannot reopen planning")
|
||||
}
|
||||
if domain.CanReopenPhase(domain.WorkPhaseReview, domain.WorkPhasePlan) {
|
||||
t.Fatal("review is reopenable into planning, which nothing asked for")
|
||||
}
|
||||
}
|
||||
|
||||
// A request may report an observation. It may not carry the next plan: writing
|
||||
// one is the planning phase's work.
|
||||
func TestMismatchRequiresAnObservationAndAnAction(t *testing.T) {
|
||||
base := mismatch("ref")
|
||||
cases := map[string]func(m *domain.PlanMismatch){
|
||||
"no observed": func(m *domain.PlanMismatch) { m.Observed = "" },
|
||||
"no contradicts": func(m *domain.PlanMismatch) { m.Contradicts = "" },
|
||||
"no phase": func(m *domain.PlanMismatch) { m.PhaseID = "" },
|
||||
"short sha": func(m *domain.PlanMismatch) { m.AtSHA = "abc" },
|
||||
"bad action": func(m *domain.PlanMismatch) { m.RequestedAction = "rewrite_it_yourself" },
|
||||
"no action": func(m *domain.PlanMismatch) { m.RequestedAction = "" },
|
||||
"essay": func(m *domain.PlanMismatch) { m.Observed = strings.Repeat("x", 1001) },
|
||||
}
|
||||
for name, mutate := range cases {
|
||||
m := base
|
||||
mutate(&m)
|
||||
if err := m.Validate(); err == nil {
|
||||
t.Errorf("%s: accepted, want a refusal", name)
|
||||
}
|
||||
}
|
||||
if err := base.Validate(); err != nil {
|
||||
t.Fatalf("a well-formed report was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertPhase(t *testing.T, s *store.Store, id string, want domain.WorkPhase) {
|
||||
t.Helper()
|
||||
task, _ := s.Task(id)
|
||||
if task.WorkPhase != want {
|
||||
t.Fatalf("work phase = %q, want %q", task.WorkPhase, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoMismatchRecorded(t *testing.T, s *store.Store, id string) {
|
||||
t.Helper()
|
||||
for _, e := range s.Events(0) {
|
||||
if e.TaskID == id && e.Type == domain.EventPlanMismatchRecorded {
|
||||
t.Fatal("a refused report was recorded anyway")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user