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 }