Give the phase brief a protocol, and end the session it advances
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
This commit is contained in:
@@ -104,6 +104,41 @@ var phaseBrief = map[domain.WorkPhase]string{
|
||||
domain.WorkPhaseReview: "Check the implementation against the goal, the decisions, and the accepted plan. Report findings with evidence. Do not rewrite the work under review.",
|
||||
}
|
||||
|
||||
// phaseRequestBrief states the mechanism behind the sentence above it. The
|
||||
// brief used to tell an agent to ask for a phase change while nothing carried
|
||||
// the asking: the agent asked in prose, no code represented the request, and
|
||||
// the session idled until its lease expired. The request is a file because
|
||||
// prose in a pane is not a protocol.
|
||||
func phaseRequestBrief(phase domain.WorkPhase) string {
|
||||
next := domain.NextPhases(phase)
|
||||
if len(next) == 0 {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString("\nAsk by writing .orchestra/phase-request.json at the end of a turn:\n\n")
|
||||
fmt.Fprintf(&b, " {\"from\": %q, \"to\": %q}\n", string(phase), string(next[0]))
|
||||
if len(next) > 1 {
|
||||
var names []string
|
||||
for _, p := range next {
|
||||
names = append(names, string(p))
|
||||
}
|
||||
fmt.Fprintf(&b, "\nLegal values for \"to\" from here: %s. This project may allow fewer, and a request outside its path is refused with the phase you may ask for.\n", strings.Join(names, ", "))
|
||||
}
|
||||
if artifact := phaseSealFile[phase]; artifact != "" {
|
||||
fmt.Fprintf(&b, "\nSeal .orchestra/%s before you ask. The request is refused without it.\n", artifact)
|
||||
}
|
||||
b.WriteString("\nAn accepted request ends this session and starts the next phase with your sealed result. Saying you are ready in the pane is not a request and nothing reads it.\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// phaseSealFile is the artifact a phase must seal before it may be left. It
|
||||
// mirrors the worker's table; both exist because the agent needs to be told
|
||||
// and the worker needs to check.
|
||||
var phaseSealFile = map[domain.WorkPhase]string{
|
||||
domain.WorkPhaseResearch: "research.json",
|
||||
domain.WorkPhasePlan: "plan.json",
|
||||
}
|
||||
|
||||
// askingBrief narrows step 4 per phase. The bar is not the same everywhere: a
|
||||
// research phase that has not looked yet has no standing to ask, and an
|
||||
// implementation phase asks only when a discovery invalidates the trajectory
|
||||
@@ -204,6 +239,7 @@ func renderTask(in Input) string {
|
||||
fmt.Fprintf(&b, "\nAsking the human, in this phase: %s\n", brief)
|
||||
}
|
||||
b.WriteString("\nOrchestra decides when this phase ends. Ask for a phase change, do not declare one.\n")
|
||||
b.WriteString(phaseRequestBrief(in.Phase))
|
||||
|
||||
if len(in.Policy) > 0 {
|
||||
b.WriteString("\n## Operating policy\n\n")
|
||||
|
||||
@@ -306,3 +306,48 @@ func TestAcceptanceCriteriaRenderInOrder(t *testing.T) {
|
||||
t.Fatalf("absent acceptance did not render Not stated:\n%s", got.Task)
|
||||
}
|
||||
}
|
||||
|
||||
// The brief used to tell an agent to ask for a phase change while nothing
|
||||
// carried the asking. Whatever else the wording says, it has to name the file
|
||||
// the worker actually reads, or the instruction is a promise again.
|
||||
func TestPhaseBriefNamesTheRequestFile(t *testing.T) {
|
||||
for _, phase := range []domain.WorkPhase{
|
||||
domain.WorkPhaseFrame, domain.WorkPhaseResearch, domain.WorkPhasePlan, domain.WorkPhaseImplement,
|
||||
} {
|
||||
out, err := Build(Input{
|
||||
Task: domain.Task{ID: "t1", Title: "demo"},
|
||||
Phase: phase,
|
||||
Git: GitState{Worktree: "/w", Branch: "orchestra/t1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", phase, err)
|
||||
}
|
||||
if !strings.Contains(out.Task, ".orchestra/phase-request.json") {
|
||||
t.Fatalf("%s brief does not name the request file", phase)
|
||||
}
|
||||
if !strings.Contains(out.Task, `"from"`) || !strings.Contains(out.Task, `"to"`) {
|
||||
t.Fatalf("%s brief does not state the request shape", phase)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A phase that seals an artifact must say so where it says how to ask,
|
||||
// because the request is refused without it.
|
||||
func TestPhaseBriefNamesTheArtifactToSeal(t *testing.T) {
|
||||
for phase, file := range map[domain.WorkPhase]string{
|
||||
domain.WorkPhaseResearch: "research.json",
|
||||
domain.WorkPhasePlan: "plan.json",
|
||||
} {
|
||||
out, err := Build(Input{
|
||||
Task: domain.Task{ID: "t1", Title: "demo"},
|
||||
Phase: phase,
|
||||
Git: GitState{Worktree: "/w", Branch: "orchestra/t1"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", phase, err)
|
||||
}
|
||||
if !strings.Contains(out.Task, ".orchestra/"+file) {
|
||||
t.Fatalf("%s brief does not name %s", phase, file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,3 +88,13 @@ func ValidateWorkPhaseChanged(p map[string]any) error {
|
||||
}
|
||||
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]...)
|
||||
}
|
||||
|
||||
@@ -144,6 +144,30 @@ func (c Client) Turn(ctx context.Context, taskID, epoch, verdict string, deliver
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// AdvancePhase carries an agent's bounded phase-change request to the
|
||||
// coordinator, which decides. The accepted phase comes back so the worker
|
||||
// knows the session it owns has been superseded and must rotate.
|
||||
//
|
||||
// artifact is the sealed output of the phase being left, and is empty for a
|
||||
// phase that produces none.
|
||||
func (c Client) AdvancePhase(ctx context.Context, taskID, epoch, operationID string, from, to domain.WorkPhase, artifact []byte) (domain.WorkPhase, error) {
|
||||
resp, err := c.request(ctx, http.MethodPost, "/v1/federation/phase", map[string]any{
|
||||
"task_id": taskID, "lease_epoch": epoch, "operation_id": operationID,
|
||||
"from": string(from), "to": string(to), "artifact": artifact,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
Phase domain.WorkPhase `json:"phase"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return out.Phase, nil
|
||||
}
|
||||
|
||||
// Intent fetches the reduced authority for one task: its contract plus the
|
||||
// human decisions still standing. A worker renders its launch instruction
|
||||
// from this, never from handoff prose.
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"orchestra/internal/continuity"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -173,7 +174,23 @@ func (a CLIAdapter) prompt(ctx context.Context, s Session, text string, wait tim
|
||||
if client, ok := backend.(*Client); ok {
|
||||
client.BindAgent(s.PaneID, s.AgentName)
|
||||
}
|
||||
return backend.Prompt(ctx, s.PaneID, text, wait)
|
||||
if err := backend.Prompt(ctx, s.PaneID, text, wait); err != nil {
|
||||
return err
|
||||
}
|
||||
// Every Orchestra-originated pane write confirms (F20). This is the
|
||||
// shared path for handoff and rotation prompts, so leaving it unconfirmed
|
||||
// left the exact failure F20 exists to catch: a prompt sitting unsubmitted
|
||||
// in the editor while Orchestra waits for a reply that cannot come.
|
||||
c, ok := backend.(InputConfirmer)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
evidence, err := c.ConfirmInput(ctx, s, text)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("input to %s confirmed: %s", s.PaneID, evidence)
|
||||
return nil
|
||||
}
|
||||
|
||||
const handoffPrompt = `Orchestra is about to rotate this task. Write ONLY the following labelled answers to ` + HandoffReportFile + `, then stop. Output nothing else.
|
||||
@@ -216,6 +233,8 @@ func (a CLIAdapter) RequestHandoffReason(ctx context.Context, s Session, reason
|
||||
sb.WriteString("Signs of thrashing were detected (repeated failing test runs, repeated edits to the same file, or the same tool call repeated back to back). Stop the current approach rather than trying it again.\n")
|
||||
case "milestone":
|
||||
sb.WriteString("A coherent unit of work looks complete (a successful commit). If the next step is independent of what you just did, this is a good point to hand off.\n")
|
||||
case "phase_changed":
|
||||
sb.WriteString("This task has moved to its next work phase, so this session's context is no longer the right one for it. This is not a judgement about your work: the next phase starts fresh with the result you sealed. Stop at a clean point and hand off.\n")
|
||||
case "reconcile_failure":
|
||||
sb.WriteString("Orchestra cannot currently read the human input for this task, so it can no longer guarantee your instructions are current. Stop at a clean point and hand off. This is not a judgement about your work.\n")
|
||||
}
|
||||
@@ -528,7 +547,7 @@ func (a CLIAdapter) lastObservedCommand(s Session) string {
|
||||
|
||||
func handoffReason(s Session) string {
|
||||
switch s.HandoffReason {
|
||||
case "threshold", "milestone", "thrash", "manual", "reconcile_failure":
|
||||
case "threshold", "milestone", "thrash", "manual", "reconcile_failure", "phase_changed":
|
||||
return s.HandoffReason
|
||||
default:
|
||||
return "threshold"
|
||||
|
||||
@@ -160,6 +160,11 @@ type Session struct {
|
||||
// session's lease was created — the immutable-spec hash continuity's
|
||||
// pickup validation compares against on the next rotation (§6.2).
|
||||
TaskFileSHA string `json:"task_file_sha,omitempty"`
|
||||
// Phase is the work phase this session was launched in. Orchestra may
|
||||
// advance the phase while the session runs; a session that no longer
|
||||
// matches its task's phase is finished, because a phase change is a
|
||||
// change of cognitive context and not a change of instruction.
|
||||
Phase string `json:"phase,omitempty"`
|
||||
// DeliveredDecisions holds the ids of the human decisions this session has
|
||||
// already been shown. A decision recorded while the lease is live is
|
||||
// delivered at the next verified turn boundary, and recording it here is
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package human
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The two concepts must stay separate. Pull-request feedback is a response to
|
||||
// a submission, so anything written at or before the submission was already
|
||||
// visible when it was made and cannot be a response to it. Steering written
|
||||
// earlier is issue input, reconciled through the task's own source, and it
|
||||
// stays valid in every phase.
|
||||
func TestPullRequestFeedbackIgnoresAnythingNotAfterTheSubmission(t *testing.T) {
|
||||
submitted := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||
state := PullRequestState{
|
||||
Comments: []Input{
|
||||
{Provider: "gitea", ExternalID: "1", Author: "kami", At: submitted.Add(-time.Hour), Body: "before"},
|
||||
{Provider: "gitea", ExternalID: "2", Author: "kami", At: submitted, Body: "at"},
|
||||
{Provider: "gitea", ExternalID: "3", Author: "kami", At: submitted.Add(time.Hour), Body: "after"},
|
||||
},
|
||||
Reviews: []ReviewObservation{
|
||||
{Actor: "kami", State: "changes_requested", At: submitted.Add(-time.Minute), Body: "early review"},
|
||||
},
|
||||
}
|
||||
got := state.FeedbackAfter("gitea", submitted, Trust{})
|
||||
if len(got) != 1 || got[0].Body != "after" {
|
||||
t.Fatalf("feedback = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// An untrusted actor's words never move a task, whenever they arrive.
|
||||
func TestPullRequestFeedbackAppliesTrust(t *testing.T) {
|
||||
submitted := time.Date(2026, 8, 27, 12, 0, 0, 0, time.UTC)
|
||||
state := PullRequestState{Comments: []Input{
|
||||
{Provider: "gitea", ExternalID: "1", Author: "bot", At: submitted.Add(time.Hour), Body: "merged by automation"},
|
||||
{Provider: "gitea", ExternalID: "2", Author: "kami", At: submitted.Add(time.Hour), Body: "change this"},
|
||||
}}
|
||||
got := state.FeedbackAfter("gitea", submitted, Trust{Ignored: []string{"bot"}})
|
||||
if len(got) != 1 || got[0].Author != "kami" {
|
||||
t.Fatalf("feedback = %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package integration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/human"
|
||||
"orchestra/internal/operations"
|
||||
"orchestra/internal/orchestrator"
|
||||
"orchestra/internal/registry"
|
||||
"orchestra/internal/router"
|
||||
"orchestra/internal/workphase"
|
||||
)
|
||||
|
||||
// F23. Human steering must work throughout the task, not only after a pull
|
||||
// request exists. Pull-request feedback is a separate concept with a narrower
|
||||
// window; conflating them would leave every pre-submission phase unsteerable,
|
||||
// which is the state run 3 was diagnosed in.
|
||||
//
|
||||
// The task here never submits anything. It sits in its first phase, leased and
|
||||
// running, and a comment on its own issue still becomes a standing decision
|
||||
// that the live session is handed at its next verified turn boundary.
|
||||
func TestPreSubmissionCommentSteersALiveSession(t *testing.T) {
|
||||
s, reg, _ := setup(t)
|
||||
task := ingest(t, s, "381")
|
||||
src := &tracingSource{tr: &trace{}}
|
||||
rec := &human.Reconciler{Store: s, Sources: map[string]human.Source{"gitea": src}}
|
||||
s.PreLease = func(id string) error { return rec.Reconcile(context.Background(), id) }
|
||||
|
||||
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{}, Adapters: adapters{&harness{occupancy: .1}}, StatePath: t.TempDir() + "/sessions.json", Hard: .8}
|
||||
c.ReconcileHumanInput = rec.Reconcile
|
||||
rt := router.Router{Store: s, Registry: reg, Reachability: alwaysReachable{}, OnLease: func(e domain.Event) error {
|
||||
return c.Start(context.Background(), e)
|
||||
}}
|
||||
if leased, err := rt.AssignPending(); err != nil || len(leased) != 1 {
|
||||
t.Fatalf("leased=%d err=%v", len(leased), err)
|
||||
}
|
||||
leasedTask, _ := s.Task(task.ID)
|
||||
if leasedTask.Lease == nil {
|
||||
t.Fatal("task is not leased")
|
||||
}
|
||||
// Still in the first phase, and nothing has been submitted.
|
||||
if leasedTask.WorkPhase != "" && leasedTask.WorkPhase != domain.WorkPhaseFrame {
|
||||
t.Fatalf("phase = %q", leasedTask.WorkPhase)
|
||||
}
|
||||
epoch := leasedTask.Lease.Epoch
|
||||
|
||||
// No human input yet: the boundary answers with nothing to deliver.
|
||||
_, decisions, err := c.RemoteTurn(context.Background(), task.ID, epoch, orchestrator.TurnContinue, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decisions) != 0 {
|
||||
t.Fatalf("decisions before any comment = %+v", decisions)
|
||||
}
|
||||
|
||||
// The human comments on the issue while the session runs.
|
||||
src.next = "918"
|
||||
src.inputs = []human.Input{{Provider: "gitea", ExternalID: "918", Author: "kami", Body: "no, use b"}}
|
||||
|
||||
_, decisions, err = c.RemoteTurn(context.Background(), task.ID, epoch, orchestrator.TurnContinue, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decisions) != 1 || decisions[0].Value != "no, use b" {
|
||||
t.Fatalf("decisions = %+v", decisions)
|
||||
}
|
||||
|
||||
// Reported as delivered, so the same correction is not re-sent every turn.
|
||||
_, decisions, err = c.RemoteTurn(context.Background(), task.ID, epoch, orchestrator.TurnContinue, []string{decisions[0].ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(decisions) != 0 {
|
||||
t.Fatalf("a delivered decision repeated: %+v", decisions)
|
||||
}
|
||||
}
|
||||
|
||||
// F21 and F22 at the coordinator boundary: an accepted request moves the phase
|
||||
// and seals what the phase produced, and a stale session's request is refused.
|
||||
func TestPhaseRequestPathSealsAndFences(t *testing.T) {
|
||||
s, reg, _ := setup(t)
|
||||
task := ingest(t, s, "381")
|
||||
c := &orchestrator.Coordinator{Store: s, Worktrees: worktrees{}, Adapters: adapters{&harness{occupancy: .1}}, StatePath: t.TempDir() + "/sessions.json", Hard: .8}
|
||||
rt := router.Router{Store: s, Registry: reg, Reachability: alwaysReachable{}, OnLease: func(e domain.Event) error {
|
||||
return c.Start(context.Background(), e)
|
||||
}}
|
||||
if _, err := rt.AssignPending(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
leased, _ := s.Task(task.ID)
|
||||
epoch := leased.Lease.Epoch
|
||||
project := registry.Project{ID: "p"}
|
||||
|
||||
if _, err := operations.RequestWorkPhase(s, project, task.ID, epoch, "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := s.Task(task.ID)
|
||||
if got.WorkPhase != domain.WorkPhaseResearch {
|
||||
t.Fatalf("phase = %q", got.WorkPhase)
|
||||
}
|
||||
|
||||
// A request carrying a lease epoch that no longer owns the task is a stale
|
||||
// opinion from a session that has been superseded.
|
||||
if _, err := operations.RequestWorkPhase(s, project, task.ID, "stale-epoch", "op-2", domain.WorkPhaseResearch, domain.WorkPhasePlan, sealed(t, researchArtifact)); err == nil {
|
||||
t.Fatal("a stale epoch advanced the phase")
|
||||
}
|
||||
if got, _ := s.Task(task.ID); got.WorkPhase != domain.WorkPhaseResearch {
|
||||
t.Fatalf("phase moved on a stale request: %q", got.WorkPhase)
|
||||
}
|
||||
|
||||
// The real owner's request seals the research the next phase will read.
|
||||
if _, err := operations.RequestWorkPhase(s, project, task.ID, epoch, "op-3", domain.WorkPhaseResearch, domain.WorkPhasePlan, sealed(t, researchArtifact)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = s.Task(task.ID)
|
||||
if got.WorkPhase != domain.WorkPhasePlan || got.ResearchRef == "" {
|
||||
t.Fatalf("task = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
var researchArtifact = workphase.Research{Findings: []workphase.Finding{{Claim: "runs per figure", Evidence: "attr.go:88"}}}
|
||||
|
||||
func sealed(t *testing.T, v interface{ Validate() error }) []byte {
|
||||
t.Helper()
|
||||
b, err := workphase.Encode(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package operations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"orchestra/internal/domain"
|
||||
"orchestra/internal/registry"
|
||||
"orchestra/internal/store"
|
||||
)
|
||||
|
||||
// epochOf returns the epoch of the task's current lease, which fences every
|
||||
// worker-driven call.
|
||||
func epochOf(t *testing.T, s *store.Store, id string) string {
|
||||
t.Helper()
|
||||
task, ok := s.Task(id)
|
||||
if !ok || task.Lease == nil {
|
||||
t.Fatal("task has no lease")
|
||||
}
|
||||
return task.Lease.Epoch
|
||||
}
|
||||
|
||||
func TestRequestWorkPhaseAdvancesAndSealsEachArtifact(t *testing.T) {
|
||||
s, id := phaseStore(t)
|
||||
project := registry.Project{ID: "p"}
|
||||
lease(t, s, id)
|
||||
epoch := epochOf(t, s, id)
|
||||
|
||||
// frame -> research seals nothing: framing produces no artifact.
|
||||
if _, err := RequestWorkPhase(s, project, id, epoch, "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseResearch {
|
||||
t.Fatalf("phase = %q", got.WorkPhase)
|
||||
}
|
||||
|
||||
// research -> plan is refused until the research is sealed.
|
||||
if _, err := RequestWorkPhase(s, project, id, epoch, "op-2", domain.WorkPhaseResearch, domain.WorkPhasePlan, nil); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Fatalf("leaving research unsealed must fail, got %v", err)
|
||||
}
|
||||
if _, err := RequestWorkPhase(s, project, id, epoch, "op-3", domain.WorkPhaseResearch, domain.WorkPhasePlan, sealed(t, research)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ := s.Task(id)
|
||||
if got.WorkPhase != domain.WorkPhasePlan || got.ResearchRef == "" {
|
||||
t.Fatalf("task = %+v", got)
|
||||
}
|
||||
|
||||
// plan -> implement is refused until the plan is sealed.
|
||||
if _, err := RequestWorkPhase(s, project, id, epoch, "op-4", domain.WorkPhasePlan, domain.WorkPhaseImplement, nil); !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Fatalf("leaving plan unsealed must fail, got %v", err)
|
||||
}
|
||||
if _, err := RequestWorkPhase(s, project, id, epoch, "op-5", domain.WorkPhasePlan, domain.WorkPhaseImplement, sealed(t, plan)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = s.Task(id)
|
||||
if got.WorkPhase != domain.WorkPhaseImplement || got.PlanRef == "" || got.ResearchRef == "" {
|
||||
t.Fatalf("task = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestWorkPhaseRefusesASkippedPhase(t *testing.T) {
|
||||
s, id := phaseStore(t)
|
||||
lease(t, s, id)
|
||||
// frame -> implement is a legal domain transition, but not the next step
|
||||
// on this project's declared path. The agent is refused rather than
|
||||
// silently corrected.
|
||||
_, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, epochOf(t, s, id), "op-1", domain.WorkPhaseFrame, domain.WorkPhaseImplement, nil)
|
||||
if !errors.Is(err, ErrPhaseRequest) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if got, _ := s.Task(id); got.WorkPhase != "" {
|
||||
t.Fatalf("phase moved to %q", got.WorkPhase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestWorkPhaseRefusesAStalePhaseBelief(t *testing.T) {
|
||||
s, id := phaseStore(t)
|
||||
lease(t, s, id)
|
||||
epoch := epochOf(t, s, id)
|
||||
if _, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, epoch, "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The agent still believes it is framing. Acting on this would advance a
|
||||
// phase it never ran.
|
||||
_, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, epoch, "op-2", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil)
|
||||
if !errors.Is(err, ErrPhaseRequest) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestWorkPhaseRefusesAStaleLeaseEpoch(t *testing.T) {
|
||||
s, id := phaseStore(t)
|
||||
lease(t, s, id)
|
||||
_, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, "not-the-epoch", "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil)
|
||||
if !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
if got, _ := s.Task(id); got.WorkPhase != "" {
|
||||
t.Fatalf("phase moved to %q", got.WorkPhase)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestWorkPhaseRequiresAnOperationID(t *testing.T) {
|
||||
s, id := phaseStore(t)
|
||||
lease(t, s, id)
|
||||
_, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, epochOf(t, s, id), "", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil)
|
||||
if !errors.Is(err, domain.ErrInvalid) {
|
||||
t.Fatalf("err = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A lost response is the ordinary case, not the exotic one: the worker resends
|
||||
// the same request and must not advance the phase a second time.
|
||||
func TestRequestWorkPhaseIsIdempotentPerOperationID(t *testing.T) {
|
||||
s, id := phaseStore(t)
|
||||
project := registry.Project{ID: "p"}
|
||||
lease(t, s, id)
|
||||
epoch := epochOf(t, s, id)
|
||||
|
||||
first, err := RequestWorkPhase(s, project, id, epoch, "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := RequestWorkPhase(s, project, id, epoch, "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("redelivery produced a new event: %s then %s", first.ID, second.ID)
|
||||
}
|
||||
if got, _ := s.Task(id); got.WorkPhase != domain.WorkPhaseResearch {
|
||||
t.Fatalf("phase = %q", got.WorkPhase)
|
||||
}
|
||||
var changes int
|
||||
for _, e := range s.Events(0) {
|
||||
if e.TaskID == id && e.Type == domain.EventWorkPhaseChanged {
|
||||
changes++
|
||||
}
|
||||
}
|
||||
if changes != 1 {
|
||||
t.Fatalf("recorded %d phase changes, want 1", changes)
|
||||
}
|
||||
}
|
||||
|
||||
// The operation id is what makes redelivery safe, so it has to survive into
|
||||
// the event the next redelivery reads.
|
||||
func TestRequestWorkPhaseRecordsTheOperationID(t *testing.T) {
|
||||
s, id := phaseStore(t)
|
||||
lease(t, s, id)
|
||||
e, err := RequestWorkPhase(s, registry.Project{ID: "p"}, id, epochOf(t, s, id), "op-1", domain.WorkPhaseFrame, domain.WorkPhaseResearch, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var p struct {
|
||||
OperationID string `json:"operation_id"`
|
||||
From string `json:"from"`
|
||||
Phase string `json:"phase"`
|
||||
}
|
||||
if err := json.Unmarshal(e.Payload, &p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.OperationID != "op-1" || p.From != "frame" || p.Phase != "research" {
|
||||
t.Fatalf("payload = %+v", p)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package operations
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"orchestra/internal/authz"
|
||||
@@ -94,3 +95,77 @@ func current(t domain.Task) domain.WorkPhase {
|
||||
}
|
||||
return t.WorkPhase
|
||||
}
|
||||
|
||||
// ErrPhaseRequest reports that an agent's phase request was refused. It is
|
||||
// distinct from ErrTrajectoryGate: a gate is the human being asked, this is
|
||||
// the request itself being wrong.
|
||||
var ErrPhaseRequest = errors.New("phase request refused")
|
||||
|
||||
// RequestWorkPhase is the agent-initiated half of a phase change (F21).
|
||||
//
|
||||
// The phase brief tells the agent to ask for a phase change rather than
|
||||
// declare one, and until this existed nothing carried the request. The agent
|
||||
// asked, the worker had no representation of the asking, and the session sat
|
||||
// idle until its lease expired. That is what made run 3 fail conformance.
|
||||
//
|
||||
// The agent asks; Orchestra still decides. Everything the agent supplies is
|
||||
// checked here: the phase it believes it is in, the phase it wants, the
|
||||
// artifact the phase it is leaving must seal. The transition itself is
|
||||
// AdvanceWorkPhase, unchanged, so a request can never reach a move the
|
||||
// operator surface could not also make.
|
||||
//
|
||||
// leaseEpoch fences the request the way every other worker-driven call is
|
||||
// fenced: a request written by a session whose lease has since been
|
||||
// reassigned is a stale opinion, not an instruction.
|
||||
//
|
||||
// operationID makes redelivery idempotent. A lost response must not advance
|
||||
// the phase twice, so a request already recorded under the same id returns
|
||||
// its event rather than moving again.
|
||||
func RequestWorkPhase(s *store.Store, project registry.Project, taskID, leaseEpoch, operationID string, from, to domain.WorkPhase, artifact []byte) (domain.Event, error) {
|
||||
if operationID == "" {
|
||||
return domain.Event{}, fmt.Errorf("%w: operation_id required", domain.ErrInvalid)
|
||||
}
|
||||
if e, ok := phaseOperation(s, taskID, operationID); ok {
|
||||
return e, nil
|
||||
}
|
||||
t, ok := s.Task(taskID)
|
||||
if !ok {
|
||||
return domain.Event{}, domain.ErrNotFound
|
||||
}
|
||||
if t.Lease == nil || leaseEpoch == "" || t.Lease.Epoch != leaseEpoch {
|
||||
return domain.Event{}, domain.ErrConflict
|
||||
}
|
||||
// The agent states the phase it believes it is in. Disagreeing with the
|
||||
// log means it is working from a stale context, and acting on its request
|
||||
// would advance a phase it never actually ran.
|
||||
if from != current(t) {
|
||||
return domain.Event{}, fmt.Errorf("%w: task %s is in work phase %q, not %q", ErrPhaseRequest, taskID, current(t), from)
|
||||
}
|
||||
next, ok := project.NextPhase(current(t))
|
||||
if !ok {
|
||||
return domain.Event{}, fmt.Errorf("%w: work phase %q is the end of project %s's path", ErrPhaseRequest, current(t), project.ID)
|
||||
}
|
||||
// Only the next phase on the project's declared path. An agent that asks
|
||||
// to skip one is refused rather than quietly corrected, because a silent
|
||||
// correction would teach it the wrong protocol.
|
||||
if to != next {
|
||||
return domain.Event{}, fmt.Errorf("%w: task %s may only move to %q, not %q", ErrPhaseRequest, taskID, next, to)
|
||||
}
|
||||
return advanceWorkPhase(s, project, taskID, artifact, map[string]any{"operation_id": operationID})
|
||||
}
|
||||
|
||||
// phaseOperation finds a phase change already recorded under this operation id.
|
||||
func phaseOperation(s *store.Store, taskID, operationID string) (domain.Event, bool) {
|
||||
for _, e := range s.Events(0) {
|
||||
if e.TaskID != taskID || e.Type != domain.EventWorkPhaseChanged {
|
||||
continue
|
||||
}
|
||||
var p struct {
|
||||
OperationID string `json:"operation_id"`
|
||||
}
|
||||
if json.Unmarshal(e.Payload, &p) == nil && p.OperationID != "" && p.OperationID == operationID {
|
||||
return e, true
|
||||
}
|
||||
}
|
||||
return domain.Event{}, false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user