End the implementer's trajectory when Orchestra reopens the phase

Run 20 showed the leak precisely: the reopen rotated the session, and 31
seconds later the planning session that replaced it recorded phase-2 of the
plan being replaced. The implementer's .orchestra/plan-progress.json had
outlived it in the worktree, and the successor executed it.

Two changes. Phase verification now belongs to the implement phase and is
refused anywhere else, which covers every worker and every path rather than
the one that produced it. The rotation also drops the ended session's
request files, so a successor never inherits work from a trajectory
Orchestra has already invalidated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
This commit is contained in:
2026-08-30 05:46:11 +04:00
parent 575e3ef87e
commit 76da8c40b7
4 changed files with 72 additions and 0 deletions
+9
View File
@@ -2089,6 +2089,15 @@ func (w *worker) rotateForPhase(ctx context.Context, id string, a herdr.Adapter,
s.HandoffRequested, s.HandoffReason, s.HandoffRequestedAt = true, "phase_changed", time.Now().UTC()
w.sessions[id] = s
_ = w.save()
// A request belongs to the session that wrote it. Both files sit in the
// worktree, which outlives the session, so a successor in a different
// phase would find and execute them: that is how a reopened planning
// session verified a phase of the plan it was replacing.
for _, name := range []string{planProgressFile, phaseRequestFile} {
if err := os.Remove(filepath.Join(s.Worktree, ".orchestra", name)); err != nil && !os.IsNotExist(err) {
w.recordError(fmt.Errorf("phase rotation %s: drop %s: %w", id, name, err))
}
}
log.Printf("phase changed for %s: session rotating", id)
}
+29
View File
@@ -457,3 +457,32 @@ func TestPlanVerificationRunsThePlansCommandsAndReportsExitCodes(t *testing.T) {
t.Fatalf("the outcome was not delivered: %v", backend.prompts)
}
}
// A request belongs to the session that wrote it. The worktree outlives the
// session, so a rotation that leaves these files behind hands them to a
// successor running in a different phase (run 20).
func TestRotationDropsTheEndedSessionsRequests(t *testing.T) {
w, backend, wt, done := phaseWorker(t, func(rw http.ResponseWriter, r *http.Request) {
rw.Write([]byte(`{}`))
})
defer done()
progress := filepath.Join(wt, ".orchestra", planProgressFile)
request := filepath.Join(wt, ".orchestra", phaseRequestFile)
for _, p := range []string{progress, request} {
if err := os.WriteFile(p, []byte(`{"phase":"phase-1","status":"ready_for_verification"}`), 0o644); err != nil {
t.Fatal(err)
}
}
a := herdr.CLIAdapter{Backend: backend, Harness: "claude"}
w.rotateForPhase(context.Background(), "task", a, w.sessions["task"])
for _, p := range []string{progress, request} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("%s survived the rotation that ended the session that wrote it", filepath.Base(p))
}
}
if s := w.sessions["task"]; !s.HandoffRequested || s.HandoffReason != "phase_changed" {
t.Fatalf("the session was not rotated: %+v", s)
}
}