Files
orchestra/internal/operations/retry_test.go
T
kami edff021265 Give an operator one way to retry a terminal task
A task that reached the router's MaxAttempts was permanently terminal.
TaskReleased only ever increments Attempt, TaskCorrected could not touch it,
and no HTTP route emitted a correction at all. The only way to work an
exhausted issue again was to invent a second task for it, which defeats
(source, external_id) dedupe and abandons the task's own history.

POST /v1/tasks/{id}/retry, full-control surfaces only. It requires the task
to be failed, unleased, and failed with reason retry_limit: restoring a retry
budget is not an answer to a failure that was not the budget running out. The
effect is one TaskCorrected naming that failure, setting state queued and
attempt 0 and clearing next_retry_at, failure_class and last_error. Task id,
source pair, goal, acceptance, decisions, work phase and artifact refs all
stay, and the original failure events stay in the log.

operation_id is required and makes the call idempotent, so a repeated request
cannot reset an attempt that has since started running.

This is RetryTask, not a generic correction endpoint: arbitrary task mutation
over HTTP is a different and much larger authority. It also does not address
F9, which is an operator releasing a lease someone else owns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 13:07:48 +04:00

212 lines
7.6 KiB
Go

package operations
import (
"encoding/json"
"errors"
"testing"
"orchestra/internal/authz"
"orchestra/internal/domain"
"orchestra/internal/store"
)
// exhausted builds a task that the router gave up on: three reclaims and a
// retry-limit failure, with a work phase and a research ref to prove retry
// preserves them.
func exhausted(t *testing.T) *store.Store {
t.Helper()
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
created := mustJSONBytes(t, map[string]any{
"source": "gitea:test-e2e", "external_id": "3", "project": "p",
"title": "extend the healthcheck", "description": "do the thing",
"acceptance": []string{"--help keeps working", "no arguments exits 0"},
})
if err := s.Append(domain.Event{ID: "task", TaskID: "task", Type: "TaskCreated", Version: 1, Payload: created, Surface: string(authz.System)}); err != nil {
t.Fatal(err)
}
phase := mustJSONBytes(t, map[string]any{"phase": string(domain.WorkPhaseResearch), "research_ref": domain.Hash([]byte("research"))})
task, _ := s.Task("task")
if err := s.Append(domain.Event{ID: "phase", TaskID: "task", Type: domain.EventWorkPhaseChanged, Version: task.Version + 1, Payload: phase, Surface: string(authz.System)}); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
lease(t, s, "task")
release(t, s)
}
task, _ = s.Task("task")
failed := mustJSONBytes(t, map[string]any{"reason": "retry_limit", "attempts": task.Attempt, "failure_class": "prompt_not_submitted"})
if err := s.Append(domain.Event{ID: "failed", TaskID: "task", Type: "TaskFailed", Version: task.Version + 1, Payload: failed, Surface: string(authz.System)}); err != nil {
t.Fatal(err)
}
return s
}
func release(t *testing.T, s *store.Store) {
t.Helper()
task, _ := s.Task("task")
p := mustJSONBytes(t, map[string]any{
"reason": "lease_expired", "failure_class": "prompt_not_submitted",
"harness_id": task.Lease.HarnessID, "lease_epoch": task.Lease.Epoch,
})
if err := s.Append(domain.Event{ID: domain.NewID(), TaskID: "task", Type: "TaskReleased", Version: task.Version + 1, Payload: p, Surface: string(authz.System)}); err != nil {
t.Fatal(err)
}
}
func TestRetryTaskRestoresTheAttemptBudget(t *testing.T) {
s := exhausted(t)
before, _ := s.Task("task")
if before.State != domain.StateFailed || before.Attempt != 3 {
t.Fatalf("setup: state=%s attempt=%d, want failed at 3", before.State, before.Attempt)
}
if _, err := RetryTask(s, authz.TUI, "task", "op-1"); err != nil {
t.Fatal(err)
}
got, _ := s.Task("task")
if got.State != domain.StateQueued || got.Attempt != 0 {
t.Fatalf("state=%s attempt=%d, want queued at 0", got.State, got.Attempt)
}
if !got.NextRetryAt.IsZero() || got.FailureClass != "" || got.LastError != "" {
t.Fatalf("retry left failure bookkeeping behind: %+v", got)
}
// The task is the same task, not a second one for the same issue.
if got.ID != before.ID || got.Source != before.Source || got.ExternalID != before.ExternalID {
t.Fatalf("identity changed: %s/%s/%s", got.ID, got.Source, got.ExternalID)
}
if got.Title != before.Title || got.Description != before.Description || len(got.Acceptance) != len(before.Acceptance) {
t.Fatalf("goal or acceptance changed: %+v", got)
}
if got.WorkPhase != before.WorkPhase || got.ResearchRef != before.ResearchRef {
t.Fatalf("phase artifacts changed: phase=%q research=%q", got.WorkPhase, got.ResearchRef)
}
// History is appended to, never rewritten.
var failures, releases int
for _, e := range s.Events(0) {
switch e.Type {
case "TaskFailed":
failures++
case "TaskReleased":
releases++
}
}
if failures != 1 || releases != 3 {
t.Fatalf("history lost: %d failures, %d releases", failures, releases)
}
}
func TestRetryTaskRefusesATaskThatIsNotTerminal(t *testing.T) {
s := exhausted(t)
if _, err := RetryTask(s, authz.TUI, "task", "op-1"); err != nil {
t.Fatal(err)
}
// Queued: nothing to recover.
if _, err := RetryTask(s, authz.TUI, "task", "op-2"); !errors.Is(err, ErrNotRetryable) {
t.Fatalf("err=%v, want ErrNotRetryable for a queued task", err)
}
lease(t, s, "task")
if _, err := RetryTask(s, authz.TUI, "task", "op-3"); !errors.Is(err, ErrNotRetryable) {
t.Fatalf("err=%v, want ErrNotRetryable for a leased task", err)
}
}
// A failure that was not the retry budget running out needs a decision about
// the failure, not another identical attempt.
func TestRetryTaskRefusesANonRetryFailure(t *testing.T) {
s, err := store.Open(t.TempDir())
if err != nil {
t.Fatal(err)
}
created := mustJSONBytes(t, map[string]any{"source": "test", "external_id": "1", "project": "p", "title": "x"})
if err := s.Append(domain.Event{ID: "task", TaskID: "task", Type: "TaskCreated", Version: 1, Payload: created, Surface: string(authz.System)}); err != nil {
t.Fatal(err)
}
task, _ := s.Task("task")
p := mustJSONBytes(t, map[string]any{"reason": "unrecoverable"})
if err := s.Append(domain.Event{ID: "failed", TaskID: "task", Type: "TaskFailed", Version: task.Version + 1, Payload: p, Surface: string(authz.System)}); err != nil {
t.Fatal(err)
}
if _, err := RetryTask(s, authz.TUI, "task", "op-1"); !errors.Is(err, ErrNotRetryable) {
t.Fatalf("err=%v, want ErrNotRetryable", err)
}
}
// A gated surface holds an agent credential. Reviving a terminal task is an
// operator decision.
func TestRetryTaskRefusesAGatedSurface(t *testing.T) {
s := exhausted(t)
for _, surface := range []authz.Surface{authz.Agent, authz.MCP, authz.Maven, authz.Ntfy} {
if _, err := RetryTask(s, surface, "task", "op-1"); err == nil {
t.Fatalf("surface %s retried a terminal task", surface)
}
}
if got, _ := s.Task("task"); got.State != domain.StateFailed {
t.Fatalf("state=%s, want the task untouched", got.State)
}
}
// Idempotency: a repeated request under the same operation id must not reset
// an attempt that has since started running.
func TestRetryTaskIsIdempotentPerOperation(t *testing.T) {
s := exhausted(t)
first, err := RetryTask(s, authz.TUI, "task", "op-1")
if err != nil {
t.Fatal(err)
}
lease(t, s, "task")
again, err := RetryTask(s, authz.TUI, "task", "op-1")
if err != nil {
t.Fatalf("duplicate retry returned %v, want the recorded event", err)
}
if again.ID != first.ID {
t.Fatalf("event %s, want the original %s", again.ID, first.ID)
}
got, _ := s.Task("task")
if got.State != domain.StateLeased || got.Lease == nil {
t.Fatalf("duplicate retry disturbed a running attempt: %+v", got)
}
}
// Exhausting the budget a second time is retryable again, explicitly.
func TestRetryTaskWorksAfterASecondExhaustion(t *testing.T) {
s := exhausted(t)
if _, err := RetryTask(s, authz.TUI, "task", "op-1"); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
lease(t, s, "task")
release(t, s)
}
task, _ := s.Task("task")
failed := mustJSONBytes(t, map[string]any{"reason": "retry_limit", "attempts": task.Attempt})
if err := s.Append(domain.Event{ID: "failed-2", TaskID: "task", Type: "TaskFailed", Version: task.Version + 1, Payload: failed, Surface: string(authz.System)}); err != nil {
t.Fatal(err)
}
if _, err := RetryTask(s, authz.TUI, "task", "op-2"); err != nil {
t.Fatal(err)
}
got, _ := s.Task("task")
if got.State != domain.StateQueued || got.Attempt != 0 {
t.Fatalf("state=%s attempt=%d, want queued at 0", got.State, got.Attempt)
}
// The correction names the second failure, not the first.
var corrects string
for _, e := range s.Events(0) {
if e.Type != "TaskCorrected" {
continue
}
var p struct {
Corrects string `json:"corrects"`
OperationID string `json:"operation_id"`
}
if json.Unmarshal(e.Payload, &p) == nil && p.OperationID == "op-2" {
corrects = p.Corrects
}
}
if corrects != "failed-2" {
t.Fatalf("corrects=%q, want failed-2", corrects)
}
}