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)
}