c587f2cc8d
F62. The rotation is agent-driven: the worker asks, and the agent must write its handoff. When the agent never does, renewals stopped on the ordinary progress gate, the lease expired, and the task lost an attempt with nothing on record saying a handoff had ever been requested. Run 16 showed only "agent status idle and pane unchanged", 34 times. The request is now stamped, and the wait around it is bounded. While Orchestra is explicitly waiting the lease renews, because a quiet pane is the answer the agent was told to give. The request is re-sent once after four minutes, with the reason it was first asked with. At ten minutes the worker nacks with failure class handoff_unanswered, and the coordinator releases the task naming that cause instead of letting the lease die as generic idleness. The class is known to DebtClassForFailureClass, so a harness that ignores handoff requests accumulates as its own debt item rather than hiding inside lease_expired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CVbaKucEYBjMqVeUgJUsc1
111 lines
3.9 KiB
Go
111 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/federation"
|
|
"orchestra/internal/herdr"
|
|
)
|
|
|
|
// F62. Run 16: the agent was asked to hand off, never wrote HANDOFF.md,
|
|
// renewals stopped, and the lease died as ordinary idleness. Waiting is now
|
|
// bounded: re-ask once, then give the task up with a class that says why.
|
|
func TestUnansweredHandoffIsRetriedThenGivenUp(t *testing.T) {
|
|
var nack map[string]any
|
|
w, backend, _, done := phaseWorker(t, func(rw http.ResponseWriter, r *http.Request) {
|
|
if strings.HasSuffix(r.URL.Path, "/nack") {
|
|
_ = json.NewDecoder(r.Body).Decode(&nack)
|
|
}
|
|
rw.Write([]byte(`{}`))
|
|
})
|
|
defer done()
|
|
ctx := context.Background()
|
|
requested := func(ago time.Duration) herdr.Session {
|
|
s := w.sessions["task"]
|
|
s.HandoffRequested, s.HandoffReason = true, "phase_changed"
|
|
s.HandoffRequestedAt = time.Now().UTC().Add(-ago)
|
|
w.sessions["task"] = s
|
|
return s
|
|
}
|
|
|
|
// Still inside the answering window: nothing said, nothing given up.
|
|
if s, gaveUp := w.watchHandoff(ctx, "task", requested(time.Minute)); gaveUp || s.HandoffRetried {
|
|
t.Fatalf("gave up while still waiting: gaveUp=%v session=%+v", gaveUp, s)
|
|
}
|
|
if len(backend.prompts) != 0 {
|
|
t.Fatalf("re-asked too early: %q", backend.prompts)
|
|
}
|
|
|
|
// Past the retry point: asked again, exactly once.
|
|
s, gaveUp := w.watchHandoff(ctx, "task", requested(handoffRetryAfter+time.Minute))
|
|
if gaveUp || !s.HandoffRetried || len(backend.prompts) != 1 {
|
|
t.Fatalf("retry: gaveUp=%v retried=%v prompts=%q", gaveUp, s.HandoffRetried, backend.prompts)
|
|
}
|
|
if _, gaveUp = w.watchHandoff(ctx, "task", s); gaveUp || len(backend.prompts) != 1 {
|
|
t.Fatalf("re-asked every tick: %q", backend.prompts)
|
|
}
|
|
|
|
// Past the bound: a causal reclaim, and no lease left to renew.
|
|
s = requested(handoffAnswerTimeout + time.Second)
|
|
s.HandoffRetried = true
|
|
w.sessions["task"] = s
|
|
if _, gaveUp = w.watchHandoff(ctx, "task", s); !gaveUp {
|
|
t.Fatal("an unanswered handoff waited forever")
|
|
}
|
|
if nack["failure_class"] != "handoff_unanswered" {
|
|
t.Fatalf("nack = %+v", nack)
|
|
}
|
|
if detail, _ := nack["last_error"].(string); !strings.Contains(detail, "phase_changed") {
|
|
t.Fatalf("the reclaim does not name the request: %q", detail)
|
|
}
|
|
if _, held := w.leases["task"]; held {
|
|
t.Fatal("the given-up task kept its lease")
|
|
}
|
|
}
|
|
|
|
// The lease must survive the wait it was asked to make: an idle pane is the
|
|
// answer Orchestra requested, not evidence of an agent that stopped working.
|
|
func TestWaitingForAHandoffKeepsTheLease(t *testing.T) {
|
|
renewals := 0
|
|
api := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
renewals++
|
|
rw.Write([]byte(`{}`))
|
|
}))
|
|
defer api.Close()
|
|
backend := &recordingBackend{status: "idle", progress: "same screen"}
|
|
w := &worker{
|
|
api: federation.Client{BaseURL: api.URL, WorkerID: "h", Token: "t"},
|
|
backend: backend,
|
|
harness: "claude",
|
|
sessions: map[string]herdr.Session{"task": {PaneID: "pane", HandoffRequested: true, HandoffRequestedAt: time.Now().UTC()}},
|
|
leases: map[string]lease{"task": {Epoch: "e", Version: 1, Until: time.Now(), ProgressSHA: domain.Hash([]byte("same screen"))}},
|
|
quarantined: map[string]bool{},
|
|
statePath: filepath.Join(t.TempDir(), "state.json"),
|
|
}
|
|
w.renewLeases(context.Background())
|
|
if renewals != 1 {
|
|
t.Fatalf("a lease waiting on a requested handoff renewed %d times, want 1", renewals)
|
|
}
|
|
|
|
// Past the bound the exemption stops: watchHandoff has given the task up
|
|
// by then, and nothing keeps an unanswered request alive.
|
|
s := w.sessions["task"]
|
|
s.HandoffRequestedAt = time.Now().UTC().Add(-handoffAnswerTimeout - time.Second)
|
|
w.sessions["task"] = s
|
|
l := w.leases["task"]
|
|
l.Until = time.Now()
|
|
w.leases["task"] = l
|
|
w.renewLeases(context.Background())
|
|
if renewals != 1 {
|
|
t.Fatalf("the exemption outlived its bound: renewals=%d", renewals)
|
|
}
|
|
}
|