PR #3315
Sections
Review

fix(orchestrator): make kanban boards recover from agent failures too

main ← feature/on-agent-error-never-j4v 17 files +1847 −312 PR #3315 ↗

Kanban tasks now dispatch on_agent_error through the workflow engine when an agent session fails, so a board can auto-recover, move the card, or restart the agent instead of leaving the failure silent.

Why this change

on_agent_error was declared, compiled, and persisted, but only Office tasks ever dispatched it. A kanban step that declared on_agent_error passed validation and then did nothing when the agent failed.

What it does

Architecture, end to end

All terminal kanban failures converge on one funnel. The funnel releases the session guard, then dispatches on_agent_error through the engine. The engine evaluates the current step and applies transitions and callbacks.

flowchart LR
  R1[agent.failed bus event] --> Funnel[handleRecoverableFailureLockedState]
  R2[handleAgentStartFailed npm] --> Funnel
  R3[handleAgentStartFailed auth] --> Funnel
  R4[retryTransientPrompt no prompt] --> Funnel
  R5[retryTransientPrompt prompt error] --> Funnel
  Funnel --> Guard[release cancelInFlight guard]
  Guard --> Dispatch[dispatchKanbanAgentErrorTrigger]
  Dispatch --> Engine[Engine HandleTrigger EvaluateOnly]
  Engine --> Transition[applyEngineTransition]
  Transition --> OnEnter[destination on_enter]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

func (s *Service) dispatchKanbanAgentErrorTrigger(ctx context.Context, data watcher.AgentEventData)
Click for details →

This is the new kanban fire site. It checks guards, builds PreloadedState after reconciliation, and calls the engine.

Guard sequence and engine call
func (s *Service) dispatchKanbanAgentErrorTrigger(ctx context.Context, data watcher.AgentEventData) {
  deps := s.agentErrorDeps.Load()
  if deps == nil || deps.engine == nil {
    return
  }
  if data.SessionID == "" {
    return
  }
  if data.UserInitiated {
    s.logger.Debug(msgAgentErrorUserInitiated,
      zap.String("task_id", data.TaskID),
      zap.String("session_id", data.SessionID))
    return
  }
  session, task, ok := s.resolveAgentErrorDispatchTarget(ctx, data)
  if !ok {
    return
  }
  state := s.buildMachineState(ctx, task, session)
  operationID := agentErrorOperationID(data.SessionID, data.AgentExecutionID)
  s.warnUnregisteredAgentErrorActions(ctx, deps, task, data.SessionID)
  result, err := deps.engine.HandleTrigger(ctx, engine.HandleInput{
    TaskID: data.TaskID,
    SessionID: data.SessionID,
    Trigger: engine.TriggerOnAgentError,
    OperationID: operationID,
    EvaluateOnly: true,
    PreloadedState: &state,
    Payload: engine.OnAgentErrorPayload{
      FailedAgentID: agentErrorFailedAgentID(data, session),
      FailedSessionID: data.SessionID,
      ErrorMessage: agentErrorMessage(data),
    },
  })
  if err != nil {
    s.logger.Error(msgAgentErrorDispatchFailed, zap.Error(err))
    return
  }
  if result.Idempotent {
    return
  }
  if result.Transitioned {
    s.applyEngineTransition(ctx, data.TaskID, session, result, engine.TriggerOnAgentError, task.Description, true)
  }
  if result.ActionCount > 0 {
    s.logger.Info(msgAgentErrorDispatched, zap.String("operation_id", operationID))
  }
}
Panic recovery wrapper
func (s *Service) dispatchKanbanAgentErrorTriggerRecovered(ctx context.Context, data watcher.AgentEventData) {
  defer func() {
    if r := recover(); r != nil {
      s.logger.Error(msgAgentErrorDispatchPanicked,
        zap.String("task_id", data.TaskID),
        zap.String("session_id", data.SessionID),
        zap.Any("panic", r),
        zap.String("stack", string(debug.Stack())))
    }
  }()
  s.dispatchKanbanAgentErrorTrigger(ctx, data)
}
func (s *Service) handleRecoverableFailureLockedState(ctx context.Context, data watcher.AgentEventData) func()
Click for details →

