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 }