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:
2026-08-28 12:19:17 +04:00
parent a221502356
commit c76112a309
10 changed files with 941 additions and 9 deletions
+93
View File
@@ -1776,6 +1776,13 @@ func (w *worker) federatedTurn(ctx context.Context, id string, a herdr.Adapter,
w.rotateForPhase(ctx, id, a, s)
return
}
// A contradiction outranks both requests below. An implementer that has
// found the plan wrong should not verify a phase of it or ask to leave the
// phase; Orchestra decides what happens to the plan first.
if w.reportPlanMismatch(ctx, id, s) {
w.rotateForPhase(ctx, id, a, s)
return
}
// A plan phase asks to be verified before the work phase asks to move.
// Checked first because verifying the last phase is usually what makes a
// session ready to leave implement at all, and this session keeps running
@@ -2186,3 +2193,89 @@ func (w *worker) answerRefusedProgress(ctx context.Context, id string, s herdr.S
}
log.Printf("plan verification %s refused: %s", id, reason)
}
// planMismatchFile is the implementer's bounded report that the accepted plan
// is contradicted by the code. It carries an observation, never a replacement
// plan: writing the next plan is the planning phase's work, and an implementer
// that could supply one would be planning from inside the phase meant to
// execute a plan.
const planMismatchFile = "plan-mismatch.json"
// reportPlanMismatch carries the report to the coordinator, which decides
// whether to reopen planning, reopen research, or stop for the human. It
// reports whether the phase moved, because an accepted reopen ends this
// session exactly as an accepted phase request does.
func (w *worker) reportPlanMismatch(ctx context.Context, id string, s herdr.Session) bool {
path := filepath.Join(s.Worktree, ".orchestra", planMismatchFile)
b, err := os.ReadFile(path)
if err != nil {
return false
}
var m domain.PlanMismatch
if err := json.Unmarshal(b, &m); err != nil {
w.answerRefusedMismatch(ctx, id, s, path, fmt.Sprintf(".orchestra/%s is not valid JSON: %v", planMismatchFile, err))
return false
}
t, ok := w.tasks[id]
if !ok {
return false
}
// Both bindings are filled from what this worker can verify, not from what
// the agent wrote. A report is about the plan the task actually works from
// and the tree it actually has; letting the agent state either would make
// the staleness check meaningless.
m.PlanRef = t.PlanRef
head, err := git(ctx, s.Worktree, "rev-parse", "HEAD")
if err != nil {
w.recordError(fmt.Errorf("plan mismatch %s: head: %s: %w", id, head, err))
return false
}
m.AtSHA = strings.TrimSpace(string(head))
if err := m.Validate(); err != nil {
w.answerRefusedMismatch(ctx, id, s, path, err.Error())
return false
}
l := w.leases[id]
phase, err := w.api.ReportPlanMismatch(ctx, id, l.Epoch, m)
if err != nil {
var status *federation.StatusError
if errors.As(err, &status) && status.Code == http.StatusConflict {
w.answerRefusedMismatch(ctx, id, s, path, status.Body)
return false
}
w.recordError(fmt.Errorf("plan mismatch %s: %w", id, err))
return false
}
// Durable before the file goes. A removal that raced the response would
// lose the report and leave the agent waiting on an answer that arrived.
if err := os.Remove(path); err != nil {
w.recordError(fmt.Errorf("plan mismatch %s: %w", id, err))
}
if phase == "" {
// Recorded, and the task stopped for the human. The session ends: the
// contradiction it found is now someone else's to resolve.
log.Printf("plan mismatch %s: blocked for the human", id)
return true
}
t.WorkPhase = domain.WorkPhase(phase)
w.tasks[id] = t
log.Printf("plan mismatch %s: reopened %s", id, phase)
return true
}
// answerRefusedMismatch tells the implementer why its report was refused. A
// stale report is the common case, and the agent has to learn that rather than
// rewrite the same rejected file at every boundary.
func (w *worker) answerRefusedMismatch(ctx context.Context, id string, s herdr.Session, path, reason string) {
w.recordError(fmt.Errorf("plan mismatch %s refused: %s", id, reason))
text := "Orchestra refused your plan mismatch report: " + reason +
"\n\nA report needs observed, contradicts, the phase id, and requested_action of replan, research, or human_decision. Do not propose a replacement plan: that is the planning phase's work. Write a corrected .orchestra/" + planMismatchFile + ", or keep working."
if err := w.sendPrompt(ctx, s, text); err != nil {
w.recordError(fmt.Errorf("deliver plan mismatch refusal %s: %w", id, err))
return
}
if err := os.Remove(path); err != nil {
w.recordError(fmt.Errorf("plan mismatch %s: %w", id, err))
}
log.Printf("plan mismatch %s refused: %s", id, reason)
}
+25 -1
View File
@@ -1339,7 +1339,7 @@ func main() {
w.WriteHeader(http.StatusNoContent)
})
mux.HandleFunc("/v1/federation/workers/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || (!strings.HasSuffix(r.URL.Path, "/heartbeat") && !strings.HasSuffix(r.URL.Path, "/renew") && !strings.HasSuffix(r.URL.Path, "/start") && !strings.HasSuffix(r.URL.Path, "/nack") && !strings.HasSuffix(r.URL.Path, "/handoff") && !strings.HasSuffix(r.URL.Path, "/pickup") && !strings.HasSuffix(r.URL.Path, "/complete") && !strings.HasSuffix(r.URL.Path, "/submit") && !strings.HasSuffix(r.URL.Path, "/plan-phase") && !strings.HasSuffix(r.URL.Path, "/plan-phase-result") && !strings.HasSuffix(r.URL.Path, "/captures")) {
if r.Method != http.MethodPost || (!strings.HasSuffix(r.URL.Path, "/heartbeat") && !strings.HasSuffix(r.URL.Path, "/renew") && !strings.HasSuffix(r.URL.Path, "/start") && !strings.HasSuffix(r.URL.Path, "/nack") && !strings.HasSuffix(r.URL.Path, "/handoff") && !strings.HasSuffix(r.URL.Path, "/pickup") && !strings.HasSuffix(r.URL.Path, "/complete") && !strings.HasSuffix(r.URL.Path, "/submit") && !strings.HasSuffix(r.URL.Path, "/plan-phase") && !strings.HasSuffix(r.URL.Path, "/plan-phase-result") && !strings.HasSuffix(r.URL.Path, "/plan-mismatch") && !strings.HasSuffix(r.URL.Path, "/captures")) {
http.Error(w, "not found", 404)
return
}
@@ -1409,6 +1409,7 @@ func main() {
PhaseID string `json:"phase_id"`
AtSHA string `json:"at_sha"`
Runs []operations.VerificationRun `json:"runs"`
Mismatch domain.PlanMismatch `json:"mismatch"`
}
if json.NewDecoder(r.Body).Decode(&b) != nil || b.TaskID == "" {
http.Error(w, "invalid lease body", 400)
@@ -1435,6 +1436,29 @@ func main() {
http.Error(w, "lease version conflict", http.StatusConflict)
return
}
if strings.HasSuffix(r.URL.Path, "/plan-mismatch") {
project, ok := rr.Project(t.Project)
if !ok {
http.Error(w, "unknown project "+t.Project, 409)
return
}
// The worker fills plan_ref and at_sha from what it can verify, so
// the staleness check here compares Orchestra's view against the
// worktree's rather than against anything the agent asserted.
if _, err := operations.RecordPlanMismatch(s, project, b.TaskID, b.Mismatch, b.Mismatch.AtSHA); err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
after, _ := s.Task(b.TaskID)
phase := string(after.WorkPhase)
if after.State == domain.StateBlocked {
// Stopped for the human. An empty phase says the session ends
// without a reopen.
phase = ""
}
json.NewEncoder(w).Encode(map[string]any{"phase": phase})
return
}
if strings.HasSuffix(r.URL.Path, "/plan-phase") || strings.HasSuffix(r.URL.Path, "/plan-phase-result") {
project, ok := rr.Project(t.Project)
if !ok {
+25 -1
View File
@@ -182,7 +182,7 @@ var askingBrief = map[domain.WorkPhase]string{
domain.WorkPhaseFrame: "Ask only when the task itself is ambiguous about what would count as done.",
domain.WorkPhaseResearch: "Ask only about behaviour the repository genuinely does not establish, after you have looked. Do not ask which approach is preferred.",
domain.WorkPhasePlan: "This is the usual place to ask. Ask when two defensible directions differ in consequence, and name both.",
domain.WorkPhaseImplement: "Ask only when a discovery invalidates the accepted plan. Names, local structure, and equivalent options are yours to choose.",
domain.WorkPhaseImplement: "Ask only when a discovery invalidates the accepted plan. Names, local structure, and equivalent options are yours to choose. When the plan is contradicted by the code rather than merely ambiguous, report it instead of asking: write .orchestra/plan-mismatch.json.",
domain.WorkPhaseReview: "Ask only when correctness depends on intended behaviour that the task and the decisions still do not establish.",
}
@@ -455,6 +455,7 @@ func renderPlanProgress(in Input) string {
b.WriteString("\nEvery phase is verified.\n")
return b.String()
}
b.WriteString(planMismatchBrief)
fmt.Fprintf(&b, "\nYour current phase is %s. When you believe it is done, write .orchestra/plan-progress.json:\n\n {\"phase\": %q, \"status\": \"ready_for_verification\"}\n\nThat is a request, not a result. Orchestra runs that phase's own automated commands and records what they exit. No other status is writable: you cannot mark a phase verified, and claiming one would be refused.\n", current, current)
return b.String()
}
@@ -653,3 +654,26 @@ Rules the seal enforces, so a plan that breaks one is refused:
- Every "research:<id>" you cite must exist in the accepted research above.
- The whole document is at most 128 KiB. There is no per-line limit: write
paragraphs, code blocks and lists as the content needs.`
// planMismatchBrief tells the implementer what to do when the plan is wrong
// rather than merely hard. Without a stated route, an agent that finds a
// contradiction either implements against a plan it knows is wrong or
// improvises a different one, and both are worse than saying so.
const planMismatchBrief = `
If the code contradicts the plan, do not work around it and do not rewrite the
plan yourself. Write .orchestra/plan-mismatch.json:
{"phase_id": "phase-2",
"observed": "what the code actually does",
"contradicts": "what the plan says instead",
"evidence": ["path:line you can point at"],
"requested_action": "replan | research | human_decision"}
Use replan when the goal still holds and the route does not. Use research when
the plan rests on something the repository does not establish. Use
human_decision when the contradiction is about what was wanted, which no amount
of reading the repository settles.
The action is a recommendation. Orchestra decides whether to reopen planning,
reopen research, or stop for the human, and your session ends either way.
`
+94
View File
@@ -566,3 +566,97 @@ None.
## References
- research:r1
`
// The implement brief has to name both routes, or an agent that finds the plan
// contradicted either works around it or rewrites the plan itself.
func TestImplementBriefNamesProgressAndMismatch(t *testing.T) {
doc, err := workphase.ParsePlan([]byte(planFixture))
if err != nil {
t.Fatal(err)
}
out, err := Build(Input{
Task: domain.Task{ID: "t1", Title: "demo", PlanRef: "ref"},
Phase: domain.WorkPhaseImplement,
Git: GitState{Worktree: "/w", Branch: "orchestra/t1", HeadSHA: "abc"},
Plan: &doc,
})
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
".orchestra/plan-progress.json",
`"status": "ready_for_verification"`,
"You cannot write it",
".orchestra/plan-mismatch.json",
"requested_action",
"phase-1",
} {
if !strings.Contains(out.Task, want) {
t.Fatalf("implement brief omits %q:\n%s", want, out.Task)
}
}
}
// A verified phase whose commit has moved must read as stale. Otherwise
// "verified" becomes another artifact that outlives what made it true.
func TestPlanProgressLabelsAStaleVerification(t *testing.T) {
doc, err := workphase.ParsePlan([]byte(planFixture))
if err != nil {
t.Fatal(err)
}
const verifiedAt = "1111111111111111111111111111111111111111"
const nowAt = "2222222222222222222222222222222222222222"
task := domain.Task{
ID: "t1", Title: "demo", PlanRef: "ref",
PlanProgress: &domain.PlanProgress{PlanRef: "ref", Phases: []domain.PlanPhaseRecord{
{PlanRef: "ref", PhaseID: "phase-1", Status: domain.PlanPhaseVerified, AtSHA: verifiedAt},
}},
}
fresh, err := Build(Input{Task: task, Phase: domain.WorkPhaseImplement, Plan: &doc,
Git: GitState{Worktree: "/w", Branch: "b", HeadSHA: verifiedAt}})
if err != nil {
t.Fatal(err)
}
if strings.Contains(fresh.Task, "stale") {
t.Fatal("a verification at the current head was labelled stale")
}
moved, err := Build(Input{Task: task, Phase: domain.WorkPhaseImplement, Plan: &doc,
Git: GitState{Worktree: "/w", Branch: "b", HeadSHA: nowAt}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(moved.Task, "stale") {
t.Fatalf("a verification at an older commit reads as current:\n%s", moved.Task)
}
// Phase 1 is verified but stale, so phase 2 is still what to work on.
if !strings.Contains(moved.Task, "Your current phase is phase-2") {
t.Fatalf("the current phase is wrong:\n%s", moved.Task)
}
}
// A plan sealed before plan.md declares no executable unit, and the brief has
// to say so rather than showing an empty progress section.
func TestLegacyPlanSaysProgressIsUnavailable(t *testing.T) {
legacy, err := workphase.DecodeStoredPlan([]byte(`{"changes":[{"target":"a.go","intent":"do a thing"}]}`))
if err != nil {
t.Fatal(err)
}
out, err := Build(Input{
Task: domain.Task{ID: "t1", Title: "demo", PlanRef: "ref"},
Phase: domain.WorkPhaseImplement,
Git: GitState{Worktree: "/w", Branch: "b", HeadSHA: "abc"},
Plan: &legacy,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out.Task, "legacy accepted plan") || !strings.Contains(out.Task, "Phase progress is unavailable") {
t.Fatalf("a legacy plan does not say progress is unavailable:\n%s", out.Task)
}
if strings.Contains(out.Task, "## Plan progress") {
t.Fatal("a legacy plan rendered a progress section it cannot have")
}
if !strings.Contains(out.Task, "do a thing") {
t.Fatal("the legacy plan text was lost")
}
}
+8 -2
View File
@@ -87,7 +87,7 @@ func (r BlockReason) Valid() bool {
case BlockReasonLeaseFailure, BlockReasonWorkerOffline, BlockReasonLeaseExpired,
BlockReasonApproval, BlockReasonHandoffValidation, BlockReasonOperator,
BlockReasonSystem, BlockReasonUnknown, BlockReasonTrajectoryGate,
BlockReasonHumanDecision, BlockReasonOperatorRequired:
BlockReasonHumanDecision, BlockReasonOperatorRequired, BlockReasonPlanMismatch:
return true
}
return false
@@ -207,6 +207,10 @@ type Task struct {
// phases. Read it through PlanPhases, which discards records belonging to
// a superseded plan.
PlanProgress *PlanProgress `json:"plan_progress,omitempty"`
// PlanHistory holds the plan refs this task worked from before the
// current one, oldest first. A superseded plan stays queryable: the
// verification recorded against it is provenance, not garbage.
PlanHistory []string `json:"plan_history,omitempty"`
LastError string `json:"last_error,omitempty"`
}
@@ -266,7 +270,7 @@ func ValidateEvent(e Event) error {
if e.SchemaVersion >= 2 && strings.TrimSpace(e.Surface) == "" {
return fmt.Errorf("%w: surface required", ErrInvalid)
}
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskLeaseRenewed": true, "TaskReleased": true, "TaskLaunchAcknowledged": true, "TaskPickupValidated": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "TaskNeedsAttention": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true, EventHumanDecisionRecorded: true, EventHumanDecisionSuperseded: true, EventWorkPhaseChanged: true, EventDeferredFindingRecorded: true, EventReviewRecorded: true, EventTaskSubmitted: true, EventTaskChangesRequested: true, EventPlanPhaseVerified: true}
allowed := map[string]bool{"TaskCreated": true, "TaskLeased": true, "TaskLeaseRenewed": true, "TaskReleased": true, "TaskLaunchAcknowledged": true, "TaskPickupValidated": true, "TaskCompleted": true, "TaskFailed": true, "TaskBlocked": true, "TaskNeedsAttention": true, "ApprovalRequested": true, "ApprovalGranted": true, "ApprovalDenied": true, "TaskAmended": true, "TaskCorrected": true, "QuotaReported": true, "StandupAdvisory": true, EventHumanDecisionRecorded: true, EventHumanDecisionSuperseded: true, EventWorkPhaseChanged: true, EventDeferredFindingRecorded: true, EventReviewRecorded: true, EventTaskSubmitted: true, EventTaskChangesRequested: true, EventPlanPhaseVerified: true, EventPlanMismatchRecorded: true}
if !allowed[e.Type] {
return fmt.Errorf("%w: unknown type %q", ErrInvalid, e.Type)
}
@@ -546,6 +550,8 @@ func ValidatePayload(typ string, p map[string]any) error {
}
case EventPlanPhaseVerified:
return ValidatePlanPhaseVerified(p)
case EventPlanMismatchRecorded:
return ValidatePlanMismatchRecorded(p)
case EventReviewRecorded:
if err := requiredHash(p, "artifact_ref"); err != nil {
return err
+155
View File
@@ -0,0 +1,155 @@
package domain
import (
"fmt"
"strings"
)
// EventPlanMismatchRecorded records that implementation found the accepted
// plan contradicted by the code.
//
// It is deliberately not the same thing as the plan being superseded. A
// mismatch is an observation; a plan stops being accepted only when a
// replacement is sealed. Conflating the two would let an abandoned replan
// erase the plan the task is still working from.
const EventPlanMismatchRecorded = "PlanMismatchRecorded"
// BlockReasonPlanMismatch is a deliberate stop, not a fault: the implementer
// found a contradiction it may not resolve alone, and the human decides
// whether the plan still holds.
const BlockReasonPlanMismatch BlockReason = "plan_mismatch"
// PlanMismatchAction is what the implementer believes should happen. It is
// advisory: Orchestra owns the reopen, and a request that asks for a replan
// may still get a human decision instead.
type PlanMismatchAction string
const (
// PlanMismatchReplan: the goal still holds, the route does not.
PlanMismatchReplan PlanMismatchAction = "replan"
// PlanMismatchResearch: the plan rests on something the repository does
// not actually establish, so planning again would repeat the mistake.
PlanMismatchResearch PlanMismatchAction = "research"
// PlanMismatchHumanDecision: the contradiction is about intent, which no
// amount of reading the repository settles.
PlanMismatchHumanDecision PlanMismatchAction = "human_decision"
)
func (a PlanMismatchAction) Valid() bool {
switch a {
case PlanMismatchReplan, PlanMismatchResearch, PlanMismatchHumanDecision:
return true
}
return false
}
// PlanMismatch is the implementer's bounded report that the plan does not
// match the code.
//
// It carries an observation and nothing else. A request may not propose a
// replacement plan: writing the next plan is the planning phase's work, and an
// implementer that could supply one would be planning from inside the phase
// that was supposed to execute a plan.
type PlanMismatch struct {
// PlanRef, PhaseID and AtSHA bind the report to what the implementer was
// actually looking at. All three are checked before anything is recorded,
// so a request written against an older plan or an older tree is refused
// rather than replayed against the current one.
PlanRef string `json:"plan_ref"`
PhaseID string `json:"phase_id"`
AtSHA string `json:"at_sha"`
// Observed is what the code does.
Observed string `json:"observed"`
// Contradicts is the part of the plan that says otherwise.
Contradicts string `json:"contradicts"`
// Evidence points at what can be checked: paths, symbols, commands.
Evidence []string `json:"evidence,omitempty"`
RequestedAction PlanMismatchAction `json:"requested_action"`
}
const (
maxMismatchField = 1000
maxMismatchEvidence = 8
)
func (m PlanMismatch) Validate() error {
if strings.TrimSpace(m.PlanRef) == "" {
return fmt.Errorf("%w: plan_ref required", ErrInvalid)
}
if strings.TrimSpace(m.PhaseID) == "" {
return fmt.Errorf("%w: phase_id required", ErrInvalid)
}
if len(m.AtSHA) != 40 {
return fmt.Errorf("%w: at_sha must be a full commit sha", ErrInvalid)
}
if !m.RequestedAction.Valid() {
return fmt.Errorf("%w: requested_action %q is not replan, research, or human_decision", ErrInvalid, m.RequestedAction)
}
for name, v := range map[string]string{"observed": m.Observed, "contradicts": m.Contradicts} {
if err := mismatchField(name, v); err != nil {
return err
}
}
if len(m.Evidence) > maxMismatchEvidence {
return fmt.Errorf("%w: %d evidence entries exceeds the %d bound", ErrInvalid, len(m.Evidence), maxMismatchEvidence)
}
for i, v := range m.Evidence {
if err := mismatchField(fmt.Sprintf("evidence[%d]", i), v); err != nil {
return err
}
}
return nil
}
func mismatchField(name, v string) error {
s := strings.TrimSpace(v)
if s == "" {
return fmt.Errorf("%w: %s is required", ErrInvalid, name)
}
if len(s) > maxMismatchField {
return fmt.Errorf("%w: %s is %d characters, at most %d", ErrInvalid, name, len(s), maxMismatchField)
}
return nil
}
func ValidatePlanMismatchRecorded(p map[string]any) error {
m := PlanMismatch{}
m.PlanRef, _ = p["plan_ref"].(string)
m.PhaseID, _ = p["phase_id"].(string)
m.AtSHA, _ = p["at_sha"].(string)
m.Observed, _ = p["observed"].(string)
m.Contradicts, _ = p["contradicts"].(string)
action, _ := p["requested_action"].(string)
m.RequestedAction = PlanMismatchAction(action)
if raw, ok := p["evidence"].([]any); ok {
for _, v := range raw {
s, _ := v.(string)
m.Evidence = append(m.Evidence, s)
}
}
return m.Validate()
}
// reopenPhases is the set of backward moves Orchestra may make, and no agent
// may ask for. They exist because a contradiction found during implementation
// is real information, and refusing to act on it would leave the task
// implementing against a plan everyone knows is wrong.
//
// The edge belongs to Orchestra rather than the phase graph so that
// phase-request.json still refuses a backward move: an agent asks by reporting
// a mismatch, and Orchestra decides.
var reopenPhases = map[WorkPhase][]WorkPhase{
WorkPhaseImplement: {WorkPhasePlan, WorkPhaseResearch},
}
// CanReopenPhase reports whether Orchestra may reopen this phase. It is
// separate from CanTransitionPhase on purpose: every caller that validates an
// agent's request uses that one, so a reopen cannot be reached by asking.
func CanReopenPhase(from, to WorkPhase) bool {
for _, allowed := range reopenPhases[from] {
if allowed == to {
return true
}
}
return false
}
+20
View File
@@ -372,6 +372,26 @@ func (c Client) RecordPlanPhase(ctx context.Context, taskID, phaseID, atSHA, epo
return out.Status, nil
}
// ReportPlanMismatch carries the implementer's contradiction report. It
// returns the phase Orchestra reopened, or an empty phase when the task
// stopped for the human instead.
func (c Client) ReportPlanMismatch(ctx context.Context, taskID, epoch string, m domain.PlanMismatch) (string, error) {
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/plan-mismatch", map[string]any{
"task_id": taskID, "lease_epoch": epoch, "mismatch": m,
})
if err != nil {
return "", err
}
defer resp.Body.Close()
var out struct {
Phase string `json:"phase"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
return out.Phase, nil
}
func (c Client) PublishCapture(ctx context.Context, capture Capture) (Capture, error) {
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/workers/"+url.PathEscape(c.WorkerID)+"/captures", capture)
if err != nil {
+187
View File
@@ -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
}
+319
View File
@@ -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")
}
}
}
+15 -5
View File
@@ -299,10 +299,14 @@ func (s *Store) apply(e domain.Event) error {
}
case domain.WorkPhasePlan:
if p.ArtifactRef != "" && p.ArtifactRef != t.PlanRef {
// A new plan supersedes the old one, and the progress earned
// against the old one with it. PlanPhases would filter these
// out anyway; clearing here means a superseded record is not
// carried around waiting for a reader that forgets to.
// Sealing a replacement is the moment the old plan is
// superseded, not the moment a mismatch was reported. An
// abandoned replan therefore leaves the accepted plan intact.
// The old ref is retained so its verification stays queryable
// as provenance.
if t.PlanRef != "" {
t.PlanHistory = append(t.PlanHistory, t.PlanRef)
}
t.PlanRef = p.ArtifactRef
t.PlanProgress = nil
}
@@ -758,6 +762,7 @@ func (s *Store) Append(e domain.Event) error {
var p struct {
Phase domain.WorkPhase `json:"phase"`
ArtifactRef string `json:"artifact_ref"`
Reopen string `json:"reopen"`
}
if err := json.Unmarshal(e.Payload, &p); err != nil {
return err
@@ -766,7 +771,12 @@ func (s *Store) Append(e domain.Event) error {
return domain.ErrNotFound
}
if !domain.CanTransitionPhase(t.WorkPhase, p.Phase) {
return fmt.Errorf("%w: cannot move from work phase %q to %q", domain.ErrInvalid, t.WorkPhase, p.Phase)
// A reopen is the one backward move, and it is Orchestra's alone:
// it must name why, and every path that validates an agent's
// request uses CanTransitionPhase, which still refuses it.
if p.Reopen == "" || !domain.CanReopenPhase(t.WorkPhase, p.Phase) {
return fmt.Errorf("%w: cannot move from work phase %q to %q", domain.ErrInvalid, t.WorkPhase, p.Phase)
}
}
// Leaving research or plan without sealing the artifact would hand the
// next phase a conversation to reconstruct instead of a result to read.