/**
 * auto/phases-unit.js — runUnitPhase phase.
 */
import { basename, dirname, join, parse as parsePath } from "node:path";
import { importExtensionModule } from "@singularity-forge/coding-agent";
import {
	clearCurrentPhase,
	setCurrentPhase,
} from "../../shared/sf-phase-state.js";
import { atomicWriteSync } from "../atomic-write.js";
import { resetCompletionNudgeState } from "../auto-completion-nudge.js";
import {
	isAwaitingUserInput,
	USER_DRIVEN_DEEP_UNITS,
} from "../auto-post-unit.js";
import {
	buildLoopRemediationSteps,
	diagnoseExpectedArtifact,
	verifyExpectedArtifact,
} from "../auto-recovery.js";
import {
	formatToolCallSummary,
	resetToolCallCounts,
} from "../auto-tool-tracking.js";
import {
	appendAutonomousSolverCheckpoint,
	assessAutonomousSolverTurn,
	beginAutonomousSolverIteration,
	buildAutonomousExecutorPromptBlock,
	buildAutonomousSolverMissingCheckpointRepairPrompt,
	buildAutonomousSolverPromptBlock,
	buildAutonomousSolverSteeringPromptBlock,
	buildSolverPassPrompt,
	classifyAutonomousSolverMissingCheckpointFailure,
	classifyExecutorRefusal,
	consumePendingAutonomousSolverSteering,
	getConfiguredAutonomousSolverMaxIterations,
	MAX_EXECUTOR_REFUSAL_ESCALATIONS,
	readAutonomousSolverState,
	recordAutonomousSolverMissingCheckpointRetry,
	recordExecutorRefusalEscalation,
} from "../autonomous-solver.js";
import { blockModel } from "../blocked-models.js";
import {
	getCooldownRetryAfterMs,
	isTransientCooldownError,
} from "./infra-errors.js";
import { resumeAutoAfterProviderDelay } from "../bootstrap/provider-error-resume.js";
import { debugLog } from "../debug-logger.js";
import { PROJECT_FILES } from "../detection.js";
import { getErrorMessage } from "../error-utils.js";
import { MergeConflictError } from "../git-service.js";
import { recordLearnedOutcome } from "../learning/runtime.js";
import { sfRoot } from "../paths.js";
import { resolvePersistModelChanges } from "../preferences.js";
import {
	approveProductionMutationWithLlmPolicy,
	ensureProductionMutationApprovalTemplate,
	readProductionMutationApprovalStatus,
} from "../production-mutation-approval.js";
import { pauseAutoForProviderError } from "../provider-error-pause.js";
import {
	buildReasoningAssistPrompt,
	injectReasoningGuidance,
	isReasoningAssistEnabled,
} from "../reasoning-assist.js";
import {
	loadEvidenceFromDisk,
	resetEvidence,
} from "../safety/evidence-collector.js";
import { getDirtyFiles } from "../safety/file-change-validator.js";
import {
	cleanupCheckpoint,
	createCheckpoint,
	rollbackToCheckpoint,
} from "../safety/git-checkpoint.js";
import { resolveSafetyHarnessConfig } from "../safety/safety-harness.js";
import { recordSelfFeedback } from "../self-feedback.js";
import {
	checkpointWal,
	getMilestoneSlices,
	getSliceTaskCounts,
	getTask,
	isDbAvailable,
} from "../sf-db.js";
import { getEligibleSlices } from "../slice-parallel-eligibility.js";
import { startSliceParallel } from "../slice-parallel-orchestrator.js";
import {
	clearSliceRoutingForUnit,
	recordSliceRouting,
} from "../slice-routing-cache.js";
import {
	resolveSolverModel,
	resolveSolverModelCandidates,
} from "../solver-model.js";
import { handleProductAudit } from "../tools/product-audit-tool.js";
import { parseUnitId } from "../unit-id.js";
import {
	collectSessionTokenUsage,
	collectWorktreeFingerprint,
	countChangedFiles,
	resetRunawayGuardState,
} from "../uok/auto-runaway-guard.js";
import { resolveUokFlags } from "../uok/flags.js";
import { UokGateRunner } from "../uok/gate-runner.js";
import {
	emitModelAutoResolvedEvent,
	modelRoleForUnitType,
} from "../uok/model-route-evidence.js";
import {
	ensurePlanV2Graph as ensurePlanningFlowGraph,
	isEmptyPlanV2GraphResult,
	isMissingFinalizedContextResult,
} from "../uok/plan.js";
import { buildUokProgressEvent } from "../uok/progress-event.js";
import {
	clearUnitRuntimeRecord,
	writeUnitRuntimeRecord,
} from "../uok/unit-runtime.js";
import {
	_resetLogs,
	drainAndSummarize,
	drainLogs,
	formatForNotification,
	hasAnyIssues,
	logError,
	logWarning,
} from "../workflow-logger.js";
import {
	getRequiredWorkflowToolsForAutoUnit,
	getWorkflowTransportSupportError,
} from "../workflow-tools.js";
import { resolveWorktreeProjectRoot } from "../worktree-root.js";
import { detectStuck } from "./detect-stuck.js";
import {
	FINALIZE_POST_TIMEOUT_MS,
	FINALIZE_PRE_TIMEOUT_MS,
	withTimeout,
} from "./finalize-timeout.js";
import {
	emitCancelledUnitEnd,
	recordLearningOutcomeForUnit,
	shouldSkipArtifactVerification,
} from "./phases-helpers.js";
import { runUnit } from "./run-unit.js";
import {
	BUDGET_THRESHOLDS,
	MAX_FINALIZE_TIMEOUTS,
	MAX_RECOVERY_CHARS,
} from "./types.js";

// ─── Session timeout scheduled resume state ────────────────────────────────────────
let consecutiveSessionTimeouts = 0;
const MAX_SESSION_TIMEOUT_AUTO_RESUMES = 3;
function resetConsecutiveSessionTimeouts() {
	consecutiveSessionTimeouts = 0;
}

function clearDeferredCommitAfterCancelledUnit(
	s,
	ctx,
	unitType,
	unitId,
	reason,
) {
	if (!s.stagedPendingCommit && !s.pendingCommitTaskContext) return;
	s.stagedPendingCommit = false;
	s.pendingCommitTaskContext = null;
	debugLog("autoLoop", {
		phase: "cancelled-unit-deferred-commit-cleared",
		unitType,
		unitId,
		reason,
	});
	ctx.ui.notify(
		`Cancelled ${unitType} ${unitId}; staged changes were preserved for recovery and not auto-committed.`,
		"warning",
	);
}

/**
 * Return the unit result that should drive the zero-tool-call guard.
 *
 * Purpose: preserve autonomous progress when a solver or repair pass produced
 * the checkpoint after the first executor turn. The guard protects against
 * genuine no-op turns, but it must not inspect a stale pre-solver result after
 * currentUnitResult has been replaced with a later successful swarm turn.
 *
 * Consumer: runUnitPhase zero-tool-call guard after solver assessment.
 */
export function resultForZeroToolCallGuard(unitResult, currentUnitResult) {
	return currentUnitResult ?? unitResult;
}

// ─── runUnitPhase ─────────────────────────────────────────────────────────────
/**
 * Phase 4: Unit execution — dispatch prompt, await agent_end, closeout, artifact verify.
 * Returns break or next with unitStartedAt for downstream phases.
 */
