feat: retry transient provider 529 errors with visible backoff (#1173)

This commit is contained in:
Carlos Florêncio
2026-05-30 19:16:49 +01:00
committed by GitHub
parent d35f36ae73
commit ec289c799b
23 changed files with 1478 additions and 7 deletions
+6
View File
@@ -26,6 +26,12 @@ When a real-agent message renders poorly (markdown table, tool card, diff panel,
This avoids burning real-agent tokens, gives you a deterministic payload to iterate against, and the scenario stays around as a permanent regression hook — anyone can re-trigger it after the fix.
## Special case: emitting a real prompt-time ACP *error*
Scenarios in `scenarioRegistry` can only emit `SessionUpdate` notifications — they cannot make the prompt itself fail. When you need a real JSON-RPC error response (the kind the backend turns into `agent.failed` with a populated `data.ErrorMessage`), intercept the command in the `Prompt` method (`main.go`) and return a non-nil `error` — typically `&acp.RequestError{Code, Message, Data}`, whose `Error()` serializes the exact `{"code":...,"message":...,"data":...}` envelope a real agent sends.
`/overloaded[:N]` is the reference example (`handler.go: handleOverloaded`): it returns the production `529 Overloaded` error for the first `N` prompts of a session (default 1), then recovers with a normal text response. The orchestrator's backoff retry tears the agent process down and relaunches it between attempts, so the fail-count is persisted in a **temp file** keyed by the session id (`overloadedCounterPath`) rather than an in-memory map — it survives the relaunch (and is cleaned up on recovery and in `CloseSession`). Use `/overloaded` to demo the yellow retry status, or a large `N` like `/overloaded:9` to keep failing so the retry loop stays visible / exhausts to the red recovery banner.
## Emitter helpers worth knowing
`emitter.go` wraps the ACP `sessionUpdater` with helpers that shield scenarios from SDK plumbing:
+91
View File
@@ -1,16 +1,107 @@
package main
import (
"context"
"fmt"
"math/rand"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
acp "github.com/coder/acp-go-sdk"
)
// overloaded529Message is the exact transient 529 message the real Anthropic
// API returns and the claude-agent-acp adapter surfaces over ACP. The
// orchestrator's routingerr classifier matches the "529 ... Overloaded"
// signature in this string and routes it to the retry-with-backoff path.
const overloaded529Message = "Internal error: API Error: 529 Overloaded. This is a server-side issue, " +
"usually temporary — try again in a moment. If it persists, check https://status.claude.com."
// overloadedCmdRe matches `/overloaded` or `/e2e:overloaded`, optionally
// followed by `:N` — the number of consecutive prompts to fail with a 529
// before recovering (default 1, so the turn self-heals on the first retry).
// Use a large N (e.g. `/overloaded:9`) to exhaust the retry budget and fall
// through to the red recovery banner.
var overloadedCmdRe = regexp.MustCompile(`(?i)^/(?:e2e:)?overloaded(?::(\d+))?$`)
// parseOverloadedCmd reports whether the prompt is the /overloaded command and,
// if so, how many consecutive prompts it should fail before recovering
// (default 1). Returns ok=false for any other prompt.
func parseOverloadedCmd(prompt string) (failTimes int, ok bool) {
cmd := stripKandevSystem(strings.TrimSpace(prompt))
m := overloadedCmdRe.FindStringSubmatch(cmd)
if m == nil {
return 0, false
}
failTimes = 1
if m[1] != "" {
if n, err := strconv.Atoi(m[1]); err == nil && n >= 0 {
failTimes = n
}
}
return failTimes, true
}
// handleOverloaded implements the /overloaded scenario: it returns a real
// prompt-time ACP error carrying the production 529 string for the first N
// prompts of a session, then recovers with a normal text response. Returns
// handled=false when the prompt is not the overloaded command.
func (a *mockAgent) handleOverloaded(ctx context.Context, sid acp.SessionId, prompt string) (acp.PromptResponse, error, bool) {
failTimes, ok := parseOverloadedCmd(prompt)
if !ok {
return acp.PromptResponse{}, nil, false
}
// The orchestrator's backoff retry tears the agent process down and
// relaunches it via --resume, so an in-memory counter would reset every
// attempt and never recover. Persist the attempt count in a file keyed by
// the (resume-stable) session id so fail-then-recover survives relaunch.
attempt := nextOverloadedAttempt(sid)
if attempt <= failTimes {
_, _ = fmt.Fprintf(logOutput, "mock-agent[%d]: emitting 529 Overloaded for session %s (failure %d/%d)\n",
os.Getpid(), sid, attempt, failTimes)
return acp.PromptResponse{}, &acp.RequestError{
Code: -32603,
Message: overloaded529Message,
Data: map[string]any{"errorKind": "server_error"},
}, true
}
// Recovered: clear the counter and emit a normal success response so the
// turn completes (and the orchestrator clears the retry budget).
_ = os.Remove(overloadedCounterPath(sid))
e := &emitter{ctx: ctx, conn: a.conn, sid: sid}
e.text(fmt.Sprintf("Provider recovered after %d transient failure(s) — here is your response.", failTimes))
return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil, true
}
// overloadedCounterPath returns the temp-file path tracking how many times the
// /overloaded scenario has fired for a session. Keyed by the resume-stable ACP
// session id so the count survives process relaunch across backoff retries.
func overloadedCounterPath(sid acp.SessionId) string {
safe := strings.NewReplacer("/", "_", "\\", "_", "..", "_").Replace(string(sid))
return filepath.Join(os.TempDir(), "kandev-mock-overloaded-"+safe+".count")
}
// nextOverloadedAttempt increments and returns the persisted attempt count.
func nextOverloadedAttempt(sid acp.SessionId) int {
path := overloadedCounterPath(sid)
prev := 0
if raw, err := os.ReadFile(path); err == nil {
if n, convErr := strconv.Atoi(strings.TrimSpace(string(raw))); convErr == nil {
prev = n
}
}
next := prev + 1
_ = os.WriteFile(path, []byte(strconv.Itoa(next)), 0o600)
return next
}
// delayRange returns min/max delay in milliseconds based on model name.
func delayRange(model string) (int, int) {
switch model {
+8
View File
@@ -168,6 +168,12 @@ func (a *mockAgent) LoadSession(_ context.Context, req acp.LoadSessionRequest) (
func (a *mockAgent) Prompt(ctx context.Context, req acp.PromptRequest) (acp.PromptResponse, error) {
a.emitAvailableCommandsOnce(ctx, req.SessionId)
prompt := extractPromptText(req.Prompt)
// The /overloaded scenario must surface a real prompt-time ACP *error*
// (a JSON-RPC error response), which handlePrompt's emitter cannot do —
// so intercept it here and return the error from Prompt directly.
if resp, err, handled := a.handleOverloaded(ctx, req.SessionId, prompt); handled {
return resp, err
}
e := &emitter{ctx: ctx, conn: a.conn, sid: req.SessionId}
handlePrompt(e, prompt, a.model)
return acp.PromptResponse{StopReason: acp.StopReasonEndTurn}, nil
@@ -196,6 +202,7 @@ func (a *mockAgent) CloseSession(_ context.Context, req acp.CloseSessionRequest)
delete(a.sessions, req.SessionId)
delete(a.commandsEmitted, req.SessionId)
a.mu.Unlock()
_ = os.Remove(overloadedCounterPath(req.SessionId))
return acp.CloseSessionResponse{}, nil
}
@@ -254,6 +261,7 @@ func mockAvailableCommands() []acp.AvailableCommand {
return []acp.AvailableCommand{
{Name: "slow", Description: "Run a slow response (default 5s)", Input: hint("duration (e.g. 10s)")},
{Name: "error", Description: "Simulate an error"},
{Name: "overloaded", Description: "Simulate a transient 529 Overloaded error (fails once, then recovers)"},
{Name: "thinking", Description: "Emit thinking/reasoning blocks"},
{Name: "crash", Description: "Simulate agent crash"},
{Name: "all", Description: "Demonstrate all message types"},
@@ -0,0 +1,102 @@
package main
import (
"context"
"os"
"strings"
"testing"
acp "github.com/coder/acp-go-sdk"
)
func newOverloadedTestAgent() *mockAgent {
return &mockAgent{
model: "mock-fast",
sessions: make(map[acp.SessionId]bool),
commandsEmitted: make(map[acp.SessionId]bool),
}
}
func TestParseOverloadedCmd(t *testing.T) {
cases := []struct {
prompt string
wantFail int
wantOK bool
}{
{"/overloaded", 1, true},
{"/OVERLOADED", 1, true},
{"/e2e:overloaded", 1, true},
{"/overloaded:3", 3, true},
{"/e2e:overloaded:9", 9, true},
{"/overloaded:0", 0, true},
{"<kandev-system>ctx</kandev-system>/overloaded:2", 2, true},
{"hello", 0, false},
{"/overload", 0, false},
{"overloaded", 0, false},
}
for _, tc := range cases {
gotFail, gotOK := parseOverloadedCmd(tc.prompt)
if gotOK != tc.wantOK || (tc.wantOK && gotFail != tc.wantFail) {
t.Errorf("parseOverloadedCmd(%q) = (%d, %v), want (%d, %v)", tc.prompt, gotFail, gotOK, tc.wantFail, tc.wantOK)
}
}
}
func TestHandleOverloaded_NotTheCommand(t *testing.T) {
a := newOverloadedTestAgent()
_, err, handled := a.handleOverloaded(context.Background(), "s1", "hello there")
if handled {
t.Fatal("handled = true for a non-overloaded prompt, want false")
}
if err != nil {
t.Fatalf("err = %v, want nil", err)
}
}
func TestHandleOverloaded_EmitsProduction529(t *testing.T) {
a := newOverloadedTestAgent()
const sid acp.SessionId = "emit529-sess"
_ = os.Remove(overloadedCounterPath(sid))
t.Cleanup(func() { _ = os.Remove(overloadedCounterPath(sid)) })
_, err, handled := a.handleOverloaded(context.Background(), sid, "/overloaded")
if !handled {
t.Fatal("handled = false, want true")
}
reqErr, ok := err.(*acp.RequestError)
if !ok {
t.Fatalf("err type = %T, want *acp.RequestError", err)
}
if reqErr.Code != -32603 {
t.Errorf("code = %d, want -32603", reqErr.Code)
}
if !strings.Contains(reqErr.Message, "529 Overloaded") {
t.Errorf("message = %q, want it to contain '529 Overloaded'", reqErr.Message)
}
}
func TestHandleOverloaded_FailsExactlyNTimes(t *testing.T) {
a := newOverloadedTestAgent()
const sid acp.SessionId = "failN-sess"
_ = os.Remove(overloadedCounterPath(sid))
t.Cleanup(func() { _ = os.Remove(overloadedCounterPath(sid)) })
// failTimes=2: the first two prompts must error (the persisted counter
// drives this, so it survives across the simulated relaunches the
// orchestrator performs between retries).
for i := 1; i <= 2; i++ {
_, err, handled := a.handleOverloaded(context.Background(), sid, "/overloaded:2")
if !handled {
t.Fatalf("attempt %d: handled = false, want true", i)
}
if err == nil {
t.Fatalf("attempt %d: err = nil, want a 529 error", i)
}
}
// The third attempt would recover (emits text via the live conn) — not
// exercised here to avoid needing a real ACP connection; the recovery path
// is covered end-to-end by the Playwright spec. Assert the persisted count
// reached the fail budget so recovery is next.
if got := nextOverloadedAttempt(sid) - 1; got != 2 {
t.Errorf("persisted attempt count = %d, want 2", got)
}
}
@@ -0,0 +1,106 @@
package routingerr
import "testing"
// realOverloaded529 is the exact transient provider error surfaced by the
// claude-agent-acp adapter when the Anthropic API returns 529 Overloaded
// mid-turn. Unlike resume-corruption this is a server-side, temporary
// condition — retrying after a short backoff is expected to succeed.
const realOverloaded529 = `{"code":-32603,"message":"Internal error: API Error: 529 Overloaded. ` +
`This is a server-side issue, usually temporary — try again in a moment. If it persists, ` +
`check https://status.claude.com.","data":{"errorKind":"server_error"}}`
func TestMatchRuntimeEnvironmentRules_Overloaded(t *testing.T) {
got, ok := matchRuntimeEnvironmentRules(realOverloaded529)
if !ok {
t.Fatalf("expected match, got none")
}
if got.Code != CodeProviderOverloaded {
t.Fatalf("Code = %q, want %q", got.Code, CodeProviderOverloaded)
}
if got.ClassifierRule != overloadedRuleID {
t.Fatalf("ClassifierRule = %q, want %q", got.ClassifierRule, overloadedRuleID)
}
if got.Confidence != ConfHigh {
t.Fatalf("Confidence = %q, want %q", got.Confidence, ConfHigh)
}
}
func TestMatchRuntimeEnvironmentRules_OverloadedErrorToken(t *testing.T) {
// The Anthropic error-type token alone (no numeric code) must also fire.
msg := `{"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}`
got, ok := matchRuntimeEnvironmentRules(msg)
if !ok || got.Code != CodeProviderOverloaded {
t.Fatalf("expected provider_overloaded match, got ok=%v err=%v", ok, got)
}
}
func TestClassify_Overloaded_Invariants(t *testing.T) {
resetInjection()
e := Classify(Input{
Phase: PhasePromptSend,
ProviderID: "claude-acp",
Stderr: realOverloaded529,
})
if e == nil {
t.Fatal("expected non-nil Error")
}
if e.Code != CodeProviderOverloaded {
t.Fatalf("Code = %q, want %q", e.Code, CodeProviderOverloaded)
}
if !e.AutoRetryable {
t.Errorf("AutoRetryable = false, want true (transient server-side overload)")
}
if !e.FallbackAllowed {
t.Errorf("FallbackAllowed = false, want true")
}
if e.UserAction {
t.Errorf("UserAction = true, want false (no user action needed)")
}
if e.Phase != PhasePromptSend {
t.Errorf("Phase = %q, want %q", e.Phase, PhasePromptSend)
}
}
func TestClassify_HTTPStatus529(t *testing.T) {
// Callers that surface the status code separately from the body text (e.g.
// a structured HTTP error) must also classify 529 as transient/overloaded.
resetInjection()
e := Classify(Input{Phase: PhasePromptSend, ProviderID: "claude-acp", HTTPStatus: statusOverloaded})
if e == nil {
t.Fatal("expected non-nil Error")
}
if e.Code != CodeProviderOverloaded {
t.Fatalf("Code = %q, want %q", e.Code, CodeProviderOverloaded)
}
if !e.AutoRetryable {
t.Errorf("AutoRetryable = false, want true")
}
if e.ClassifierRule != "http.529" {
t.Errorf("ClassifierRule = %q, want http.529", e.ClassifierRule)
}
}
func TestIsTransientProviderError(t *testing.T) {
cases := []struct {
name string
msg string
want bool
}{
{"real 529 envelope", realOverloaded529, true},
{"overloaded_error token", `"type":"overloaded_error"`, true},
{"529 then overloaded on one line", "API Error: 529 Overloaded.", true},
{"prefixed overloaded error token", `"type":"not_overloaded_error"`, false},
{"unrelated 401", "API Error: 401 authentication_error", false},
{"plain overloaded word, no code", "the disk is overloaded with files", false},
{"bare 529 no overloaded", "got status 529 from upstream", false},
{"empty", "", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := IsTransientProviderError(tc.msg); got != tc.want {
t.Errorf("IsTransientProviderError(%q) = %v, want %v", tc.msg, got, tc.want)
}
})
}
}
@@ -22,6 +22,7 @@ const (
CodeQuotaLimited Code = "quota_limited"
CodeRateLimited Code = "rate_limited"
CodeProviderUnavailable Code = "provider_unavailable"
CodeProviderOverloaded Code = "provider_overloaded"
CodeModelUnavailable Code = "model_unavailable"
CodeProviderNotConfigured Code = "provider_not_configured"
CodeUnknownProvider Code = "unknown_provider_error"
@@ -96,6 +97,10 @@ type Input struct {
const exitCodeBinaryMissing = 127
// statusOverloaded is the non-standard HTTP 529 ("Overloaded") that Anthropic
// returns when temporarily overloaded. Not in net/http, so defined here.
const statusOverloaded = 529
// Classify normalizes a failure into a routing-aware Error. See package doc.
func Classify(in Input) *Error {
excerpt := Sanitize(in.Stderr + "\n" + in.Stdout)
@@ -176,6 +181,8 @@ func httpStatusToCode(status int) Code {
return CodeRateLimited
case http.StatusServiceUnavailable:
return CodeProviderUnavailable
case statusOverloaded:
return CodeProviderOverloaded
}
return ""
}
@@ -218,6 +225,12 @@ func applyInvariants(e *Error) *Error {
case CodeProviderUnavailable, CodeUnknownProvider:
e.AutoRetryable = true
e.FallbackAllowed = true
case CodeProviderOverloaded:
// 529 Overloaded is a transient, server-side condition. Retrying the
// same provider after a short backoff is the right move; falling back
// to another provider is also fine if backoff keeps failing.
e.AutoRetryable = true
e.FallbackAllowed = true
case CodeNpxCacheCorrupted:
e.AutoRetryable = true
e.FallbackAllowed = true
@@ -44,10 +44,42 @@ var runtimeEnvironmentRules = []runtimeRule{
}
},
},
{
// Anthropic 529 Overloaded surfaced over ACP as a prompt-time error
// event: a transient, server-side condition the orchestrator should
// retry with backoff rather than tear down. Provider-agnostic because
// the "529 ... overloaded" / "overloaded_error" signature is unique
// enough not to false-positive on unrelated logs.
id: overloadedRuleID,
pattern: overloadedRe,
build: func(string) *Error {
return &Error{
Code: CodeProviderOverloaded,
Confidence: ConfHigh,
}
},
},
}
const resumeCorruptedRuleID = "anthropic.thinking_blocks.immutable.v1"
const overloadedRuleID = "anthropic.overloaded.529.v1"
// overloadedRe matches the transient 529 Overloaded signature: either the
// numeric code adjacent to "overloaded" on a single line (in either order), or
// the Anthropic "overloaded_error" type token. Kept tight ([^\n] stays within
// one line) so a stray "529" and "overloaded" in unrelated multi-line logs
// can't bridge into a false positive.
var overloadedRe = regexp.MustCompile(`(?i)\b529\b[^\n]*overloaded|overloaded[^\n]*\b529\b|\boverloaded_error\b`)
// IsTransientProviderError reports whether the error message carries the
// transient provider-overload (529 Overloaded) signature. Exposed for callers
// outside the classify path (e.g. the orchestrator's retry-with-backoff) that
// need to branch on transience without re-running full Classify.
func IsTransientProviderError(message string) bool {
return message != "" && overloadedRe.MatchString(message)
}
// thinkingBlocksImmutableRe matches the Anthropic "thinking blocks cannot be
// modified" 400 in either the `thinking` or `redacted_thinking` form. The
// gaps stay bounded ([^\n] is non-greedy-friendly within a single line) so a
@@ -224,6 +224,10 @@ func (s *Service) handleAgentReady(ctx context.Context, data watcher.AgentEventD
return
}
// A turn completed successfully — clear any transient retry budget so a
// later, unrelated provider overload starts its backoff fresh at attempt 1.
s.resetTransientRetry(data.SessionID)
// Complete the current turn
s.completeTurnForSession(ctx, data.SessionID)
@@ -408,6 +412,9 @@ func (s *Service) handleAgentCompleted(ctx context.Context, data watcher.AgentEv
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID))
// A successful completion clears any transient retry budget for the session.
s.resetTransientRetry(data.SessionID)
// Update scheduler and remove from queue
s.scheduler.HandleTaskCompleted(data.TaskID, true)
s.scheduler.RemoveTask(data.TaskID)
@@ -501,10 +508,21 @@ func (s *Service) handleAgentFailed(ctx context.Context, data watcher.AgentEvent
zap.String("agent_execution_id", data.AgentExecutionID),
zap.String("error_message", data.ErrorMessage))
// Finalize run-mode automation runs first — every other branch below
// returns early (resume failure, session-backed recoverable failure,
// Transient provider errors (529 Overloaded) get a paced, visible
// retry-with-backoff before any red banner. This is the ONLY non-terminal
// failure path, so it runs before automation finalization below — otherwise
// a transient 529 on a run-mode automation would mark the run failed and
// reap its ephemeral worktree out from under the in-flight retry.
// handleTransientFailure returns false (falling through) for non-transient
// errors, office tasks, or an exhausted budget.
if data.SessionID != "" && s.handleTransientFailure(ctx, data) {
return
}
// Terminal from here. Finalize run-mode automation runs — every branch
// below returns early (resume failure, session-backed recoverable failure,
// no-session retry), and run-mode automations need their AutomationRun
// flipped + worktree reaped on *every* failure path.
// flipped + worktree reaped on *every* terminal failure path.
errMsg := data.ErrorMessage
if errMsg == "" {
errMsg = "agent failed"
@@ -683,6 +701,10 @@ func (s *Service) createRecoveryStatusMessage(ctx context.Context, data watcher.
statusMsg := fmt.Sprintf("Agent encountered an error: %s", displayMsg)
if resumeCorrupted {
statusMsg = "This agent session can't be resumed — its saved reasoning state is corrupted. Start a fresh session to continue."
} else if routingerr.IsTransientProviderError(data.ErrorMessage) {
// Reached after the transient retry budget is exhausted — show friendly
// copy instead of dumping the raw 529 JSON envelope.
statusMsg = "The provider stayed overloaded after several retries. Resume to try again, or start a fresh session."
}
hasResumeToken := s.wasResumeAttempt(ctx, data.SessionID)
meta := map[string]interface{}{
@@ -844,6 +866,12 @@ func (s *Service) handleAgentStopped(ctx context.Context, data watcher.AgentEven
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID))
// NOTE: we deliberately do NOT resetTransientRetry here — the transient
// retry tears down the failed execution via StopExecution as part of its
// own re-drive, which surfaces as an agent.stopped event; clearing the loop
// on that self-inflicted stop would abort the retry. The loop is freed on
// ready/completed (success), cancel, and exhaustion instead.
// Complete the current turn if there is one
s.completeTurnForSession(ctx, data.SessionID)
@@ -0,0 +1,334 @@
package orchestrator
import (
"context"
"fmt"
"sync"
"time"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
"github.com/kandev/kandev/internal/orchestrator/watcher"
"github.com/kandev/kandev/internal/task/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// transientMaxAttempts caps how many times a transient provider error (529
// Overloaded) is auto-retried with backoff before falling through to the
// manual recovery banner.
const transientMaxAttempts = 3
// recoverActionCancelRetry is the session.recover action that stops an
// in-progress transient retry loop and surfaces manual recovery.
const recoverActionCancelRetry = "cancel_retry"
const recoveryCancelRetryButtonTestID = "recovery-cancel-retry-button"
// Shared status-message metadata keys. Defined as constants because the same
// keys are built in more than one place in this package (recovery + retry
// status messages), which otherwise trips goconst on new code.
const (
metaKeyVariant = "variant"
metaKeySessionID = "session_id"
metaKeyTaskID = "task_id"
)
// metaVariantWarning is the status-message variant that drives the frontend's
// yellow (non-alarming) styling, as opposed to the red "error" variant.
const metaVariantWarning = "warning"
// transientRetryBackoff is the per-attempt delay before re-driving a turn that
// failed transiently. Index is attempt-1 (5s → 15s → 30s).
var transientRetryBackoff = []time.Duration{
5 * time.Second,
15 * time.Second,
30 * time.Second,
}
// transientRetryDelay returns the backoff for a 1-based attempt, clamping to
// the longest step so an over-count never panics.
func transientRetryDelay(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
if attempt > len(transientRetryBackoff) {
attempt = len(transientRetryBackoff)
}
return transientRetryBackoff[attempt-1]
}
// capturedPrompt is the minimal context needed to re-drive a failed turn.
type capturedPrompt struct {
text string
model string
planMode bool
attachments []v1.MessageAttachment
}
// transientRetryEntry tracks one session's in-progress retry loop: the current
// attempt count and the cancel func for the armed backoff timer.
type transientRetryEntry struct {
attempt int
cancel func()
mu sync.Mutex
claimed bool
}
func (e *transientRetryEntry) claim() bool {
e.mu.Lock()
defer e.mu.Unlock()
if e.claimed {
return false
}
e.claimed = true
return true
}
// rememberTurnPrompt caches the raw outbound prompt so a transient retry can
// re-drive the same turn without the original caller's context.
func (s *Service) rememberTurnPrompt(sessionID, text, model string, planMode bool, attachments []v1.MessageAttachment) {
if sessionID == "" {
return
}
s.lastTurnPrompt.Store(sessionID, capturedPrompt{
text: text,
model: model,
planMode: planMode,
attachments: attachments,
})
}
// handleTransientFailure routes a transient provider error (529 Overloaded)
// into a paced, visible retry-with-backoff instead of the red recovery banner.
// Returns true when it takes ownership (caller must NOT fall through to
// handleRecoverableFailure); false for non-transient errors, office tasks,
// or an exhausted retry budget.
func (s *Service) handleTransientFailure(ctx context.Context, data watcher.AgentEventData) bool {
if data.SessionID == "" || !routingerr.IsTransientProviderError(data.ErrorMessage) {
return false
}
// Genuine office tasks (those with an assignee agent profile) render their
// own structured error UI — keep them on the existing path rather than the
// kanban-style yellow retry card. Note: we intentionally check the task's
// assignee, NOT isOfficeSession (session.AgentProfileID), which is also set
// for ordinary kanban tasks started with an agent profile.
if s.isOfficeTask(ctx, data.TaskID) {
return false
}
attempt := s.nextTransientAttempt(data.SessionID)
if attempt > transientMaxAttempts {
s.logger.Warn("transient retry budget exhausted; falling through to recovery banner",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Int("attempts", attempt-1))
s.resetTransientRetry(data.SessionID)
return false
}
delay := transientRetryDelay(attempt)
s.logger.Info("scheduling transient provider-error retry",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Int("attempt", attempt),
zap.Int("max_attempts", transientMaxAttempts),
zap.Duration("delay", delay))
// Emit the yellow status (against the failed turn) before completing it.
s.createTransientRetryStatusMessage(ctx, data, attempt, delay)
s.completeTurnForSession(ctx, data.SessionID)
// Park the session in WAITING_FOR_INPUT (a calm, banner-less state that
// also lets the yellow retry card render — ActionMessage hides itself while
// the session is RUNNING). Deliberately NOT FAILED and NOT task→REVIEW.
s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, models.TaskSessionStateWaitingForInput, "", false)
s.scheduleTransientRetry(data.TaskID, data.SessionID, data.AgentExecutionID, attempt, delay)
return true
}
// nextTransientAttempt returns the next 1-based attempt number for a session,
// cancelling any still-armed timer from a prior attempt.
func (s *Service) nextTransientAttempt(sessionID string) int {
prev := 0
if v, ok := s.transientRetries.Load(sessionID); ok {
if entry, ok := v.(*transientRetryEntry); ok {
prev = entry.attempt
if entry.cancel != nil {
entry.cancel()
}
}
}
return prev + 1
}
// scheduleTransientRetry stores a fresh retry entry and arms its backoff timer.
func (s *Service) scheduleTransientRetry(taskID, sessionID, execID string, attempt int, delay time.Duration) {
retryCtx, cancel := context.WithCancel(context.Background())
entry := &transientRetryEntry{attempt: attempt, cancel: cancel}
s.transientRetries.Store(sessionID, entry)
go s.runTransientRetry(retryCtx, taskID, sessionID, execID, entry, delay)
}
// runTransientRetry waits out the backoff (or cancellation) then re-drives the
// turn. Mirrors the clarification-watchdog goroutine ownership pattern.
func (s *Service) runTransientRetry(retryCtx context.Context, taskID, sessionID, execID string, entry *transientRetryEntry, delay time.Duration) {
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-retryCtx.Done():
return
case <-timer.C:
// Only fire if this entry is still the active one for the session.
if cur, ok := s.transientRetries.Load(sessionID); !ok || cur != entry || !entry.claim() {
return
}
s.retryTransientPrompt(retryCtx, taskID, sessionID, execID)
}
}
// retryTransientPrompt re-drives the failed turn after backoff. The failed
// execution is torn down first so PromptTask's ensureSessionRunning resumes a
// fresh agent via the resume token (re-establishing the ACP session) rather
// than reusing the FAILED execution, which rejects prompts. The session was
// parked in WAITING_FOR_INPUT by handleTransientFailure so PromptTask accepts
// the re-send straight away.
func (s *Service) retryTransientPrompt(ctx context.Context, taskID, sessionID, execID string) {
if ctx.Err() != nil {
return
}
v, ok := s.lastTurnPrompt.Load(sessionID)
if !ok {
if ctx.Err() != nil {
return
}
// No prompt to re-drive (e.g. an uncached launch path). Don't leave the
// retry loop parked behind a stuck yellow card — clear it and surface
// the manual recovery banner so the user can resume or start fresh.
s.logger.Warn("transient retry has no cached prompt; surfacing recovery banner",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
s.resetTransientRetry(sessionID)
s.handleRecoverableFailure(context.Background(), watcher.AgentEventData{
TaskID: taskID,
SessionID: sessionID,
AgentExecutionID: execID,
ErrorMessage: "Provider overloaded — automatic retry was not possible. Resume or start fresh to continue.",
})
return
}
cp, _ := v.(capturedPrompt)
if execID != "" {
if err := s.executor.StopExecution(ctx, execID, "transient retry: relaunching agent", true); err != nil {
s.logger.Debug("failed to stop failed execution before transient retry",
zap.String("session_id", sessionID),
zap.String("execution_id", execID),
zap.Error(err))
}
}
if ctx.Err() != nil {
return
}
if _, err := s.PromptTask(ctx, taskID, sessionID, cp.text, cp.model, cp.planMode, cp.attachments, false); err != nil {
if ctx.Err() != nil {
return
}
s.logger.Error("transient retry prompt failed synchronously; surfacing recovery banner",
zap.String("task_id", taskID),
zap.String("session_id", sessionID),
zap.Error(err))
s.resetTransientRetry(sessionID)
s.handleRecoverableFailure(context.Background(), watcher.AgentEventData{
TaskID: taskID,
SessionID: sessionID,
AgentExecutionID: execID,
ErrorMessage: "Provider overloaded — automatic retry could not be started. Resume or start fresh to continue.",
})
}
}
// createTransientRetryStatusMessage emits the calm yellow "retrying" status
// (variant=warning) with a Cancel action, driving the frontend's
// AgentWarningStatus instead of the red AgentErrorStatus.
func (s *Service) createTransientRetryStatusMessage(ctx context.Context, data watcher.AgentEventData, attempt int, delay time.Duration) {
if s.messageCreator == nil {
return
}
secs := int(delay.Seconds())
content := fmt.Sprintf("Provider overloaded — retrying in %ds (attempt %d/%d)", secs, attempt, transientMaxAttempts)
cancelAction := wsRecoveryAction(data.TaskID, data.SessionID, recoverActionCancelRetry,
"Cancel", "x", "Stop retrying and choose how to recover", recoveryCancelRetryButtonTestID)
meta := map[string]interface{}{
metaKeyVariant: metaVariantWarning,
"retrying": true,
"attempt": attempt,
"max_attempts": transientMaxAttempts,
"retry_in_seconds": secs,
metaKeySessionID: data.SessionID,
metaKeyTaskID: data.TaskID,
"actions": []map[string]interface{}{cancelAction},
}
if err := s.messageCreator.CreateSessionMessage(
ctx,
data.TaskID,
content,
data.SessionID,
string(v1.MessageTypeStatus),
s.getActiveTurnID(data.SessionID),
meta,
false,
); err != nil {
s.logger.Warn("failed to create transient retry status message",
zap.String("task_id", data.TaskID),
zap.Error(err))
}
}
// resetTransientRetry clears a session's retry entry, cancels its timer, and
// drops the cached prompt (which may hold large/sensitive attachment data).
// Called on a successful turn, on cancel, and on exhaustion.
func (s *Service) resetTransientRetry(sessionID string) {
s.lastTurnPrompt.Delete(sessionID)
if v, ok := s.transientRetries.LoadAndDelete(sessionID); ok {
if entry, ok := v.(*transientRetryEntry); ok && entry.cancel != nil {
entry.cancel()
}
}
}
// cancelAllTransientRetries drains every armed retry timer at shutdown.
func (s *Service) cancelAllTransientRetries() {
s.transientRetries.Range(func(key, _ interface{}) bool {
if keyStr, ok := key.(string); ok {
s.resetTransientRetry(keyStr)
}
return true
})
}
// CancelTransientRetry stops an in-progress retry loop (user clicked Cancel)
// and surfaces the manual recovery banner so they can Resume or Start fresh.
// Returns true if a retry loop was active.
func (s *Service) CancelTransientRetry(ctx context.Context, taskID, sessionID string) bool {
_, active := s.transientRetries.Load(sessionID)
s.resetTransientRetry(sessionID)
if !active {
return false
}
s.logger.Info("user cancelled transient retry loop",
zap.String("task_id", taskID),
zap.String("session_id", sessionID))
execID, _ := s.agentManager.GetExecutionIDForSession(ctx, sessionID)
s.handleRecoverableFailure(ctx, watcher.AgentEventData{
TaskID: taskID,
SessionID: sessionID,
AgentExecutionID: execID,
ErrorMessage: "Provider overloaded — retries cancelled. Resume or start fresh to continue.",
})
return true
}
@@ -0,0 +1,309 @@
package orchestrator
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/orchestrator/watcher"
"github.com/kandev/kandev/internal/task/models"
)
// overloaded529 is the exact transient 529 Overloaded envelope the
// claude-agent-acp adapter surfaces over ACP as a prompt-time error event.
const overloaded529 = `{"code":-32603,"message":"Internal error: API Error: 529 Overloaded. ` +
`This is a server-side issue, usually temporary — try again in a moment.","data":{"errorKind":"server_error"}}`
func newTransientTestService(t *testing.T) (*Service, *mockMessageCreator) {
t.Helper()
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
agentMgr := &mockAgentManager{repoForExecutionLookup: repo}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
mc := &mockMessageCreator{}
svc.messageCreator = mc
return svc, mc
}
func TestHandleTransientFailure_SchedulesRetryAndEmitsWarning(t *testing.T) {
svc, mc := newTransientTestService(t)
t.Cleanup(svc.cancelAllTransientRetries)
took := svc.handleTransientFailure(context.Background(), watcher.AgentEventData{
TaskID: "t1",
SessionID: "s1",
ErrorMessage: overloaded529,
})
if !took {
t.Fatal("handleTransientFailure = false, want true (should own the transient failure)")
}
// A retry entry must be armed for the session.
if _, ok := svc.transientRetries.Load("s1"); !ok {
t.Fatal("expected a transient retry entry for s1")
}
if len(mc.sessionMessages) != 1 {
t.Fatalf("expected 1 status message, got %d", len(mc.sessionMessages))
}
msg := mc.sessionMessages[0]
if msg.metadata["variant"] != "warning" {
t.Errorf("variant = %v, want warning", msg.metadata["variant"])
}
if msg.metadata["retrying"] != true {
t.Errorf("retrying = %v, want true", msg.metadata["retrying"])
}
if msg.metadata["attempt"] != 1 {
t.Errorf("attempt = %v, want 1", msg.metadata["attempt"])
}
if msg.metadata["max_attempts"] != transientMaxAttempts {
t.Errorf("max_attempts = %v, want %d", msg.metadata["max_attempts"], transientMaxAttempts)
}
if msg.metadata["retry_in_seconds"] != 5 {
t.Errorf("retry_in_seconds = %v, want 5", msg.metadata["retry_in_seconds"])
}
if !strings.Contains(strings.ToLower(msg.content), "retrying") {
t.Errorf("content = %q, want a 'retrying' message", msg.content)
}
// Must NOT be the red recovery banner.
if msg.metadata["recovery_actions"] == true {
t.Errorf("transient retry must not set recovery_actions=true (that is the red banner)")
}
// Must carry a Cancel action.
actions, ok := msg.metadata["actions"].([]map[string]interface{})
if !ok || actionByTestID(actions, recoveryCancelRetryButtonTestID) == nil {
t.Errorf("expected a cancel action with test_id %q, got %v", recoveryCancelRetryButtonTestID, msg.metadata["actions"])
}
}
func TestHandleTransientFailure_NonTransientReturnsFalse(t *testing.T) {
svc, mc := newTransientTestService(t)
t.Cleanup(svc.cancelAllTransientRetries)
took := svc.handleTransientFailure(context.Background(), watcher.AgentEventData{
TaskID: "t1",
SessionID: "s1",
ErrorMessage: "agent crashed with exit code 1",
})
if took {
t.Fatal("handleTransientFailure = true for a non-transient error, want false")
}
if len(mc.sessionMessages) != 0 {
t.Errorf("expected no status message for non-transient error, got %d", len(mc.sessionMessages))
}
if _, ok := svc.transientRetries.Load("s1"); ok {
t.Error("expected no retry entry for a non-transient error")
}
}
func TestHandleTransientFailure_ExhaustedFallsThrough(t *testing.T) {
svc, mc := newTransientTestService(t)
t.Cleanup(svc.cancelAllTransientRetries)
// Pre-seed an entry that has already used the full budget.
svc.transientRetries.Store("s1", &transientRetryEntry{attempt: transientMaxAttempts, cancel: func() {}})
took := svc.handleTransientFailure(context.Background(), watcher.AgentEventData{
TaskID: "t1",
SessionID: "s1",
ErrorMessage: overloaded529,
})
if took {
t.Fatal("handleTransientFailure = true after budget exhausted, want false (fall through to recovery)")
}
if len(mc.sessionMessages) != 0 {
t.Errorf("expected no new retry status message on exhaustion, got %d", len(mc.sessionMessages))
}
if _, ok := svc.transientRetries.Load("s1"); ok {
t.Error("expected retry entry cleared on exhaustion")
}
}
func TestCancelTransientRetry_StopsLoopAndShowsRecovery(t *testing.T) {
svc, mc := newTransientTestService(t)
t.Cleanup(svc.cancelAllTransientRetries)
svc.scheduleTransientRetry("t1", "s1", "", 1, 5*time.Second)
if _, ok := svc.transientRetries.Load("s1"); !ok {
t.Fatal("expected an armed retry entry before cancel")
}
cancelled := svc.CancelTransientRetry(context.Background(), "t1", "s1")
if !cancelled {
t.Fatal("CancelTransientRetry = false, want true (a loop was active)")
}
if _, ok := svc.transientRetries.Load("s1"); ok {
t.Error("expected retry entry cleared after cancel")
}
// Cancel surfaces the red recovery banner so the user can recover manually.
if len(mc.sessionMessages) != 1 {
t.Fatalf("expected 1 recovery status message after cancel, got %d", len(mc.sessionMessages))
}
if mc.sessionMessages[0].metadata["recovery_actions"] != true {
t.Errorf("expected recovery_actions=true after cancel, got %v", mc.sessionMessages[0].metadata["recovery_actions"])
}
}
func TestCancelTransientRetry_NoActiveLoop(t *testing.T) {
svc, _ := newTransientTestService(t)
if svc.CancelTransientRetry(context.Background(), "t1", "s1") {
t.Error("CancelTransientRetry = true with no active loop, want false")
}
}
func TestResetTransientRetry_ClearsEntry(t *testing.T) {
svc, _ := newTransientTestService(t)
svc.scheduleTransientRetry("t1", "s1", "", 1, 5*time.Second)
svc.resetTransientRetry("s1")
if _, ok := svc.transientRetries.Load("s1"); ok {
t.Error("expected entry cleared after resetTransientRetry")
}
}
func TestResetTransientRetry_DropsCachedPrompt(t *testing.T) {
svc, _ := newTransientTestService(t)
// The cached prompt can hold large/sensitive attachment data — it must be
// released when the retry loop ends, not retained for the backend's life.
svc.rememberTurnPrompt("s1", "hello", "", false, nil)
if _, ok := svc.lastTurnPrompt.Load("s1"); !ok {
t.Fatal("expected a cached prompt")
}
svc.resetTransientRetry("s1")
if _, ok := svc.lastTurnPrompt.Load("s1"); ok {
t.Error("expected cached prompt cleared after resetTransientRetry")
}
}
func TestRetryTransientPrompt_NoCachedPromptSurfacesRecovery(t *testing.T) {
svc, mc := newTransientTestService(t)
t.Cleanup(svc.cancelAllTransientRetries)
// Arm a retry entry but never cache a prompt (an uncached launch path).
svc.scheduleTransientRetry("t1", "s1", "", 1, time.Hour)
// The timer firing with no cached prompt must NOT leave the loop parked —
// it clears the entry and surfaces the manual recovery banner.
svc.retryTransientPrompt(context.Background(), "t1", "s1", "")
if _, ok := svc.transientRetries.Load("s1"); ok {
t.Error("expected retry entry cleared when no prompt is cached")
}
hasRecovery := false
for _, m := range mc.sessionMessages {
if m.metadata["recovery_actions"] == true {
hasRecovery = true
}
}
if !hasRecovery {
t.Error("expected a recovery_actions banner after an uncached retry")
}
}
func TestRetryTransientPrompt_SynchronousPromptErrorSurfacesRecovery(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
session, err := repo.GetTaskSession(context.Background(), "s1")
if err != nil {
t.Fatalf("failed to load session: %v", err)
}
session.State = models.TaskSessionStateWaitingForInput
if err := repo.UpdateTaskSession(context.Background(), session); err != nil {
t.Fatalf("failed to update session: %v", err)
}
seedExecutorRunning(t, repo, "s1", "t1", "exec-1")
agentMgr := &mockAgentManager{
repoForExecutionLookup: repo,
promptErr: errors.New("session rejected prompt synchronously"),
}
svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), agentMgr)
mc := &mockMessageCreator{}
svc.messageCreator = mc
t.Cleanup(svc.cancelAllTransientRetries)
svc.rememberTurnPrompt("s1", "hello", "", false, nil)
svc.transientRetries.Store("s1", &transientRetryEntry{attempt: 1, cancel: func() {}})
svc.retryTransientPrompt(context.Background(), "t1", "s1", "exec-1")
if _, ok := svc.transientRetries.Load("s1"); ok {
t.Error("expected retry entry cleared after synchronous PromptTask failure")
}
if _, ok := svc.lastTurnPrompt.Load("s1"); ok {
t.Error("expected cached prompt cleared after synchronous PromptTask failure")
}
hasRecovery := false
for _, m := range mc.sessionMessages {
if m.metadata["recovery_actions"] == true {
hasRecovery = true
}
}
if !hasRecovery {
t.Error("expected recovery banner after synchronous PromptTask failure")
}
}
func TestTransientRetryEntryClaimPreventsDoubleFire(t *testing.T) {
entry := &transientRetryEntry{}
if !entry.claim() {
t.Fatal("first claim = false, want true")
}
if entry.claim() {
t.Fatal("second claim = true, want false")
}
}
func TestCancelAllTransientRetries_DropsCachedPrompt(t *testing.T) {
svc, _ := newTransientTestService(t)
svc.rememberTurnPrompt("s1", "hello", "", false, nil)
svc.scheduleTransientRetry("t1", "s1", "", 1, time.Hour)
svc.cancelAllTransientRetries()
if _, ok := svc.transientRetries.Load("s1"); ok {
t.Error("expected retry entry cleared after cancelAllTransientRetries")
}
if _, ok := svc.lastTurnPrompt.Load("s1"); ok {
t.Error("expected cached prompt cleared after cancelAllTransientRetries")
}
}
func TestTransientRetryDelay(t *testing.T) {
cases := []struct {
attempt int
want time.Duration
}{
{0, 5 * time.Second}, // clamps up
{1, 5 * time.Second},
{2, 15 * time.Second},
{3, 30 * time.Second},
{4, 30 * time.Second}, // clamps to last
}
for _, tc := range cases {
if got := transientRetryDelay(tc.attempt); got != tc.want {
t.Errorf("transientRetryDelay(%d) = %v, want %v", tc.attempt, got, tc.want)
}
}
}
func TestHandleTransientFailure_AttemptCounterIncrements(t *testing.T) {
svc, _ := newTransientTestService(t)
t.Cleanup(svc.cancelAllTransientRetries)
data := watcher.AgentEventData{TaskID: "t1", SessionID: "s1", ErrorMessage: overloaded529}
svc.handleTransientFailure(context.Background(), data)
svc.handleTransientFailure(context.Background(), data)
v, ok := svc.transientRetries.Load("s1")
if !ok {
t.Fatal("expected retry entry")
}
entry, _ := v.(*transientRetryEntry)
if entry.attempt != 2 {
t.Errorf("attempt = %d, want 2 after two transient failures", entry.attempt)
}
}
@@ -180,7 +180,7 @@ func (h *Handlers) wsSetPlanMode(ctx context.Context, msg *ws.Message) (*ws.Mess
type wsRecoverSessionRequest struct {
TaskID string `json:"task_id"`
SessionID string `json:"session_id"`
Action string `json:"action"` // "resume" or "fresh_start"
Action string `json:"action"` // "resume", "fresh_start", or "cancel_retry"
}
func (h *Handlers) wsRecoverSession(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
@@ -194,8 +194,16 @@ func (h *Handlers) wsRecoverSession(ctx context.Context, msg *ws.Message) (*ws.M
if req.SessionID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "session_id is required", nil)
}
// Cancel an in-progress transient (529 Overloaded) retry loop and surface
// the manual recovery banner. Distinct from resume/fresh_start: it does not
// relaunch the agent, it stops the backoff timer.
if req.Action == "cancel_retry" {
cancelled := h.service.CancelTransientRetry(ctx, req.TaskID, req.SessionID)
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{"cancelled": cancelled})
}
if req.Action != "resume" && req.Action != "fresh_start" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "action must be 'resume' or 'fresh_start'", nil)
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "action must be 'resume', 'fresh_start', or 'cancel_retry'", nil)
}
resp, err := h.service.RecoverSession(ctx, req.TaskID, req.SessionID, req.Action)
@@ -0,0 +1,39 @@
package handlers
import (
"context"
"encoding/json"
"testing"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/orchestrator"
ws "github.com/kandev/kandev/pkg/websocket"
"github.com/stretchr/testify/require"
)
func setupOrchestratorHandlers(t *testing.T) *Handlers {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "console",
OutputPath: "stderr",
})
require.NoError(t, err)
return NewHandlers(&orchestrator.Service{}, log)
}
func TestWsRecoverSessionCancelRetryReportsServiceResult(t *testing.T) {
handlers := setupOrchestratorHandlers(t)
response, err := handlers.wsRecoverSession(context.Background(), createTestMessage(t, ws.ActionSessionRecover, map[string]interface{}{
"task_id": "t1",
"session_id": "s1",
"action": "cancel_retry",
}))
require.NoError(t, err)
var payload struct {
Cancelled bool `json:"cancelled"`
}
require.NoError(t, json.Unmarshal(response.Payload, &payload))
require.False(t, payload.Cancelled)
}
@@ -334,6 +334,17 @@ type Service struct {
// phantom turn just to host it.
cancelInFlight sync.Map
// transientRetries tracks in-progress transient-provider-error (529
// Overloaded) retry loops. key: sessionID, value: *transientRetryEntry.
// A backoff timer per session re-drives the failed prompt; cancelled on
// success, user-cancel, or service shutdown.
transientRetries sync.Map
// lastTurnPrompt caches the most recent outbound prompt per session so a
// transient-failure retry can re-drive the same turn without the caller's
// context. key: sessionID, value: capturedPrompt. Replaced every turn.
lastTurnPrompt sync.Map
// Service state
mu sync.RWMutex
running bool
@@ -844,6 +855,7 @@ func (s *Service) Stop() error {
}
s.cancelAllClarificationWatchdogs()
s.cancelAllTransientRetries()
if len(errs) > 0 {
return errs[0]
@@ -13,6 +13,7 @@ import (
"github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
"github.com/kandev/kandev/internal/agent/runtime/routingerr"
"github.com/kandev/kandev/internal/orchestrator/dto"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/queue"
@@ -380,6 +381,10 @@ func (s *Service) StartCreatedSession(ctx context.Context, taskID, sessionID, ag
executorID := session.ExecutorID
// Cache the raw prompt so a transient-provider-error (529) retry can
// re-drive this first turn — initial launches bypass PromptTask.
s.rememberTurnPrompt(sessionID, prompt, "", planMode, attachments)
execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, executor.LaunchOptions{AgentProfileID: effectiveProfileID, ExecutorID: executorID, Prompt: effectivePrompt, StartAgent: true, Attachments: attachments})
if err != nil {
return nil, err
@@ -606,6 +611,10 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s
mcpMode = executor.McpModeOffice
}
// Cache the raw prompt so a transient-provider-error (529) retry can
// re-drive this first turn — initial launches bypass PromptTask.
s.rememberTurnPrompt(sessionID, prompt, "", planMode, attachments)
execution, err := s.executor.LaunchPreparedSession(ctx, task, sessionID, executor.LaunchOptions{
AgentProfileID: agentProfileID,
ExecutorID: executorID,
@@ -1999,6 +2008,11 @@ func (s *Service) PromptTask(ctx context.Context, taskID, sessionID string, prom
previousSessionState := session.State
// Cache the raw prompt so a transient-provider-error (529) retry can
// re-drive this turn after backoff without the caller's context. Stores
// the pre-injection prompt; PromptTask re-applies config/plan transforms.
s.rememberTurnPrompt(sessionID, prompt, model, planMode, attachments)
s.setSessionRunning(ctx, taskID, sessionID, session)
s.startTurnForSession(ctx, sessionID)
@@ -2076,7 +2090,11 @@ func (s *Service) handlePromptError(ctx context.Context, taskID, sessionID strin
// force-unblock a hung agent. That is not a "review this failure" condition —
// Service.CancelAgent reconciles DB state (WAITING_FOR_INPUT, cancel message,
// complete turn) already.
if !isTransientPromptError(err) && !errors.Is(err, lifecycle.ErrCancelEscalated) {
// A transient provider error (529 Overloaded) is owned by the async
// retry-with-backoff path (handleTransientFailure), which keeps the task
// in progress while it retries — so don't flap it to REVIEW here.
if !isTransientPromptError(err) && !errors.Is(err, lifecycle.ErrCancelEscalated) &&
!routingerr.IsTransientProviderError(err.Error()) {
_ = s.taskRepo.UpdateTaskState(ctx, taskID, v1.TaskStateReview)
}
s.completeTurnForSession(ctx, sessionID)
@@ -0,0 +1,108 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { StateProvider } from "@/components/state-provider";
import { ActionMessage } from "./action-message";
import { sessionId as toSessionId, taskId as toTaskId, type Message } from "@/lib/types/http";
const requestMock = vi.fn().mockResolvedValue({});
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ request: requestMock }),
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const CANCEL_TEST_ID = "recovery-cancel-retry-button";
function retryMessage(overrides: Partial<Message> = {}): Message {
return {
id: "msg-1",
session_id: toSessionId("sess-1"),
task_id: toTaskId("task-1"),
author_type: "system",
content: "Provider overloaded — retrying in 5s (attempt 1/3)",
type: "status",
created_at: "2026-05-30T00:00:00Z",
metadata: {
variant: "warning",
retrying: true,
attempt: 1,
max_attempts: 3,
retry_in_seconds: 5,
session_id: "sess-1",
task_id: "task-1",
actions: [
{
type: "ws_request",
label: "Cancel",
icon: "x",
test_id: CANCEL_TEST_ID,
params: {
method: "session.recover",
payload: { task_id: "task-1", session_id: "sess-1", action: "cancel_retry" },
},
},
],
},
...overrides,
} as Message;
}
function Wrapper({ children }: { children: ReactNode }) {
return <StateProvider initialState={{}}>{children}</StateProvider>;
}
describe("ActionMessage — transient retry (warning variant)", () => {
it("renders the retrying copy in amber, not red", () => {
render(<ActionMessage comment={retryMessage()} sessionState="WAITING_FOR_INPUT" />, {
wrapper: Wrapper,
});
const text = screen.getByText(/retrying in 5s \(attempt 1\/3\)/i);
expect(text.className).toContain("text-amber-600");
expect(text.className).not.toContain("text-red-600");
});
it("Cancel fires a session.recover ws_request with action cancel_retry", async () => {
render(<ActionMessage comment={retryMessage()} sessionState="WAITING_FOR_INPUT" />, {
wrapper: Wrapper,
});
fireEvent.click(screen.getByTestId(CANCEL_TEST_ID));
await waitFor(() => expect(requestMock).toHaveBeenCalledTimes(1));
expect(requestMock).toHaveBeenCalledWith("session.recover", {
task_id: "task-1",
session_id: "sess-1",
action: "cancel_retry",
});
});
it("hides while the session is RUNNING (retry in flight) to avoid a stale card", () => {
const { container } = render(
<ActionMessage comment={retryMessage()} sessionState="RUNNING" />,
{ wrapper: Wrapper },
);
expect(container.firstChild).toBeNull();
});
it("renders the red variant for a non-warning recovery banner", () => {
const errorMsg = retryMessage({
content: "Agent encountered an error",
metadata: {
variant: "error",
recovery_actions: true,
actions: [
{ type: "ws_request", label: "Resume session", test_id: "recovery-resume-button" },
],
},
} as Partial<Message>);
render(<ActionMessage comment={errorMsg} sessionState="WAITING_FOR_INPUT" />, {
wrapper: Wrapper,
});
const text = screen.getByText(/Agent encountered an error/i);
expect(text.className).toContain("text-red-600");
expect(text.className).not.toContain("text-amber-600");
});
});
@@ -9,6 +9,7 @@ import {
IconPlayerPlay,
IconSparkles,
IconGitCommit,
IconX,
} from "@tabler/icons-react";
import { cn } from "@/lib/utils";
import { Button } from "@kandev/ui/button";
@@ -31,6 +32,7 @@ const ICON_MAP: Record<string, React.ElementType> = {
sparkles: IconSparkles,
"git-commit": IconGitCommit,
"alert-triangle": IconAlertTriangle,
x: IconX,
};
type ActionMeta = {
+6
View File
@@ -130,6 +130,12 @@ export type StatusMetadata = {
message?: string;
variant?: "default" | "warning" | "error";
cancelled?: boolean;
// Transient provider-error (529 Overloaded) retry state. Present on the
// yellow "retrying" status message the orchestrator emits during backoff.
retrying?: boolean;
attempt?: number;
max_attempts?: number;
retry_in_seconds?: number;
};
export type RecoveryAuthMethod = {
+33 -1
View File
@@ -1,5 +1,7 @@
import { expect } from "@playwright/test";
import { expect, type Page } from "@playwright/test";
import type { SeedData } from "../fixtures/test-base";
import type { ApiClient } from "./api-client";
import { SessionPage } from "../pages/session-page";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
@@ -64,3 +66,33 @@ export async function waitForSessionEnvironment(
)
.toBe(options.expectedEnvironmentId);
}
/**
* Seed a task + session and navigate to it, waiting for the first (normal)
* turn to complete. Follow-up prompts can then exercise retry flows from a
* clean idle state.
*/
export async function seedIdleSession(
testPage: Page,
apiClient: ApiClient,
seedData: SeedData,
title: string,
): Promise<SessionPage> {
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
title,
seedData.agentProfileId,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
return session;
}
+21
View File
@@ -419,6 +419,16 @@ export class SessionPage {
return this.page.getByTestId("recovery-fresh-button");
}
/** "Cancel" button shown on the yellow transient-retry (529 Overloaded) card. */
recoveryCancelRetryButton(): Locator {
return this.page.getByTestId("recovery-cancel-retry-button");
}
/** The yellow "Provider overloaded — retrying…" status card text. */
transientRetryCard(): Locator {
return this.chat.getByText(/Provider overloaded — retrying/i);
}
/** Context reset divider shown in chat after resetting agent context. */
contextResetDivider(): Locator {
return this.chat.getByText("Context reset");
@@ -741,6 +751,17 @@ export class SessionPage {
await editor.press(`${modifier}+Enter`);
}
/**
* Type and submit a chat message via the Send button. Mobile (touch) layouts
* don't submit on Ctrl/Cmd+Enter, so mobile specs use this instead.
*/
async sendMessageViaButton(text: string) {
const editor = this.page.locator(".tiptap.ProseMirror").first();
await editor.click();
await editor.fill(text);
await this.page.getByTestId("submit-message-button").click();
}
/** Toggle plan mode on/off by clicking the plan mode toggle button in the toolbar.
*
* Waits for the button to advertise `data-plan-available="true"` before clicking.
@@ -0,0 +1,27 @@
// Filename starts with "mobile-" so it runs on the mobile-chrome Playwright
// project (Pixel 5 emulation) — see e2e/playwright.config.ts. Mobile parity for
// the transient provider-error (529 Overloaded) retry flow: the yellow retry
// card and its Cancel button must render and work on a narrow touch viewport.
import { test, expect } from "../../fixtures/test-base";
import { seedIdleSession } from "../../helpers/session";
test.describe("mobile: transient provider error retry", () => {
test("yellow retry card + Cancel works on mobile and surfaces recovery", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await seedIdleSession(testPage, apiClient, seedData, "Mobile Overloaded Test");
await session.sendMessageViaButton("/overloaded:9");
// Yellow retry card + Cancel button render on the narrow viewport.
await expect(session.transientRetryCard()).toBeVisible({ timeout: 30_000 });
await expect(session.recoveryCancelRetryButton()).toBeVisible();
await expect(session.recoveryResumeButton()).toBeHidden();
// Tap Cancel → red recovery banner.
await session.recoveryCancelRetryButton().click();
await expect(session.recoveryResumeButton()).toBeVisible({ timeout: 30_000 });
});
});
@@ -0,0 +1,88 @@
import { test, expect } from "../../fixtures/test-base";
import { seedIdleSession } from "../../helpers/session";
import { SessionPage } from "../../pages/session-page";
test.describe("transient provider error (529 Overloaded) retry", () => {
test("shows the yellow retrying card, not the red error banner", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await seedIdleSession(testPage, apiClient, seedData, "Overloaded Retry Test");
// /overloaded:9 keeps failing so the retry loop stays visible until cancel.
await session.sendMessage("/overloaded:9");
// The calm yellow "retrying" card + Cancel button must appear...
await expect(session.transientRetryCard()).toBeVisible({ timeout: 30_000 });
await expect(session.recoveryCancelRetryButton()).toBeVisible();
// ...and the red recovery banner must NOT be shown yet (retries in flight).
await expect(session.recoveryResumeButton()).toBeHidden();
});
test("Cancel stops the retry loop and surfaces the recovery banner", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await seedIdleSession(testPage, apiClient, seedData, "Overloaded Cancel Test");
await session.sendMessage("/overloaded:9");
await expect(session.recoveryCancelRetryButton()).toBeVisible({ timeout: 30_000 });
await session.recoveryCancelRetryButton().click();
// Cancelling falls through to the red Resume / Start-fresh recovery banner.
await expect(session.recoveryResumeButton()).toBeVisible({ timeout: 30_000 });
await expect(session.recoveryFreshButton()).toBeVisible();
});
test("retries are paced — the attempt counter advances across the backoff", async ({
testPage,
apiClient,
seedData,
}) => {
const session = await seedIdleSession(testPage, apiClient, seedData, "Overloaded Backoff Test");
// /overloaded:9 keeps failing, so each backoff retry re-drives the prompt
// and the orchestrator advances the attempt counter. The yellow card must
// progress from attempt 1/3 to attempt 2/3 — proving the retry loop is live
// and paced rather than an instant, silent resume.
await session.sendMessage("/overloaded:9");
await expect(session.chat.getByText(/retrying in 5s \(attempt 1\/3\)/i)).toBeVisible({
timeout: 30_000,
});
await expect(session.chat.getByText(/attempt 2\/3/i)).toBeVisible({ timeout: 30_000 });
});
test("a 529 on the very first turn retries (launch prompt is cached)", async ({
testPage,
apiClient,
seedData,
}) => {
// Start the task with the failing prompt as the INITIAL turn. Initial
// launches go through LaunchPreparedSession, not PromptTask, so the prompt
// must be cached there too or the retry would park behind a stuck card.
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Initial Overload Test",
seedData.agentProfileId,
{
description: "/overloaded:9",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.chat.getByText(/retrying in 5s \(attempt 1\/3\)/i)).toBeVisible({
timeout: 30_000,
});
await expect(session.chat.getByText(/attempt 2\/3/i)).toBeVisible({ timeout: 30_000 });
});
});
@@ -0,0 +1,80 @@
# 0011: Transient provider errors (529 Overloaded) auto-retry with visible backoff
**Status:** accepted
**Date:** 2026-05-30
**Area:** backend, frontend
## Context
When the Anthropic API returns a transient `529 Overloaded` mid-turn, the agent
surfaces it over ACP as an error event that the backend turns into `agent.failed`.
Previously this was handled identically to a terminal failure: a red "Agent has
encountered an error" banner, the execution torn down, the task dropped to
`REVIEW`, and (in some paths) an instant silent re-resume — reading as a flickery
glitch rather than a deliberate retry. The classifier had no rule for 529, so it
fell through to `phase.poststart.unknown` → `CodeAgentRuntime` (`AutoRetryable=false`).
## Decision
Transient provider overload is now a first-class, auto-retryable condition with a
calm, paced, visible retry — separate from generic agent failures.
- **Classification** (`internal/agent/runtime/routingerr/`): new runtime rule
`anthropic.overloaded.529.v1` matches the `529 … overloaded` / `overloaded_error`
signature and maps it to a dedicated `CodeProviderOverloaded`
(`AutoRetryable=true, FallbackAllowed=true`). Chose a dedicated code over reusing
`CodeProviderUnavailable` (HTTP-503 semantics) for distinct UX copy and clean
`routing_code: provider_overloaded` metrics. Exposes
`IsTransientProviderError(msg) bool`, mirroring `IsResumeCorrupted`.
- **Orchestrator** (`internal/orchestrator/event_handlers_transient.go`):
`handleAgentFailed` routes transient errors to `handleTransientFailure` before
the red recoverable-failure path. It schedules a backoff retry (5s → 15s → 30s,
max 3 attempts) via a Service-owned cancellable timer goroutine (mirroring the
clarification-watchdog pattern; drained in `Stop()`), parks the session in
`WAITING_FOR_INPUT` (calm, banner-less) without moving the task to `REVIEW` or
cleaning up, and emits a `variant: "warning"` status message with a Cancel
action. On each retry it tears down the failed execution and re-drives the
cached prompt via `PromptTask` (resuming a fresh agent). The prompt is cached
both in `PromptTask` (follow-ups) and in the `LaunchPreparedSession` initial
paths (`startTask`, `StartCreatedSession`) so a 529 on the very first turn can
retry too; if no prompt is cached, the retry clears itself and surfaces the
recovery banner rather than parking. After the budget is exhausted, or on
Cancel (`session.recover` action `cancel_retry`), it falls through to the
existing red Resume / Start-fresh banner (with friendly "stayed overloaded
after several retries" copy instead of the raw 529). Gated to non-office tasks
(`isOfficeTask`, i.e. `AssigneeAgentProfileID`) so genuine office tasks keep
their structured error UI. The transient branch runs *before* run-mode
automation finalization in `handleAgentFailed` — it's the only non-terminal
failure path, so finalizing (which reaps the ephemeral worktree) must wait
until the budget is actually spent. The synchronous prompt-error path
(`handlePromptError`) also skips the `REVIEW` transition for transient errors.
`httpStatusToCode` maps a structured `529` to `CodeProviderOverloaded` too,
for adapters that surface the status separately from the body.
- **Frontend** (`components/task/chat/messages/action-message.tsx`): the existing
`variant: "warning"` rendering (amber, not red) drives the retry card; the
bottom `AgentStatus` banner is never `FAILED` during retry, so there is no red
flicker. The Cancel button dispatches the `cancel_retry` `ws_request`.
- **Repro** (`cmd/mock-agent/`): a `/overloaded[:N]` scenario returns a real
prompt-time ACP error carrying the production 529 string for the first N
prompts (file-backed counter), then recovers.
## Consequences
- A 529 mid-turn now reads as "Provider overloaded — retrying in Ns (attempt X/Y)"
with a Cancel button instead of a red error flash, and self-heals when the
provider recovers, without user intervention.
- Adds a per-session retry-loop owner the Service must drain on shutdown
(goleak-covered).
- Re-driving via `PromptTask` re-sends the user prompt to the resumed agent; for a
real agent this could append a duplicate user turn in agent-side history — an
accepted limitation for the transient-recovery case.
## Alternatives Considered
- **Reuse `CodeProviderUnavailable`** — rejected: conflates HTTP-503 "unavailable"
with "temporarily overloaded, retry"; muddies metrics and UX copy.
- **Keep the agent process alive and re-prompt in place** — rejected: the lifecycle
marks the execution FAILED on a prompt error and rejects further prompts, so the
retry must tear down and resume a fresh execution.
- **Office-style FAILED state for transient errors** — rejected: the goal is an
uninterrupted working→retrying→working chat, not a terminal failure surface.
+1
View File
@@ -16,3 +16,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 0008 | [DB upgrade safety - meta table, pre-migration backup, migration logging](0008-db-upgrade-safety.md) | accepted | backend | 2026-05-16 |
| 0009 | [Fail-closed GC semantics for filesystem and container cleanup](0009-fail-closed-gc-semantics.md) | accepted | backend | 2026-05-16 |
| 0010 | [Worktree copy-files — per-repo, idempotent, host-local](0010-worktree-copy-files.md) | accepted | backend, frontend | 2026-05-19 |
| 0011 | [Transient provider errors (529 Overloaded) auto-retry with visible backoff](0011-transient-provider-error-retry.md) | accepted | backend, frontend | 2026-05-30 |