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:
@@ -354,6 +354,9 @@ func (w *worker) start(ctx context.Context, t domain.Task, ref string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The phase this session was launched to run. A later phase change makes
|
||||
// this session's context the wrong one, which is what rotates it (F22).
|
||||
s.Phase = string(currentPhase(t))
|
||||
s.TaskFileSHA = taskHash(t)
|
||||
if w.harness == "claude" {
|
||||
s.ContextHandoffSHA, _ = fileSHA256(filepath.Join(wt, "HANDOFF.md"))
|
||||
@@ -1608,6 +1611,19 @@ func (w *worker) federatedTurn(ctx context.Context, id string, a herdr.Adapter,
|
||||
if !at {
|
||||
return
|
||||
}
|
||||
// A phase this session no longer runs ends it, whether this worker asked
|
||||
// for the change or an operator made it (F22). Checked before the request
|
||||
// below so a session cannot advance a phase twice.
|
||||
if w.phaseChanged(id, s) {
|
||||
w.rotateForPhase(ctx, id, a, s)
|
||||
return
|
||||
}
|
||||
// The agent asks for a phase change here, at a boundary it has reached
|
||||
// (F21). Orchestra decides, and an accepted change ends this session.
|
||||
if w.requestPhase(ctx, id, s) {
|
||||
w.rotateForPhase(ctx, id, a, s)
|
||||
return
|
||||
}
|
||||
answer, err := w.api.Turn(ctx, id, l.Epoch, verdict, s.DeliveredDecisions)
|
||||
if err != nil {
|
||||
// Observable, not fatal. A coordinator that cannot be reached does not
|
||||
@@ -1648,6 +1664,148 @@ func (w *worker) federatedTurn(ctx context.Context, id string, a herdr.Adapter,
|
||||
_ = w.save()
|
||||
}
|
||||
|
||||
// phaseRequestFile is the agent's bounded phase-change intent (F21). Prose in
|
||||
// the pane is not a request: matching on it would make the protocol depend on
|
||||
// wording the agent is free to vary, and on Orchestra reading its own echo.
|
||||
const phaseRequestFile = "phase-request.json"
|
||||
|
||||
// phaseRequest is what the agent writes. It states the phase it believes it
|
||||
// is in as well as the one it wants, so a request written from a stale
|
||||
// context is refused rather than applied to whatever phase is current.
|
||||
type phaseRequest struct {
|
||||
From domain.WorkPhase `json:"from"`
|
||||
To domain.WorkPhase `json:"to"`
|
||||
}
|
||||
|
||||
// phaseArtifact names the sealed output each phase must produce before it may
|
||||
// be left. Phases absent from this table seal nothing.
|
||||
var phaseArtifact = map[domain.WorkPhase]string{
|
||||
domain.WorkPhaseResearch: "research.json",
|
||||
domain.WorkPhasePlan: "plan.json",
|
||||
}
|
||||
|
||||
func currentPhase(t domain.Task) domain.WorkPhase {
|
||||
if t.WorkPhase == "" {
|
||||
return domain.WorkPhaseFrame
|
||||
}
|
||||
return t.WorkPhase
|
||||
}
|
||||
|
||||
// phaseChanged reports whether this session is running a phase the task has
|
||||
// since left. It covers a change this worker requested and one an operator
|
||||
// made through the coordinator equally, because both leave the same evidence:
|
||||
// a session whose context was built for a phase that is no longer current.
|
||||
func (w *worker) phaseChanged(id string, s herdr.Session) bool {
|
||||
t, ok := w.tasks[id]
|
||||
if !ok || s.Phase == "" {
|
||||
return false
|
||||
}
|
||||
return string(currentPhase(t)) != s.Phase
|
||||
}
|
||||
|
||||
// rotateForPhase ends the current cognitive session because the phase moved
|
||||
// (F22). A phase change is a change of context, not of instruction: leaving
|
||||
// the old agent running would either waste the lease waiting for it to idle
|
||||
// out, as run 3 did, or let it keep working under a brief that no longer
|
||||
// applies.
|
||||
func (w *worker) rotateForPhase(ctx context.Context, id string, a herdr.Adapter, s herdr.Session) {
|
||||
if s.HandoffRequested {
|
||||
return
|
||||
}
|
||||
requester, ok := a.(herdr.ReasonedHandoffRequester)
|
||||
if !ok {
|
||||
w.recordError(fmt.Errorf("phase rotation %s: adapter cannot state a reason", id))
|
||||
return
|
||||
}
|
||||
if err := requester.RequestHandoffReason(ctx, s, "phase_changed", nil); err != nil {
|
||||
w.recordError(fmt.Errorf("phase rotation %s: %w", id, err))
|
||||
return
|
||||
}
|
||||
s.HandoffRequested, s.HandoffReason = true, "phase_changed"
|
||||
w.sessions[id] = s
|
||||
_ = w.save()
|
||||
log.Printf("phase changed for %s: session rotating", id)
|
||||
}
|
||||
|
||||
// requestPhase carries an agent's phase request to the coordinator (F21).
|
||||
// It reports whether the phase moved.
|
||||
//
|
||||
// Everything checkable locally is checked before the call, so an agent that
|
||||
// asked for the wrong thing learns it from a recorded error rather than from
|
||||
// a lease that quietly stops being renewed.
|
||||
func (w *worker) requestPhase(ctx context.Context, id string, s herdr.Session) bool {
|
||||
path := filepath.Join(s.Worktree, ".orchestra", phaseRequestFile)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var req phaseRequest
|
||||
if err := json.Unmarshal(b, &req); err != nil {
|
||||
w.recordError(fmt.Errorf("phase request %s: %w", id, err))
|
||||
return false
|
||||
}
|
||||
t, ok := w.tasks[id]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if req.From != currentPhase(t) {
|
||||
w.recordError(fmt.Errorf("phase request %s: task is in work phase %q, not %q", id, currentPhase(t), req.From))
|
||||
return false
|
||||
}
|
||||
if !domain.CanTransitionPhase(req.From, req.To) {
|
||||
w.recordError(fmt.Errorf("phase request %s: %q to %q is not a legal transition", id, req.From, req.To))
|
||||
return false
|
||||
}
|
||||
// The phase being left seals its result before it may be left. Decoding
|
||||
// here means a malformed artifact is reported against the agent that
|
||||
// wrote it, while its session is still alive to be told.
|
||||
var artifact []byte
|
||||
if name := phaseArtifact[req.From]; name != "" {
|
||||
artifact, err = os.ReadFile(filepath.Join(s.Worktree, ".orchestra", name))
|
||||
if err != nil {
|
||||
w.recordError(fmt.Errorf("phase request %s: work phase %q must seal .orchestra/%s first: %w", id, req.From, name, err))
|
||||
return false
|
||||
}
|
||||
switch req.From {
|
||||
case domain.WorkPhaseResearch:
|
||||
if _, decErr := workphase.DecodeResearch(artifact); decErr != nil {
|
||||
w.recordError(fmt.Errorf("phase request %s: research artifact: %w", id, decErr))
|
||||
return false
|
||||
}
|
||||
case domain.WorkPhasePlan:
|
||||
if _, decErr := workphase.DecodePlan(artifact); decErr != nil {
|
||||
w.recordError(fmt.Errorf("phase request %s: plan artifact: %w", id, decErr))
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
l := w.leases[id]
|
||||
// Derived, not random: a redelivery after a lost response must carry the
|
||||
// same id so the coordinator recognises it instead of advancing twice.
|
||||
op := "phase:" + id + ":" + l.Epoch + ":" + string(req.From) + ":" + string(req.To)
|
||||
phase, err := w.api.AdvancePhase(ctx, id, l.Epoch, op, req.From, req.To, artifact)
|
||||
if err != nil {
|
||||
w.recordError(fmt.Errorf("phase request %s: %w", id, err))
|
||||
return false
|
||||
}
|
||||
if phase == "" {
|
||||
// Accepted but not advanced: the coordinator raised a trajectory gate
|
||||
// and the human now owns the move. Keep the request so the same ask is
|
||||
// re-sent, under the same operation id, once the gate clears.
|
||||
return false
|
||||
}
|
||||
// Durable before the file is removed. A removal that raced the response
|
||||
// would lose the request and leave the agent waiting on an answer that
|
||||
// already arrived.
|
||||
if err := os.Remove(path); err != nil {
|
||||
w.recordError(fmt.Errorf("phase request %s: %w", id, err))
|
||||
}
|
||||
t.WorkPhase = phase
|
||||
w.tasks[id] = t
|
||||
log.Printf("phase request %s accepted: %s to %s", id, req.From, phase)
|
||||
return true
|
||||
}
|
||||
|
||||
// sendPrompt delivers Orchestra-originated input and confirms the harness took
|
||||
// it. A phase continuation or a decision notice whose Enter is lost strands the
|
||||
// session exactly as a lost launch does.
|
||||
|
||||
Reference in New Issue
Block a user