Files
orchestra/internal/integration/phase_protocol_test.go
T
kami 1f5bf7e66e 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
2026-08-27 16:35:37 +04:00

133 lines
5.1 KiB
Go

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
}