Files
orchestra/internal/domain/planmismatch.go
T
kami c76112a309 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
2026-08-28 12:19:17 +04:00

156 lines
5.5 KiB
Go

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
}