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:
2026-08-27 16:35:37 +04:00
parent 15408a5463
commit 1f5bf7e66e
14 changed files with 1380 additions and 2 deletions
+36
View File
@@ -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")
+45
View File
@@ -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)
}
}
}