The funnel now returns a closure. The caller releases the guard, then runs the closure, so auto_start_agent can lock the same guard.

Return dispatch closure
func (s *Service) handleRecoverableFailureLockedState(ctx context.Context, data watcher.AgentEventData) func() {
  s.logger.Warn("handling recoverable agent failure", zap.String("task_id", data.TaskID))
  s.completeTurnForSession(ctx, data.SessionID)
  s.persistLastAgentError(ctx, data)
  if s.messageCreator != nil && !s.isOfficeSession(ctx, data.SessionID) {
    s.createRecoveryStatusMessage(ctx, data)
  }
  nextState := models.TaskSessionStateWaitingForInput
  if s.isOfficeSession(ctx, data.SessionID) {
    nextState = models.TaskSessionStateFailed
  }
  s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, nextState, data.ErrorMessage, false)
  s.writeTaskReviewState(ctx, data.TaskID, data.SessionID)
  if nextState == models.TaskSessionStateWaitingForInput && data.SessionID != "" {
    session, err := s.repo.GetTaskSession(ctx, data.SessionID)
    if err == nil {
      if signal, ok := models.LoadPendingStepSignal(session.Metadata); ok {
        s.reconcileStepCompletionSignalLocked(ctx, data.TaskID, data.SessionID, signal.StepID)
      }
    }
  }
  go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
  return func() {
    s.dispatchKanbanAgentErrorTriggerRecovered(context.WithoutCancel(ctx), data)
  }
}
Caller releases guard then dispatches
func (s *Service) handleRecoverableFailure(ctx context.Context, data watcher.AgentEventData) {
  if data.SessionID == "" {
    if dispatch := s.handleRecoverableFailureLockedState(ctx, data); dispatch != nil {
      dispatch()
    }
    return
  }
  lock, release := s.acquireCancelInFlightGuard(data.SessionID)
  lock.Lock()
  if _, err := s.repo.GetTaskSession(ctx, data.SessionID); err != nil {
    if errors.Is(err, models.ErrTaskSessionNotFound) {
      lock.Unlock()
      release()
      return
    }
  }
  dispatch := s.handleRecoverableFailureLockedState(ctx, data)
  lock.Unlock()
  release()
  if dispatch != nil {
    dispatch()
  }
}
func (s *Service) handleAgentFailedLocked(ctx context.Context, data watcher.AgentEventData) func()
Click for details →

handleAgentFailedLocked and handleAgentStartFailed now return the dispatch closure and run it after the guard, covering routes R1, R2, and R3.

