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) } }