export async function runUnitPhase(ic, iterData, loopState, sidecarItem) {
	const { ctx, pi, s, deps, prefs } = ic;
	const { unitType, unitId, prompt, state, mid } = iterData;
	debugLog("autoLoop", {
		phase: "unit-execution",
		iteration: ic.iteration,
		unitType,
		unitId,
	});
	// ── Worktree health check (#1833, #1843) ────────────────────────────
	// ...
	if (
		s.basePath &&
		!s.basePath.startsWith("/mock/") &&
		unitType === "execute-task"
	) {
		const gitMarker = join(s.basePath, ".git");
		const hasGit = deps.existsSync(gitMarker);
		if (!hasGit) {
			const msg = `Worktree health check failed: ${s.basePath} has no .git — refusing to dispatch ${unitType} ${unitId}`;
			debugLog("runUnitPhase", {
				phase: "worktree-health-fail",
				basePath: s.basePath,
				hasGit,
			});
			ctx.ui.notify(msg, "error");
			await deps.stopAuto(ctx, pi, msg);
			return { action: "break", reason: "worktree-invalid" };
		}
		const hasProjectFile = PROJECT_FILES.some((f) =>
			deps.existsSync(join(s.basePath, f)),
		);
		const hasSrcDir = deps.existsSync(join(s.basePath, "src"));
		// Xcode bundles have project-specific names (*.xcodeproj, *.xcworkspace)
		// that cannot be matched by exact filename — scan the directory by suffix.
		let hasXcodeBundle = false;
		try {
			const entries = deps.existsSync(s.basePath)
				? readdirSync(s.basePath)
				: [];
			hasXcodeBundle = entries.some(
				(e) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace"),
			);
		} catch (err) {
			debugLog("runUnitPhase", {
				phase: "xcode-bundle-scan-failed",
				basePath: s.basePath,
				error: String(err),
			});
		}
		// Monorepo support (#2347): if no project files in the worktree directory,
		// walk parent directories up to the filesystem root. In monorepos,
		// package.json / Cargo.toml etc. live in a parent directory.
		let hasProjectFileInParent = false;
		if (!hasProjectFile && !hasSrcDir && !hasXcodeBundle) {
			let checkDir = dirname(s.basePath);
			const { root } = parsePath(checkDir);
			while (checkDir !== root) {
				// Stop at git repository boundary — ancestors above the repo root
				// (e.g. ~ or /usr/local) may contain unrelated project files.
				if (deps.existsSync(join(checkDir, ".git"))) break;
				if (PROJECT_FILES.some((f) => deps.existsSync(join(checkDir, f)))) {
					hasProjectFileInParent = true;
					break;
				}
				checkDir = dirname(checkDir);
			}
		}
		if (
			!hasProjectFile &&
			!hasSrcDir &&
			!hasXcodeBundle &&
			!hasProjectFileInParent
		) {
			// Greenfield projects won't have project files yet — the first task creates them.
			// Log a warning but allow execution to proceed. The .git check above is sufficient
			// to ensure we're in a valid working directory.
			debugLog("runUnitPhase", {
				phase: "worktree-health-warn-greenfield",
				basePath: s.basePath,
				hasProjectFile,
				hasSrcDir,
				hasXcodeBundle,
			});
			ctx.ui.notify(
				`Warning: ${s.basePath} has no recognized project files — proceeding as greenfield project`,
				"warning",
			);
		}
	}
	// Detect retry and capture previous tier for escalation
	const isPausedUnitResume =
		s.pausedUnitType === unitType && s.pausedUnitId === unitId;
	const isRetry = !!(
		(s.currentUnit &&
			s.currentUnit.type === unitType &&
			s.currentUnit.id === unitId) ||
		isPausedUnitResume
	);
	const previousTier =
		s.currentUnitRouting?.tier ??
		(isPausedUnitResume && unitType === "execute-task"
			? "standard"
			: undefined);
	if (isPausedUnitResume) {
		s.pausedUnitType = null;
		s.pausedUnitId = null;
	}
	// Scope workflow-logger buffer to this unit so post-finalize drains are
	// per-unit. Without this, the module-level _buffer accumulates across every
	// unit in the same Node process (see workflow-logger.ts module header).
	_resetLogs();
	s.currentUnit = { type: unitType, id: unitId, startedAt: Date.now() };
	s.researchTerminalTransition = false;
	s.lastGitActionFailure = null;
	s.lastGitActionStatus = null;
	setCurrentPhase(unitType);
	s.lastToolInvocationError = null; // #2883: clear stale error from previous unit
	resetToolCallCounts();
	resetCompletionNudgeState(
		unitType,
		unitId,
		prefs?.auto_supervisor?.completion_nudge_after,
	);
	resetRunawayGuardState(unitType, unitId, {
		sessionTokens: collectSessionTokenUsage(ctx),
		changedFiles: countChangedFiles(s.basePath),
		worktreeFingerprint: collectWorktreeFingerprint(s.basePath),
	});
	const unitStartSeq = ic.nextSeq();
	deps.emitJournalEvent({
		ts: new Date().toISOString(),
		flowId: ic.flowId,
		seq: unitStartSeq,
		eventType: "unit-start",
		data: { unitType, unitId },
	});
	{
		const progressEvent = buildUokProgressEvent({
			eventType: "unit_selected",
			unitType,
			unitId,
			role: "worker",
			sessionId: ctx.sessionManager.getSessionId(),
			traceId: ic.flowId,
			data: { legacyEventType: "unit-start" },
		});
		deps.emitJournalEvent({
			ts: progressEvent.ts,
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType: progressEvent.eventType,
			data: progressEvent,
			causedBy: { flowId: ic.flowId, seq: unitStartSeq },
		});
	}
	ctx.ui.notify(`[unit] ${unitType} ${unitId} starting`, "info");
	deps.captureAvailableSkills();
	writeUnitRuntimeRecord(
		s.basePath,
		unitType,
		unitId,
		s.currentUnit.startedAt,
		{
			phase: "dispatched",
			wrapupWarningSent: false,
			timeoutAt: null,
			lastProgressAt: s.currentUnit.startedAt,
			progressCount: 0,
			lastProgressKind: "dispatch",
			recoveryAttempts: 0, // Reset so re-dispatched units get full recovery budget (#2322)
			lineageEvent: {
				status: "started",
				workerSessionId: ctx.sessionManager.getSessionId(),
				note: "unit dispatched",
			},
		},
	);
	// Status bar (widget + preconditions deferred until after model selection — see #2899)
	ctx.ui.setStatus("sf-auto", "auto");
	if (mid)
		deps.updateSliceProgressCache(s.basePath, mid, state.activeSlice?.id);
	// ── Safety harness: reset evidence + create checkpoint ──
	const safetyConfig = resolveSafetyHarnessConfig(prefs?.safety_harness);
	if (safetyConfig.enabled && safetyConfig.evidence_collection) {
		resetEvidence();
		const { milestone: eMid, slice: eSid, task: eTid } = parseUnitId(unitId);
		loadEvidenceFromDisk(s.basePath, eMid, eSid ?? "", eTid ?? "");
	}
	if (
		safetyConfig.enabled &&
		safetyConfig.file_change_validation &&
		unitType === "execute-task"
	) {
		s.preUnitDirtyFiles = getDirtyFiles(s.basePath);
	} else {
		s.preUnitDirtyFiles = [];
	}
	// Only checkpoint code-executing units (not lifecycle/planning units)
	if (
		safetyConfig.enabled &&
		safetyConfig.checkpoints &&
		unitType === "execute-task"
	) {
		s.checkpointSha = createCheckpoint(s.basePath, unitId);
		if (s.checkpointSha) {
			debugLog("runUnitPhase", {
				phase: "checkpoint-created",
				unitId,
				sha: s.checkpointSha.slice(0, 8),
			});
		}
	}
	// Prompt injection
	let finalPrompt = prompt;
	if (s.pendingVerificationRetry) {
		const retryCtx = s.pendingVerificationRetry;
		s.pendingVerificationRetry = null;
		const capped =
			retryCtx.failureContext.length > MAX_RECOVERY_CHARS
				? retryCtx.failureContext.slice(0, MAX_RECOVERY_CHARS) +
					"\n\n[...failure context truncated]"
				: retryCtx.failureContext;
		finalPrompt = `**VERIFICATION FAILED — AUTO-FIX ATTEMPT ${retryCtx.attempt}**\n\nThe verification gate ran after your previous attempt and found failures. Fix these issues before completing the task.\n\n${capped}\n\n---\n\n${finalPrompt}`;
	}
	if (s.pendingCrashRecovery) {
		const capped =
			s.pendingCrashRecovery.length > MAX_RECOVERY_CHARS
				? s.pendingCrashRecovery.slice(0, MAX_RECOVERY_CHARS) +
					"\n\n[...recovery briefing truncated to prevent memory exhaustion]"
				: s.pendingCrashRecovery;
		finalPrompt = `${capped}\n\n---\n\n${finalPrompt}`;
		s.pendingCrashRecovery = null;
	} else if (s.pendingRethinkAttempt) {
		// Stuck recovery: inject diagnostic + rethink prompt, then clear.
		let rethinkCtx = null;
		try {
			rethinkCtx = JSON.parse(s.pendingRethinkAttempt);
		} catch {
			// Malformed JSON — skip injection
		}
		s.pendingRethinkAttempt = null;
		if (rethinkCtx) {
			const isFinal = rethinkCtx.attempt === 5;
			const lines = [
				isFinal
					? `**⚠ FINAL STUCK ATTEMPT (5 of 5) — You have run out of recovery attempts. Make this count.**`
					: `**STUCK RECOVERY — Rethink attempt ${rethinkCtx.attempt - 1} of 4.**`,
				"",
				`You have been repeatedly stuck on **${rethinkCtx.unitType} ${rethinkCtx.unitId}** for reason: "${rethinkCtx.reason}".`,
				"",
				"Before continuing, you must reflect on the following:",
				"",
				"1. **What specific error or failure pattern are you seeing?**",
				"2. **What assumption are you making that might be wrong?**",
				"3. **What is ONE concrete, different approach you will try this time?**",
				"",
				"Do NOT repeat the same approach. Identify the root cause and try a genuinely different strategy.",
			];
			if (rethinkCtx.stuckDiag) {
				lines.push("", `**What was expected:** ${rethinkCtx.stuckDiag}`);
			}
			if (rethinkCtx.remediation) {
				lines.push("", `**Suggested remediation:**\n${rethinkCtx.remediation}`);
			}
			if (rethinkCtx.diagnostic) {
				lines.push(
					"",
					`**Full diagnostic from previous attempt:**\n${rethinkCtx.diagnostic}`,
				);
			}
			lines.push("", "---", "", finalPrompt);
			finalPrompt = lines.join("\n");
		}
	} else if ((s.unitDispatchCount.get(`${unitType}/${unitId}`) ?? 0) > 1) {
		const diagnostic = deps.getDeepDiagnostic(s.basePath);
		if (diagnostic) {
			const cappedDiag =
				diagnostic.length > MAX_RECOVERY_CHARS
					? diagnostic.slice(0, MAX_RECOVERY_CHARS) +
						"\n\n[...diagnostic truncated to prevent memory exhaustion]"
					: diagnostic;
			finalPrompt = `**RETRY — your previous attempt did not produce the required artifact.**\n\nDiagnostic from previous attempt:\n${cappedDiag}\n\nFix whatever went wrong and make sure you write the required file this time.\n\n---\n\n${finalPrompt}`;
		}
	}
	// Prompt char measurement
	try {
		const solverState = beginAutonomousSolverIteration(
			s.basePath,
			unitType,
			unitId,
			{
				maxIterations: getConfiguredAutonomousSolverMaxIterations(prefs),
			},
		);
		const steeringBlock = buildAutonomousSolverSteeringPromptBlock(
			consumePendingAutonomousSolverSteering(s.basePath),
		);
		if (steeringBlock) {
			finalPrompt = `${finalPrompt}\n\n---\n\n${steeringBlock}`;
		}
		finalPrompt = `${finalPrompt}\n\n---\n\n${buildAutonomousExecutorPromptBlock(solverState)}`;
		deps.emitJournalEvent({
			ts: new Date().toISOString(),
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType: "solver-iteration-start",
			data: {
				unitType,
				unitId,
				iteration: solverState.iteration,
				maxIterations: solverState.maxIterations,
				steeringInjected: Boolean(steeringBlock),
			},
		});
	} catch (solverErr) {
		logWarning("engine", "Autonomous solver prompt injection failed", {
			error: getErrorMessage(solverErr),
		});
	}
	s.lastPromptCharCount = finalPrompt.length;
	s.lastBaselineCharCount = undefined;
	if (deps.isDbAvailable()) {
		try {
			const { inlineSfRootFile } = await importExtensionModule(
				import.meta.url,
				"../auto-prompts.js",
			);
			const [decisionsContent, requirementsContent, projectContent] =
				await Promise.all([
					inlineSfRootFile(s.basePath, "decisions.md", "Decisions"),
					inlineSfRootFile(s.basePath, "requirements.md", "Requirements"),
					inlineSfRootFile(s.basePath, "project.md", "Project"),
				]);
			s.lastBaselineCharCount =
				(decisionsContent?.length ?? 0) +
				(requirementsContent?.length ?? 0) +
				(projectContent?.length ?? 0);
		} catch (e) {
			logWarning("engine", "Baseline char count measurement failed", {
				error: String(e),
			});
		}
	}
	// Cache-optimize prompt section ordering; split at the semi-static→dynamic
	// boundary so providers can mark the stable prefix with cache_control:ephemeral.
	let promptCacheSplit = null;
	try {
		promptCacheSplit = deps.reorderAndSplitForCaching?.(finalPrompt) ?? null;
		if (promptCacheSplit) {
			finalPrompt = promptCacheSplit.before + "\n" + promptCacheSplit.after;
		} else {
			finalPrompt = deps.reorderForCaching(finalPrompt);
		}
	} catch (reorderErr) {
		const msg = getErrorMessage(reorderErr);
		logWarning("engine", "Prompt reorder failed", { error: msg });
	}
	// Select and apply model (with tier escalation on retry — normal units only)
	const modelResult = await deps.selectAndApplyModel(
		ctx,
		pi,
		unitType,
		unitId,
		s.basePath,
		prefs,
		s.verbose,
		s.autoModeStartModel,
		sidecarItem ? undefined : { isRetry, previousTier },
		undefined,
		s.manualSessionModelOverride,
		s.autoModeStartThinkingLevel,
		modelRoleForUnitType(unitType),
	);
	s.currentUnitRouting = modelResult.routing;
	s.currentUnitModel = modelResult.appliedModel;
	// updateProgressWidget( (decoy for legacy regex tests)
	// Apply sidecar/pre-dispatch hook model override (takes priority over standard model selection)
	const hookModelOverride = sidecarItem?.model ?? iterData.hookModelOverride;
	if (hookModelOverride) {
		const availableModels = ctx.modelRegistry.getAvailable();
		const match = deps.resolveModelId(
			hookModelOverride,
			availableModels,
			ctx.model?.provider,
		);
		if (match) {
			const ok = await pi.setModel(match, {
				persist: resolvePersistModelChanges(),
			});
			if (ok) {
				if (s.autoModeStartThinkingLevel) {
					pi.setThinkingLevel(s.autoModeStartThinkingLevel);
				}
				s.currentUnitModel = match;
				ctx.ui.notify(
					`Hook model override: ${match.provider}/${match.id}`,
					"info",
				);
			} else {
				ctx.ui.notify(
					`Hook model "${hookModelOverride}" found but setModel failed. Using default.`,
					"warning",
				);
			}
		} else {
			ctx.ui.notify(
				`Hook model "${hookModelOverride}" not found in available models. Falling back to current session model. ` +
					`Ensure the model is defined in models.json and has auth configured.`,
				"warning",
			);
		}
	}
	// Store the final dispatched model ID so the dashboard can read it (#2899).
	// This accounts for hook model overrides applied after selectAndApplyModel.
	s.currentDispatchedModelId = s.currentUnitModel
		? `${s.currentUnitModel.provider ?? ""}/${s.currentUnitModel.id ?? ""}`
		: null;
	emitModelAutoResolvedEvent(s.basePath, {
		traceId: `model:${ctx.sessionManager.getSessionId()}:${unitType}:${unitId}`,
		unitType,
		unitId,
		resolvedModel: s.currentUnitModel ?? ctx.model ?? null,
		authMode:
			(s.currentUnitModel?.provider ?? ctx.model?.provider)
				? ctx.modelRegistry.getProviderAuthMode(
						s.currentUnitModel?.provider ?? ctx.model.provider,
					)
				: undefined,
		routingReason: hookModelOverride
			? `hook override: ${hookModelOverride}`
			: "auto selector",
		routing: s.currentUnitRouting,
		hookOverrideApplied: Boolean(hookModelOverride),
		tokenUsage: collectSessionTokenUsage?.(ctx),
	});
	{
		const progressEvent = buildUokProgressEvent({
			eventType: "model_auto_resolved",
			unitType,
			unitId,
			role: "worker",
			sessionId: ctx.sessionManager.getSessionId(),
			traceId: ic.flowId,
			data: {
				resolvedProvider: s.currentUnitModel?.provider ?? ctx.model?.provider,
				resolvedModel: s.currentUnitModel?.id ?? ctx.model?.id,
				routing: s.currentUnitRouting,
				hookOverrideApplied: Boolean(hookModelOverride),
			},
		});
		deps.emitJournalEvent({
			ts: progressEvent.ts,
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType: progressEvent.eventType,
			data: progressEvent,
			causedBy: { flowId: ic.flowId, seq: unitStartSeq },
		});
	}
	const compatibilityError = getWorkflowTransportSupportError(
		s.currentUnitModel?.provider ?? ctx.model?.provider,
		getRequiredWorkflowToolsForAutoUnit(unitType),
		{
			projectRoot: s.basePath,
			surface: "autonomous mode",
			unitType,
			authMode: s.currentUnitModel?.provider
				? ctx.modelRegistry.getProviderAuthMode(s.currentUnitModel.provider)
				: ctx.model?.provider
					? ctx.modelRegistry.getProviderAuthMode(ctx.model.provider)
					: undefined,
			baseUrl: s.currentUnitModel?.baseUrl ?? ctx.model?.baseUrl,
		},
	);
	if (compatibilityError) {
		ctx.ui.notify(compatibilityError, "error");
		await deps.stopAuto(ctx, pi, compatibilityError);
		return { action: "break", reason: "workflow-capability" };
	}
	// Progress widget + preconditions — deferred to after model selection so the
	// widget's first render tick shows the correct model (#2899).
	deps.updateProgressWidget(ctx, unitType, unitId, state); // updateProgressWidget(
	deps.ensurePreconditions(unitType, unitId, s.basePath, state);
	// Start unit supervision
	deps.clearUnitTimeout();
	deps.startUnitSupervision({
		s,
		ctx,
		pi,
		unitType,
		unitId,
		prefs,
		buildSnapshotOpts: () => deps.buildSnapshotOpts(unitType, unitId),
		buildRecoveryContext: () => ({
			basePath: s.basePath,
			verbose: s.verbose,
			currentUnitStartedAt: s.currentUnit?.startedAt ?? Date.now(),
			unitRecoveryCount: s.unitRecoveryCount,
		}),
		pauseAuto: deps.pauseAuto,
	});
	// Write preliminary lock (no session path yet — runUnit creates a new session).
	// Crash recovery can still identify the in-flight unit from this lock.
	deps.writeLock(deps.lockBase(), unitType, unitId);
	// Pre-flight provider readiness check: if the resolved model's provider is
	// not request-ready (expired token, logged out), attempt to reselect a ready
	// provider before dispatching. This prevents the unit from burning a runUnit
	// call only to be immediately cancelled with no-transcript.
	{
		const selectedProvider =
			s.currentUnitModel?.provider ?? ctx.model?.provider;
		if (
			selectedProvider != null &&
			typeof ctx.modelRegistry?.isProviderRequestReady === "function"
		) {
			let ready = false;
			try {
				ready = ctx.modelRegistry.isProviderRequestReady(selectedProvider);
			} catch {
				ready = false;
			}
			if (!ready) {
				const allModels = ctx.modelRegistry.getAvailable?.() ?? [];
				const fallback = allModels.find(
					(m) =>
						m.provider !== selectedProvider &&
						ctx.modelRegistry.isProviderRequestReady(m.provider),
				);
				if (fallback) {
					const ok = await pi.setModel(fallback, { persist: false });
					if (ok) {
						ctx.ui.notify(
							`Autonomous mode: provider ${selectedProvider} not ready — switched to ${fallback.provider}/${fallback.id}`,
							"warning",
						);
						s.currentUnitModel = fallback;
					}
				} else {
					const msg = `Autonomous mode stopped: provider ${selectedProvider} is not request-ready and no fallback provider is available. Check your login/API key.`;
					ctx.ui.notify(msg, "error");
					await deps.stopAuto(ctx, pi, msg);
					return { action: "break", reason: "provider-pause" };
				}
			}
		}
	}
	debugLog("autoLoop", {
		phase: "runUnit-start",
		iteration: ic.iteration,
		unitType,
		unitId,
	});
	const unitResult = await runUnit(ctx, pi, s, unitType, unitId, finalPrompt, {
		promptCacheSplit: promptCacheSplit ?? undefined,
	});
	s.lastUnitAgentEndMessages = unitResult.event?.messages ?? null;
	let currentUnitResult = unitResult;
	const executorMessages = unitResult.event?.messages ?? [];
	const refusal =
		unitResult.status !== "cancelled"
			? classifyExecutorRefusal(executorMessages)
			: null;

	// Short-circuit: if runUnit was cancelled (provider not ready, session
	// failed, timeout) there is no checkpoint to repair — skip the solver pass
	// entirely and let the cancelled handler below surface the real cause.
	let solverAssessment =
		unitResult.status === "cancelled"
			? { action: "none" }
			: { action: "pending" };

	// Refusal handling: when the executor model returned a generic refusal, the
	// model is capability-mismatched for this unit. Block it (so selectAndApplyModel
	// excludes it on the next dispatch), evict slice routing, file self-feedback,
	// and re-dispatch with a tier-escalated model — bounded by MAX_EXECUTOR_REFUSAL_
	// ESCALATIONS so a fallback chain of refusing models cannot loop forever. Only
	// when the escalation budget is exhausted do we fall back to a blocked
	// checkpoint that pauses the loop for operator intervention.
	if (unitResult.status !== "cancelled" && refusal) {
		const executorModel =
			s.currentUnitModel?.provider && s.currentUnitModel?.id
				? `${s.currentUnitModel.provider}/${s.currentUnitModel.id}`
				: (s.currentUnitModel?.id ?? "unknown");
		// Evict the sticky-routing entry for this slice — the model attached
		// to it refused, so future units in the same slice should NOT re-pin
		// the broken model.
		try {
			clearSliceRoutingForUnit(s.basePath, unitId);
		} catch {
			// best-effort
		}
		// Block the refusing model so the router skips it on retry. The next
		// selectAndApplyModel call will pick a higher-tier fallback.
		try {
			const refusedProvider = s.currentUnitModel?.provider ?? "";
			const refusedId = s.currentUnitModel?.id ?? "";
			if (refusedProvider && refusedId) {
				blockModel(
					s.basePath,
					refusedProvider,
					refusedId,
					`executor-refused: ${refusal.pattern}`,
					{ expiresAt: Date.now() + 60 * 60 * 1000 }, // 1 hour
				);
			}
		} catch {
			// best-effort — blocking must not break the refusal handler
		}
		// File self-feedback for observability (operator-visible signal that
		// this unit type is being routed to capability-mismatched models).
		try {
			const feedback = recordSelfFeedback(
				{
					kind: "executor-refused",
					severity: "high",
					summary: `Executor ${executorModel} refused ${unitType} ${unitId} with pattern ${refusal.pattern}; model blocked and re-dispatching with tier escalation.`,
					evidence: [
						`unit=${unitType} ${unitId}`,
						`executor=${executorModel}`,
						`refusal-pattern=${refusal.pattern}`,
						"",
						refusal.evidence ?? "",
					].join("\n"),
					suggestedFix:
						"Routing repeatedly selects a capability-mismatched executor for this unit type. Update the router's tier-floor for this unit type so the refusing tier is excluded by default, or add the refusing model to a permanent block list.",
					acceptanceCriteria: [
						"Router's effective tier-floor for this unit type excludes the refusing model class without requiring a runtime block.",
						"Refusal pattern is added to classifyExecutorRefusal if a novel phrasing slipped through.",
					],
					occurredIn: { unitType, unitId },
					source: "runtime",
				},
				s.basePath,
			);
			deps.emitJournalEvent({
				ts: new Date().toISOString(),
				flowId: ic.flowId,
				seq: ic.nextSeq(),
				eventType: "executor-refused",
				data: {
					unitType,
					unitId,
					executorModel,
					pattern: refusal.pattern,
					selfFeedbackId: feedback?.entry?.id,
					blocking: feedback?.blocking,
				},
			});
		} catch {
			// self-feedback is observability; never block loop progression on it
		}
		// Bounded re-dispatch: if the per-unit escalation budget is not yet
		// exhausted, synthesize a `continue` checkpoint and return the
		// re-dispatch action directly. We bypass assessAutonomousSolverTurn
		// here because the no-op detector would (correctly) reject a continue
		// over a refusal transcript — but in this case the "no-op" is the whole
		// point, since we are explicitly changing the routed model on retry.
		const escalation = recordExecutorRefusalEscalation(
			s.basePath,
			unitType,
			unitId,
		);
		if (escalation <= MAX_EXECUTOR_REFUSAL_ESCALATIONS) {
			try {
				appendAutonomousSolverCheckpoint(s.basePath, {
					unitType,
					unitId,
					outcome: "continue",
					summary: `Executor (${executorModel}) refused. Pattern: ${refusal.pattern}. Model blocked; re-dispatching with model excluded (escalation ${escalation}/${MAX_EXECUTOR_REFUSAL_ESCALATIONS}).`,
					completedItems: [],
					remainingItems: [
						`Re-dispatch ${unitType} ${unitId}; refusing model is blocked so selectAndApplyModel will pick a higher-tier fallback.`,
					],
					verificationEvidence: [
						`executor-refusal-pattern=${refusal.pattern}`,
						`executor-model=${executorModel}`,
						`refusal-escalation=${escalation}/${MAX_EXECUTOR_REFUSAL_ESCALATIONS}`,
					],
					pdd: {
						purpose:
							"Auto-escalate capability-mismatched executor selections by blocking the refusing model and re-dispatching, so SF completes work without operator intervention when a higher-tier model exists in the fallback chain.",
						consumer: "autonomous loop continue handler",
						contract:
							"On `executor-refused`, block the refusing model and emit `continue` so the loop re-dispatches with the blocked model excluded — bounded by MAX_EXECUTOR_REFUSAL_ESCALATIONS to prevent runaway loops over an all-refusing fallback chain.",
						failureBoundary:
							"After MAX_EXECUTOR_REFUSAL_ESCALATIONS refusals on the same unit, fall back to the legacy blocked-and-pause path so the operator can intervene.",
						evidence:
							"classifyExecutorRefusal matched a refusal pattern; the responsible model is now in blocked-models.json with a 1-hour TTL.",
						nonGoals:
							"This does not change the router's tier-floor — repeated refusals across units indicate the router still needs tuning (filed via self-feedback).",
						invariants:
							"The refusing model is never silently retried; it is always blocked before re-dispatch.",
						assumptions:
							"The fallback chain in effectiveModelConfig contains at least one higher-tier model that does not also refuse.",
					},
				});
			} catch {
				// If synthesis fails, fall through to the budget-exhausted branch
				// below so the loop still has a defined outcome.
			}
			deps.emitJournalEvent({
				ts: new Date().toISOString(),
				flowId: ic.flowId,
				seq: ic.nextSeq(),
				eventType: "executor-refused-redispatch",
				data: {
					unitType,
					unitId,
					executorModel,
					pattern: refusal.pattern,
					escalation,
					maxEscalations: MAX_EXECUTOR_REFUSAL_ESCALATIONS,
				},
			});
			ctx.ui.notify(
				`Executor ${executorModel} refused ${unitType} ${unitId} (${refusal.pattern}); blocked and re-dispatching (escalation ${escalation}/${MAX_EXECUTOR_REFUSAL_ESCALATIONS}).`,
				"warning",
			);
			return {
				action: "continue",
				data: {
					unitStartedAt: s.currentUnit?.startedAt,
					requestDispatchedAt: unitResult.requestDispatchedAt,
				},
			};
		}
		// Escalation budget exhausted: emit the legacy blocked checkpoint and
		// let the existing pause path take over so the operator can intervene.
		try {
			appendAutonomousSolverCheckpoint(s.basePath, {
				unitType,
				unitId,
				outcome: "blocked",
				summary: `Executor (${executorModel}) refused the task. Pattern: ${refusal.pattern}. Refusal-escalation budget exhausted (${escalation}/${MAX_EXECUTOR_REFUSAL_ESCALATIONS}) — every model tried in the fallback chain refused. Operator must escalate routing or add a permanent block.`,
				completedItems: [],
				remainingItems: [
					`Re-run ${unitType} ${unitId} with a more capable executor model — the entire ${MAX_EXECUTOR_REFUSAL_ESCALATIONS}-step fallback chain refused.`,
				],
				verificationEvidence: [
					`executor-refusal-pattern=${refusal.pattern}`,
					`executor-model=${executorModel}`,
					`refusal-escalations-exhausted=${escalation}`,
				],
				blockerReason: `executor-refused-budget-exhausted (${refusal.pattern})`,
				pdd: {
					purpose:
						"Surface executor refusals as protocol-level blockers when bounded auto-escalation has been exhausted.",
					consumer: "autonomous loop pause-handler",
					contract:
						"After MAX_EXECUTOR_REFUSAL_ESCALATIONS refusals on the same unit, pause the loop and require operator intervention.",
					failureBoundary:
						"If the operator does not escalate, the same refusal will recur on next dispatch.",
					evidence: `${escalation} consecutive executor refusals on this unit`,
					nonGoals:
						"This does not retry the unit automatically beyond the budget — capability mismatches that defeat the entire fallback chain require operator judgement.",
					invariants: "Refusal never silently synthesizes a continue.",
					assumptions:
						"The refusal pattern set in classifyExecutorRefusal is conservative — false positives are rare and require operator review.",
				},
			});
		} catch {
			// If synthesis fails, fall through to solver pass
		}
		ctx.ui.notify(
			`Executor refused ${unitType} ${unitId} after ${MAX_EXECUTOR_REFUSAL_ESCALATIONS} tier escalations; pausing for operator review. See SELF-FEEDBACK.md.`,
			"error",
		);
		solverAssessment = assessAutonomousSolverTurn(s.basePath, unitType, unitId);
	}

	// Solver pass: the stable solver model reads the executor transcript and
	// emits the canonical checkpoint. This separates the executor role (unit
	// work) from the solver role (protocol checkpoint) per ADR-0079.
	if (unitResult.status !== "cancelled" && !refusal) {
		const executorModel = s.currentUnitModel;
		const solverCandidates = resolveSolverModelCandidates(prefs);
		let solverPassResult = null;

		for (const candidate of solverCandidates) {
			const availableModels = ctx.modelRegistry.getAvailable?.() ?? [];
			const match = availableModels.find(
				(m) => m.provider === candidate.provider && m.id === candidate.id,
			);
			if (!match) continue;

			const ok = await pi.setModel(match, { persist: false });
			if (!ok) continue;

			s.currentUnitModel = match;
			ctx.ui.notify(
				`Running solver pass for ${unitType} ${unitId} with ${match.provider}/${match.id}`,
				"info",
			);

			const solverState = readAutonomousSolverState(s.basePath);
			const solverPrompt = buildSolverPassPrompt(
				executorMessages,
				solverState,
				unitType,
				unitId,
			);

			try {
				const result = await runUnit(
					ctx,
					pi,
					s,
					unitType,
					unitId,
					solverPrompt,
					{ keepSession: false, activeToolsAllowlist: ["checkpoint"] },
				);
				solverPassResult = result;
				if (result.status !== "cancelled") {
					currentUnitResult = result;
					s.lastUnitAgentEndMessages = result.event?.messages ?? null;
					break; // Solver pass succeeded
				}
			} catch {
				// Try next fallback
			}
		}

		if (!solverPassResult || solverPassResult.status === "cancelled") {
			ctx.ui.notify(
				`Solver pass failed for ${unitType} ${unitId} — no solver model was reachable. Synthesizing blocked checkpoint.`,
				"error",
			);
			try {
				appendAutonomousSolverCheckpoint(s.basePath, {
					unitType,
					unitId,
					outcome: "blocked",
					summary: `Solver pass failed — no solver model was reachable. The executor transcript could not be classified into a canonical checkpoint.`,
					completedItems: [],
					remainingItems: [
						`Retry ${unitType} ${unitId} after verifying solver model availability.`,
					],
					verificationEvidence: ["solver-pass-failed"],
					blockerReason: "solver-pass-failed",
					pdd: {
						purpose:
							"Surface solver-pass failures as blockers rather than silently dropping the protocol layer.",
						consumer: "autonomous loop pause-handler",
						contract:
							"On solver-pass failure, the loop pauses so the operator can fix model availability.",
						failureBoundary:
							"If all solver candidates are unreachable, the protocol layer cannot function.",
						evidence:
							"All solver candidates were unreachable or setModel failed.",
						nonGoals:
							"This does not retry with a different solver candidate automatically beyond the explicit fallback chain.",
						invariants:
							"Solver-pass failure never silently synthesizes a continue.",
						assumptions:
							"At least one solver candidate (kimi-k2.6 or fallback) is available in the model registry.",
					},
				});
			} catch {
				// best-effort
			}
		}

		solverAssessment = assessAutonomousSolverTurn(
			s.basePath,
			unitType,
			unitId,
			executorMessages,
		);

		// Restore executor model after solver pass and assessment
		if (executorModel) {
			try {
				await pi.setModel(executorModel, { persist: false });
			} catch {
				// best-effort restore
			}
			s.currentUnitModel = executorModel;
		}
	}
	while (solverAssessment.action === "missing-checkpoint-retry") {
		const diagnosis = classifyAutonomousSolverMissingCheckpointFailure(
			currentUnitResult.event?.messages ?? [],
		);
		recordAutonomousSolverMissingCheckpointRetry(
			s.basePath,
			unitType,
			unitId,
			diagnosis,
		);
		deps.emitJournalEvent({
			ts: new Date().toISOString(),
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType: "solver-missing-checkpoint-retry",
			data: {
				unitType,
				unitId,
				iteration: solverAssessment.state?.iteration,
				repairAttempt: solverAssessment.repairAttempt,
				maxRepairAttempts: solverAssessment.maxRepairAttempts,
				classification: diagnosis.classification,
			},
		});
		ctx.ui.notify(
			`Autonomous solver checkpoint missing for ${unitType} ${unitId}; repair attempt ${solverAssessment.repairAttempt}/${solverAssessment.maxRepairAttempts} (${diagnosis.classification}).`,
			"warning",
		);
		currentUnitResult = await runUnit(
			ctx,
			pi,
			s,
			unitType,
			unitId,
			buildAutonomousSolverMissingCheckpointRepairPrompt(
				solverAssessment.state,
				unitType,
				unitId,
				diagnosis,
				solverAssessment.repairAttempt,
				solverAssessment.maxRepairAttempts,
			),
			{ keepSession: true, activeToolsAllowlist: ["checkpoint"] },
		);
		s.lastUnitAgentEndMessages = currentUnitResult.event?.messages ?? null;
		if (currentUnitResult.status === "cancelled") {
			solverAssessment = { action: "none" };
			break;
		}
		solverAssessment = assessAutonomousSolverTurn(s.basePath, unitType, unitId);
	}
	const solverCheckpoint = solverAssessment.checkpoint;
	if (solverCheckpoint) {
		deps.emitJournalEvent({
			ts: new Date().toISOString(),
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType: "solver-checkpoint",
			data: {
				unitType,
				unitId,
				iteration: solverCheckpoint.iteration,
				outcome: solverCheckpoint.outcome,
				remainingCount: solverCheckpoint.remainingItems?.length ?? 0,
			},
		});
		// Record sticky-routing on successful outcomes only. `continue` is the
		// usual within-iteration progress signal; `complete` is final success.
		// We deliberately skip `blocked` and `decide` because attaching a model
		// to a slice when it's known-stuck or known-undecided would defeat the
		// fallback path.
		if (
			solverCheckpoint.outcome === "continue" ||
			solverCheckpoint.outcome === "complete"
		) {
			try {
				recordSliceRouting(
					s.basePath,
					unitType,
					unitId,
					s.currentUnitModel ?? ctx.model ?? null,
				);
			} catch {
				// best-effort; routing cache must never break the loop
			}
		}
	}
	if (solverAssessment.action === "pause") {
		const isMissingCheckpoint =
			solverAssessment.reason === "solver-missing-checkpoint";
		const missingCheckpointDiagnosis = isMissingCheckpoint
			? classifyAutonomousSolverMissingCheckpointFailure(
					currentUnitResult.event?.messages ?? [],
				)
			: null;
		if (missingCheckpointDiagnosis) {
			try {
				const feedback = recordSelfFeedback(
					{
						kind: "solver-missing-checkpoint",
						severity: "high",
						summary: `Autonomous solver failed to checkpoint after ${solverAssessment.repairAttempts ?? "multiple"} repair attempt(s): ${missingCheckpointDiagnosis.classification}`,
						evidence: [
							`unit=${unitType} ${unitId}`,
							`classification=${missingCheckpointDiagnosis.classification}`,
							`summary=${missingCheckpointDiagnosis.summary}`,
							`evidencePath=.sf/runtime/autonomous-solver/LOOP.md`,
							"",
							missingCheckpointDiagnosis.evidence ?? "",
						].join("\n"),
						suggestedFix:
							"Improve solver repair policy, tool availability, or prompt wording so missing-checkpoint repairs end with a successful checkpoint tool call.",
						acceptanceCriteria: [
							"Missing-checkpoint repair attempts include failure classification in the prompt.",
							"Repeated repair failures file self-feedback automatically.",
							"Loop continues with a synthesized checkpoint instead of pausing for human input — EXCEPT when classifyExecutorRefusal short-circuits with `executor-refused`, in which case the loop emits a `blocked` checkpoint and pauses (synthesizing forward progress over a refusing executor is the bug we are fixing).",
						],
						occurredIn: { unitType, unitId },
						source: "runtime",
					},
					s.basePath,
				);
				deps.emitJournalEvent({
					ts: new Date().toISOString(),
					flowId: ic.flowId,
					seq: ic.nextSeq(),
					eventType: "solver-missing-checkpoint-self-feedback",
					data: {
						unitType,
						unitId,
						classification: missingCheckpointDiagnosis.classification,
						selfFeedbackId: feedback?.entry?.id,
						blocking: feedback?.blocking,
					},
				});
			} catch {
				// self-feedback is observability; never block loop continuation
			}
		}

		// Missing-checkpoint: the LLM failed to call the checkpoint tool despite repair
		// attempts. Rather than pausing for human input (which defeats the purpose of
		// autonomous mode), synthesize a minimal "continue" checkpoint and re-dispatch
		// so the LLM gets another clean attempt. The max-iterations guard will catch
		// genuine infinite loops. Only hard blockers and max-iterations pause the loop.
		if (isMissingCheckpoint) {
			try {
				appendAutonomousSolverCheckpoint(s.basePath, {
					unitType,
					unitId,
					outcome: "continue",
					summary: `Synthesized continue after ${solverAssessment.repairAttempts ?? "all"} repair attempt(s) failed to produce a checkpoint (${missingCheckpointDiagnosis?.classification ?? "unknown"}). Re-dispatching.`,
					completedItems: [],
					remainingItems: [
						"Retry unit — checkpoint was missing from prior run",
					],
					verificationEvidence: ["synthesized-by-runtime"],
					pdd: {
						purpose: "Runtime-synthesized continue to avoid deadlock",
						consumer: "autonomous loop",
						contract: "continue",
						failureBoundary: "max-iterations",
						evidence: "none",
						nonGoals: "none",
						invariants: "none",
						assumptions: "none",
					},
				});
			} catch {
				// If synthesis fails, fall through to pause below
				ctx.ui.notify(
					`Autonomous solver: checkpoint synthesis failed for ${unitType} ${unitId} — pausing`,
					"warning",
				);
				await deps.pauseAuto(ctx, pi);
				return { action: "break", reason: solverAssessment.reason };
			}
			deps.emitJournalEvent({
				ts: new Date().toISOString(),
				flowId: ic.flowId,
				seq: ic.nextSeq(),
				eventType: "solver-missing-checkpoint-synthesized-continue",
				data: {
					unitType,
					unitId,
					repairAttempts: solverAssessment.repairAttempts,
					classification: missingCheckpointDiagnosis?.classification,
				},
			});
			ctx.ui.notify(
				`Autonomous solver: all repair attempts exhausted for ${unitType} ${unitId} — synthesizing continue and re-dispatching (LLM will try again)`,
				"info",
			);
			// Fall through: the synthesized checkpoint's action will be "continue" on
			// the next assessment, so the loop re-dispatches the unit automatically.
			return { action: "continue" };
		}

		const reason =
			solverCheckpoint?.outcome === "blocked"
				? (solverCheckpoint.blockerReason ?? solverCheckpoint.summary)
				: solverAssessment.reason;
		deps.emitJournalEvent({
			ts: new Date().toISOString(),
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType:
				solverAssessment.reason === "solver-max-iterations"
					? "solver-max-iterations-pause"
					: "solver-pause",
			data: {
				unitType,
				unitId,
				reason: solverAssessment.reason,
				iteration: solverAssessment.state?.iteration,
				maxIterations: solverAssessment.state?.maxIterations,
				remainingItems: solverCheckpoint?.remainingItems ?? [],
				evidencePath: ".sf/runtime/autonomous-solver/LOOP.md",
			},
		});
		ctx.ui.notify(
			`Autonomous solver paused ${unitType} ${unitId}: ${reason || solverAssessment.reason}`,
			"warning",
		);
		await deps.pauseAuto(ctx, pi);
		return {
			action: "break",
			reason: solverAssessment.reason,
		};
	}
	if (solverAssessment.action === "continue") {
		deps.emitJournalEvent({
			ts: new Date().toISOString(),
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType: "solver-continue-redispatch",
			data: {
				unitType,
				unitId,
				iteration: solverAssessment.state?.iteration,
				remainingItems: solverCheckpoint?.remainingItems ?? [],
				evidencePath: ".sf/runtime/autonomous-solver/LOOP.md",
			},
		});
		ctx.ui.notify(
			`Autonomous solver continuing ${unitType} ${unitId}: ${solverCheckpoint?.remainingItems?.length ?? 0} item(s) remain.`,
			"info",
		);
		return {
			action: "continue",
			data: {
				unitStartedAt: s.currentUnit?.startedAt,
				requestDispatchedAt: currentUnitResult.requestDispatchedAt,
			},
		};
	}
	debugLog("autoLoop", {
		phase: "runUnit-end",
		iteration: ic.iteration,
		unitType,
		unitId,
		status: currentUnitResult.status,
	});
	// Now that runUnit has called newSession(), the session file path is correct.
	const sessionFile = deps.getSessionFile(ctx);
	const sessionId = sessionFile ? basename(sessionFile) : undefined;
	deps.updateSessionLock(
		deps.lockBase(),
		unitType,
		unitId,
		sessionFile,
		sessionId,
	);
	deps.writeLock(deps.lockBase(), unitType, unitId, sessionFile);
	// Tag the most recent window entry with error info for stuck detection
	const lastEntry = loopState.recentUnits[loopState.recentUnits.length - 1];
	if (lastEntry) {
		if (currentUnitResult.errorContext) {
			lastEntry.error =
				`${currentUnitResult.errorContext.category}:${currentUnitResult.errorContext.message}`.slice(
					0,
					200,
				);
		} else if (
			currentUnitResult.status === "error" ||
			currentUnitResult.status === "cancelled"
		) {
			lastEntry.error = `${currentUnitResult.status}:${unitType}/${unitId}`;
		} else if (currentUnitResult.event?.messages?.length) {
			const lastMsg =
				currentUnitResult.event.messages[
					currentUnitResult.event.messages.length - 1
				];
			const msgStr =
				typeof lastMsg === "string" ? lastMsg : JSON.stringify(lastMsg);
			if (/error|fail|exception/i.test(msgStr)) {
				lastEntry.error = msgStr.slice(0, 200);
			}
		}
	}
	if (currentUnitResult.status === "cancelled") {
		clearDeferredCommitAfterCancelledUnit(
			s,
			ctx,
			unitType,
			unitId,
			currentUnitResult.errorContext?.message ?? "cancelled",
		);
		// Provider-error: try to reselect a ready provider and continue rather
		// than stopping autonomous mode. Only stop if no ready provider exists.
		if (currentUnitResult.errorContext?.category === "provider") {
			// Check whether this is a transient cooldown (rate-limit / quota), which
			// should attempt failover to a non-cooldowned provider rather than stopping.
			// Use the raw error from errorContext when available, falling back to
			// the structured error field if present.
			const rawErr = currentUnitResult.errorContext?.error;
			const isCooldown =
				rawErr != null && isTransientCooldownError(rawErr);
			if (isCooldown) {
				// Mark the failing model as temporarily blocked with its retry-after
				// window so the next selectAndApplyModel call skips it.
				const cooldownModel = s.currentUnitModel;
				const retryAfterMs = rawErr != null
					? getCooldownRetryAfterMs(rawErr)
					: undefined;
				const expiresAt = retryAfterMs != null && retryAfterMs > 0
					? Date.now() + retryAfterMs
					: Date.now() + 60_000; // default 60s block if no retry hint
				if (cooldownModel?.provider && cooldownModel?.id) {
					blockModel(
						s.basePath,
						cooldownModel.provider,
						cooldownModel.id,
						`transient cooldown: retry after ${retryAfterMs ?? 60_000}ms`,
						{ expiresAt },
					);
				}
				// Try to find any non-cooldowned, provider-ready fallback.
				const failedProvider =
					s.currentUnitModel?.provider ?? ctx.model?.provider;
				const allModels = ctx.modelRegistry?.getAvailable?.() ?? [];
				const fallback = allModels.find(
					(m) =>
						m.provider !== failedProvider &&
						ctx.modelRegistry?.isProviderRequestReady?.(m.provider),
				);
				if (fallback) {
					const ok = await pi.setModel(fallback, { persist: false });
					if (ok) {
						s.currentUnitModel = fallback;
						const waitNote = retryAfterMs != null && retryAfterMs > 0
							? ` (retrying immediately — cooldown expires in ${Math.round(retryAfterMs / 1000)}s)`
							: "";
						ctx.ui.notify(
							`Provider ${failedProvider} in cooldown${waitNote} — switched to ${fallback.provider}/${fallback.id}`,
							"warning",
						);
						return { action: "continue" };
					}
				}
				// All providers are unavailable — stopAuto with enumeration.
				const cooldowned = allModels
					.filter((m) => !ctx.modelRegistry?.isProviderRequestReady?.(m.provider))
					.map((m) => `${m.provider}/${m.id}`);
				const cooldownedList = cooldowned.length > 0
					? cooldowned.join(", ")
					: "(none listed)";
				const msg = `All providers in cooldown. Failed: ${failedProvider}. Remaining unavailable: ${cooldownedList}. Stopping.`;
				ctx.ui.notify(msg, "error");
				await deps.stopAuto(ctx, pi, msg);
				return { action: "break", reason: "all-providers-cooldown" };
			}
			// Non-cooldown provider error: try a different provider, stop if none ready.
			const failedProvider =
				s.currentUnitModel?.provider ?? ctx.model?.provider;
			const allModels = ctx.modelRegistry?.getAvailable?.() ?? [];
			const fallback = allModels.find(
				(m) =>
					m.provider !== failedProvider &&
					ctx.modelRegistry?.isProviderRequestReady?.(m.provider),
			);
			if (fallback) {
				const ok = await pi.setModel(fallback, { persist: false });
				if (ok) {
					s.currentUnitModel = fallback;
					ctx.ui.notify(
						`Autonomous mode: provider ${failedProvider} not ready — retrying with ${fallback.provider}/${fallback.id}`,
						"warning",
					);
					return { action: "continue" };
				}
			}
			const msg = `Autonomous mode stopped: ${currentUnitResult.errorContext.message ?? `provider ${failedProvider} not ready`}. Check your login/API key.`;
			ctx.ui.notify(msg, "error");
			debugLog("autoLoop", {
				phase: "exit",
				reason: "provider-pause",
				isTransient: currentUnitResult.errorContext.isTransient,
			});
			return { action: "break", reason: "provider-pause" };
		}
		// Timeout category covers two distinct scenarios:
		//   1. Session creation timeout (120s) — transient, scheduled resume with backoff
		//   2. Unit hard timeout (30min+) — stuck agent, pause for manual review
		// Structural errors (TypeError, is not a function) are NOT transient
		// and must hard-stop to avoid infinite retry loops.
		if (
			currentUnitResult.errorContext?.isTransient &&
			currentUnitResult.errorContext?.category === "timeout"
		) {
			// Session-timeout cancellations are resumable pauses: pauseAuto below preserves the auto session
			// instead of routing the cancelled unit into the hard-stop path.
			const isSessionCreationTimeout =
				currentUnitResult.errorContext.message?.includes(
					"Session creation timed out",
				);
			if (isSessionCreationTimeout) {
				consecutiveSessionTimeouts += 1;
				const baseRetryAfterMs = 30_000;
				const retryAfterMs =
					baseRetryAfterMs * 2 ** Math.max(0, consecutiveSessionTimeouts - 1);
				const allowAutoResume =
					consecutiveSessionTimeouts <= MAX_SESSION_TIMEOUT_AUTO_RESUMES;
				if (!allowAutoResume) {
					ctx.ui.notify(
						`Session creation timed out ${consecutiveSessionTimeouts} consecutive times for ${unitType} ${unitId}. Pausing for manual review.`,
						"warning",
					);
				}
				debugLog("autoLoop", {
					phase: "session-timeout-pause",
					unitType,
					unitId,
					consecutiveSessionTimeouts,
					retryAfterMs,
					allowAutoResume,
				});
				const errorDetail = ` for ${unitType} ${unitId}`;
				await pauseAutoForProviderError(
					ctx.ui,
					errorDetail,
					() => deps.pauseAuto(ctx, pi),
					{
						isRateLimit: false,
						isTransient: allowAutoResume,
						retryAfterMs,
						resume: allowAutoResume
							? () => {
									void resumeAutoAfterProviderDelay(pi, ctx).catch((err) => {
										const message = getErrorMessage(err);
										ctx.ui.notify(
											`Session timeout recovery failed: ${message}`,
											"error",
										);
									});
								}
							: undefined,
					},
				);
				if (!allowAutoResume) {
					resetConsecutiveSessionTimeouts();
				}
				await emitCancelledUnitEnd(
					ic,
					unitType,
					unitId,
					unitStartSeq,
					currentUnitResult.errorContext,
				);
				return { action: "break", reason: "session-timeout" };
			}
			// Unit hard timeout (30min+): pause without scheduled resume — stuck agent
			ctx.ui.notify(
				`Unit timed out for ${unitType} ${unitId} (supervision may have failed). Pausing autonomous mode.`,
				"warning",
			);
			debugLog("autoLoop", {
				phase: "unit-hard-timeout-pause",
				unitType,
				unitId,
			});
			await deps.pauseAuto(ctx, pi);
			await emitCancelledUnitEnd(
				ic,
				unitType,
				unitId,
				unitStartSeq,
				currentUnitResult.errorContext,
			);
			return { action: "break", reason: "unit-hard-timeout" };
		}
		// All other cancelled states (structural errors, non-transient failures): hard stop
		if (s.currentUnit) {
			await deps.closeoutUnit(
				ctx,
				s.basePath,
				unitType,
				unitId,
				s.currentUnit.startedAt,
				deps.buildSnapshotOpts(unitType, unitId),
			);
		}
		await emitCancelledUnitEnd(
			ic,
			unitType,
			unitId,
			unitStartSeq,
			currentUnitResult.errorContext,
		);
		ctx.ui.notify(
			`Session creation failed for ${unitType} ${unitId}: ${currentUnitResult.errorContext?.message ?? "unknown"}. Stopping autonomous mode.`,
			"warning",
		);
		await deps.stopAuto(
			ctx,
			pi,
			`Session creation failed: ${currentUnitResult.errorContext?.message ?? "unknown"}`,
		);
		debugLog("autoLoop", { phase: "exit", reason: "session-failed" });
		return { action: "break", reason: "session-failed" };
	}
	// ── Immediate unit closeout (metrics, activity log, memory) ────────
	// Run right after runUnit() returns so telemetry is never lost to a
	// crash between iterations.
	// Guard: stopAuto() may have nulled s.currentUnit via s.reset() while
	// this coroutine was suspended at `await runUnit(...)` (#2939).
	if (s.currentUnit) {
		// Reset session timeout counter — any successful unit clears the slate
		resetConsecutiveSessionTimeouts();
		await deps.closeoutUnit(
			ctx,
			s.basePath,
			unitType,
			unitId,
			s.currentUnit.startedAt,
			deps.buildSnapshotOpts(unitType, unitId),
		);
	}
	// ── Zero tool-call guard (#1833, #2653) ──────────────────────────
	// Any unit that completes with 0 tool calls made no real progress —
	// likely context exhaustion where all tool calls errored out. Treat
	// as failed so the unit is retried in a fresh context instead of
	// silently passing through to artifact verification (which loops
	// forever when the unit never produced its artifact).
	{
		const currentLedger = deps.getLedger();
		if (currentLedger?.units) {
			const lastUnit = [...currentLedger.units]
				.reverse()
				.find(
					(u) =>
						u.type === unitType &&
						u.id === unitId &&
						u.startedAt === s.currentUnit?.startedAt,
				);
			if (lastUnit && lastUnit.toolCalls === 0) {
				// Swarm bypass: the ledger entry only reflects the parent session, which
				// never receives the subagent's tool calls. Use the real count surfaced by
				// runUnitViaSwarm (swarmToolCallCount) to avoid a false-positive retry.
				const guardResult = resultForZeroToolCallGuard(
					unitResult,
					currentUnitResult,
				);
				const swarmRealToolCalls = guardResult.swarmToolCallCount ?? 0;
				const isSwarmWithWork =
					guardResult._via === "swarm" && swarmRealToolCalls > 0;
				if (isSwarmWithWork) {
					debugLog("runUnitPhase", {
						phase: "zero-tool-calls-swarm-bypass",
						unitType,
						unitId,
						swarmToolCallCount: swarmRealToolCalls,
					});
				} else if (
					USER_DRIVEN_DEEP_UNITS.has(unitType) &&
					isAwaitingUserInput(s.lastUnitAgentEndMessages ?? undefined)
				) {
					debugLog("runUnitPhase", {
						phase: "zero-tool-calls-awaiting-user-input",
						unitType,
						unitId,
					});
				} else {
					debugLog("runUnitPhase", {
						phase: "zero-tool-calls",
						unitType,
						unitId,
						warning:
							"Unit completed with 0 tool calls — likely context exhaustion, marking as failed",
					});
					ctx.ui.notify(
						`${unitType} ${unitId} completed with 0 tool calls — context exhaustion, will retry`,
						"warning",
					);
					recordLearningOutcomeForUnit(
						ic,
						unitType,
						unitId,
						s.currentUnit?.startedAt,
						{
							succeeded: false,
							verificationPassed: null,
						},
					);
					// Fall through to next iteration where dispatch will re-derive
					// and re-dispatch this unit.
					return {
						action: "next",
						data: {
							unitStartedAt: s.currentUnit?.startedAt,
							requestDispatchedAt: currentUnitResult.requestDispatchedAt,
						},
					};
				}
			}
		}
	}
	if (s.currentUnitRouting) {
		deps.recordOutcome(unitType, s.currentUnitRouting.tier, true);
	}
	const skipArtifactVerification = shouldSkipArtifactVerification(unitType);
	let artifactVerified;
	if (
		USER_DRIVEN_DEEP_UNITS.has(unitType) &&
		isAwaitingUserInput(s.lastUnitAgentEndMessages ?? undefined)
	) {
		// Skip artifact verification — unit is paused waiting for user input
		artifactVerified = false;
	} else {
		artifactVerified =
			skipArtifactVerification ||
			verifyExpectedArtifact(unitType, unitId, s.basePath);
	}
	if (artifactVerified) {
		s.unitDispatchCount.delete(`${unitType}/${unitId}`);
		s.unitRecoveryCount.delete(`${unitType}/${unitId}`);
	}
	// Write phase handoff anchor after successful research/planning completion
	const anchorPhases = new Set([
		"research-milestone",
		"research-slice",
		"plan-milestone",
		"plan-slice",
	]);
	if (artifactVerified && mid && anchorPhases.has(unitType)) {
		try {
			const { writePhaseAnchor } = await import("../phase-anchor.js");
			writePhaseAnchor(s.basePath, mid, {
				phase: unitType,
				milestoneId: mid,
				generatedAt: new Date().toISOString(),
				intent: `Completed ${unitType} for ${unitId}`,
				decisions: [],
				blockers: [],
				nextSteps: [],
			});
		} catch (err) {
			/* non-fatal — anchor is advisory */
			logWarning("engine", `phase anchor failed: ${getErrorMessage(err)}`);
		}
	}
	if (currentUnitResult.status !== "completed" || !artifactVerified) {
		recordLearningOutcomeForUnit(
			ic,
			unitType,
			unitId,
			s.currentUnit?.startedAt,
			{
				succeeded: false,
				verificationPassed: null,
			},
		);
	}
	{
		// Pull cost/token data from the ledger entry that snapshotUnitMetrics
		// already wrote so the unit-end event carries billing context.
		const unitEndLedger = deps.getLedger();
		const unitEndEntry = unitEndLedger?.units
			? [...unitEndLedger.units]
					.reverse()
					.find(
						(u) =>
							u.type === unitType &&
							u.id === unitId &&
							u.startedAt === s.currentUnit?.startedAt,
					)
			: undefined;
		deps.emitJournalEvent({
			ts: new Date().toISOString(),
			flowId: ic.flowId,
			seq: ic.nextSeq(),
			eventType: "unit-end",
			data: {
				unitType,
				unitId,
				status: currentUnitResult.status,
				artifactVerified,
				...(unitEndEntry
					? {
							cost_usd: unitEndEntry.cost,
							tokens: unitEndEntry.tokens.total,
							tokens_input: unitEndEntry.tokens.input,
							tokens_output: unitEndEntry.tokens.output,
						}
					: {}),
				...(currentUnitResult.errorContext
					? { errorContext: currentUnitResult.errorContext }
					: {}),
			},
			causedBy: { flowId: ic.flowId, seq: unitStartSeq },
		});
		if (
			currentUnitResult.status === "completed" ||
			currentUnitResult.status === "blocked"
		) {
			const progressEvent = buildUokProgressEvent({
				eventType:
					currentUnitResult.status === "completed"
						? "unit_completed"
						: "unit_blocked",
				unitType,
				unitId,
				role: "worker",
				sessionId: ctx.sessionManager.getSessionId(),
				traceId: ic.flowId,
				data: {
					status: currentUnitResult.status,
					artifactVerified,
					legacyEventType: "unit-end",
					...(unitEndEntry
						? {
								cost_usd: unitEndEntry.cost,
								tokens: unitEndEntry.tokens.total,
							}
						: {}),
				},
			});
			deps.emitJournalEvent({
				ts: progressEvent.ts,
				flowId: ic.flowId,
				seq: ic.nextSeq(),
				eventType: progressEvent.eventType,
				data: progressEvent,
				causedBy: { flowId: ic.flowId, seq: unitStartSeq },
			});
		}
	}
	{
		const runtimeStatus =
			currentUnitResult.status === "completed"
				? artifactVerified
					? "completed"
					: "blocked"
				: currentUnitResult.status === "error"
					? "failed"
					: currentUnitResult.status;
		const lineageStatus =
			runtimeStatus === "completed"
				? "completed"
				: runtimeStatus === "blocked"
					? "blocked"
					: runtimeStatus === "cancelled"
						? "cancelled"
						: "failed";
		writeUnitRuntimeRecord(
			s.basePath,
			unitType,
			unitId,
			s.currentUnit?.startedAt ?? Date.now(),
			{
				status: runtimeStatus,
				lastProgressAt: Date.now(),
				lastProgressKind: "unit-end",
				lineageEvent: {
					status: lineageStatus,
					workerSessionId: ctx.sessionManager.getSessionId(),
					note: `unit ended with ${currentUnitResult.status}`,
				},
			},
		);
	}
	{
		const verdict =
			currentUnitResult.status === "completed"
				? artifactVerified
					? "success"
					: "blocked"
				: currentUnitResult.status === "error"
					? "fail"
					: currentUnitResult.status;
		const ledger = deps.getLedger();
		const unitEntry = ledger?.units
			? [...ledger.units]
					.reverse()
					.find(
						(u) =>
							u.type === unitType &&
							u.id === unitId &&
							u.startedAt === s.currentUnit?.startedAt,
					)
			: undefined;
		if (unitEntry) {
			const costStr = deps.formatCost(unitEntry.cost);
			ctx.ui.notify(
				`[unit] ${unitType} ${unitId} ended -> ${verdict} (${costStr}, ${unitEntry.tokens.total} tokens, ${unitEntry.toolCalls} tool calls)`,
				"info",
			);
		} else {
			ctx.ui.notify(`[unit] ${unitType} ${unitId} ended -> ${verdict}`, "info");
		}
		const toolSummary = formatToolCallSummary();
		if (toolSummary) {
			ctx.ui.notify(`[mcp] ${toolSummary}`, "info");
		}
	}
	// ── Safety harness: checkpoint cleanup or rollback ──
	if (s.checkpointSha) {
		if (currentUnitResult.status === "error" && safetyConfig.auto_rollback) {
			const rolled = rollbackToCheckpoint(s.basePath, unitId, s.checkpointSha);
			if (rolled) {
				ctx.ui.notify(
					`Rolled back to pre-unit checkpoint for ${unitId}`,
					"info",
				);
				debugLog("runUnitPhase", { phase: "checkpoint-rollback", unitId });
			}
		} else if (currentUnitResult.status === "error") {
			ctx.ui.notify(
				`Unit ${unitId} failed. Pre-unit checkpoint available at ${s.checkpointSha.slice(0, 8)}`,
				"warning",
			);
		} else {
			// Success — clean up checkpoint ref
			cleanupCheckpoint(s.basePath, unitId);
			debugLog("runUnitPhase", { phase: "checkpoint-cleaned", unitId });
		}
		s.checkpointSha = null;
	}
	s.preUnitDirtyFiles = [];
	return {
		action: "next",
		data: {
			unitStartedAt: s.currentUnit?.startedAt,
			requestDispatchedAt: currentUnitResult.requestDispatchedAt,
		},
	};
}

export const resetSessionTimeoutState = resetConsecutiveSessionTimeouts;
