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>
This commit is contained in:
2026-08-27 13:07:48 +04:00
parent f54fb0036d
commit edff021265
6 changed files with 446 additions and 1 deletions
+21 -1
View File
@@ -954,7 +954,7 @@ func main() {
}
var e domain.Event
var err error
actionTypes := map[string]string{"lease": "TaskLeased", "release": "TaskReleased", "complete": "TaskCompleted", "block": "TaskBlocked", "attention": "TaskNeedsAttention"}
actionTypes := map[string]string{"lease": "TaskLeased", "release": "TaskReleased", "complete": "TaskCompleted", "block": "TaskBlocked", "attention": "TaskNeedsAttention", "retry": "TaskCorrected"}
if typ, known := actionTypes[action]; known {
if err := authz.AuthorizeEvent(surface(r), typ); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
@@ -962,6 +962,26 @@ func main() {
}
}
switch action {
case "retry":
// Narrow on purpose: RetryTask restores a retry budget on a
// terminal task. There is no generic correction endpoint, because
// one would be arbitrary task mutation over HTTP.
var p struct {
OperationID string `json:"operation_id"`
}
if r.Body == nil || json.NewDecoder(r.Body).Decode(&p) != nil || p.OperationID == "" {
http.Error(w, "operation_id required", http.StatusBadRequest)
return
}
e, err = operations.RetryTask(s, surface(r), taskID, p.OperationID)
switch {
case errors.Is(err, domain.ErrNotFound):
http.Error(w, err.Error(), http.StatusNotFound)
return
case errors.Is(err, operations.ErrNotRetryable), errors.Is(err, domain.ErrInvalid):
http.Error(w, err.Error(), http.StatusConflict)
return
}
case "lease":
var p struct {
HarnessID string `json:"harness_id"`