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
+16
View File
@@ -505,6 +505,22 @@ func (s *Store) apply(e domain.Event) error {
t.Lease = nil
}
}
// Retry recovery: the router treats Attempt as terminal once it
// reaches MaxAttempts, and no other event lowers it. See
// operations.RetryTask, which is the only intended producer.
if v, ok := p["attempt"].(float64); ok {
t.Attempt = int(v)
}
if v, ok := p["next_retry_at"].(string); ok {
if v == "" {
t.NextRetryAt = time.Time{}
} else if d, err := time.Parse(time.RFC3339, v); err == nil {
t.NextRetryAt = d
}
}
if v, ok := p["failure_class"].(string); ok {
t.FailureClass = v
}
}
// A question only stands while the task is blocked on it. Afterwards the
// answer is an ordinary standing decision and the log still holds the