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

188 lines
7.0 KiB
Go

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
}