handleAgentFailedLocked returns dispatch
func (s *Service) handleAgentFailedLocked(ctx context.Context, data watcher.AgentEventData) func() {
  data = s.withPromptAttemptEvidence(data)
  defer s.clearPromptAttemptEvidence(data.SessionID, data.AgentExecutionID, data.PromptGeneration)
  s.markExecutionFailed(data.SessionID, data.AgentExecutionID)
  if drop, _ := s.shouldDropSessionFailure(ctx, data, "agent.failed", true); drop {
    s.retireExecutionActivityAndPublish(context.WithoutCancel(ctx), data.TaskID, data.SessionID, data.AgentExecutionID)
    return nil
  }
  if data.SessionID != "" && s.handleTransientFailure(ctx, data) {
    return nil
  }
  if data.SessionID != "" && s.routeDynamicAgentFailure(ctx, data, classifyKanbanFailure(data)) {
    return nil
  }
  s.retireExecutionActivityAndPublish(context.WithoutCancel(ctx), data.TaskID, data.SessionID, data.AgentExecutionID)
  errMsg := data.ErrorMessage
  if errMsg == "" {
    errMsg = defaultAgentFailedMessage
  }
  s.finalizeAutomationRun(ctx, data.TaskID, false, errMsg)
  if data.SessionID != "" {
    return s.handleRecoverableFailureLockedState(ctx, data)
  }
  s.scheduler.HandleTaskCompleted(data.TaskID, false)
  s.scheduler.RetryTask(data.TaskID)
  s.writeTaskReviewState(ctx, data.TaskID, data.SessionID)
  go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
  return nil
}
handleAgentStartFailed defers dispatch
func (s *Service) handleAgentStartFailed(ctx context.Context, taskID, sessionID, agentExecutionID string, err error, fromResume bool) bool {
  failureData := watcher.AgentEventData{TaskID: taskID, SessionID: sessionID, AgentExecutionID: agentExecutionID, ErrorMessage: err.Error()}
  var unlockGuard func()
  var releaseGuard func()
  var dispatch func()
  defer func() {
    if unlockGuard != nil {
      unlockGuard()
      releaseGuard()
    }
    if dispatch != nil {
      dispatch()
    }
  }()
  if sessionID != "" {
    lock, release := s.acquireCancelInFlightGuard(sessionID)
    unlockGuard = lock.Unlock
    releaseGuard = release
    if s.isCancelInFlight(sessionID) {
      return true
    }
  }
  if failureData.FailureCode == string(routingerr.CodeManagedRuntimeNpmResolution) {
    dispatch = s.handleRecoverableFailureLockedState(ctx, failureData)
    return true
  }
  if !isAuthError(err.Error()) {
    return false
  }
  dispatch = s.handleRecoverableFailureLockedState(ctx, failureData)
  return true
}
func (s *Service) retryTransientPrompt(ctx context.Context, taskID, sessionID, execID string)
Click for details →

When the retry loop exhausts or cannot start, it now surfaces recovery through the same funnel, so R4 and R5 dispatch.

No cached prompt path R4
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
    }
    s.logger.Warn("transient retry has no cached prompt; surfacing recovery banner", 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
  }
  // ... stop execution, retire activity ...
  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.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.",
    })
  }
}
User cancel marks UserInitiated
func (s *Service) CancelTransientRetry(ctx context.Context, taskID, sessionID string) bool {
  if err := s.authorizeTaskSessionPair(ctx, taskID, sessionID); err != nil {
    return false
  }
  _, active := s.transientRetries.Load(sessionID)
  s.resetTransientRetryWithContext(ctx, sessionID, true)
  if !active {
    return false
  }
  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
}
Payload, operation ID, and trigger labelapps/backend/internal/workflow/models/models.go ↗
const StepTransitionTriggerAgentError StepTransitionTrigger = "on_agent_error"
Click for details →

The payload maps failure identity to engine fields, the operation ID makes retries idempotent, and the new trigger label keeps recovery moves distinct in history.

Watcher event field
type AgentEventData struct {
  TaskID string `json:"task_id"`
  SessionID string `json:"session_id"`
  AgentExecutionID string `json:"agent_execution_id"`
  AgentProfileID string `json:"agent_profile_id"`
  ErrorMessage string `json:"error_message,omitempty"`
  UserInitiated bool `json:"user_initiated,omitempty"`
}
Operation ID and payload helpers
func agentErrorOperationID(sessionID, agentExecutionID string) string {
  if agentExecutionID == "" {
    return fmt.Sprintf("agent_error:session:%s", sessionID)
  }
  return fmt.Sprintf("agent_error:session:%s:%s", sessionID, agentExecutionID)
}
func agentErrorFailedAgentID(data watcher.AgentEventData, session *models.TaskSession) string {
  if data.AgentProfileID != "" {
    return data.AgentProfileID
  }
  if session != nil {
    return session.AgentProfileID
  }
  return ""
}
func agentErrorMessage(data watcher.AgentEventData) string {
  if data.ErrorMessage != "" {
    return data.ErrorMessage
  }
  return defaultAgentFailedMessage
}
New history trigger
const (
  StepTransitionTriggerManual StepTransitionTrigger = "manual"
  StepTransitionTriggerAutoComplete StepTransitionTrigger = "auto_complete"
  StepTransitionTriggerAgentError StepTransitionTrigger = "on_agent_error"
)
func TestOnAgentErrorFireSitesArePinned(t *testing.T)
Click for details →

