Files
orchestra/internal/operations/planmismatch.go
T
kami de18f372d3 Fence the two coordinator-side stops nobody had leased
F65, found live on run 20. A plan mismatch asking for a human decision
recorded its observation, then failed to block the task: Store.Append fences
every lifecycle event on a leased task against the current owner and epoch,
and this TaskBlocked carried neither. The task kept implementing while the
contradiction sat durable in the log, and the agent was told its report was
refused. The trajectory gate had the same omission.

The human-decision path already did this correctly and explained why in a
comment. That comment is now a helper all three call.

The tests could not have caught it. planWith never leased its task, so
every plan test ran in a state no agent can be in, which is exactly what
the lease helper's own comment warns against. It leases now, and the
mismatch block test fails without the fence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
2026-08-29 20:07:23 +04:00

190 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"
payload := map[string]any{
"blocker": packet,
"block_reason": string(domain.BlockReasonPlanMismatch),
"lifecycle_phase": "awaiting_human",
}
fenceToLease(payload, t)
b, err := json.Marshal(payload)
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
}