Files
orchestra/internal/domain/workphase.go
T
kami 7f12c7fc37 v3 workflow: intent, phases, review, submission, enforcement, burn-in
The v3 stack, previously an uncommitted working tree, plus this session's two
units and the burn-in instrument. This commit is the burn-in build identity:
coordinator and worker must both report this revision before a task is created.

Workflow (earlier sessions, uncommitted until now): human decision events and
reduction, source cursors and reconcile-before-launch, turn-boundary
reconciliation, internal/agentctx as the single renderer, ace-fca phases with
sealed artifacts, the trajectory gate, bounded grilling, independent review,
task pr enforcement, and human review reflection.

Capability restrictions at the agent boundary: an authz.Agent surface at
GatedWrite may ask and may not act. It also fixes two bugs the unit exposed --
gated surfaces could not reach the two endpoints written for them, and
RequestHumanDecision would block an unowned task while rejecting a question
from the session that did own it.

Turn-boundary reconcile-failure escalation: a streak of consecutive failures
asks the session to hand off, fenced on the lease epoch, with reconcile_failure
as a real handoff reason. The worker was dropping the coordinator's verdict on
the floor; it now acts on it.

Burn-in: herdr.WriteLaunchContext dumps the exact agentctx.Build result to
<worktree>/.orchestra/launch.md at every launch, local and federated. BURNIN.md
is the runbook. deploy/build.sh stamps both binaries from one commit.

go build, go vet and go test ./... pass, 20 packages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 18:31:20 +04:00

91 lines
2.9 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
}