The test walks the whole backend AST and asserts exactly two functions fire TriggerOnAgentError, so a third dispatcher or a removed one fails the build.

Registered sites
var registeredAgentErrorFireSites = []string{
  "internal/office/service/Service.dispatchAgentErrorTrigger",
  "internal/orchestrator/Service.dispatchKanbanAgentErrorTrigger",
}
func TestOnAgentErrorFireSitesArePinned(t *testing.T) {
  root, err := findAgentErrorBackendSourceRoot(".")
  found, err := findAgentErrorFireSites(root)
  registered := make(map[string]bool)
  for _, name := range registeredAgentErrorFireSites {
    registered[name] = true
  }
  var unregistered []string
  for name := range found {
    if !registered[name] {
      unregistered = append(unregistered, name)
    }
  }
  if len(unregistered) > 0 {
    t.Fatalf("function(s) %v fire engine.TriggerOnAgentError but are not in registeredAgentErrorFireSites", unregistered)
  }
}
Syntactic fire-site definition
func functionFiresTriggerOnAgentError(body *ast.BlockStmt) bool {
  fires := false
  ast.Inspect(body, func(n ast.Node) bool {
    switch v := n.(type) {
    case *ast.CompositeLit:
      sel, ok := v.Type.(*ast.SelectorExpr)
      if !ok || sel.Sel.Name != "HandleInput" {
        return true
      }
      for _, elt := range v.Elts {
        kv, ok := elt.(*ast.KeyValueExpr)
        if !ok { continue }
        key, ok := kv.Key.(*ast.Ident)
        if !ok || key.Name != "Trigger" { continue }
        if identIsTriggerOnAgentError(kv.Value) {
          fires = true
        }
      }
    case *ast.CallExpr:
      sel, ok := v.Fun.(*ast.SelectorExpr)
      if !ok || sel.Sel.Name != "dispatchEngineTrigger" { return true }
      for _, arg := range v.Args {
        if identIsTriggerOnAgentError(arg) { fires = true }
      }
    }
    return true
  })
  return fires
}
Read the changes as a list

Kanban on_agent_error dispatcher

apps/backend/internal/orchestrator/event_handlers_agent_error.go

This is the new kanban fire site. It checks guards, builds PreloadedState after reconciliation, and calls the engine.

Guard sequence and engine call
func (s *Service) dispatchKanbanAgentErrorTrigger(ctx context.Context, data watcher.AgentEventData) {
  deps := s.agentErrorDeps.Load()
  if deps == nil || deps.engine == nil {
    return
  }
  if data.SessionID == "" {
    return
  }
  if data.UserInitiated {
    s.logger.Debug(msgAgentErrorUserInitiated,
      zap.String("task_id", data.TaskID),
      zap.String("session_id", data.SessionID))
    return
  }
  session, task, ok := s.resolveAgentErrorDispatchTarget(ctx, data)
  if !ok {
    return
  }
  state := s.buildMachineState(ctx, task, session)
  operationID := agentErrorOperationID(data.SessionID, data.AgentExecutionID)
  s.warnUnregisteredAgentErrorActions(ctx, deps, task, data.SessionID)
  result, err := deps.engine.HandleTrigger(ctx, engine.HandleInput{
    TaskID: data.TaskID,
    SessionID: data.SessionID,
    Trigger: engine.TriggerOnAgentError,
    OperationID: operationID,
    EvaluateOnly: true,
    PreloadedState: &state,
    Payload: engine.OnAgentErrorPayload{
      FailedAgentID: agentErrorFailedAgentID(data, session),
      FailedSessionID: data.SessionID,
      ErrorMessage: agentErrorMessage(data),
    },
  })
  if err != nil {
    s.logger.Error(msgAgentErrorDispatchFailed, zap.Error(err))
    return
  }
  if result.Idempotent {
    return
  }
  if result.Transitioned {
    s.applyEngineTransition(ctx, data.TaskID, session, result, engine.TriggerOnAgentError, task.Description, true)
  }
  if result.ActionCount > 0 {
    s.logger.Info(msgAgentErrorDispatched, zap.String("operation_id", operationID))
  }
}
Panic recovery wrapper
func (s *Service) dispatchKanbanAgentErrorTriggerRecovered(ctx context.Context, data watcher.AgentEventData) {
  defer func() {
    if r := recover(); r != nil {
      s.logger.Error(msgAgentErrorDispatchPanicked,
        zap.String("task_id", data.TaskID),
        zap.String("session_id", data.SessionID),
        zap.Any("panic", r),
        zap.String("stack", string(debug.Stack())))
    }
  }()
  s.dispatchKanbanAgentErrorTrigger(ctx, data)
}

