PR #3441
Sections
Review

fix(orchestrator): retry on_agent_error recovery after a failed commit instead of giving up

main ← feature/fix-evaluateonly-mar-m8c 12 files +2384 −32 PR #3441 ↗

Engine no longer marks an EvaluateOnly transition as applied before the caller commits; the caller now marks only after a successful commit and serializes concurrent retries, so a failed commit can retry instead of stranding the task.

Why this change

In EvaluateOnly mode the engine marks the operation as applied even though it skips ApplyTransition. If the caller's commit fails, the marker still says the work is done and every later delivery short-circuits as idempotent. The task stays on the old step with no recovery.

What it does

Architecture, end to end

Failure flows through the new commit-then-mark path. The engine evaluates but does not mark; the caller commits, then marks, under a per-operation lock.

flowchart LR
  Fail[Agent failure event] --> Lock[lockAgentErrorOperation]
  Lock --> Load[resolveAgentErrorDispatchTarget + buildMachineState]
  Load --> Engine[Engine.HandleTrigger EvaluateOnly=true]
  Engine -- Transitioned + OperationMarkDeferred --> Commit[applyEngineTransition]
  Engine -- No transition --> MarkNow[Engine marks directly]
  Commit -- applied=true --> MarkDeferred[deps.store.MarkOperationApplied]
  Commit -- applied=false --> Retry[Leave unmarked: retry on redelivery]
  MarkDeferred --> Done[Log dispatched]
  Retry --> Done

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

Engine defers the mark for EvaluateOnly transitionsapps/backend/internal/workflow/engine/engine.go ↗
func (e *Engine) handleTrigger(ctx context.Context, in HandleInput, filter func(ActionKind) bool) (HandleResult, error)
Click for details →

The engine now skips MarkOperationApplied when EvaluateOnly produced a transition and tells the caller it owns the mark.

HandleResult flag
type HandleResult struct {
  Transitioned bool
  FromStepID   string
  ToStepID     string
  Guards      []QuorumGuardState
  DataPatch   map[string]any
  Idempotent  bool
  ActionCount int
  TransitionAbandoned bool
  OperationMarkDeferred bool
}
Deferred-mark branch
  result, err := e.processActions(ctx, in, state, step, actions, filter)
  if err != nil {
    return HandleResult{}, err
  }

  // A deferred transition is work this call evaluated but did not commit
  // (processActions skips ApplyTransition under EvaluateOnly). Marking the
  // operation applied here would claim a commit the caller still owes, so
  // ownership of the marker passes to the caller instead.
  if in.EvaluateOnly && result.Transitioned {
    result.OperationMarkDeferred = in.OperationID != ""
    return result, nil
  }

  return result, e.markOperationAppliedForInput(ctx, in)
func (s *Service) lockAgentErrorOperation(operationID string) func()
Click for details →

The lock serializes the full load-evaluate-commit-mark window for one operation id so a blocked racer reloads fresh state.

Lock type
type agentErrorOperationLock struct {
  mu   sync.Mutex
  refs int
}
Lock helper
func (s *Service) lockAgentErrorOperation(operationID string) func() {
  s.agentErrorOperationLocksMu.Lock()
  if s.agentErrorOperationLocks == nil {
    s.agentErrorOperationLocks = make(map[string]*agentErrorOperationLock)
  }
  entry := s.agentErrorOperationLocks[operationID]
  if entry == nil {
    entry = &agentErrorOperationLock{}
    s.agentErrorOperationLocks[operationID] = entry
  }
  entry.refs++
  s.agentErrorOperationLocksMu.Unlock()

  entry.mu.Lock()
  return func() {
    s.agentErrorOperationLocksMu.Lock()
    entry.refs--
    if entry.refs == 0 {
      delete(s.agentErrorOperationLocks, operationID)
    }
    s.agentErrorOperationLocksMu.Unlock()
    entry.mu.Unlock()
  }
}
func (s *Service) dispatchKanbanAgentErrorTrigger(ctx context.Context, data watcher.AgentEventData)
Click for details →

The dispatch acquires the lock before any load, captures the commit result, and marks only after a successful commit.

Lock before load
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
  }

  operationID := agentErrorOperationID(data.SessionID, data.AgentExecutionID)
  unlock := s.lockAgentErrorOperation(operationID)
  defer unlock()

  session, task, ok := s.resolveAgentErrorDispatchTarget(ctx, data)
  if !ok {
    return
  }

  state := s.buildMachineState(ctx, task, session)
Commit then mark
  if result.Transitioned {
    applied := s.applyEngineTransition(ctx, data.TaskID, session, result, engine.TriggerOnAgentError, task.Description, true)
    if applied && result.OperationMarkDeferred {
      if markErr := deps.store.MarkOperationApplied(ctx, operationID); markErr != nil {
        s.logger.Warn(msgAgentErrorMarkFailed,
          zap.String("task_id", data.TaskID),
          zap.String("session_id", data.SessionID),
          zap.String("operation_id", operationID),
          zap.Error(markErr))
      }
    }
  }
