1f5bf7e66e
The brief told the agent to ask for a phase change and never carried the asking. The agent asked in prose, no code represented the request, and the session idled until its lease expired. That is what failed run 3. F21. The agent asks with .orchestra/phase-request.json, and seals research.json or plan.json where the phase it is leaving produces one. At a verified turn boundary the worker checks the phase belief, the transition and the artifact, then calls the coordinator with its lease epoch and a derived operation id. AdvanceWorkPhase is unchanged, so a request cannot reach a move the operator surface could not also make. Redelivery is idempotent. F22. A session now records the phase it was launched to run. One that no longer matches its task rotates with reason phase_changed, whether this worker asked for the change or an operator made it. F20. CLIAdapter.prompt sent handoff and rotation prompts without confirming them, which is the failure F20 exists to catch. Fixed at the shared call site. F23 needed no change. Issue comments already become decisions with no submission, through Reconciler.Reconcile at PreLease and at every turn boundary. The earlier finding searched internal/operations alone and was wrong. Tests now cover the boundary it turns on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011xsXyr5J1RACo71YeKG3Pu
101 lines
3.3 KiB
Go
101 lines
3.3 KiB
Go
package domain
|
|
|
|
import "fmt"
|
|
|
|
// WorkPhase is the cognitive phase of a task. It is orthogonal to TaskState:
|
|
// a task can be leased in any phase, and a phase change is not a lifecycle
|
|
// transition. Keeping them separate is what stops a rotation from looking
|
|
// like progress and a failed experiment from looking like a failed task.
|
|
type WorkPhase string
|
|
|
|
const (
|
|
WorkPhaseFrame WorkPhase = "frame"
|
|
WorkPhaseResearch WorkPhase = "research"
|
|
WorkPhasePlan WorkPhase = "plan"
|
|
WorkPhaseImplement WorkPhase = "implement"
|
|
WorkPhaseReview WorkPhase = "review"
|
|
)
|
|
|
|
// EventWorkPhaseChanged is emitted by Orchestra, never by an agent. An agent
|
|
// asks for a phase change through the approval surface and Orchestra decides.
|
|
const EventWorkPhaseChanged = "WorkPhaseChanged"
|
|
|
|
func (p WorkPhase) Valid() bool {
|
|
switch p {
|
|
case WorkPhaseFrame, WorkPhaseResearch, WorkPhasePlan, WorkPhaseImplement, WorkPhaseReview:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// legalPhaseTransitions is the full set of moves Orchestra may make. A
|
|
// project's declared path is a subset of this, checked where the registry is
|
|
// visible. Skipping ahead is allowed, going backwards is not, except for
|
|
// review sending work back to implement.
|
|
var legalPhaseTransitions = map[WorkPhase][]WorkPhase{
|
|
WorkPhaseFrame: {WorkPhaseResearch, WorkPhaseImplement},
|
|
WorkPhaseResearch: {WorkPhasePlan, WorkPhaseImplement},
|
|
WorkPhasePlan: {WorkPhaseImplement},
|
|
WorkPhaseImplement: {WorkPhaseReview},
|
|
WorkPhaseReview: {WorkPhaseImplement},
|
|
}
|
|
|
|
// CanTransitionPhase reports whether Orchestra may move from one phase to
|
|
// another. An empty from is treated as frame, the phase every task starts in.
|
|
func CanTransitionPhase(from, to WorkPhase) bool {
|
|
if from == "" {
|
|
from = WorkPhaseFrame
|
|
}
|
|
if !from.Valid() || !to.Valid() {
|
|
return false
|
|
}
|
|
for _, allowed := range legalPhaseTransitions[from] {
|
|
if allowed == to {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ValidateWorkPhaseChanged checks the payload shape. Whether the transition
|
|
// is legal from the task's current phase is checked at the append boundary,
|
|
// where the current phase is visible.
|
|
func ValidateWorkPhaseChanged(p map[string]any) error {
|
|
phase, _ := p["phase"].(string)
|
|
if !WorkPhase(phase).Valid() {
|
|
return fmt.Errorf("%w: phase invalid", ErrInvalid)
|
|
}
|
|
if v, ok := p["from"]; ok {
|
|
s, ok := v.(string)
|
|
if !ok || !WorkPhase(s).Valid() {
|
|
return fmt.Errorf("%w: from invalid", ErrInvalid)
|
|
}
|
|
}
|
|
// A sealed artifact is what makes the next phase's context cheap. It is
|
|
// required when leaving research or plan, because those phases exist to
|
|
// produce one.
|
|
if v, ok := p["result_sha"]; ok {
|
|
s, ok := v.(string)
|
|
if !ok || len(s) != 40 {
|
|
return fmt.Errorf("%w: result_sha invalid", ErrInvalid)
|
|
}
|
|
}
|
|
if v, ok := p["artifact_ref"]; ok {
|
|
s, _ := v.(string)
|
|
if err := requiredHash(map[string]any{"artifact_ref": s}, "artifact_ref"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// NextPhases returns the phases Orchestra may move to from this one. A
|
|
// project's declared path narrows this further, so it is what an agent may
|
|
// legally ask for rather than what it will certainly be granted.
|
|
func NextPhases(from WorkPhase) []WorkPhase {
|
|
if from == "" {
|
|
from = WorkPhaseFrame
|
|
}
|
|
return append([]WorkPhase(nil), legalPhaseTransitions[from]...)
|
|
}
|