Off-guard recovery funnel

apps/backend/internal/orchestrator/event_handlers_agent.go

The funnel now returns a closure. The caller releases the guard, then runs the closure, so auto_start_agent can lock the same guard.

Return dispatch closure
func (s *Service) handleRecoverableFailureLockedState(ctx context.Context, data watcher.AgentEventData) func() {
  s.logger.Warn("handling recoverable agent failure", zap.String("task_id", data.TaskID))
  s.completeTurnForSession(ctx, data.SessionID)
  s.persistLastAgentError(ctx, data)
  if s.messageCreator != nil && !s.isOfficeSession(ctx, data.SessionID) {
    s.createRecoveryStatusMessage(ctx, data)
  }
  nextState := models.TaskSessionStateWaitingForInput
  if s.isOfficeSession(ctx, data.SessionID) {
    nextState = models.TaskSessionStateFailed
  }
  s.updateTaskSessionState(ctx, data.TaskID, data.SessionID, nextState, data.ErrorMessage, false)
  s.writeTaskReviewState(ctx, data.TaskID, data.SessionID)
  if nextState == models.TaskSessionStateWaitingForInput && data.SessionID != "" {
    session, err := s.repo.GetTaskSession(ctx, data.SessionID)
    if err == nil {
      if signal, ok := models.LoadPendingStepSignal(session.Metadata); ok {
        s.reconcileStepCompletionSignalLocked(ctx, data.TaskID, data.SessionID, signal.StepID)
      }
    }
  }
  go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
  return func() {
    s.dispatchKanbanAgentErrorTriggerRecovered(context.WithoutCancel(ctx), data)
  }
}
Caller releases guard then dispatches
func (s *Service) handleRecoverableFailure(ctx context.Context, data watcher.AgentEventData) {
  if data.SessionID == "" {
    if dispatch := s.handleRecoverableFailureLockedState(ctx, data); dispatch != nil {
      dispatch()
    }
    return
  }
  lock, release := s.acquireCancelInFlightGuard(data.SessionID)
  lock.Lock()
  if _, err := s.repo.GetTaskSession(ctx, data.SessionID); err != nil {
    if errors.Is(err, models.ErrTaskSessionNotFound) {
      lock.Unlock()
      release()
      return
    }
  }
  dispatch := s.handleRecoverableFailureLockedState(ctx, data)
  lock.Unlock()
  release()
  if dispatch != nil {
    dispatch()
  }
}

Failure entry points wire the closure

apps/backend/internal/orchestrator/event_handlers_agent.go

handleAgentFailedLocked and handleAgentStartFailed now return the dispatch closure and run it after the guard, covering routes R1, R2, and R3.

