edff021265
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>
115 lines
3.9 KiB
Go
115 lines
3.9 KiB
Go
package operations
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"orchestra/internal/authz"
|
|
"orchestra/internal/domain"
|
|
"orchestra/internal/store"
|
|
)
|
|
|
|
// ErrNotRetryable reports that a task cannot be given another retry budget.
|
|
// Retry is deliberately narrow: it recovers a task the router gave up on, and
|
|
// nothing else.
|
|
var ErrNotRetryable = errors.New("task is not retryable")
|
|
|
|
// RetryTask gives a retry-exhausted task a fresh attempt budget.
|
|
//
|
|
// A task that reaches the router's MaxAttempts is terminal, and nothing in the
|
|
// log resets Attempt, so before this the only way to work an exhausted issue
|
|
// again was to invent a second task for it. That defeats (source, external_id)
|
|
// dedupe and loses the task's own history. This keeps the task: its id, its
|
|
// source pair, its goal, acceptance, decisions, work phase and artifact refs
|
|
// all stay as they are, and the original failure events stay in the log.
|
|
//
|
|
// This is not an escape hatch for a live lease. An owned task still has to be
|
|
// released by its owner (F9); retrying a terminal task is a different
|
|
// operation and stays separate on purpose.
|
|
//
|
|
// operationID makes the call idempotent. A retry that already ran under the
|
|
// same id returns its event instead of resetting an attempt that has since
|
|
// started running.
|
|
func RetryTask(s *store.Store, surface authz.Surface, taskID, operationID string) (domain.Event, error) {
|
|
if err := authz.AuthorizeEvent(surface, "TaskCorrected"); err != nil {
|
|
return domain.Event{}, err
|
|
}
|
|
if operationID == "" {
|
|
return domain.Event{}, fmt.Errorf("%w: operation_id required", domain.ErrInvalid)
|
|
}
|
|
if e, ok := retryOperation(s, taskID, operationID); ok {
|
|
return e, nil
|
|
}
|
|
t, ok := s.Task(taskID)
|
|
if !ok {
|
|
return domain.Event{}, domain.ErrNotFound
|
|
}
|
|
if t.State != domain.StateFailed {
|
|
return domain.Event{}, fmt.Errorf("%w: task %s is %s, not failed", ErrNotRetryable, taskID, t.State)
|
|
}
|
|
if t.Lease != nil {
|
|
return domain.Event{}, fmt.Errorf("%w: task %s still holds a lease owned by %s", ErrNotRetryable, taskID, t.Lease.HarnessID)
|
|
}
|
|
failure, reason := lastFailure(s, taskID)
|
|
if failure.ID == "" {
|
|
return domain.Event{}, fmt.Errorf("%w: task %s has no failure to correct", ErrNotRetryable, taskID)
|
|
}
|
|
if reason != "retry_limit" {
|
|
// Retry restores a retry budget. A failure that was not the budget
|
|
// running out needs a decision about the failure itself, not another
|
|
// identical attempt.
|
|
return domain.Event{}, fmt.Errorf("%w: task %s failed with %q, not retry exhaustion", ErrNotRetryable, taskID, reason)
|
|
}
|
|
b, err := json.Marshal(map[string]any{
|
|
"corrects": failure.ID,
|
|
"operation_id": operationID,
|
|
"state": string(domain.StateQueued),
|
|
"attempt": 0,
|
|
"next_retry_at": "",
|
|
"failure_class": "",
|
|
"last_error": "",
|
|
})
|
|
if err != nil {
|
|
return domain.Event{}, err
|
|
}
|
|
e := domain.Event{ID: domain.NewID(), Type: "TaskCorrected", TaskID: taskID, Version: t.Version + 1, Payload: b, Surface: string(surface)}
|
|
if err := s.Append(e); err != nil {
|
|
return domain.Event{}, err
|
|
}
|
|
return e, nil
|
|
}
|
|
|
|
// retryOperation finds a retry already recorded under this operation id.
|
|
func retryOperation(s *store.Store, taskID, operationID string) (domain.Event, bool) {
|
|
for _, e := range s.Events(0) {
|
|
if e.TaskID != taskID || e.Type != "TaskCorrected" {
|
|
continue
|
|
}
|
|
var p struct {
|
|
OperationID string `json:"operation_id"`
|
|
}
|
|
if json.Unmarshal(e.Payload, &p) == nil && p.OperationID != "" && p.OperationID == operationID {
|
|
return e, true
|
|
}
|
|
}
|
|
return domain.Event{}, false
|
|
}
|
|
|
|
// lastFailure returns the most recent TaskFailed event and its reason.
|
|
func lastFailure(s *store.Store, taskID string) (domain.Event, string) {
|
|
var found domain.Event
|
|
var reason string
|
|
for _, e := range s.Events(0) {
|
|
if e.TaskID != taskID || e.Type != "TaskFailed" {
|
|
continue
|
|
}
|
|
var p struct {
|
|
Reason string `json:"reason"`
|
|
}
|
|
_ = json.Unmarshal(e.Payload, &p)
|
|
found, reason = e, p.Reason
|
|
}
|
|
return found, reason
|
|
}
|