/**
 * Loop handler for iterative task/pipeline execution
 * ARCHITECTURE: Event-driven iteration engine for v0.7.0 task loops
 * Pattern: Factory pattern for async initialization (matches ScheduleHandler)
 * Rationale: Manages loop lifecycle, iteration dispatch, exit condition evaluation,
 *   and crash recovery — all driven by events from task completion/failure
 */

import type { Loop, LoopIteration, Task } from '../../core/domain.js';
import {
  createTask,
  EvalMode,
  isTerminalState,
  LoopId,
  LoopStatus,
  LoopStrategy,
  OptimizeDirection,
  TaskId,
  TaskStatus,
  updateLoop,
} from '../../core/domain.js';
import { AutobeatError, ErrorCode } from '../../core/errors.js';
import { EventBus } from '../../core/events/event-bus.js';
import type {
  LoopCancelledEvent,
  LoopCreatedEvent,
  LoopPausedEvent,
  LoopResumedEvent,
  TaskCancelledEvent,
  TaskCompletedEvent,
  TaskFailedEvent,
} from '../../core/events/events.js';
import { BaseEventHandler } from '../../core/events/handlers.js';
import type {
  CheckpointRepository,
  EvalResult,
  ExitConditionEvaluator,
  Logger,
  LoopRepository,
  SyncLoopOperations,
  SyncTaskOperations,
  TaskRepository,
  TransactionRunner,
} from '../../core/interfaces.js';
import { err, ok, type Result } from '../../core/result.js';
import {
  captureGitDiff,
  commitAllChanges,
  createAndCheckoutBranch,
  getCurrentCommitSha,
  getRecentGitDiffStat,
  getRecentGitLog,
  resetToCommit,
} from '../../utils/git-state.js';

// ── Convergence detection constants ──────────────────────────────────────────
/** DB query window: fetch this many recent iterations to find CONVERGENCE_MIN_ITERATIONS completed ones (headroom for cancelled/running/failed iterations in between). */
const CONVERGENCE_WINDOW = 5;
/** Minimum completed iterations required before convergence can be detected. */
const CONVERGENCE_MIN_ITERATIONS = 3;
/** Max changed lines per iteration (git diff insertions + deletions) to count as convergence. */
const CONVERGENCE_MAX_CHANGED_LINES = 10;
/** Maximum byte size of git context to prepend to freshContext iteration prompts. */
const MAX_GIT_CONTEXT_BYTES = 4096;
/** Tolerance for floating-point score equality in plateau convergence detection. */
const SCORE_EPSILON = 1e-9;

/**
 * Parse the total changed lines (insertions + deletions) from a git diff --stat summary string.
 * Extracts from the last non-empty line, which git formats as:
 *   "N files changed, X insertions(+), Y deletions(-)"
 *
 * Exported for unit testing.
 *
 * @param summary - The git diff --stat output string (may be multi-line)
 * @returns Total changed lines, or 0 if the summary cannot be parsed
 */
export function parseGitDiffChangedLines(summary: string | undefined | null): number {
  if (!summary) return 0;
  const lines = summary.split('\n').filter((l) => l.trim().length > 0);
  if (lines.length === 0) return 0;
  const lastLine = lines[lines.length - 1];
  const insertions = lastLine.match(/(\d+) insertions?\(\+\)/);
  const deletions = lastLine.match(/(\d+) deletions?\(-\)/);
  return (insertions ? Number(insertions[1]) : 0) + (deletions ? Number(deletions[1]) : 0);
}

export interface LoopHandlerDeps {
  readonly loopRepo: LoopRepository & SyncLoopOperations;
  readonly taskRepo: TaskRepository & SyncTaskOperations;
  readonly checkpointRepo: CheckpointRepository;
  readonly eventBus: EventBus;
  readonly database: TransactionRunner;
  readonly exitConditionEvaluator: ExitConditionEvaluator;
  readonly logger: Logger;
}

/**
 * Fields from an exit condition evaluation result that are persisted on the iteration record.
 * Extracted to avoid duplicating the inline type across the five recordAndContinue call sites.
 */
export type IterationResultFields = {
  readonly score?: number;
  readonly exitCode?: number;
  readonly errorMessage?: string;
  readonly evalFeedback?: string;
  readonly evalResponse?: string; // Raw eval agent output for audit (v1.3.0)
};

export class LoopHandler extends BaseEventHandler {
  // In-memory state (rebuilt from DB on restart)
  private readonly taskToLoop: Map<TaskId, LoopId> = new Map(); // taskId → loopId
  private readonly pipelineTasks: Map<string, Set<TaskId>> = new Map(); // "loopId:iteration" → set of taskIds
  private readonly cooldownTimers: Map<LoopId, NodeJS.Timeout> = new Map(); // loopId → timer

  private readonly loopRepo: LoopRepository & SyncLoopOperations;
  private readonly taskRepo: TaskRepository & SyncTaskOperations;
  private readonly checkpointRepo: CheckpointRepository;
  private readonly eventBus: EventBus;
  private readonly database: TransactionRunner;
  private readonly exitConditionEvaluator: ExitConditionEvaluator;

  /**
   * Private constructor - use LoopHandler.create() instead
   * ARCHITECTURE: Factory pattern ensures handler is fully initialized before use
   */
  private constructor(deps: LoopHandlerDeps) {
    super(deps.logger, 'LoopHandler');
    this.loopRepo = deps.loopRepo;
    this.taskRepo = deps.taskRepo;
    this.checkpointRepo = deps.checkpointRepo;
    this.eventBus = deps.eventBus;
    this.database = deps.database;
    this.exitConditionEvaluator = deps.exitConditionEvaluator;
  }

  /**
   * Factory method to create a fully initialized LoopHandler
   * ARCHITECTURE: Guarantees handler is ready to use — no uninitialized state possible
   * Runs recovery on startup (R3) — self-healing regardless of RecoveryManager timing
   */
  static async create(deps: LoopHandlerDeps): Promise<Result<LoopHandler, AutobeatError>> {
    const handlerLogger = deps.logger.child ? deps.logger.child({ module: 'LoopHandler' }) : deps.logger;

    const handler = new LoopHandler({ ...deps, logger: handlerLogger });

    // Subscribe to events
    const subscribeResult = handler.subscribeToEvents();
    if (!subscribeResult.ok) {
      return subscribeResult;
    }

    // Recovery: rebuild in-memory maps from DB (R3)
    await handler.rebuildMaps();
    await handler.recoverStuckLoops();

    handlerLogger.info('LoopHandler initialized', {
      trackedTasks: handler.taskToLoop.size,
      trackedPipelines: handler.pipelineTasks.size,
    });

    return ok(handler);
  }

  /**
   * Subscribe to all relevant events
   * ARCHITECTURE: Called by factory after initialization
   */
  private subscribeToEvents(): Result<void, AutobeatError> {
    const subscriptions = [
      this.eventBus.subscribe('LoopCreated', this.handleLoopCreated.bind(this)),
      this.eventBus.subscribe('TaskCompleted', this.handleTaskTerminal.bind(this)),
      this.eventBus.subscribe('TaskFailed', this.handleTaskTerminal.bind(this)),
      this.eventBus.subscribe('TaskCancelled', this.handleTaskCancelled.bind(this)),
      this.eventBus.subscribe('LoopCancelled', this.handleLoopCancelled.bind(this)),
      this.eventBus.subscribe('LoopPaused', this.handleLoopPaused.bind(this)),
      this.eventBus.subscribe('LoopResumed', this.handleLoopResumed.bind(this)),
    ];

    for (const result of subscriptions) {
      if (!result.ok) {
        return err(
          new AutobeatError(ErrorCode.SYSTEM_ERROR, `Failed to subscribe to events: ${result.error.message}`, {
            error: result.error,
          }),
        );
      }
    }

    return ok(undefined);
  }

  // ============================================================================
  // EVENT HANDLERS
  // ============================================================================