handleAgentFailedLocked returns dispatch
func (s *Service) handleAgentFailedLocked(ctx context.Context, data watcher.AgentEventData) func() {
  data = s.withPromptAttemptEvidence(data)
  defer s.clearPromptAttemptEvidence(data.SessionID, data.AgentExecutionID, data.PromptGeneration)
  s.markExecutionFailed(data.SessionID, data.AgentExecutionID)
  if drop, _ := s.shouldDropSessionFailure(ctx, data, "agent.failed", true); drop {
    s.retireExecutionActivityAndPublish(context.WithoutCancel(ctx), data.TaskID, data.SessionID, data.AgentExecutionID)
    return nil
  }
  if data.SessionID != "" && s.handleTransientFailure(ctx, data) {
    return nil
  }
  if data.SessionID != "" && s.routeDynamicAgentFailure(ctx, data, classifyKanbanFailure(data)) {
    return nil
  }
  s.retireExecutionActivityAndPublish(context.WithoutCancel(ctx), data.TaskID, data.SessionID, data.AgentExecutionID)
  errMsg := data.ErrorMessage
  if errMsg == "" {
    errMsg = defaultAgentFailedMessage
  }
  s.finalizeAutomationRun(ctx, data.TaskID, false, errMsg)
  if data.SessionID != "" {
    return s.handleRecoverableFailureLockedState(ctx, data)
  }
  s.scheduler.HandleTaskCompleted(data.TaskID, false)
  s.scheduler.RetryTask(data.TaskID)
  s.writeTaskReviewState(ctx, data.TaskID, data.SessionID)
  go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID)
  return nil
}
handleAgentStartFailed defers dispatch
func (s *Service) handleAgentStartFailed(ctx context.Context, taskID, sessionID, agentExecutionID string, err error, fromResume bool) bool {
  failureData := watcher.AgentEventData{TaskID: taskID, SessionID: sessionID, AgentExecutionID: agentExecutionID, ErrorMessage: err.Error()}
  var unlockGuard func()
  var releaseGuard func()
  var dispatch func()
  defer func() {
    if unlockGuard != nil {
      unlockGuard()
      releaseGuard()
    }
    if dispatch != nil {
      dispatch()
    }
  }()
  if sessionID != "" {
    lock, release := s.acquireCancelInFlightGuard(sessionID)
    unlockGuard = lock.Unlock
    releaseGuard = release
    if s.isCancelInFlight(sessionID) {
      return true
    }
  }
  if failureData.FailureCode == string(routingerr.CodeManagedRuntimeNpmResolution) {
    dispatch = s.handleRecoverableFailureLockedState(ctx, failureData)
    return true
  }
  if !isAuthError(err.Error()) {
    return false
  }
  dispatch = s.handleRecoverableFailureLockedState(ctx, failureData)
  return true
}

Transient retry terminal paths dispatch

apps/backend/internal/orchestrator/event_handlers_transient.go

When the retry loop exhausts or cannot start, it now surfaces recovery through the same funnel, so R4 and R5 dispatch.

No cached prompt path R4
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
    }
    s.logger.Warn("transient retry has no cached prompt; surfacing recovery banner", 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
  }
  // ... stop execution, retire activity ...
  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.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.",
    })
  }
}
User cancel marks UserInitiated
func (s *Service) CancelTransientRetry(ctx context.Context, taskID, sessionID string) bool {
  if err := s.authorizeTaskSessionPair(ctx, taskID, sessionID); err != nil {
    return false
  }
  _, active := s.transientRetries.Load(sessionID)
  s.resetTransientRetryWithContext(ctx, sessionID, true)
  if !active {
    return false
  }
  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
}

Payload, operation ID, and trigger label

apps/backend/internal/workflow/models/models.go

The payload maps failure identity to engine fields, the operation ID makes retries idempotent, and the new trigger label keeps recovery moves distinct in history.

Watcher event field
type AgentEventData struct {
  TaskID string `json:"task_id"`
  SessionID string `json:"session_id"`
  AgentExecutionID string `json:"agent_execution_id"`
  AgentProfileID string `json:"agent_profile_id"`
  ErrorMessage string `json:"error_message,omitempty"`
  UserInitiated bool `json:"user_initiated,omitempty"`
}
Operation ID and payload helpers
func agentErrorOperationID(sessionID, agentExecutionID string) string {
  if agentExecutionID == "" {
    return fmt.Sprintf("agent_error:session:%s", sessionID)
  }
  return fmt.Sprintf("agent_error:session:%s:%s", sessionID, agentExecutionID)
}
func agentErrorFailedAgentID(data watcher.AgentEventData, session *models.TaskSession) string {
  if data.AgentProfileID != "" {
    return data.AgentProfileID
  }
  if session != nil {
    return session.AgentProfileID
  }
  return ""
}
func agentErrorMessage(data watcher.AgentEventData) string {
  if data.ErrorMessage != "" {
    return data.ErrorMessage
  }
  return defaultAgentFailedMessage
}
New history trigger
const (
  StepTransitionTriggerManual StepTransitionTrigger = "manual"
  StepTransitionTriggerAutoComplete StepTransitionTrigger = "auto_complete"
  StepTransitionTriggerAgentError StepTransitionTrigger = "on_agent_error"
)