type Service struct
Click for details →

A dedicated map keeps on_agent_error contention separate from on_children_completed contention.

Service fields
  // agentErrorOperationLocks serializes concurrent on_agent_error dispatches
  // that carry the same operation id — the load -> evaluate -> commit ->
  // mark window, held from before the task/session/MachineState load so a
  // blocked racer always reloads state rather than evaluating a
  // PreloadedState built before another racer's commit.
  agentErrorOperationLocksMu sync.Mutex
  agentErrorOperationLocks   map[string]*agentErrorOperationLock
func TestEvaluateOnlyOperationMarkingCallSitesArePinned(t *testing.T)
Click for details →

The test scans the backend for HandleInput{EvaluateOnly:true, OperationID:non-empty} and fails when a new site is not in the allowlist.

Allowlist
var registeredEvaluateOnlyOperationMarkingSites = []string{
  "internal/orchestrator/Service.dispatchKanbanAgentErrorTrigger",
  "internal/orchestrator/Service.evaluateChildrenCompleted",
}
Detection predicate
func functionPairsEvaluateOnlyWithOperationID(body *ast.BlockStmt, engineAlias string, isEnginePackage bool) bool {
  pairs := false
  ast.Inspect(body, func(n ast.Node) bool {
    lit, ok := n.(*ast.CompositeLit)
    if !ok {
      return true
    }
    if !isHandleInputLitType(lit.Type, engineAlias, isEnginePackage) {
      return true
    }
    hasEvaluateOnlyTrue := false
    hasNonEmptyOperationID := false
    for _, elt := range lit.Elts {
      kv, ok := elt.(*ast.KeyValueExpr)
      if !ok {
        continue
      }
      key, ok := kv.Key.(*ast.Ident)
      if !ok {
        continue
      }
      switch key.Name {
      case "EvaluateOnly":
        if ident, ok := kv.Value.(*ast.Ident); ok && ident.Name == "true" {
          hasEvaluateOnlyTrue = true
        }
      case "OperationID":
        if !isEmptyStringLiteral(kv.Value) {
          hasNonEmptyOperationID = true
        }
      }
    }
    if hasEvaluateOnlyTrue && hasNonEmptyOperationID {
      pairs = true
    }
    return true
  })
  return pairs
}
Read the changes as a list

Engine defers the mark for EvaluateOnly transitions

apps/backend/internal/workflow/engine/engine.go

The engine now skips MarkOperationApplied when EvaluateOnly produced a transition and tells the caller it owns the mark.

HandleResult flag
type HandleResult struct {
  Transitioned bool
  FromStepID   string
  ToStepID     string
  Guards      []QuorumGuardState
  DataPatch   map[string]any
  Idempotent  bool
  ActionCount int
  TransitionAbandoned bool
  OperationMarkDeferred bool
}
Deferred-mark branch
  result, err := e.processActions(ctx, in, state, step, actions, filter)
  if err != nil {
    return HandleResult{}, err
  }

  // A deferred transition is work this call evaluated but did not commit
  // (processActions skips ApplyTransition under EvaluateOnly). Marking the
  // operation applied here would claim a commit the caller still owes, so
  // ownership of the marker passes to the caller instead.
  if in.EvaluateOnly && result.Transitioned {
    result.OperationMarkDeferred = in.OperationID != ""
    return result, nil
  }

  return result, e.markOperationAppliedForInput(ctx, in)

Per-operation lock for on_agent_error

apps/backend/internal/orchestrator/event_handlers_agent_error.go

The lock serializes the full load-evaluate-commit-mark window for one operation id so a blocked racer reloads fresh state.

Lock type
type agentErrorOperationLock struct {
  mu   sync.Mutex
  refs int
}
Lock helper
func (s *Service) lockAgentErrorOperation(operationID string) func() {
  s.agentErrorOperationLocksMu.Lock()
  if s.agentErrorOperationLocks == nil {
    s.agentErrorOperationLocks = make(map[string]*agentErrorOperationLock)
  }
  entry := s.agentErrorOperationLocks[operationID]
  if entry == nil {
    entry = &agentErrorOperationLock{}
    s.agentErrorOperationLocks[operationID] = entry
  }
  entry.refs++
  s.agentErrorOperationLocksMu.Unlock()

  entry.mu.Lock()
  return func() {
    s.agentErrorOperationLocksMu.Lock()
    entry.refs--
    if entry.refs == 0 {
      delete(s.agentErrorOperationLocks, operationID)
    }
    s.agentErrorOperationLocksMu.Unlock()
    entry.mu.Unlock()
  }
}

