Files
kandev/apps/backend/internal/orchestrator/event_handlers_transient.go
T

1015 lines
34 KiB
Go

package orchestrator
import (
"context"
"fmt"
"sort"
"sync"
"time"
"go.uber.org/zap"
agentruntime "github.com/kandev/kandev/internal/agent/runtime"
"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 high-confidence short provider
// failure is auto-retried with backoff before falling through to the manual
// recovery banner.
const transientMaxAttempts = 5
const transientRetryStopTimeout = 30 * time.Second
const defaultTransientRetryNoticeFenceTTL = 5 * time.Minute
// 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"
metaKeyAgentID = "agent_id"
metaKeyNewState = "new_state"
metaKeyAgentProfileID = "agent_profile_id"
metaKeyUpdatedAt = "updated_at"
metaKeyExecutorProfile = "executor_profile_id"
metaKeyWorkflowStepID = "workflow_step_id"
metaKeyPrompt = "prompt"
metaKeyPlanMode = "plan_mode"
metaKeyAttachments = "attachments"
)
// 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"
// metaVariantCeiling is the status-message variant AC-49 requires for every
// session-ceiling card note, including AC-17c's drop note.
const metaVariantCeiling = "ceiling"
// transientRetryBackoff is the per-attempt delay before re-driving a turn that
// failed transiently. Index is attempt-1 (5s → 10s → 20s → 40s → 60s).
var transientRetryBackoff = []time.Duration{
5 * time.Second,
10 * time.Second,
20 * time.Second,
40 * time.Second,
60 * 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]
}
// transientRetryDelayFor honors a validated provider reset deadline when it
// is close enough to be useful. Longer or stale hints fall back to the stable
// local ladder so a provider cannot keep the session parked indefinitely.
func transientRetryDelayFor(classified *routingerr.Error, attempt int, now time.Time) time.Duration {
if classified != nil && classified.ResetHint != nil && !classified.ResetHint.Before(now) {
untilReset := classified.ResetHint.Sub(now)
if untilReset <= time.Minute {
return untilReset
}
}
return transientRetryDelay(attempt)
}
// capturedPrompt is the minimal context needed to re-drive a failed turn.
type capturedPrompt struct {
text string
model string
planMode bool
attachments []v1.MessageAttachment
onAccepted func(turnID string)
}
// 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()
retryCtx context.Context
mu sync.Mutex
claimed bool
armed bool
}
func (e *transientRetryEntry) claim() bool {
e.mu.Lock()
defer e.mu.Unlock()
if e.claimed {
return false
}
e.claimed = true
return true
}
func (e *transientRetryEntry) arm() bool {
e.mu.Lock()
defer e.mu.Unlock()
if e.armed {
return false
}
e.armed = true
return true
}
func (s *Service) transientRetryNoticeFenceDuration() time.Duration {
if s.transientRetryNoticeFenceTTL > 0 {
return s.transientRetryNoticeFenceTTL
}
return defaultTransientRetryNoticeFenceTTL
}
// acquireTransientRetryNoticeState returns the one state object for a
// session. The release function keeps the map entry alive until no caller can
// still hold its mutex, even when a lifecycle operation is waiting behind
// another caller.
func (s *Service) acquireTransientRetryNoticeState(sessionID string) (*transientRetryNoticeState, func()) {
if sessionID == "" {
return nil, func() {}
}
s.transientRetryNoticeStatesMu.Lock()
if s.transientRetryNoticeStates == nil {
s.transientRetryNoticeStates = make(map[string]*transientRetryNoticeState)
}
state := s.transientRetryNoticeStates[sessionID]
if state == nil {
state = &transientRetryNoticeState{}
s.transientRetryNoticeStates[sessionID] = state
}
state.refs++
s.transientRetryNoticeStatesMu.Unlock()
var releaseOnce sync.Once
release := func() {
releaseOnce.Do(func() {
s.releaseTransientRetryNoticeState(sessionID, state)
})
}
return state, release
}
func (s *Service) releaseTransientRetryNoticeState(sessionID string, state *transientRetryNoticeState) {
s.transientRetryNoticeStatesMu.Lock()
if state.refs > 0 {
state.refs--
}
s.reclaimTransientRetryNoticeStateLocked(sessionID, state)
s.transientRetryNoticeStatesMu.Unlock()
}
func (s *Service) reclaimTransientRetryNoticeStateLocked(sessionID string, state *transientRetryNoticeState) {
if state.refs != 0 || state.owned.Load() {
return
}
if state.retired.Load() && state.retiredUntil.Load() > time.Now().UnixNano() {
return
}
if current, ok := s.transientRetryNoticeStates[sessionID]; !ok || current != state {
return
}
if state.fenceTimer != nil {
state.fenceTimer.Stop()
state.fenceTimer = nil
}
delete(s.transientRetryNoticeStates, sessionID)
}
func (s *Service) retireTransientRetryNoticeLocked(sessionID string, state *transientRetryNoticeState) {
// Callers hold state.mu. Keep this order, state.mu -> statesMu, everywhere
// that touches the lifecycle map so a waiter cannot observe a replacement
// state while another caller still owns this state's mutex.
state.retired.Store(true)
ttl := s.transientRetryNoticeFenceDuration()
state.retiredUntil.Store(time.Now().Add(ttl).UnixNano())
s.transientRetryNoticeStatesMu.Lock()
if current, ok := s.transientRetryNoticeStates[sessionID]; ok && current == state {
if state.fenceTimer != nil {
state.fenceTimer.Stop()
}
state.fenceTimer = time.AfterFunc(ttl, func() {
s.expireTransientRetryNoticeFence(sessionID, state)
})
}
s.transientRetryNoticeStatesMu.Unlock()
}
func (s *Service) expireTransientRetryNoticeFence(sessionID string, state *transientRetryNoticeState) {
s.transientRetryNoticeStatesMu.Lock()
if current, ok := s.transientRetryNoticeStates[sessionID]; !ok || current != state {
s.transientRetryNoticeStatesMu.Unlock()
return
}
state.fenceTimer = nil
if state.retired.Load() {
if remaining := time.Until(time.Unix(0, state.retiredUntil.Load())); remaining > 0 {
state.fenceTimer = time.AfterFunc(remaining, func() {
s.expireTransientRetryNoticeFence(sessionID, state)
})
} else {
s.reclaimTransientRetryNoticeStateLocked(sessionID, state)
}
}
s.transientRetryNoticeStatesMu.Unlock()
}
func (s *Service) clearTransientRetryNoticeFenceLocked(sessionID string, state *transientRetryNoticeState) {
state.retired.Store(false)
state.retiredUntil.Store(0)
s.transientRetryNoticeStatesMu.Lock()
if current, ok := s.transientRetryNoticeStates[sessionID]; ok && current == state && state.fenceTimer != nil {
state.fenceTimer.Stop()
state.fenceTimer = nil
}
s.transientRetryNoticeStatesMu.Unlock()
}
// 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) {
s.rememberTurnPromptWithAccepted(sessionID, text, model, planMode, attachments, nil)
}
func (s *Service) rememberTurnPromptWithAccepted(
sessionID, text, model string, planMode bool, attachments []v1.MessageAttachment,
onAccepted func(turnID string),
) {
if sessionID == "" {
return
}
s.lastTurnPrompt.Store(sessionID, capturedPrompt{
text: text,
model: model,
planMode: planMode,
attachments: attachments,
onAccepted: onAccepted,
})
}
// handleTransientFailure routes a high-confidence short provider error
// 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 {
// Dynamic profiles own both error classes and their retry/reset policy. The
// legacy Kanban retry ladder must not consume a configured dynamic retry
// budget before the shared evaluator sees the failure.
if data.DynamicRouteAttempt {
return false
}
if data.SessionID == "" {
return false
}
noticeState, releaseNoticeState := s.acquireTransientRetryNoticeState(data.SessionID)
noticeState.mu.Lock()
if noticeState.retired.Load() {
noticeState.mu.Unlock()
releaseNoticeState()
s.logger.Debug("ignoring transient failure after retry lifecycle was retired",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID))
return true
}
data = s.withPromptAttemptEvidenceLocked(data)
if data.DynamicRouteAttempt || !s.promptAttemptPreResultSafe(data) {
noticeState.mu.Unlock()
releaseNoticeState()
s.logger.Debug("refusing automatic transient retry without safe prompt-attempt evidence",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("agent_execution_id", data.AgentExecutionID),
zap.Uint64("prompt_generation", data.PromptGeneration))
return false
}
classified := classifyKanbanFailure(data)
if routingerr.Decide(routingerr.ContextKanban, classified, time.Now().UTC()) != routingerr.DecisionShortRetry {
noticeState.mu.Unlock()
releaseNoticeState()
return false
}
// Genuine Office-owned tasks render their own structured error UI. Keep them
// on the existing path rather than the Kanban-style yellow retry card.
if s.isOfficeTask(ctx, data.TaskID) {
noticeState.mu.Unlock()
releaseNoticeState()
return false
}
attempt := s.nextTransientAttemptLocked(data.SessionID)
if attempt > transientMaxAttempts {
noticeState.mu.Unlock()
releaseNoticeState()
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
}
now := time.Now().UTC()
delay := transientRetryDelayFor(classified, attempt, now)
retryAt := now.Add(delay)
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.createTransientRetryStatusMessageLocked(noticeState, ctx, data, classified, attempt, delay, retryAt)
// Reserve the next timer while the notice lifecycle is still serialized. A
// concurrent failure can replace this reservation, but cannot arm it until
// its own failed turn has been parked.
entry := s.reserveTransientRetryWithMetadataLocked(
noticeState,
data.TaskID,
data.SessionID,
data.AgentExecutionID,
attempt,
delay,
retryAt,
classified,
)
noticeState.mu.Unlock()
releaseNoticeState()
s.reconcileCIAutoFixTurnBeforeCompletion(ctx, data.TaskID, data.SessionID, "")
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)
// Parking must complete before a zero-delay retry can dispatch. Reacquiring
// the notice mutex also lets cancellation or a later failure replace this
// reservation before it is armed.
if entry != nil {
state, release := s.acquireTransientRetryNoticeState(data.SessionID)
state.mu.Lock()
if current, ok := s.transientRetries.Load(data.SessionID); ok && current == entry && !state.retired.Load() {
s.armTransientRetryEntryLocked(data.TaskID, data.SessionID, data.AgentExecutionID, entry, delay)
}
state.mu.Unlock()
release()
}
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 {
state, release := s.acquireTransientRetryNoticeState(sessionID)
if state == nil {
return 1
}
state.mu.Lock()
attempt := s.nextTransientAttemptLocked(sessionID)
state.mu.Unlock()
release()
return attempt
}
func (s *Service) nextTransientAttemptLocked(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) {
s.scheduleTransientRetryWithMetadata(taskID, sessionID, execID, attempt, delay, time.Now().UTC().Add(delay), nil)
}
func (s *Service) scheduleTransientRetryWithMetadata(
taskID, sessionID, execID string,
attempt int,
delay time.Duration,
retryAt time.Time,
classified *routingerr.Error,
) {
state, release := s.acquireTransientRetryNoticeState(sessionID)
if state == nil {
return
}
state.mu.Lock()
s.scheduleTransientRetryWithMetadataLocked(state, taskID, sessionID, execID, attempt, delay, retryAt, classified)
state.mu.Unlock()
release()
}
func (s *Service) scheduleTransientRetryWithMetadataLocked(
state *transientRetryNoticeState,
taskID, sessionID, execID string,
attempt int,
delay time.Duration,
retryAt time.Time,
classified *routingerr.Error,
) {
entry := s.reserveTransientRetryWithMetadataLocked(state, taskID, sessionID, execID, attempt, delay, retryAt, classified)
if entry != nil {
s.armTransientRetryEntryLocked(taskID, sessionID, execID, entry, delay)
}
}
func (s *Service) reserveTransientRetryWithMetadataLocked(
state *transientRetryNoticeState,
taskID, sessionID, execID string,
attempt int,
delay time.Duration,
retryAt time.Time,
classified *routingerr.Error,
) *transientRetryEntry {
if state.retired.Load() {
return nil
}
retryCtx, cancel := context.WithCancel(context.Background())
entry := &transientRetryEntry{attempt: attempt, cancel: cancel, retryCtx: retryCtx}
state.owned.Store(true)
s.transientRetries.Store(sessionID, entry)
return entry
}
func (s *Service) armTransientRetryEntryLocked(
taskID, sessionID, execID string,
entry *transientRetryEntry,
delay time.Duration,
) {
if entry == nil || !entry.arm() {
return
}
go s.runTransientRetry(entry.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: "Automatic provider retry was not possible. Resume or start fresh to continue.",
})
return
}
cp, _ := v.(capturedPrompt)
initialCreatePromptPassthrough := false
if session, sessionErr := s.repo.GetTaskSession(ctx, sessionID); sessionErr == nil && session != nil {
_, initialCreatePromptPassthrough = s.hydrateInitialCreatePromptPassthrough(session)
}
if execID != "" {
if !s.claimForcedExecutionCleanup(sessionID, execID) {
s.logger.Debug("skipping transient retry because execution teardown is already owned",
zap.String("session_id", sessionID),
zap.String("execution_id", execID))
s.resetTransientRetry(sessionID)
return
}
claim, claimed := s.executionTeardownClaimFor(sessionID, execID)
if err := s.stopTransientRetryExecution(ctx, execID); err != nil && !agentruntime.IsNotFound(err) {
s.logger.Debug("failed to stop failed execution before transient retry",
zap.String("session_id", sessionID),
zap.String("execution_id", execID),
zap.Error(err))
} else if claimed {
s.completeExecutionTeardownClaim(sessionID, execID, claim)
}
// handleAgentFailed terminal-marked this exact execution before the
// retry was scheduled, so no later frame may reclaim activity even when
// runtime teardown times out. Retirement is safe and idempotent on both
// stop outcomes.
s.retireExecutionActivityAndPublish(
context.WithoutCancel(ctx),
taskID,
sessionID,
execID,
)
}
if ctx.Err() != nil {
return
}
if _, err := s.promptTask(ctx, taskID, sessionID, cp.text, cp.model, cp.planMode, cp.attachments, false, launchOriginAutomatic, promptTaskOptions{
onAccepted: cp.onAccepted,
initialCreatePromptPassthrough: initialCreatePromptPassthrough,
}); 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: "Automatic provider retry could not be started. Resume or start fresh to continue.",
})
}
}
func (s *Service) stopTransientRetryExecution(ctx context.Context, executionID string) error {
stopCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), transientRetryStopTimeout)
defer cancel()
if s.lspLeases != nil {
s.lspLeases.StopLSPLeasesForExecution(executionID)
}
return s.executor.StopExecution(stopCtx, executionID, "transient retry: relaunching agent", true)
}
// 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,
classified *routingerr.Error,
attempt int,
delay time.Duration,
retryAt time.Time,
) {
state, release := s.acquireTransientRetryNoticeState(data.SessionID)
if state == nil {
return
}
state.mu.Lock()
s.createTransientRetryStatusMessageLocked(state, ctx, data, classified, attempt, delay, retryAt)
state.mu.Unlock()
release()
}
func (s *Service) createTransientRetryStatusMessageLocked(
state *transientRetryNoticeState,
ctx context.Context,
data watcher.AgentEventData,
classified *routingerr.Error,
attempt int,
delay time.Duration,
retryAt time.Time,
) {
if s.messageCreator == nil || state.retired.Load() {
return
}
content, meta := transientRetryStatusMessage(data, classified, attempt, delay, retryAt)
if s.transientRetryMessages != nil {
s.updateTransientRetryStatusMessageLocked(ctx, data, content, meta)
return
}
s.createTransientRetryStatusMessageRecord(ctx, data, content, meta)
}
func transientRetryStatusMessage(
data watcher.AgentEventData,
classified *routingerr.Error,
attempt int,
delay time.Duration,
retryAt time.Time,
) (string, map[string]interface{}) {
secs := int(delay.Seconds())
label := transientFailureLabel(classified)
content := fmt.Sprintf("%s — retrying in %ds (attempt %d/%d)", label, 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,
"retry_at": retryAt.UTC().Format(time.RFC3339Nano),
metaKeySessionID: data.SessionID,
metaKeyTaskID: data.TaskID,
"actions": []map[string]interface{}{cancelAction},
}
if classified != nil {
meta["failure_code"] = string(classified.Code)
}
providerID := data.AgentID
if providerError := data.ProviderError; providerError != nil {
if providerError.ProviderID != "" {
providerID = providerError.ProviderID
}
if modelID := routingerr.Sanitize(providerError.ModelID); modelID != "" {
meta["model_id"] = modelID
}
}
if providerID = routingerr.Sanitize(providerID); providerID != "" {
meta["provider_name"] = providerID
}
return content, meta
}
func (s *Service) createTransientRetryStatusMessageRecord(
ctx context.Context,
data watcher.AgentEventData,
content string,
meta map[string]interface{},
) {
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))
}
}
func (s *Service) updateTransientRetryStatusMessageLocked(
ctx context.Context,
data watcher.AgentEventData,
content string,
metadata map[string]interface{},
) {
// state.mu intentionally covers this DB I/O. Retirement and notice writes
// must serialize so cancellation cannot delete a row that this update then
// resurrects.
messages, err := s.transientRetryMessages.ListMessages(ctx, data.SessionID)
if err != nil {
s.logger.Warn("failed to list transient retry status messages before write",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.Error(err))
return
}
notices := transientRetryNotices(messages, data.TaskID, data.SessionID)
if len(notices) == 0 {
s.createTransientRetryStatusMessageRecord(ctx, data, content, metadata)
return
}
current := notices[0]
current.Content = content
current.Type = models.MessageTypeStatus
current.Metadata = metadata
current.RequestsInput = false
if err := s.transientRetryMessages.UpdateMessage(ctx, current); err != nil {
s.logger.Warn("failed to update transient retry status message",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("message_id", current.ID),
zap.Error(err))
return
}
for _, duplicate := range notices[1:] {
if err := s.transientRetryMessages.DeleteMessage(ctx, duplicate.ID); err != nil {
s.logger.Warn("failed to delete duplicate transient retry status message",
zap.String("task_id", data.TaskID),
zap.String("session_id", data.SessionID),
zap.String("message_id", duplicate.ID),
zap.Error(err))
}
}
}
func transientRetryNotices(messages []*models.Message, taskID, sessionID string) []*models.Message {
notices := make([]*models.Message, 0, len(messages))
for _, message := range messages {
if message == nil || message.TaskID != taskID || message.TaskSessionID != sessionID ||
message.Type != models.MessageTypeStatus || message.Metadata == nil {
continue
}
if retrying, ok := message.Metadata["retrying"].(bool); !ok || !retrying {
continue
}
notices = append(notices, message)
}
sort.SliceStable(notices, func(i, j int) bool {
if notices[i].CreatedAt.Equal(notices[j].CreatedAt) {
return notices[i].ID > notices[j].ID
}
return notices[i].CreatedAt.After(notices[j].CreatedAt)
})
return notices
}
func classifyKanbanFailure(data watcher.AgentEventData) *routingerr.Error {
providerID := data.AgentID
message := data.ErrorMessage
var resetHint *time.Time
if providerError := data.ProviderError; providerError != nil {
// Provider rules are keyed by agent ID. OpenCode diagnostics carry the
// model-provider ID instead ("opencode-go"), which has no rules; keeping
// the agent ID there lets the OpenCode usage-limit rule classify the
// failure so dynamic routing can advance to the next candidate.
if id := providerError.ProviderID; id != "" &&
!routingerr.HasProviderRules(providerID) && routingerr.HasProviderRules(id) {
providerID = id
}
if providerError.Message != "" {
message = providerError.Message
}
resetHint = providerError.ResetAt
}
phase := routingerr.PhasePromptSend
if data.DynamicRouteAttempt {
switch {
case data.EffectObserved:
phase = routingerr.PhaseToolExecution
case data.OutputObserved:
phase = routingerr.PhaseStreaming
case !data.EvidenceKnown:
// Unknown attempt state is deliberately classified outside the
// pre-result phases. The dynamic route gate also requires explicit
// evidence, so this remains a defensive second fence.
phase = routingerr.PhaseStreaming
}
}
return routingerr.Classify(routingerr.Input{
Phase: phase,
ProviderID: providerID,
ResetHint: resetHint,
Stderr: message,
})
}
func transientFailureLabel(classified *routingerr.Error) string {
if classified == nil {
return "Provider temporarily unavailable"
}
switch classified.Code {
case routingerr.CodeModelCapacity:
return "Model at capacity"
case routingerr.CodeNetworkUnavailable:
return "Network unavailable"
case routingerr.CodeProviderOverloaded:
return "Provider overloaded"
case routingerr.CodeRateLimited:
return "Rate limited"
case routingerr.CodeAgentTransportLost:
return "Agent connection lost"
default:
return "Provider temporarily unavailable"
}
}
func transientFailureExhaustedMessage(classified *routingerr.Error) string {
condition := "The provider remained unavailable"
if classified != nil {
switch classified.Code {
case routingerr.CodeModelCapacity:
condition = "The selected model remained at capacity"
case routingerr.CodeNetworkUnavailable:
condition = "The network remained unavailable"
case routingerr.CodeProviderOverloaded:
condition = "The provider remained overloaded"
case routingerr.CodeRateLimited:
condition = "The rate limit remained active"
case routingerr.CodeProviderUnavailable:
condition = "The provider remained unavailable"
case routingerr.CodeAgentTransportLost:
condition = "The agent connection kept dropping"
}
}
return condition + " after several retries. Resume to try again, or start a fresh session."
}
// clearTransientRetryState clears a session's retry entry, cancels its timer,
// and drops the cached prompt (which may hold large/sensitive attachment data).
func (s *Service) clearTransientRetryState(sessionID string) bool {
if sessionID == "" {
return false
}
state, release := s.acquireTransientRetryNoticeState(sessionID)
state.mu.Lock()
active := s.clearTransientRetryStateLocked(sessionID, state)
state.mu.Unlock()
release()
return active
}
func (s *Service) clearTransientRetryStateLocked(sessionID string, state *transientRetryNoticeState) bool {
s.lastTurnPrompt.Delete(sessionID)
v, ok := s.transientRetries.LoadAndDelete(sessionID)
if ok {
if entry, ok := v.(*transientRetryEntry); ok && entry.cancel != nil {
entry.cancel()
}
}
state.owned.Store(false)
return ok
}
// resetTransientRetry clears in-memory retry state and retires the persisted
// retry notice(s). The detached context keeps durable cleanup best effort even
// when the event that ended the retry was cancelled by its caller.
func (s *Service) resetTransientRetry(sessionID string) {
s.resetTransientRetryWithContext(context.Background(), sessionID, false)
}
// forceResolve is used by explicit stop/cancel and terminal paths where a
// persisted notice can outlive the in-memory retry entry. Normal successful
// turns skip the transcript scan when no retry loop was owned.
func (s *Service) resetTransientRetryWithContext(ctx context.Context, sessionID string, forceResolve bool) {
if sessionID == "" {
return
}
state, release := s.acquireTransientRetryNoticeState(sessionID)
state.mu.Lock()
s.resetTransientRetryWithContextLocked(state, ctx, sessionID, forceResolve)
state.mu.Unlock()
release()
}
func (s *Service) resetTransientRetryWithContextLocked(
state *transientRetryNoticeState,
ctx context.Context,
sessionID string,
forceResolve bool,
) {
if !s.clearTransientRetryStateLocked(sessionID, state) && !forceResolve {
return
}
s.retireTransientRetryNoticeLocked(sessionID, state)
s.resolveTransientRetryMessagesLocked(context.WithoutCancel(ctx), sessionID)
}
// resolveTransientRetryMessages removes every persisted retry status message
// for a session. The task service owns the durable write and MessageDeleted
// publication. Cleanup is intentionally non-fatal to the transition that
// ended the retry loop.
func (s *Service) resolveTransientRetryMessages(ctx context.Context, sessionID string) {
if sessionID == "" {
return
}
state, release := s.acquireTransientRetryNoticeState(sessionID)
state.mu.Lock()
s.retireTransientRetryNoticeLocked(sessionID, state)
s.resolveTransientRetryMessagesLocked(ctx, sessionID)
state.mu.Unlock()
release()
}
func (s *Service) resolveTransientRetryMessagesLocked(ctx context.Context, sessionID string) {
if s.transientRetryMessages == nil || sessionID == "" {
return
}
messages, err := s.transientRetryMessages.ListMessages(ctx, sessionID)
if err != nil {
s.logger.Warn("failed to list transient retry status messages",
zap.String("session_id", sessionID),
zap.Error(err))
return
}
for _, message := range messages {
if message == nil || message.Metadata == nil {
continue
}
retrying, ok := message.Metadata["retrying"].(bool)
if !ok || !retrying {
continue
}
if err := s.transientRetryMessages.DeleteMessage(ctx, message.ID); err != nil {
s.logger.Warn("failed to delete transient retry status message",
zap.String("session_id", sessionID),
zap.String("message_id", message.ID),
zap.Error(err))
}
}
}
// retireAndClearTransientRetryState closes the in-memory lifecycle under the
// notice mutex. Callers use it when they already hold the task runtime mutex;
// durable cleanup remains outside that broader runtime critical section.
func (s *Service) retireAndClearTransientRetryState(sessionID string) {
if sessionID == "" {
return
}
state, release := s.acquireTransientRetryNoticeState(sessionID)
state.mu.Lock()
s.retireTransientRetryNoticeLocked(sessionID, state)
s.clearTransientRetryStateLocked(sessionID, state)
state.mu.Unlock()
release()
}
// 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 {
// Reports "nothing to cancel" on denial: the bool return carries no error
// channel, and a foreign session must not be distinguishable from an idle
// one. Guard first — resetTransientRetry below mutates retry state.
//
// Both IDs: taskID is handed to handleRecoverableFailure, which writes
// against that task, so the session check alone would leave it free to
// point at someone else's.
if err := s.authorizeTaskSessionPair(ctx, taskID, sessionID); err != nil {
return false
}
noticeState, releaseNoticeState := s.acquireTransientRetryNoticeState(sessionID)
if noticeState == nil {
return false
}
noticeState.mu.Lock()
_, active := s.transientRetries.Load(sessionID)
s.resetTransientRetryWithContextLocked(noticeState, ctx, sessionID, true)
noticeState.mu.Unlock()
releaseNoticeState()
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: "Automatic provider retries cancelled. Resume or start fresh to continue.",
UserInitiated: true,
})
return true
}