Pinned fire-site guard

apps/backend/internal/orchestrator/agent_error_fire_site_pin_test.go

The test walks the whole backend AST and asserts exactly two functions fire TriggerOnAgentError, so a third dispatcher or a removed one fails the build.

Registered sites
var registeredAgentErrorFireSites = []string{
  "internal/office/service/Service.dispatchAgentErrorTrigger",
  "internal/orchestrator/Service.dispatchKanbanAgentErrorTrigger",
}
func TestOnAgentErrorFireSitesArePinned(t *testing.T) {
  root, err := findAgentErrorBackendSourceRoot(".")
  found, err := findAgentErrorFireSites(root)
  registered := make(map[string]bool)
  for _, name := range registeredAgentErrorFireSites {
    registered[name] = true
  }
  var unregistered []string
  for name := range found {
    if !registered[name] {
      unregistered = append(unregistered, name)
    }
  }
  if len(unregistered) > 0 {
    t.Fatalf("function(s) %v fire engine.TriggerOnAgentError but are not in registeredAgentErrorFireSites", unregistered)
  }
}
Syntactic fire-site definition
func functionFiresTriggerOnAgentError(body *ast.BlockStmt) bool {
  fires := false
  ast.Inspect(body, func(n ast.Node) bool {
    switch v := n.(type) {
    case *ast.CompositeLit:
      sel, ok := v.Type.(*ast.SelectorExpr)
      if !ok || sel.Sel.Name != "HandleInput" {
        return true
      }
      for _, elt := range v.Elts {
        kv, ok := elt.(*ast.KeyValueExpr)
        if !ok { continue }
        key, ok := kv.Key.(*ast.Ident)
        if !ok || key.Name != "Trigger" { continue }
        if identIsTriggerOnAgentError(kv.Value) {
          fires = true
        }
      }
    case *ast.CallExpr:
      sel, ok := v.Fun.(*ast.SelectorExpr)
      if !ok || sel.Sel.Name != "dispatchEngineTrigger" { return true }
      for _, arg := range v.Args {
        if identIsTriggerOnAgentError(arg) { fires = true }
      }
    }
    return true
  })
  return fires
}

Data and storage

The dispatch carries failure identity into the engine and records recovery moves with a distinct trigger.

FieldTypeNotes
OnAgentErrorPayload.FailedSessionIDstringsession that failed, never empty on dispatch
OnAgentErrorPayload.FailedAgentIDstringevent AgentProfileID, else session AgentProfileID, else empty
OnAgentErrorPayload.ErrorMessagestringevent ErrorMessage, else literal agent failed
OperationIDstringagent_error:session:<sessionID>:<executionID> or without executionID
StepTransitionTriggerAgentErrorstringvalue on_agent_error written to session_step_history
AgentEventData.UserInitiatedbooltrue only for CancelTransientRetry, suppresses dispatch

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Touches the terminal failure path for every kanban task, so a guard mistake could suppress or double-fire recovery.
  • Changes concurrency by releasing the session guard before dispatch, which is correct but widens the window for races.
  • Idempotency is in-memory only, so a restart loses the marker, but the event has no replay so the scope is bounded.

Trade-offs and review notes

Where to look first

  1. Check the guard order in resolveAgentErrorDispatchTarget and that session is read before task.
  2. Confirm handleRecoverableFailureLockedState returns the closure and every caller releases the guard before calling it.
  3. Verify R4 and R5 in retryTransientPrompt reach handleRecoverableFailure with the correct messages.
  4. Check payload helpers and that UserInitiated is set only in CancelTransientRetry.
  5. Confirm the fire-site pin test covers both HandleInput and dispatchEngineTrigger shapes.