  /**
   * Handle loop creation — persist via repo, then start first iteration.
   * Errors are logged and dropped (matching BaseEventHandler pattern used by all
   * other handlers). The EventBus never receives thrown exceptions from handlers;
   * callers rely on handleEvent()'s structured logging for observability.
   */
  private async handleLoopCreated(event: LoopCreatedEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const loop = e.loop;

      this.logger.info('Processing new loop', {
        loopId: loop.id,
        strategy: loop.strategy,
        maxIterations: loop.maxIterations,
      });

      // Persist loop via repo
      const saveResult = await this.loopRepo.save(loop);
      if (!saveResult.ok) {
        this.logger.error('Failed to save loop', saveResult.error, { loopId: loop.id });
        return err(saveResult.error);
      }

      // Start first iteration
      await this.startNextIteration(loop);

      return ok(undefined);
    });
  }

  /**
   * Handle task terminal events (TaskCompleted, TaskFailed) — evaluate exit condition
   * ARCHITECTURE: Both events share the same handler since iteration evaluation logic
   * is identical: look up loop, check status, evaluate condition, decide next step
   */
  private async handleTaskTerminal(event: TaskCompletedEvent | TaskFailedEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const taskId = e.taskId;

      // Look up loop from in-memory map
      const loopId = this.taskToLoop.get(taskId);
      if (!loopId) {
        // Not a loop task — ignore
        return ok(undefined);
      }

      // Get loop from repo
      const loopResult = await this.loopRepo.findById(loopId);
      if (!loopResult.ok) {
        this.logger.error('Failed to fetch loop for terminal task', loopResult.error, { taskId, loopId });
        return err(loopResult.error);
      }

      const loop = loopResult.value;
      if (!loop) {
        this.logger.warn('Loop not found for terminal task', { taskId, loopId });
        return ok(undefined);
      }

      // Prevents action after cancel (R5 race condition)
      // Allow PAUSED through so iteration results are recorded (graceful pause)
      if (loop.status !== LoopStatus.RUNNING && loop.status !== LoopStatus.PAUSED) {
        this.logger.debug('Loop not running or paused, ignoring terminal event', {
          loopId,
          status: loop.status,
          taskId,
        });
        // Clean up tracking
        this.taskToLoop.delete(taskId);
        return ok(undefined);
      }

      // Get the iteration record for this task (only matches if this is the tail task)
      const iterationResult = await this.loopRepo.findIterationByTaskId(taskId);
      if (!iterationResult.ok || !iterationResult.value) {
        // Not the tail task — check if it's a non-tail pipeline intermediate task
        return this.handlePipelineIntermediateTask(event, taskId, loop);
      }

      const iteration = iterationResult.value;

      // Guard: skip processing if iteration is no longer running (e.g., force-paused → cancelled)
      // Prevents late TaskCompleted/TaskFailed from overwriting cancelled/terminal iteration status
      if (iteration.status !== 'running') {
        this.logger.debug('Iteration not running, skipping terminal processing', {
          loopId,
          taskId,
          iterationStatus: iteration.status,
        });
        this.cleanupIterationTracking(taskId, loopId, iteration);
        return ok(undefined);
      }

      // Determine outcome based on event type — branch directly on event.type so TypeScript
      // narrows the discriminated union automatically (no intermediate boolean needed).
      if (event.type === 'TaskFailed') {
        // Task FAILED — record failure, check limits
        const newConsecutiveFailures = loop.consecutiveFailures + 1;

        // Git reset: strategy-aware via getResetTargetSha.
        // RETRY resets to preIterationCommitSha; OPTIMIZE to best/start commit.
        await this.resetIterationGitState(loop, iteration, 'task failure');

        // Atomic: iteration fail + consecutiveFailures in single transaction
        const updatedLoop = updateLoop(loop, { consecutiveFailures: newConsecutiveFailures });
        const txResult = this.database.runInTransaction(() => {
          this.loopRepo.updateIterationSync({
            ...iteration,
            status: 'fail',
            exitCode: event.exitCode,
            errorMessage: event.error?.message ?? 'Task failed',
            completedAt: Date.now(),
          });
          this.loopRepo.updateSync(updatedLoop);
        });

        if (!txResult.ok) {
          this.logger.error('Failed to persist task failure', txResult.error, { loopId });
          await this.completeLoop(loop, LoopStatus.FAILED, 'Failed to persist task failure');
        } else if (loop.maxConsecutiveFailures > 0 && newConsecutiveFailures >= loop.maxConsecutiveFailures) {
          await this.completeLoop(updatedLoop, LoopStatus.FAILED, 'Max consecutive failures reached');
        } else {
          await this.scheduleNextIteration(updatedLoop);
        }
      } else {
        // Task COMPLETED — run exit condition evaluation
        // Note: agent eval mode can take a long time; re-fetch state afterwards to guard stale data
        const evalResult = await this.exitConditionEvaluator.evaluate(loop, taskId);

        // Stale state guard: re-fetch loop and iteration after potentially slow agent eval (Step 8b).
        // Shell eval completes in milliseconds so re-fetching is unnecessary; guard only applies to agent mode.
        if (loop.evalMode === EvalMode.AGENT) {
          const fresh = await this.refetchAfterAgentEval(loop, taskId);
          if (!fresh) {
            this.cleanupIterationTracking(taskId, loopId, iteration);
            return ok(undefined);
          }
          await this.handleIterationResult(fresh.loop, fresh.iteration, evalResult);
        } else {
          await this.handleIterationResult(loop, iteration, evalResult);
        }
      }

      // Clean up all pipeline task tracking for this iteration
      this.cleanupIterationTracking(taskId, loopId, iteration);

      return ok(undefined);
    });
  }

  /**
   * After agent eval (which can take seconds), the loop or iteration may have
   * transitioned (cancelled, paused, etc.). Refetch both and return null if state
   * is no longer terminal-eligible. Caller is responsible for cleanup on null.
   *
   * DECISION: Helper signals stale state by returning null. Caller owns
   * cleanupIterationTracking because the helper has no visibility into what tracking
   * the caller has set up. Full loop replacement (loop = fresh.loop) intentionally
   * discards any in-memory mutations since eval started — DB is source of truth.
   */
  private async refetchAfterAgentEval(
    loop: Loop,
    taskId: TaskId,
  ): Promise<{ loop: Loop; iteration: LoopIteration } | null> {
    // Observability: preserve the original repo-error / null-row diagnostic logs that were
    // emitted before the helper was extracted (#137). Silently returning null on repo
    // failure would hide transient DB issues that look like "loop disappeared after eval".
    const freshLoopResult = await this.loopRepo.findById(loop.id);
    if (!freshLoopResult.ok || !freshLoopResult.value) {
      const staleStatus = freshLoopResult.ok ? 'null' : 'error';
      this.logger.info('Loop no longer running after eval, skipping result processing', {
        loopId: loop.id,
        status: staleStatus,
      });
      return null;
    }
    const freshLoop = freshLoopResult.value;
    if (freshLoop.status !== LoopStatus.RUNNING && freshLoop.status !== LoopStatus.PAUSED) {
      this.logger.info('Loop no longer running after eval, skipping result processing', {
        loopId: loop.id,
        status: freshLoop.status,
      });
      return null;
    }
    const freshIterResult = await this.loopRepo.findIterationByTaskId(taskId);
    if (!freshIterResult.ok || !freshIterResult.value) {
      const staleIterStatus = freshIterResult.ok ? 'null' : 'error';
      this.logger.info('Iteration no longer running after eval, skipping result processing', {
        loopId: loop.id,
        iterationStatus: staleIterStatus,
      });
      return null;
    }
    if (freshIterResult.value.status !== 'running') {
      this.logger.info('Iteration no longer running after eval, skipping result processing', {
        loopId: loop.id,
        iterationStatus: freshIterResult.value.status,
      });
      return null;
    }
    return { loop: freshLoop, iteration: freshIterResult.value };
  }

  /**
   * Handle task cancellation — clean up if it's a loop task
   */
  private async handleTaskCancelled(event: TaskCancelledEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const taskId = e.taskId;
      const loopId = this.taskToLoop.get(taskId);
      if (!loopId) {
        return ok(undefined);
      }

      this.logger.info('Loop task cancelled', { loopId, taskId });

      // Get iteration to mark as cancelled
      const iterationResult = await this.loopRepo.findIterationByTaskId(taskId);
      if (iterationResult.ok && iterationResult.value) {
        await this.loopRepo.updateIteration({
          ...iterationResult.value,
          status: 'cancelled',
          completedAt: Date.now(),
        });
        this.cleanupPipelineTasks(loopId, iterationResult.value.iterationNumber);
      }

      this.taskToLoop.delete(taskId);

      return ok(undefined);
    });
  }

  /**
   * Handle loop cancellation — update status, cancel in-flight tasks, clear timers
   */
  private async handleLoopCancelled(event: LoopCancelledEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const { loopId, reason } = e;

      this.logger.info('Processing loop cancellation', { loopId, reason });

      // Fetch current loop state
      const loopResult = await this.loopRepo.findById(loopId);
      if (!loopResult.ok || !loopResult.value) {
        this.logger.warn('Loop not found for cancellation', { loopId });
        return ok(undefined);
      }

      const loop = loopResult.value;

      // Update loop status to CANCELLED
      const updatedLoop = updateLoop(loop, {
        status: LoopStatus.CANCELLED,
        completedAt: Date.now(),
      });
      await this.loopRepo.update(updatedLoop);

      // Clear cooldown timer if exists
      const timer = this.cooldownTimers.get(loopId);
      if (timer) {
        clearTimeout(timer);
        this.cooldownTimers.delete(loopId);
      }

      // Clean up taskToLoop entries for this loop
      for (const [taskId, lId] of this.taskToLoop.entries()) {
        if (lId === loopId) {
          this.taskToLoop.delete(taskId);
        }
      }

      // Mark current running iteration as 'cancelled'
      const iterationsResult = await this.loopRepo.getIterations(loopId, 1);
      if (iterationsResult.ok && iterationsResult.value.length > 0) {
        const latestIteration = iterationsResult.value[0];
        if (latestIteration.status === 'running') {
          await this.loopRepo.updateIteration({
            ...latestIteration,
            status: 'cancelled',
            completedAt: Date.now(),
          });
        }
      }

      // Clean up all pipeline task entries for this loop
      for (const key of this.pipelineTasks.keys()) {
        if (key.startsWith(`${loopId}:`)) {
          this.pipelineTasks.delete(key);
        }
      }

      this.logger.info('Loop cancelled', { loopId, reason });

      return ok(undefined);
    });
  }

  /**
   * Handle loop pause — update status, clear cooldown, optionally force-cancel current iteration
   * ARCHITECTURE: Graceful pause waits for current iteration to complete;
   * force pause cancels it immediately via TaskCancellationRequested
   */
  private async handleLoopPaused(event: LoopPausedEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const { loopId, force } = e;

      this.logger.info('Processing loop pause', { loopId, force });

      // Fetch current loop state
      const loopResult = await this.loopRepo.findById(loopId);
      if (!loopResult.ok || !loopResult.value) {
        this.logger.warn('Loop not found for pause', { loopId });
        return ok(undefined);
      }

      const loop = loopResult.value;

      // Defense-in-depth: only RUNNING loops can be paused
      // LoopManagerService validates this, but handler is reachable via direct EventBus emission
      if (loop.status !== LoopStatus.RUNNING) {
        this.logger.warn('Cannot pause loop that is not running', {
          loopId,
          currentStatus: loop.status,
        });
        return ok(undefined);
      }

      // Update loop status to PAUSED
      const updatedLoop = updateLoop(loop, { status: LoopStatus.PAUSED });
      await this.loopRepo.update(updatedLoop);

      // Clear cooldown timer if exists
      const timer = this.cooldownTimers.get(loopId);
      if (timer) {
        clearTimeout(timer);
        this.cooldownTimers.delete(loopId);
        this.logger.debug('Cleared cooldown timer for paused loop', { loopId });
      }

      // If force pause: cancel current running iteration and its task
      if (force) {
        await this.forceCancelCurrentIteration(loopId);
      }

      this.logger.info('Loop paused', { loopId, force });

      return ok(undefined);
    });
  }

  /**
   * Handle loop resume — update status to RUNNING and re-derive correct action via recovery
   * ARCHITECTURE: Reuses recoverSingleLoop() to handle all resume cases:
   * - Task completed while paused → recovery evaluates result, continues or completes
   * - Task still running (graceful pause) → re-registers in taskToLoop map, waits
   * - Iteration was cancelled by force pause → starts next iteration
   */
  private async handleLoopResumed(event: LoopResumedEvent): Promise<void> {
    await this.handleEvent(event, async (e) => {
      const { loopId } = e;

      this.logger.info('Processing loop resume', { loopId });

      // Fetch current loop state
      const loopResult = await this.loopRepo.findById(loopId);
      if (!loopResult.ok || !loopResult.value) {
        this.logger.warn('Loop not found for resume', { loopId });
        return ok(undefined);
      }

      const loop = loopResult.value;

      // Update loop status to RUNNING
      const updatedLoop = updateLoop(loop, { status: LoopStatus.RUNNING });
      await this.loopRepo.update(updatedLoop);

      // Reuse recovery logic to derive the correct next action
      await this.recoverSingleLoop(updatedLoop);

      this.logger.info('Loop resumed', { loopId });

      return ok(undefined);
    });
  }

  // ============================================================================
  // CORE ITERATION ENGINE
  // ============================================================================

  /**
   * Start the next iteration of a loop
   * ARCHITECTURE: Atomic iteration increment via runInTransaction prevents R4 double-start
   */
  private async startNextIteration(loop: Loop): Promise<void> {
    const loopId = loop.id;

    // Atomically increment currentIteration
    const txResult = this.database.runInTransaction(() => {
      const current = this.loopRepo.findByIdSync(loopId);
      if (!current) {
        throw new AutobeatError(ErrorCode.TASK_NOT_FOUND, `Loop ${loopId} not found in transaction`);
      }
      if (current.status !== LoopStatus.RUNNING) {
        throw new AutobeatError(ErrorCode.INVALID_OPERATION, `Loop ${loopId} not running (status: ${current.status})`);
      }

      const newIteration = current.currentIteration + 1;
      const updated = updateLoop(current, { currentIteration: newIteration });
      this.loopRepo.updateSync(updated);
      return { updatedLoop: updated, iterationNumber: newIteration };
    });

    if (!txResult.ok) {
      this.logger.error('Failed to start next iteration', txResult.error, { loopId });
      return;
    }

    const { updatedLoop, iterationNumber } = txResult.value;

    this.logger.info('Starting iteration', {
      loopId,
      iterationNumber,
      strategy: updatedLoop.strategy,
    });

    const preIterationCommitSha = await this.setupGitForIteration(updatedLoop, iterationNumber);

    if (updatedLoop.pipelineSteps && updatedLoop.pipelineSteps.length > 0) {
      await this.startPipelineIteration(updatedLoop, iterationNumber, preIterationCommitSha);
    } else {
      await this.startSingleTaskIteration(updatedLoop, iterationNumber, preIterationCommitSha);
    }
  }

  /**
   * Set up git state for a new iteration.
   * - Iteration 1 with gitBranch: create and checkout branch from base
   * - Iteration > 1 with gitBranch: re-checkout the branch (user/agent may have switched)
   * - Always (when git-enabled): capture pre-iteration commit SHA for revert/diff baseline
   *
   * Best-effort: logs warnings on failure, never throws.
   * @returns preIterationCommitSha if captured, undefined otherwise
   */
  private async setupGitForIteration(loop: Loop, iterationNumber: number): Promise<string | undefined> {
    const isGitLoop = !!(loop.gitBranch || loop.gitStartCommitSha);
    if (!isGitLoop) return undefined;

    // Create branch ONCE on first iteration only
    if (iterationNumber === 1 && loop.gitBranch) {
      const branchResult = await createAndCheckoutBranch(loop.workingDirectory, loop.gitBranch, loop.gitBaseBranch);
      if (branchResult.ok) {
        this.logger.info('Created git branch for loop', {
          loopId: loop.id,
          branchName: loop.gitBranch,
          baseBranch: loop.gitBaseBranch,
        });
      } else {
        this.logger.warn('Failed to create git branch for loop, continuing without git', {
          loopId: loop.id,
          branchName: loop.gitBranch,
          error: branchResult.error.message,
        });
      }
    } else if (iterationNumber > 1 && loop.gitBranch) {
      // Re-checkout the loop's branch (user/agent may have switched)
      const checkoutResult = await createAndCheckoutBranch(loop.workingDirectory, loop.gitBranch);
      if (!checkoutResult.ok) {
        this.logger.warn('Failed to re-checkout loop branch, continuing without git', {
          loopId: loop.id,
          branchName: loop.gitBranch,
          error: checkoutResult.error.message,
        });
      }
    }

    // Capture pre-iteration commit SHA for revert/diff baseline
    const shaResult = await getCurrentCommitSha(loop.workingDirectory);
    if (shaResult.ok) {
      this.logger.debug('Captured pre-iteration commit SHA', {
        loopId: loop.id,
        iterationNumber,
        preIterationCommitSha: shaResult.value,
      });
      return shaResult.value;
    }

    this.logger.warn('Failed to capture pre-iteration commit SHA', {
      loopId: loop.id,
      iterationNumber,
      error: shaResult.error.message,
    });
    return undefined;
  }

  /**
   * Start a single-task iteration
   * ARCHITECTURE: Creates task from template, emits TaskDelegated, tracks in taskToLoop
   */
  private async startSingleTaskIteration(
    loop: Loop,
    iterationNumber: number,
    preIterationCommitSha?: string,
  ): Promise<void> {
    const loopId = loop.id;
    let prompt = loop.taskTemplate.prompt;

    // If !freshContext: fetch previous iteration's checkpoint and enrich prompt (R2)
    if (!loop.freshContext && iterationNumber > 1) {
      prompt = await this.enrichPromptWithCheckpoint(loop, iterationNumber, prompt);
    }

    // If freshContext + iteration > 1 + workingDirectory: prepend recent git context (R3)
    // Provides lightweight cross-iteration awareness without full checkpoint overhead.
    if (loop.freshContext && iterationNumber > 1 && loop.workingDirectory) {
      prompt = await this.enrichPromptWithGitContext(loop, iterationNumber, prompt);
    }

    // Create task from template
    const task = createTask({
      ...loop.taskTemplate,
      prompt,
      workingDirectory: loop.workingDirectory,
    });

    // Build iteration record
    const iteration: LoopIteration = {
      id: 0, // Auto-increment
      loopId,
      iterationNumber,
      taskId: task.id,
      preIterationCommitSha,
      status: 'running',
      startedAt: Date.now(),
    };

    // Atomic: save task BEFORE recording iteration (FK constraint: iteration.task_id -> tasks.id)
    const txResult = this.database.runInTransaction(() => {
      this.taskRepo.saveSync(task);
      this.loopRepo.recordIterationSync(iteration);
    });

    if (!txResult.ok) {
      this.logger.error('Failed to save task and record iteration atomically', txResult.error, {
        loopId,
        iterationNumber,
        taskId: task.id,
      });
      return;
    }

    // Track task → loop mapping AFTER successful transaction
    this.taskToLoop.set(task.id, loopId);

    // Emit TaskDelegated event AFTER transaction commit
    const emitResult = await this.eventBus.emit('TaskDelegated', { task });
    if (!emitResult.ok) {
      this.logger.error('Failed to emit TaskDelegated for loop iteration', emitResult.error, {
        loopId,
        iterationNumber,
        taskId: task.id,
      });
    }

    this.logger.info('Single-task iteration started', {
      loopId,
      iterationNumber,
      taskId: task.id,
    });
  }

  /**
   * Start a pipeline iteration
   * ARCHITECTURE: Replicates ScheduleHandler.handlePipelineTrigger() pattern
   * Pre-creates N task objects with linear dependsOn chain, saves atomically,
   * emits TaskDelegated for each, tracks ALL tasks in taskToLoop for intermediate failure handling
   */
  private async startPipelineIteration(
    loop: Loop,
    iterationNumber: number,
    preIterationCommitSha?: string,
  ): Promise<void> {
    const loopId = loop.id;
    const steps = loop.pipelineSteps!;
    const defaults = loop.taskTemplate;

    this.logger.info('Starting pipeline iteration', {
      loopId,
      iterationNumber,
      stepCount: steps.length,
    });

    // Pre-create ALL task domain objects OUTSIDE transaction (pure computation)
    // Spread defaults (taskTemplate) so template fields like orchestratorId and model
    // propagate to every pipeline step — then override the per-step fields.
    const tasks: Task[] = [];
    for (let i = 0; i < steps.length; i++) {
      tasks.push(
        createTask({
          ...defaults,
          prompt: steps[i],
          workingDirectory: loop.workingDirectory,
          dependsOn: i > 0 ? [tasks[i - 1].id] : undefined,
        }),
      );
    }

    const allTaskIds = tasks.map((t) => t.id);
    const lastTaskId = tasks[tasks.length - 1].id;

    // Atomic: save N tasks + record iteration
    const txResult = this.database.runInTransaction(() => {
      for (let i = 0; i < tasks.length; i++) {
        try {
          this.taskRepo.saveSync(tasks[i]);
        } catch (error) {
          throw new AutobeatError(
            ErrorCode.SYSTEM_ERROR,
            `Pipeline iteration failed at step ${i + 1}: ${error instanceof Error ? error.message : String(error)}`,
          );
        }
      }

      this.loopRepo.recordIterationSync({
        id: 0, // Auto-increment
        loopId,
        iterationNumber,
        taskId: lastTaskId,
        pipelineTaskIds: allTaskIds,
        preIterationCommitSha,
        status: 'running',
        startedAt: Date.now(),
      });
    });

    if (!txResult.ok) {
      this.logger.error('Failed to save pipeline iteration atomically', txResult.error, {
        loopId,
        iterationNumber,
      });
      return;
    }

    // Track ALL pipeline tasks in taskToLoop for intermediate failure handling
    for (const t of tasks) {
      this.taskToLoop.set(t.id, loopId);
    }

    // Track all pipeline tasks for cleanup
    const pipelineKey = `${loopId}:${iterationNumber}`;
    this.pipelineTasks.set(pipelineKey, new Set(allTaskIds));

    // Emit TaskDelegated for each task AFTER commit
    for (let i = 0; i < tasks.length; i++) {
      const emitResult = await this.eventBus.emit('TaskDelegated', { task: tasks[i] });
      if (!emitResult.ok) {
        this.logger.error('Failed to emit TaskDelegated for pipeline step', emitResult.error, {
          loopId,
          iterationNumber,
          step: i,
          taskId: tasks[i].id,
        });
        // Step 0 failure is critical — cannot proceed
        if (i === 0) {
          return;
        }
      }
    }

    this.logger.info('Pipeline iteration started', {
      loopId,
      iterationNumber,
      stepCount: steps.length,
      tailTaskId: lastTaskId,
    });
  }

  // ============================================================================
  // ITERATION RESULT HANDLING
  // ============================================================================

  /**
   * Process the result of an iteration's exit condition evaluation
   * ARCHITECTURE: Determines whether to continue, complete, or fail the loop
   */
  private async handleIterationResult(loop: Loop, iteration: LoopIteration, evalResult: EvalResult): Promise<void> {
    if (loop.strategy === LoopStrategy.RETRY) {
      await this.handleRetryResult(loop, iteration, evalResult);
    } else {
      await this.handleOptimizeResult(loop, iteration, evalResult);
    }
  }

  /**
   * Handle retry strategy iteration result
   * - decision='continue' → commit work, schedule next iteration WITHOUT incrementing consecutiveFailures
   * - decision='stop' → complete loop (feedforward/judge modes)
   * - pass → complete loop with success (schema mode / backward compat)
   * - exit condition not met → commit work as 'progress', reset consecutiveFailures to 0 (task succeeded, exit not met)
   *
   * DECISION: RETRY non-crash paths use 'progress' status to preserve accumulated work via git commit.
   * 'fail' is reserved for TaskFailed (crash/hard failure) which resets to preIterationCommitSha.
   * This fixes the bug where successful tasks that didn't satisfy the exit condition had their
   * committed work wiped by a git reset to gitStartCommitSha.
   */
  private async handleRetryResult(loop: Loop, iteration: LoopIteration, evalResult: EvalResult): Promise<void> {
    // DECISION: Check explicit decision field first (feedforward/judge modes).
    // Uses 'progress' to commit accumulated work without incrementing consecutiveFailures.
    if (evalResult.decision === 'continue') {
      await this.recordAndContinue(
        loop,
        iteration,
        'progress', // work preserved — exit condition not yet satisfied
        0, // task succeeded — consecutiveFailures reset to 0 (matches DB update below)
        { consecutiveFailures: 0 },
        {
          exitCode: evalResult.exitCode,
          evalFeedback: evalResult.feedback,
          evalResponse: evalResult.evalResponse,
        },
      );
      return;
    }
    if (evalResult.decision === 'stop') {
      await this.handleStopDecision(loop, iteration, evalResult, 'pass');
      return;
    }

    // Fall through to passed/failed logic (schema mode and backward compat)
    if (evalResult.passed) {
      const { gitCommitSha, gitDiffSummary } = await this.handleIterationGitOutcome(loop, iteration, 'pass');

      // Atomic: iteration pass + loop completion in single transaction
      const txResult = this.database.runInTransaction(() => {
        this.loopRepo.updateIterationSync({
          ...iteration,
          status: 'pass',
          exitCode: evalResult.exitCode,
          evalFeedback: evalResult.feedback,
          evalResponse: evalResult.evalResponse,
          gitCommitSha,
          gitDiffSummary,
          completedAt: Date.now(),
        });
        this.loopRepo.updateSync(
          updateLoop(loop, {
            status: LoopStatus.COMPLETED,
            completedAt: Date.now(),
          }),
        );
      });

      if (!txResult.ok) {
        this.logger.error('Failed to persist pass result', txResult.error, { loopId: loop.id });
        await this.completeLoop(loop, LoopStatus.FAILED, 'Failed to persist pass result');
        return;
      }

      // Status already committed in transaction above — only cleanup needed
      await this.finishLoop(loop, LoopStatus.COMPLETED, 'Exit condition passed');
      return;
    }

    // Exit condition not met — task succeeded but exit condition not satisfied.
    // Use 'progress' to commit accumulated work; reset consecutiveFailures (not a crash).
    await this.recordAndContinue(
      loop,
      iteration,
      'progress', // work preserved — exit condition not yet satisfied
      0, // task succeeded — consecutiveFailures reset to 0
      { consecutiveFailures: 0 },
      {
        exitCode: evalResult.exitCode,
        errorMessage: evalResult.error,
        evalFeedback: evalResult.feedback,
        evalResponse: evalResult.evalResponse,
      },
    );
  }

  /**
   * Handle optimize strategy iteration result
   * - decision='continue' → continue without consecutiveFailures increment (feedforward mode)
   * - decision='stop' → complete loop (feedforward/judge modes)
   * - First iteration: always 'keep' as baseline (R5)
   * - Better score → 'keep', update bestScore
   * - Equal or worse → 'discard', increment consecutiveFailures
   * - NaN/Infinity → 'crash'
   */
  private async handleOptimizeResult(loop: Loop, iteration: LoopIteration, evalResult: EvalResult): Promise<void> {
    const loopId = loop.id;
    const iterationNumber = iteration.iterationNumber;

    // DECISION: Check explicit decision field first (feedforward/judge modes).
    if (evalResult.decision === 'continue') {
      await this.recordAndContinue(
        loop,
        iteration,
        'discard', // discard outcome but no consecutiveFailures increment
        loop.consecutiveFailures, // do NOT increment
        {},
        {
          exitCode: evalResult.exitCode,
          evalFeedback: evalResult.feedback,
          evalResponse: evalResult.evalResponse,
        },
      );
      this.logger.info('Optimize iteration: feedforward continue (no failure increment)', { loopId, iterationNumber });
      return;
    }
    if (evalResult.decision === 'stop') {
      await this.handleStopDecision(loop, iteration, evalResult, 'keep');
      return;
    }

    // Check for crash (NaN/Infinity or exec failure in optimize mode)
    if (!evalResult.passed || evalResult.score === undefined) {
      const newConsecutiveFailures = loop.consecutiveFailures + 1;

      await this.recordAndContinue(
        loop,
        iteration,
        'crash',
        newConsecutiveFailures,
        { consecutiveFailures: newConsecutiveFailures },
        {
          exitCode: evalResult.exitCode,
          errorMessage: evalResult.error,
          evalFeedback: evalResult.feedback,
          evalResponse: evalResult.evalResponse,
        },
      );
      return;
    }

    const score = evalResult.score;

    // First iteration or no bestScore yet: always 'keep' as baseline (R5)
    if (loop.bestScore === undefined) {
      await this.recordAndContinue(
        loop,
        iteration,
        'keep',
        0,
        { bestScore: score, bestIterationId: iterationNumber, consecutiveFailures: 0 },
        {
          score,
          exitCode: evalResult.exitCode,
          evalFeedback: evalResult.feedback,
          evalResponse: evalResult.evalResponse,
        },
      );
      this.logger.info('Baseline score established', { loopId, score, iterationNumber });
      return;
    }

    // Compare score (respecting direction)
    const isBetter = this.isScoreBetter(score, loop.bestScore, loop.evalDirection);

    if (isBetter) {
      // Better score → 'keep'
      this.logger.info('New best score', {
        loopId,
        score,
        previousBest: loop.bestScore,
        iterationNumber,
      });

      await this.recordAndContinue(
        loop,
        iteration,
        'keep',
        0,
        { bestScore: score, bestIterationId: iterationNumber, consecutiveFailures: 0 },
        {
          score,
          exitCode: evalResult.exitCode,
          evalFeedback: evalResult.feedback,
          evalResponse: evalResult.evalResponse,
        },
      );
    } else {
      // Equal or worse → 'discard'
      const newConsecutiveFailures = loop.consecutiveFailures + 1;

      await this.recordAndContinue(
        loop,
        iteration,
        'discard',
        newConsecutiveFailures,
        { consecutiveFailures: newConsecutiveFailures },
        {
          score,
          exitCode: evalResult.exitCode,
          evalFeedback: evalResult.feedback,
          evalResponse: evalResult.evalResponse,
        },
      );
    }
  }

  // ============================================================================
  // HELPERS
  // ============================================================================

  /**
   * Force-cancel the current running iteration and all its tasks.
   * ARCHITECTURE: Extracted from handleLoopPaused() to reduce nesting.
   */
  private async forceCancelCurrentIteration(loopId: LoopId): Promise<void> {
    const iterationsResult = await this.loopRepo.getIterations(loopId, 1);
    if (!iterationsResult.ok || iterationsResult.value.length === 0) return;

    const latestIteration = iterationsResult.value[0];
    if (latestIteration.status !== 'running') return;

    // Mark iteration as cancelled
    await this.loopRepo.updateIteration({
      ...latestIteration,
      status: 'cancelled',
      completedAt: Date.now(),
    });

    // Cancel the in-flight task
    if (latestIteration.taskId) {
      const cancelResult = await this.eventBus.emit('TaskCancellationRequested', {
        taskId: latestIteration.taskId,
        reason: `Loop ${loopId} force paused`,
      });
      if (!cancelResult.ok) {
        this.logger.warn('Failed to cancel task for force-paused loop', {
          taskId: latestIteration.taskId,
          loopId,
          error: cancelResult.error.message,
        });
      }
    }

    // Also cancel pipeline tasks if it's a pipeline iteration
    if (latestIteration.pipelineTaskIds) {
      for (const ptId of latestIteration.pipelineTaskIds) {
        if (ptId === latestIteration.taskId) continue; // Already cancelled above
        await this.eventBus.emit('TaskCancellationRequested', {
          taskId: ptId,
          reason: `Loop ${loopId} force paused`,
        });
      }
    }

    this.logger.info('Force-cancelled running iteration', {
      loopId,
      iterationNumber: latestIteration.iterationNumber,
    });
  }

  /**
   * Check termination conditions (maxIterations, maxConsecutiveFailures).
   *
   * DECISION: For RETRY loops, maxConsecutiveFailures only counts consecutive crashes
   * (TaskFailed events). Successful task completions that don't satisfy the exit condition
   * are 'progress' — they reset the crash counter to 0. This means maxIterations is the
   * sole budget for how many attempts a RETRY loop gets; maxConsecutiveFailures guards
   * against agent instability (repeated crashes), not slow convergence.
   *
   * @returns true if loop was terminated, false if it should continue
   */
  private async checkTerminationConditions(loop: Loop, consecutiveFailures: number): Promise<boolean> {
    // Check maxIterations
    if (loop.maxIterations > 0 && loop.currentIteration >= loop.maxIterations) {
      this.logger.info('Loop reached maxIterations', {
        loopId: loop.id,
        currentIteration: loop.currentIteration,
        maxIterations: loop.maxIterations,
      });

      const reason =
        loop.strategy === LoopStrategy.OPTIMIZE
          ? `Max iterations reached (best score: ${loop.bestScore})`
          : 'Max iterations reached';
      await this.completeLoop(loop, LoopStatus.COMPLETED, reason);
      return true;
    }

    // Check maxConsecutiveFailures
    if (loop.maxConsecutiveFailures > 0 && consecutiveFailures >= loop.maxConsecutiveFailures) {
      this.logger.info('Loop reached max consecutive failures', {
        loopId: loop.id,
        consecutiveFailures,
        maxConsecutiveFailures: loop.maxConsecutiveFailures,
      });
      await this.completeLoop(loop, LoopStatus.FAILED, 'Max consecutive failures reached', {
        consecutiveFailures,
      });
      return true;
    }

    // Convergence detection: check if the loop has stopped making meaningful progress
    if (await this.checkConvergence(loop)) return true;

    return false;
  }

  /**
   * Check whether the loop has converged (stopped making progress).
   *
   * Two convergence signals:
   * 1. Git diff convergence: last CONVERGENCE_MIN_ITERATIONS completed iterations all produced
   *    fewer than CONVERGENCE_MAX_CHANGED_LINES changed lines in their git diff summaries.
   * 2. Score plateau (OPTIMIZE only): last CONVERGENCE_MIN_ITERATIONS completed iterations
   *    all scored identically — the optimizer is stuck.
   *
   * DECISION: Uses CONVERGENCE_WINDOW to query iterations and CONVERGENCE_MIN_ITERATIONS
   * as the minimum required sample. This avoids premature termination on sparse history.
   *
   * @returns true if convergence was detected (and loop was completed), false otherwise
   */
  private async checkConvergence(loop: Loop): Promise<boolean> {
    if (loop.convergenceEnabled === false) return false;

    const iterationsResult = await this.loopRepo.getIterations(loop.id, CONVERGENCE_WINDOW, 0);
    if (!iterationsResult.ok) return false;

    const all = iterationsResult.value;

    // Signal 1: Git diff convergence — all recent successful iterations produced fewer
    // than threshold changed lines. Only counts pass/keep/progress — failed/crashed iterations
    // produce no meaningful git changes and must not trigger convergence, otherwise a
    // consistently-failing loop would be marked COMPLETED instead of FAILED.
    const isGitLoop = !!(loop.gitBranch || loop.gitStartCommitSha);
    if (isGitLoop) {
      const successful = all.filter((it) => it.status === 'pass' || it.status === 'keep' || it.status === 'progress');
      const withGitTracking = successful
        .slice(0, CONVERGENCE_MIN_ITERATIONS)
        .filter((it) => it.preIterationCommitSha !== undefined && it.preIterationCommitSha !== null);
      if (withGitTracking.length >= CONVERGENCE_MIN_ITERATIONS) {
        const changedLines = withGitTracking.map((it) => parseGitDiffChangedLines(it.gitDiffSummary));
        if (changedLines.every((lines) => lines < CONVERGENCE_MAX_CHANGED_LINES)) {
          const iterNums = withGitTracking.map((it) => it.iterationNumber);
          const reason = `Convergence detected: iterations ${iterNums[iterNums.length - 1]}-${iterNums[0]} produced ${[...changedLines].reverse().join(', ')} changed lines (threshold: ${CONVERGENCE_MAX_CHANGED_LINES})`;
          this.logger.info('Git diff convergence detected', {
            loopId: loop.id,
            iterationNumbers: iterNums,
            changedLines,
          });
          await this.completeLoop(loop, LoopStatus.COMPLETED, reason);
          return true;
        }
      }
    }

    // Signal 2: Score plateau (OPTIMIZE only) — all recent evaluated iterations scored
    // identically. Includes discard (same/worse score) since that's a valid evaluation outcome.
    // Excludes running/cancelled/fail/crash which were never scored.
    if (loop.strategy === LoopStrategy.OPTIMIZE) {
      const evaluated = all.filter(
        (it) => it.status === 'pass' || it.status === 'keep' || it.status === 'discard' || it.status === 'progress',
      );
      const recent = evaluated.slice(0, CONVERGENCE_MIN_ITERATIONS);
      const scores = recent.map((it) => it.score).filter((s): s is number => s !== undefined);
      if (scores.length >= CONVERGENCE_MIN_ITERATIONS) {
        const allSame = scores.every((s) => Math.abs(s - scores[0]!) < SCORE_EPSILON);
        if (allSame) {
          const iterNums = recent.map((it) => it.iterationNumber);
          const reason = `Convergence detected: iterations ${iterNums[iterNums.length - 1]}-${iterNums[0]} all scored ${scores[0]} (plateau)`;
          this.logger.info('Score plateau convergence detected', {
            loopId: loop.id,
            iterationNumbers: iterNums,
            score: scores[0],
          });
          await this.completeLoop(loop, LoopStatus.COMPLETED, reason);
          return true;
        }
      }
    }

    return false;
  }

  /**
   * Complete a loop with a final status and reason.
   * Writes the final status to the DB, clears cooldown timer, and emits LoopCompleted.
   *
   * Use this when no prior transaction has already written the final status.
   * When the status was already atomically committed in a transaction, use
   * finishLoop() instead to skip the redundant DB write.
   */
  private async completeLoop(
    loop: Loop,
    status: LoopStatus,
    reason: string,
    extraUpdate?: Partial<Loop>,
  ): Promise<void> {
    const updatedLoop = updateLoop(loop, {
      status,
      completedAt: Date.now(),
      ...extraUpdate,
    });
    await this.loopRepo.update(updatedLoop);
    await this.finishLoop(updatedLoop, status, reason);
  }

  /**
   * Post-commit cleanup for a loop that was already persisted in a transaction.
   * Clears cooldown timer, emits LoopCompleted, and logs — no DB write.
   *
   * DECISION: Separate from completeLoop to eliminate the redundant DB write that
   * occurs when a transaction has already set the final status. Calling completeLoop
   * after a transaction causes a double-write (harmless, but wasteful and misleading).
   */
  private async finishLoop(loop: Loop, status: LoopStatus, reason: string): Promise<void> {
    // Clear cooldown timer if exists
    const timer = this.cooldownTimers.get(loop.id);
    if (timer) {
      clearTimeout(timer);
      this.cooldownTimers.delete(loop.id);
    }

    await this.eventBus.emit('LoopCompleted', {
      loopId: loop.id,
      reason,
    });

    this.logger.info('Loop completed', {
      loopId: loop.id,
      status,
      reason,
      totalIterations: loop.currentIteration,
      bestScore: loop.bestScore,
    });
  }

  /**
   * Schedule the next iteration, respecting cooldown (R14)
   * ARCHITECTURE: Uses setTimeout with .unref() to avoid blocking process exit
   * Skips scheduling if loop is PAUSED — result was recorded, resume will re-derive action
   */
  private async scheduleNextIteration(loop: Loop): Promise<void> {
    // If loop is paused, skip scheduling — iteration result is already recorded.
    // Resume handler will re-derive the correct next action via recoverSingleLoop().
    if (loop.status === LoopStatus.PAUSED) {
      this.logger.debug('Loop is paused, skipping next iteration scheduling', {
        loopId: loop.id,
      });
      return;
    }

    if (loop.cooldownMs > 0) {
      this.logger.debug('Scheduling next iteration with cooldown', {
        loopId: loop.id,
        cooldownMs: loop.cooldownMs,
      });

      const timer = setTimeout(() => {
        this.startNextIteration(loop).catch((error) => {
          this.logger.error(
            'Failed to start next iteration after cooldown',
            error instanceof Error ? error : undefined,
            {
              loopId: loop.id,
            },
          );
        });
      }, loop.cooldownMs);

      // R14: Don't block process exit
      timer.unref();

      this.cooldownTimers.set(loop.id, timer);
    } else {
      await this.startNextIteration(loop);
    }
  }

  /**
   * Handle EvalResult.decision === 'stop' for both RETRY and OPTIMIZE strategies.
   * Persists the iteration with the appropriate terminal status, marks loop as COMPLETED,
   * and runs post-commit cleanup via finishLoop. Score is preserved from evalResult
   * (undefined for retry/pass, set for optimize/keep).
   *
   * DECISION: Extracted from handleRetryResult and handleOptimizeResult to eliminate
   * ~25 lines of duplication. Differs only in iterationStatus ('pass' vs 'keep') and
   * score inclusion (evalResult.score is undefined for retry, set for optimize).
   * Logger key standardised to loopId: loop.id (retry used loop.id, optimize used loopId
   * shorthand — functionally identical, now consistent).
   */
  private async handleStopDecision(
    loop: Loop,
    iteration: LoopIteration,
    evalResult: EvalResult,
    iterationStatus: 'pass' | 'keep',
  ): Promise<void> {
    // Record iteration result before completing the loop — prevents orphan 'running' iterations
    const { gitCommitSha, gitDiffSummary } = await this.handleIterationGitOutcome(loop, iteration, iterationStatus);
    const txResult = this.database.runInTransaction(() => {
      this.loopRepo.updateIterationSync({
        ...iteration,
        status: iterationStatus,
        score: evalResult.score,
        exitCode: evalResult.exitCode,
        evalFeedback: evalResult.feedback,
        evalResponse: evalResult.evalResponse,
        gitCommitSha,
        gitDiffSummary,
        completedAt: Date.now(),
      });
      this.loopRepo.updateSync(updateLoop(loop, { status: LoopStatus.COMPLETED, completedAt: Date.now() }));
    });
    if (!txResult.ok) {
      this.logger.error('Failed to persist stop decision', txResult.error, { loopId: loop.id });
      await this.completeLoop(loop, LoopStatus.FAILED, 'Failed to persist stop decision');
      return;
    }
    // Status already committed in transaction above — only cleanup needed
    await this.finishLoop(loop, LoopStatus.COMPLETED, 'Eval decision: stop');
  }

  /**
   * Record iteration result, emit event, check termination, update loop, and schedule next
   * ARCHITECTURE: Reduces duplication across 5 non-terminal iteration branches
   */
  private async recordAndContinue(
    loop: Loop,
    iteration: LoopIteration,
    iterationStatus: LoopIteration['status'],
    consecutiveFailures: number,
    loopUpdate: Partial<Loop>,
    evalResult?: IterationResultFields,
  ): Promise<void> {
    const { gitCommitSha, gitDiffSummary } = await this.handleIterationGitOutcome(loop, iteration, iterationStatus);

    const fullUpdate =
      iterationStatus === 'keep' && gitCommitSha ? { ...loopUpdate, bestIterationCommitSha: gitCommitSha } : loopUpdate;
    const updatedLoop = updateLoop(loop, fullUpdate);

    // Atomic: both DB writes in single transaction
    const txResult = this.database.runInTransaction(() => {
      this.loopRepo.updateIterationSync({
        ...iteration,
        status: iterationStatus,
        score: evalResult?.score ?? iteration.score,
        exitCode: evalResult?.exitCode ?? iteration.exitCode,
        errorMessage: evalResult?.errorMessage ?? iteration.errorMessage,
        evalFeedback: evalResult?.evalFeedback ?? iteration.evalFeedback,
        evalResponse: evalResult?.evalResponse ?? iteration.evalResponse,
        gitCommitSha,
        gitDiffSummary: gitDiffSummary ?? iteration.gitDiffSummary,
        completedAt: Date.now(),
      });
      this.loopRepo.updateSync(updatedLoop);
    });

    if (!txResult.ok) {
      this.logger.error('Failed to record iteration result', txResult.error, { loopId: loop.id });
      await this.completeLoop(loop, LoopStatus.FAILED, 'Failed to persist iteration result');
      return;
    }

    // Event AFTER commit (matches schedule-handler pattern)
    await this.eventBus.emit('LoopIterationCompleted', {
      loopId: loop.id,
      iterationNumber: iteration.iterationNumber,
      result: { ...iteration, status: iterationStatus },
    });

    // Check termination conditions (using updated loop for correct state)
    if (await this.checkTerminationConditions(updatedLoop, consecutiveFailures)) {
      return;
    }

    // Schedule next iteration
    await this.scheduleNextIteration(updatedLoop);
  }

  /**
   * Compare scores respecting optimize direction.
   * Uses strict comparison — equal scores are NOT "better".
   * This prevents infinite loops when a deterministic metric produces the same
   * score repeatedly. Equal scores increment consecutiveFailures, eventually
   * triggering maxConsecutiveFailures completion.
   */
  private isScoreBetter(newScore: number, bestScore: number, direction?: OptimizeDirection): boolean {
    if (direction === OptimizeDirection.MINIMIZE) {
      return newScore < bestScore;
    }
    // Default: MAXIMIZE
    return newScore > bestScore;
  }

  /**
   * Handle git commit or revert based on iteration outcome.
   * - pass/keep/progress: commit all changes, capture diff summary
   * - fail/discard/crash: delegate to resetIterationGitState for clean revert
   *
   * Best-effort: logs warnings on failure, never throws.
   * @returns { gitCommitSha, gitDiffSummary } — both undefined if non-git or on revert path
   */
  private async handleIterationGitOutcome(
    loop: Loop,
    iteration: LoopIteration,
    iterationStatus: LoopIteration['status'],
  ): Promise<{ gitCommitSha?: string; gitDiffSummary?: string }> {
    if (!iteration.preIterationCommitSha) return {};

    try {
      const isCommitPath = iterationStatus === 'pass' || iterationStatus === 'keep' || iterationStatus === 'progress';
      if (isCommitPath) {
        return await this.commitAndCaptureDiff(loop, iteration, iterationStatus);
      }

      // Discard path: delegate to resetIterationGitState (single source of truth for git reset)
      await this.resetIterationGitState(loop, iteration, 'iteration discard');
      return {};
    } catch (gitError) {
      this.logger.warn('Git operation failed during iteration outcome handling, continuing without git', {
        loopId: loop.id,
        iterationNumber: iteration.iterationNumber,
        error: gitError instanceof Error ? gitError.message : String(gitError),
      });
      return {};
    }
  }

  /**
   * Commit all changes and capture diff summary for an iteration.
   * Handles both explicit commits and agent-already-committed cases.
   * @returns { gitCommitSha, gitDiffSummary } — either or both may be undefined on failure
   */
  private async commitAndCaptureDiff(
    loop: Loop,
    iteration: LoopIteration,
    iterationStatus: LoopIteration['status'],
  ): Promise<{ gitCommitSha?: string; gitDiffSummary?: string }> {
    let gitCommitSha: string | undefined;
    let gitDiffSummary: string | undefined;

    const commitResult = await commitAllChanges(
      loop.workingDirectory,
      `Loop ${loop.id} iteration ${iteration.iterationNumber} — ${iterationStatus}`,
    );
    if (commitResult.ok && commitResult.value) {
      gitCommitSha = commitResult.value;
    } else if (commitResult.ok) {
      // null = nothing to commit (agent already committed)
      const shaResult = await getCurrentCommitSha(loop.workingDirectory);
      if (shaResult.ok) {
        gitCommitSha = shaResult.value;
      }
    } else {
      this.logger.warn('Failed to commit iteration changes', {
        loopId: loop.id,
        iterationNumber: iteration.iterationNumber,
        error: commitResult.error.message,
      });
    }

    // Capture diff summary between pre-iteration and new commit
    if (gitCommitSha && iteration.preIterationCommitSha) {
      const diffResult = await captureGitDiff(loop.workingDirectory, iteration.preIterationCommitSha, gitCommitSha);
      if (diffResult.ok && diffResult.value) {
        gitDiffSummary = diffResult.value;
      }
    }

    return { gitCommitSha, gitDiffSummary };
  }

  /**
   * Determine the commit SHA to reset to after a failed/discarded iteration.
   * Strategy-aware: each strategy has its own reset semantics.
   * - RETRY: reset to preIterationCommitSha to preserve accumulated 'progress' commits
   * - OPTIMIZE: reset to bestIterationCommitSha if available, fallback to gitStartCommitSha
   * - Default: reset to gitStartCommitSha
   * @returns SHA to reset to, or undefined if no git tracking
   */
  private getResetTargetSha(loop: Loop, iteration: LoopIteration): string | undefined {
    if (loop.strategy === LoopStrategy.RETRY) {
      return iteration.preIterationCommitSha ?? loop.gitStartCommitSha;
    }
    if (loop.strategy === LoopStrategy.OPTIMIZE && loop.bestIterationCommitSha) {
      return loop.bestIterationCommitSha;
    }
    return loop.gitStartCommitSha;
  }

  /**
   * Reset the working directory to the appropriate commit after a failed/discarded iteration.
   * No-op when the iteration has no preIterationCommitSha (non-git loop).
   * Logs warnings on failure but never throws — git reset is best-effort.
   *
   * DECISION: Reset target is determined by strategy via getResetTargetSha:
   * - RETRY: resets to preIterationCommitSha to preserve accumulated 'progress' commits
   * - OPTIMIZE: resets to bestIterationCommitSha (or gitStartCommitSha as fallback)
   *
   * @param loop - The loop owning the iteration
   * @param iteration - The iteration that failed/was discarded
   * @param context - Human-readable label for log messages (e.g., "task failure", "pipeline step failure")
   */
  private async resetIterationGitState(loop: Loop, iteration: LoopIteration, context: string): Promise<void> {
    if (!iteration.preIterationCommitSha) return;

    try {
      const resetTarget = this.getResetTargetSha(loop, iteration);
      if (!resetTarget) return;

      const resetResult = await resetToCommit(loop.workingDirectory, resetTarget);
      if (!resetResult.ok) {
        this.logger.warn(`Failed to reset git state after ${context}`, {
          loopId: loop.id,
          iterationNumber: iteration.iterationNumber,
          resetTarget,
          error: resetResult.error.message,
        });
      }
    } catch (gitError) {
      this.logger.warn(`Git reset failed after ${context}, continuing without git`, {
        loopId: loop.id,
        iterationNumber: iteration.iterationNumber,
        error: gitError instanceof Error ? gitError.message : String(gitError),
      });
    }
  }

  /**
   * Enrich prompt with checkpoint context from previous iteration (R2)
   * ARCHITECTURE: NO dependsOn for iteration chaining — LoopHandler manages sequencing directly
   */
  private async enrichPromptWithCheckpoint(loop: Loop, iterationNumber: number, prompt: string): Promise<string> {
    // Get last 11 iterations (enough for 10 feedback entries + the running one)
    const iterationsResult = await this.loopRepo.getIterations(loop.id, 11, 0);
    if (!iterationsResult.ok || iterationsResult.value.length === 0) {
      return prompt;
    }

    const contextParts: string[] = [prompt];

    // --- Previous Iteration Checkpoint (existing behavior) ---
    const previousIteration = iterationsResult.value.find(
      (i) => i.iterationNumber === iterationNumber - 1 && i.status !== 'running',
    );
    if (previousIteration?.taskId) {
      const checkpointResult = await this.checkpointRepo.findLatest(previousIteration.taskId);
      if (checkpointResult.ok && checkpointResult.value) {
        const cp = checkpointResult.value;
        contextParts.push('', '--- Previous Iteration Context ---');
        if (cp.outputSummary) contextParts.push(`Output: ${cp.outputSummary}`);
        if (cp.errorSummary) contextParts.push(`Errors: ${cp.errorSummary}`);
        if (cp.gitCommitSha) contextParts.push(`Git commit: ${cp.gitCommitSha}`);
        contextParts.push(`Iteration ${iterationNumber - 1} status: ${previousIteration.status}`);
        contextParts.push('---');
      } else {
        this.logger.debug('No checkpoint available for previous iteration', {
          loopId: loop.id,
          previousTaskId: previousIteration.taskId,
        });
      }
    }

    /**
     * DECISION: Accumulate last 10 iterations' feedback, capped at 8KB.
     * Why: each iteration needs full evaluation trajectory to avoid repeating mistakes.
     * Cap prevents prompt bloat from degrading model performance.
     */
    const feedbackIterations = iterationsResult.value
      .filter((i) => i.iterationNumber < iterationNumber && i.evalFeedback && i.status !== 'running')
      .sort((a, b) => a.iterationNumber - b.iterationNumber);

    if (feedbackIterations.length > 0) {
      contextParts.push('', '--- Evaluation History ---');
      let totalBytes = 0;
      const MAX_FEEDBACK_BYTES = 8192;
      for (const iter of feedbackIterations) {
        const statusLabel = iter.status.toUpperCase();
        const scoreLabel = iter.score !== undefined ? ` (score: ${iter.score})` : '';
        const entry = `Iteration ${iter.iterationNumber} [${statusLabel}${scoreLabel}]: ${iter.evalFeedback}`;
        if (totalBytes + Buffer.byteLength(entry) > MAX_FEEDBACK_BYTES) break;
        contextParts.push(entry);
        totalBytes += Buffer.byteLength(entry);
      }
      contextParts.push('---');
    }

    return contextParts.join('\n');
  }

  /**
   * Enrich a freshContext iteration prompt with recent git context.
   * Prepends commit log and diff stat from the working directory to give the agent
   * lightweight cross-iteration awareness without a full checkpoint.
   *
   * DECISION: Cap at 4KB (MAX_GIT_CONTEXT_BYTES) to prevent prompt bloat.
   * When both git log and diff stat are null (not a git repo), returns the
   * original prompt unchanged.
   */
  private async enrichPromptWithGitContext(loop: Loop, iterationNumber: number, prompt: string): Promise<string> {
    const [gitLogResult, gitDiffStatResult] = await Promise.all([
      getRecentGitLog(loop.workingDirectory, 15),
      getRecentGitDiffStat(loop.workingDirectory, 5),
    ]);

    const gitLog = gitLogResult.ok ? gitLogResult.value : null;
    const gitDiffStat = gitDiffStatResult.ok ? gitDiffStatResult.value : null;

    // Not a git repo or no history — skip enrichment
    if (!gitLog && !gitDiffStat) return prompt;

    const sections: string[] = [`--- Iteration ${iterationNumber} Context ---`];
    if (gitLog) sections.push(`Recent commits:\n${gitLog}`);
    if (gitDiffStat) sections.push(`Recent changes:\n${gitDiffStat}`);

    let gitContext = sections.join('\n\n');

    // Cap at MAX_GIT_CONTEXT_BYTES to prevent prompt bloat.
    // Uses binary search on lines array for O(n log n) instead of O(n^2).
    if (Buffer.byteLength(gitContext) > MAX_GIT_CONTEXT_BYTES) {
      const lines = gitContext.split('\n');
      let lo = 0;
      let hi = lines.length;
      // Binary search for the maximum number of lines that fit within the byte budget.
      // Bounded: at most ceil(log2(lines.length)) iterations.
      while (lo < hi) {
        const mid = (lo + hi + 1) >>> 1;
        if (Buffer.byteLength(lines.slice(0, mid).join('\n')) <= MAX_GIT_CONTEXT_BYTES) {
          lo = mid;
        } else {
          hi = mid - 1;
        }
      }
      gitContext = lines.slice(0, lo).join('\n');
      // Guard: binary search may yield lo=0 when every line exceeds the budget.
      // In that edge case skip enrichment rather than prepending a bare separator.
      if (!gitContext) return prompt;
    }

    return `${gitContext}\n\n---\n\n${prompt}`;
  }

  /**
   * Handle a non-tail pipeline task terminal event
   * ARCHITECTURE: Intermediate task completion is a no-op; intermediate failure cancels remaining tasks
   * and fails the iteration to prevent the loop from getting stuck
   */
  private async handlePipelineIntermediateTask(
    event: TaskCompletedEvent | TaskFailedEvent,
    taskId: TaskId,
    loop: Loop,
  ): Promise<Result<void>> {
    const loopId = loop.id;

    // Get the latest iteration for this loop to verify this is indeed a pipeline intermediate task
    const iterationsResult = await this.loopRepo.getIterations(loopId, 1);
    if (!iterationsResult.ok || iterationsResult.value.length === 0) {
      this.taskToLoop.delete(taskId);
      return ok(undefined);
    }

    const iteration = iterationsResult.value[0];

    // Verify: must be a pipeline iteration with this taskId in pipelineTaskIds but NOT the tail task
    if (!iteration.pipelineTaskIds || !iteration.pipelineTaskIds.includes(taskId) || iteration.taskId === taskId) {
      this.logger.error('Iteration not found for terminal task', undefined, { taskId, loopId });
      this.taskToLoop.delete(taskId);
      return ok(undefined);
    }

    // Intermediate task completed successfully — just clean up tracking, no-op
    if (event.type === 'TaskCompleted') {
      this.logger.debug('Pipeline intermediate task completed', { taskId, loopId });
      this.taskToLoop.delete(taskId);
      return ok(undefined);
    }

    // Intermediate task FAILED — concurrent failure guard: only process if iteration is still running
    if (iteration.status !== 'running') {
      this.logger.debug('Pipeline iteration already terminal, ignoring intermediate failure', {
        taskId,
        loopId,
        iterationStatus: iteration.status,
      });
      this.taskToLoop.delete(taskId);
      return ok(undefined);
    }

    // Cancel remaining pipeline tasks — event is narrowed to TaskFailedEvent (TaskCompleted returned early above)
    this.logger.info('Pipeline intermediate task failed, failing iteration', {
      taskId,
      loopId,
      iterationNumber: iteration.iterationNumber,
    });

    await this.cancelRemainingPipelineTasks(iteration.pipelineTaskIds, taskId, loopId);

    // Git reset: strategy-aware via getResetTargetSha.
    // RETRY resets to preIterationCommitSha; OPTIMIZE to best/start commit.
    await this.resetIterationGitState(loop, iteration, 'pipeline step failure');

    // Atomic: iteration fail + consecutiveFailures in single transaction
    const newConsecutiveFailures = loop.consecutiveFailures + 1;
    const updatedLoop = updateLoop(loop, { consecutiveFailures: newConsecutiveFailures });
    const txResult = this.database.runInTransaction(() => {
      this.loopRepo.updateIterationSync({
        ...iteration,
        status: 'fail',
        exitCode: event.exitCode,
        errorMessage: `Pipeline step failed: ${event.error?.message ?? 'Task failed'}`,
        completedAt: Date.now(),
      });
      this.loopRepo.updateSync(updatedLoop);
    });

    if (!txResult.ok) {
      this.logger.error('Failed to persist pipeline step failure', txResult.error, { loopId });
      await this.completeLoop(loop, LoopStatus.FAILED, 'Failed to persist pipeline step failure');
      return ok(undefined);
    }

    // Post-commit: check limits or schedule next
    if (loop.maxConsecutiveFailures > 0 && newConsecutiveFailures >= loop.maxConsecutiveFailures) {
      await this.completeLoop(updatedLoop, LoopStatus.FAILED, 'Max consecutive failures reached');
    } else {
      await this.scheduleNextIteration(updatedLoop);
    }

    // Clean up all pipeline task tracking
    this.cleanupPipelineTaskTracking(iteration);
    this.cleanupPipelineTasks(loopId, iteration.iterationNumber);

    return ok(undefined);
  }

  /**
   * Cancel remaining pipeline tasks after an intermediate step failure
   * Emits TaskCancellationRequested for each non-terminal pipeline task except the failed one
   */
  private async cancelRemainingPipelineTasks(
    pipelineTaskIds: readonly TaskId[],
    failedTaskId: TaskId,
    loopId: LoopId,
  ): Promise<void> {
    for (const ptId of pipelineTaskIds) {
      if (ptId === failedTaskId) continue;

      // Check if task is still running before cancelling
      const taskResult = await this.taskRepo.findById(ptId);
      if (!taskResult.ok || !taskResult.value) continue;
      if (isTerminalState(taskResult.value.status)) continue;

      const cancelResult = await this.eventBus.emit('TaskCancellationRequested', {
        taskId: ptId,
        reason: `Pipeline step ${failedTaskId} failed in loop ${loopId}`,
      });
      if (!cancelResult.ok) {
        this.logger.warn('Failed to cancel pipeline task', {
          taskId: ptId,
          loopId,
          error: cancelResult.error.message,
        });
      }

      this.taskToLoop.delete(ptId);
    }
  }

  /**
   * Clean up all in-memory iteration tracking for a terminal task.
   * Combines taskToLoop removal, pipeline task entry tracking, and pipeline task map cleanup.
   */
  private cleanupIterationTracking(taskId: TaskId, loopId: LoopId, iteration: LoopIteration): void {
    this.cleanupPipelineTaskTracking(iteration);
    this.taskToLoop.delete(taskId);
    this.cleanupPipelineTasks(loopId, iteration.iterationNumber);
  }

  /**
   * Clean up all pipeline task entries from taskToLoop for a completed/failed iteration
   */
  private cleanupPipelineTaskTracking(iteration: LoopIteration): void {
    if (iteration.pipelineTaskIds) {
      for (const ptId of iteration.pipelineTaskIds) {
        this.taskToLoop.delete(ptId);
      }
    }
  }

  /**
   * Clean up pipeline task entries for a completed iteration
   */
  private cleanupPipelineTasks(loopId: LoopId, iterationNumber: number): void {
    const key = `${loopId}:${iterationNumber}`;
    this.pipelineTasks.delete(key);
  }

  // ============================================================================
  // RECOVERY (R3)
  // ============================================================================

  /**
   * Rebuild in-memory maps from database on startup
   * ARCHITECTURE: Ensures LoopHandler can recover state after restart
   */
  private async rebuildMaps(): Promise<void> {
    // Rebuild taskToLoop from running iterations
    const runningResult = await this.loopRepo.findRunningIterations();
    if (!runningResult.ok) {
      this.logger.error('Failed to rebuild task-to-loop maps', runningResult.error);
      return;
    }

    for (const iteration of runningResult.value) {
      // Skip iterations with cleaned-up tasks (ON DELETE SET NULL)
      if (!iteration.taskId) continue;

      // Register ALL pipeline task IDs in taskToLoop for intermediate failure handling
      if (iteration.pipelineTaskIds && iteration.pipelineTaskIds.length > 0) {
        for (const ptId of iteration.pipelineTaskIds) {
          this.taskToLoop.set(ptId, iteration.loopId);
        }
        const key = `${iteration.loopId}:${iteration.iterationNumber}`;
        this.pipelineTasks.set(key, new Set(iteration.pipelineTaskIds));
      } else {
        this.taskToLoop.set(iteration.taskId, iteration.loopId);
      }
    }

    this.logger.info('Rebuilt in-memory maps', {
      taskToLoopSize: this.taskToLoop.size,
      pipelineTasksSize: this.pipelineTasks.size,
    });
  }

  /**
   * Recover stuck loops — find running loops whose latest iteration task is terminal
   * ARCHITECTURE: Self-healing on startup regardless of RecoveryManager timing
   */
  private async recoverStuckLoops(): Promise<void> {
    const runningLoopsResult = await this.loopRepo.findByStatus(LoopStatus.RUNNING);
    if (!runningLoopsResult.ok) {
      this.logger.error('Failed to fetch running loops for recovery', runningLoopsResult.error);
      return;
    }

    for (const loop of runningLoopsResult.value) {
      await this.recoverSingleLoop(loop);
    }

    this.logger.info('Loop recovery complete');
  }

  /**
   * Recover a single loop — check latest iteration status and handle terminal task states
   * ARCHITECTURE: Early-return style for readability (flattened from nested if/else)
   */
  private async recoverSingleLoop(loop: Loop): Promise<void> {
    const iterationsResult = await this.loopRepo.getIterations(loop.id, 1);
    if (!iterationsResult.ok || iterationsResult.value.length === 0) {
      this.logger.info('Recovering loop with no iterations', { loopId: loop.id });
      await this.startNextIteration(loop);
      return;
    }

    const latestIteration = iterationsResult.value[0];

    // Iteration is terminal but loop is still RUNNING — server crashed between
    // DB commit and the post-commit action (completeLoop or scheduleNextIteration).
    // Re-derive the correct action from the iteration's terminal status.
    if (latestIteration.status !== 'running') {
      this.logger.info('Recovering loop with terminal iteration', {
        loopId: loop.id,
        iterationStatus: latestIteration.status,
        iterationNumber: latestIteration.iterationNumber,
      });

      if (latestIteration.status === 'pass') {
        // Exit condition was satisfied — complete the loop
        await this.completeLoop(loop, LoopStatus.COMPLETED, 'Recovered: exit condition already passed');
        return;
      }

      // fail / discard / crash / keep / progress / cancelled — check termination, then continue
      // Loop's consecutiveFailures is already correct (committed atomically with iteration)
      if (await this.checkTerminationConditions(loop, loop.consecutiveFailures)) {
        return;
      }
      await this.startNextIteration(loop);
      return;
    }

    // Task was cleaned up (ON DELETE SET NULL) — mark iteration cancelled and move on
    if (!latestIteration.taskId) {
      this.logger.warn('Running iteration has no task ID, marking as cancelled', {
        loopId: loop.id,
        iterationNumber: latestIteration.iterationNumber,
      });
      await this.loopRepo.updateIteration({
        ...latestIteration,
        status: 'cancelled',
        completedAt: Date.now(),
      });
      await this.startNextIteration(loop);
      return;
    }

    const taskResult = await this.taskRepo.findById(latestIteration.taskId);
    if (!taskResult.ok || !taskResult.value) {
      return;
    }

    // Task still running — will complete normally via event handler
    if (!isTerminalState(taskResult.value.status)) {
      return;
    }

    const task = taskResult.value;
    this.logger.info('Recovering stuck iteration', {
      loopId: loop.id,
      taskId: task.id,
      taskStatus: task.status,
      iterationNumber: latestIteration.iterationNumber,
    });

    if (task.status === TaskStatus.COMPLETED) {
      const evalResult = await this.exitConditionEvaluator.evaluate(loop, task.id);
      await this.handleIterationResult(loop, latestIteration, evalResult);
      return;
    }

    if (task.status === TaskStatus.FAILED) {
      const newConsecutiveFailures = loop.consecutiveFailures + 1;

      // Git reset: strategy-aware via getResetTargetSha.
      // RETRY resets to preIterationCommitSha; OPTIMIZE to best/start commit.
      await this.resetIterationGitState(loop, latestIteration, 'recovered task failure');

      // Atomic: iteration fail + consecutiveFailures in single transaction
      const updatedLoop = updateLoop(loop, { consecutiveFailures: newConsecutiveFailures });
      const txResult = this.database.runInTransaction(() => {
        this.loopRepo.updateIterationSync({
          ...latestIteration,
          status: 'fail',
          completedAt: Date.now(),
        });
        this.loopRepo.updateSync(updatedLoop);
      });

      if (!txResult.ok) {
        this.logger.error('Failed to persist recovery failure', txResult.error, { loopId: loop.id });
        await this.completeLoop(loop, LoopStatus.FAILED, 'Failed to persist recovery failure');
        return;
      }

      // Post-commit: check limits or schedule next
      if (loop.maxConsecutiveFailures > 0 && newConsecutiveFailures >= loop.maxConsecutiveFailures) {
        await this.completeLoop(updatedLoop, LoopStatus.FAILED, 'Max consecutive failures reached (recovered)');
      } else {
        await this.scheduleNextIteration(updatedLoop);
      }
      return;
    }

    // CANCELLED — mark iteration as cancelled and continue
    await this.loopRepo.updateIteration({
      ...latestIteration,
      status: 'cancelled',
      completedAt: Date.now(),
    });
    if (await this.checkTerminationConditions(loop, loop.consecutiveFailures)) {
      return;
    }
    await this.startNextIteration(loop);
  }
}