Dispatch now commits before it marks

apps/backend/internal/orchestrator/event_handlers_agent_error.go

The dispatch acquires the lock before any load, captures the commit result, and marks only after a successful commit.

Lock before load
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
  }

  operationID := agentErrorOperationID(data.SessionID, data.AgentExecutionID)
  unlock := s.lockAgentErrorOperation(operationID)
  defer unlock()

  session, task, ok := s.resolveAgentErrorDispatchTarget(ctx, data)
  if !ok {
    return
  }

  state := s.buildMachineState(ctx, task, session)
Commit then mark
  if result.Transitioned {
    applied := s.applyEngineTransition(ctx, data.TaskID, session, result, engine.TriggerOnAgentError, task.Description, true)
    if applied && result.OperationMarkDeferred {
      if markErr := deps.store.MarkOperationApplied(ctx, operationID); markErr != nil {
        s.logger.Warn(msgAgentErrorMarkFailed,
          zap.String("task_id", data.TaskID),
          zap.String("session_id", data.SessionID),
          zap.String("operation_id", operationID),
          zap.Error(markErr))
      }
    }
  }

Separate lock map on Service

apps/backend/internal/orchestrator/service.go

A dedicated map keeps on_agent_error contention separate from on_children_completed contention.

Service fields
  // agentErrorOperationLocks serializes concurrent on_agent_error dispatches
  // that carry the same operation id — the load -> evaluate -> commit ->
  // mark window, held from before the task/session/MachineState load so a
  // blocked racer always reloads state rather than evaluating a
  // PreloadedState built before another racer's commit.
  agentErrorOperationLocksMu sync.Mutex
  agentErrorOperationLocks   map[string]*agentErrorOperationLock

Pin test for future EvaluateOnly call sites

apps/backend/internal/orchestrator/evaluate_only_operation_marking_pin_test.go

The test scans the backend for HandleInput{EvaluateOnly:true, OperationID:non-empty} and fails when a new site is not in the allowlist.

Allowlist
var registeredEvaluateOnlyOperationMarkingSites = []string{
  "internal/orchestrator/Service.dispatchKanbanAgentErrorTrigger",
  "internal/orchestrator/Service.evaluateChildrenCompleted",
}
Detection predicate
func functionPairsEvaluateOnlyWithOperationID(body *ast.BlockStmt, engineAlias string, isEnginePackage bool) bool {
  pairs := false
  ast.Inspect(body, func(n ast.Node) bool {
    lit, ok := n.(*ast.CompositeLit)
    if !ok {
      return true
    }
    if !isHandleInputLitType(lit.Type, engineAlias, isEnginePackage) {
      return true
    }
    hasEvaluateOnlyTrue := false
    hasNonEmptyOperationID := false
    for _, elt := range lit.Elts {
      kv, ok := elt.(*ast.KeyValueExpr)
      if !ok {
        continue
      }
      key, ok := kv.Key.(*ast.Ident)
      if !ok {
        continue
      }
      switch key.Name {
      case "EvaluateOnly":
        if ident, ok := kv.Value.(*ast.Ident); ok && ident.Name == "true" {
          hasEvaluateOnlyTrue = true
        }
      case "OperationID":
        if !isEmptyStringLiteral(kv.Value) {
          hasNonEmptyOperationID = true
        }
      }
    }
    if hasEvaluateOnlyTrue && hasNonEmptyOperationID {
      pairs = true
    }
    return true
  })
  return pairs
}

Data and storage

The operation marker lives in a process-local sync.Map. No table or column stores it, so suppression lasts until restart.

FieldTypeNotes
appliedOpssync.MapoperationID -> applied, in workflowStore
OperationIDstringagent_error:session:<sessionID>[:<executionID>]
OperationMarkDeferredbooltrue when engine skipped the mark and caller owns it
IsOperationAppliedfuncread check before evaluate
MarkOperationAppliedfuncwrite after successful commit only

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Fixes a silent retry-suppression bug on the recovery path; a regression strands tasks until restart.
  • Adds a new lock that spans DB reads and writes; a wrong scope can deadlock or reintroduce the race.
  • Engine contract change affects every EvaluateOnly caller, but only one live caller pairs it with an operation id.

Trade-offs and review notes

Where to look first

  1. Verify handleTrigger returns OperationMarkDeferred only for EvaluateOnly+transition+non-empty OperationID and never marks there.
  2. Confirm dispatchKanbanAgentErrorTrigger locks before resolveAgentErrorDispatchTarget and holds through MarkOperationApplied.
  3. Check that a declined transition (missing step, credential preflight, source load, commit error) leaves the operation unmarked and retries.
  4. Confirm the pin test allowlist and its detection predicate match the spec and do not false-fire on unrelated HandleInput types.