/**
 * auto/run-unit.ts — Single unit execution: session create → prompt → await agent_end.
 *
 * Imports from: auto/types, auto/resolve
 *
 * ## Swarm dispatch path (opt-in)
 * When `SF_AUTONOMOUS_VIA_SWARM=1` (or `=true`) is set, `runUnit` routes each
 * unit through `swarmDispatchAndWait` instead of the parent-session `pi.sendMessage`
 * path. Default behavior is unchanged when the flag is unset.
 */

import {
	appendAutonomousSolverCheckpoint,
	MAX_CHECKPOINTS_PER_ITERATION,
} from "../autonomous-solver.js";
import { scopeActiveToolsForUnitType } from "../constants.js";
import { debugLog } from "../debug-logger.js";
import { getErrorMessage } from "../error-utils.js";
import { emitJournalEvent } from "../journal.js";
import {
	resolveAutoSupervisorConfig,
	resolvePersistModelChanges,
} from "../preferences.js";
import {
	collectSessionTokenUsage,
	collectWorktreeFingerprint,
	countChangedFiles,
	resetRunawayGuardState,
} from "../uok/auto-runaway-guard.js";
import { DispatchLayer } from "../dispatch/dispatch-layer.js";
import { isInlineEligible } from "../dispatch/run-unit-inline.js";
import { swarmDispatchAndWait } from "../uok/swarm-dispatch.js";

/**
 * #M010/S05: Try inline-scope dispatch via DispatchLayer.
 *
 * Returns a UnitResult-shaped object if the inline path was taken; null if
 * the unit isn't inline-eligible (caller falls through to swarm/legacy).
 *
 * On failure, returns a structured `{status: "cancelled", errorContext: {...}}`
 * matching the contract that runUnitViaSwarm produces, so the autoLoop's
 * downstream handling (resolveAgentEnd, finalize, etc.) works unchanged.
 *
 * Default-on for inline-eligible unit types; `SF_INLINE_DISPATCH=0` is the
 * explicit off-switch. Codex adversarial review 2026-05-17 flagged that this
 * default-on behavior runs ahead of R020 (inline-vs-spawn equivalence proof)
 * and R066 (autonomous regression quarantine) — silent prompt/tool-call drift
 * in validate/complete/reassess milestone closeout is the named risk. The
 * runtime gate is kept default-on per operator direction; the equivalence
 * proof and quarantine gates remain the explicit dependencies before this
 * default can be considered safe across the autonomous loop.
 */
async function tryInlineDispatch(ctx, s, unitType, unitId, _prompt, options) {
	if (!isInlineEligible(unitType)) return null;
	const basePath = s.basePath ?? ctx.basePath ?? process.cwd();
	debugLog("runUnit", {
		phase: "inline-route",
		unitType,
		unitId,
		basePath,
	});
	let layer;
	try {
		layer = new DispatchLayer(basePath);
	} catch (err) {
		debugLog("runUnit", {
			phase: "inline-route-construct-failed",
			unitType,
			unitId,
			error: err instanceof Error ? err.message : String(err),
		});
		return null; // fall through to swarm
	}
	const dispatchOpts = {
		isolation: "full",
		coordination: "managed",
		scope: "inline",
		mode: "single",
		unitType,
		unitId,
		...(options?.model ? { model: options.model } : {}),
		...(options?.timeoutMs ? { timeoutMs: options.timeoutMs } : {}),
		...(options?.noOutputTimeoutMs
			? { noOutputTimeoutMs: options.noOutputTimeoutMs }
			: {}),
		...(options?.signal ? { signal: options.signal } : {}),
		...(options?.extras ? { extras: options.extras } : {}),
	};
	const result = await layer.dispatch(dispatchOpts);
	if (result.ok) {
		debugLog("runUnit", {
			phase: "inline-route-completed",
			unitType,
			unitId,
			outputLength: (result.output ?? "").length,
		});
		return {
			status: "completed",
			event: {
				messages: [
					{
						role: "assistant",
						content: [{ type: "text", text: result.output ?? "" }],
					},
				],
			},
			_via: "inline",
		};
	}
	// Inline path returned a structured failure. Surface as cancelled UnitResult.
	debugLog("runUnit", {
		phase: "inline-route-failed",
		unitType,
		unitId,
		exitCode: result.exitCode,
		stderr: result.stderr,
	});
	return {
		status: "cancelled",
		errorContext: {
			message: result.stderr ?? "inline dispatch failed",
			category: "inline-failure",
			isTransient: false,
		},
		_via: "inline",
	};
}
import { logWarning } from "../workflow-logger.js";
import {
	_clearCurrentResolve,
	_setCurrentResolve,
	_setSessionSwitchInFlight,
} from "./resolve.js";
import { NEW_SESSION_TIMEOUT_MS } from "./session.js";
import {
	getCurrentTurnGeneration,
	runWithTurnGeneration,
} from "./turn-epoch.js";

// Tracks the latest session-switch attempt so a late timeout settlement from an
// older runUnit() call cannot clear the guard for a newer one.
let sessionSwitchGeneration = 0;
/**
 * Build the custom-message content for a unit prompt.
 *
 * Purpose: preserve the exact prompt text while allowing the provider layer to
 * cache the stable prefix separately from the dynamic suffix.
 *
 * Consumer: runUnit before pi.sendMessage dispatches the autonomous unit turn.
 */
export function buildUnitPromptMessageContent(prompt, promptCacheSplit) {
	if (!promptCacheSplit) return prompt;
	return [
		{
			type: "text",
			text: `${promptCacheSplit.before}\n`,
			cache_control: { type: "ephemeral" },
		},
		{ type: "text", text: promptCacheSplit.after },
	];
}

function sameStringList(a, b) {
	if (a.length !== b.length) return false;
	return a.every((value, index) => value === b[index]);
}

/**
 * Resolve the active tool list for one autonomous unit dispatch.
 *
 * Purpose: keep phase-specific agents inside their authority boundary. Normal
 * units use the unit-type SF allowlists; protocol-only passes can supply an
 * explicit allowlist so they cannot edit files, run shell commands, or dispatch
 * subagents.
 *
 * Consumer: runUnit before pi.sendMessage.
 */
export function scopeActiveToolsForRunUnit(
	unitType,
	currentTools,
	options = {},
) {
	const explicitAllowlist = Array.isArray(options.activeToolsAllowlist)
		? options.activeToolsAllowlist
		: null;
	if (explicitAllowlist) {
		const allowed = new Set(explicitAllowlist);
		return currentTools.filter((toolName) => allowed.has(toolName));
	}
	return scopeActiveToolsForUnitType(unitType, currentTools);
}

/**
 * Derive the swarm workMode for a given unitType.
 *
 * Purpose: map autonomous unit type strings to the swarm routing vocabulary so
 * envelopes dispatched via runUnitViaSwarm reach the correct role agent.
 * Mirrors the logic in AgentSwarm._deriveWorkMode but lives here to keep
 * run-unit self-contained.
 *
 * Consumer: runUnitViaSwarm.
 *
 * @param {string} unitType
 * @returns {string}
 */
function deriveWorkMode(unitType) {
	if (!unitType) return "build";
	const t = unitType.toLowerCase();
	if (t.includes("research") || t.includes("scout")) return "research";
	if (t.includes("review")) return "review";
	if (
		t.includes("audit") ||
		t.includes("validate") ||
		t.includes("gate") ||
		t.includes("uat")
	)
		return "verify";
	if (t.includes("repair") || t.includes("fix")) return "repair";
	if (
		t.includes("build") ||
		t.includes("code") ||
		t.includes("implement") ||
		t.includes("execute")
	)
		return "build";
	if (
		t.includes("plan") ||
		t.includes("slice") ||
		t.includes("milestone") ||
		t.includes("roadmap")
	)
		return "plan";
	if (t.includes("doc") || t.includes("rewrite") || t.includes("scribe"))
		return "document";
	if (t.includes("challenge") || t.includes("adversar")) return "challenge";
	return "build";
}

/**
 * Map SF's product permission profile to the legacy permission extension level.
 *
 * Purpose: keep in-process swarm workers aligned with the parent autonomous
 * mode. The worker gets a fresh headless AgentSession whose permission
 * extension otherwise defaults to read-only `minimal`, which blocks normal
 * autonomous edits.
 *
 * Consumer: runUnitViaSwarm dispatch envelopes.
 */
function legacyPermissionLevelForProfile(profile) {
	// #wiggums: SF orchestrator runs autonomous work that requires running
	// tsc / git diff / npm test for self-verification. Default "low" was
	// too restrictive — executors couldn't verify their own deliverables.
	// Per operator intent (2026-05-17): "SF should have permission even
	// if it can limit its agents and only allow orchestrator or whatever."
	// The parent SF process running the autonomous loop deserves enough
	// permission to verify its work; sub-agents can still be further
	// restricted via explicit envelope.executorPermissionLevel.
	switch (profile) {
		case "restricted":
			return "minimal";
		case "normal":
			// DEFAULT autonomous session profile (auto/session.js:377).
			// Witnessed M010/S04/T01: "TypeScript compilation and git diff
			// blocked by low permission level" even though autonomous was
			// running with the normal profile. "medium" allows common
			// verification shell commands.
			return "medium";
		case "trusted":
			return "medium";
		case "unrestricted":
			return "bypassed";
		default:
			// Unknown profile: default to "medium" (was "low"). Autonomous
			// work needs to run verification commands; an undefined profile
			// shouldn't cripple the executor. Operators who want a
			// restricted setup can set profile: "restricted" explicitly.
			return "medium";
	}
}

/**
 * Return true when a runUnit call is the protocol-only solver checkpoint pass.
 *
 * Purpose: keep autonomous executor dispatch and solver checkpoint classification
 * on separate execution paths. Headless mode defaults executor units through the
 * swarm layer, but the checkpoint-only solver pass must stay in the parent session
 * where `activeToolsAllowlist: ["checkpoint"]` is enforced directly; routing it
 * back through swarm turns the protocol repair into another executor job and can
 * re-enter the missing-checkpoint loop.
 *
 * Consumer: runUnit's swarm routing gate.
 */
function isCheckpointOnlyProtocolPass(options) {
	const tools = options?.activeToolsAllowlist;
	return (
		Array.isArray(tools) && tools.length === 1 && tools[0] === "checkpoint"
	);
}

function shouldRouteRunUnitViaSwarm(options) {
	if (isCheckpointOnlyProtocolPass(options)) return false;
	const swarmFlag = process.env.SF_AUTONOMOUS_VIA_SWARM;
	return (
		swarmFlag === "1" ||
		swarmFlag === "true" ||
		(swarmFlag !== "0" &&
			swarmFlag !== "false" &&
			process.env.SF_HEADLESS === "1")
	);
}

/**
 * Build a bounded fan-out plan for autonomous swarm units.
 *
 * Purpose: let headless autonomy use multiple swarm agents only when the work is
 * naturally parallel and low-conflict. Research and validation can split into
 * independent read-only lanes; implementation stays single-agent unless a future
 * planner provides explicit disjoint write scopes.
 *
 * Consumer: runUnitViaSwarm before dispatching an autonomous unit envelope.
 *
 * @param {string} unitType
 * @param {string} _unitId
 * @param {string} prompt
 * @param {object} [options={}]
 * @returns {{ reason: string, lanes: Array<{ id: string, targetAgent: string, workMode: string, instruction: string }> } | null}
 */
export function buildSwarmFanoutPlan(unitType, _unitId, prompt, options = {}) {
	const fanoutFlag = process.env.SF_SWARM_FANOUT;
	if (fanoutFlag === "0" || fanoutFlag === "false") return null;
	if (isCheckpointOnlyProtocolPass(options)) return null;
	if (options?.keepSession === true) return null;
	if (options?.swarmFanout === false) return null;

	const type = String(unitType ?? "").toLowerCase();
	const text = String(prompt ?? "").toLowerCase();

	if (type.includes("research") || text.includes("parallel research")) {
		return {
			reason:
				"research units are read-heavy and can be checked from independent angles",
			lanes: [
				{
					id: "source-scout",
					targetAgent: "scout",
					workMode: "research",
					instruction:
						"Find the most relevant repo evidence and external assumptions. Do not edit files.",
				},
				{
					id: "contract-review",
					targetAgent: "reviewer",
					workMode: "review",
					instruction:
						"Review the proposed direction for purpose, consumer, and missing falsifiers. Do not edit files.",
				},
				{
					id: "verification-map",
					targetAgent: "verifier",
					workMode: "verify",
					instruction:
						"Identify executable verification, likely tests, and failure boundaries. Do not edit files.",
				},
			],
		};
	}

	if (
		type.includes("validate") ||
		type.includes("audit") ||
		type.includes("gate") ||
		type.includes("uat")
	) {
		return {
			reason:
				"validation benefits from independent review, verification, and challenge lanes",
			lanes: [
				{
					id: "reviewer",
					targetAgent: "reviewer",
					workMode: "review",
					instruction:
						"Review the artifact against the stated contract. Do not edit files.",
				},
				{
					id: "verifier",
					targetAgent: "verifier",
					workMode: "verify",
					instruction:
						"Run or identify verification evidence and report residual risk. Do not edit files.",
				},
				{
					id: "adversary",
					targetAgent: "adversary",
					workMode: "challenge",
					instruction:
						"Challenge weak assumptions and look for counterexamples. Do not edit files.",
				},
			],
		};
	}

	return null;
}

/**
 * Build the system prompt for a swarm worker executing an autonomous unit.
 *
 * Purpose: the swarm worker runs both the executor and solver roles in one session
 * (unlike the legacy path which runs them as separate LLM turns). This means the
 * worker must be instructed to call the `checkpoint` tool directly — it cannot rely
 * on a separate solver pass to classify its work afterward.
 *
 * The prompt is intentionally compact: it establishes the worker's role (autonomous
 * executor), lists the checkpoint outcomes, and gives the mandatory contract language
 * mirroring `autonomous-solver-contract.md`. It does NOT include the full PDD loop
 * machinery (iteration/phase/stall/loop vars) because the swarm worker processes
 * exactly one dispatch at a time, not an iterative loop.
 *
 * Consumer: runUnitViaSwarm — sets envelope.executorSystemPrompt which is forwarded
 * by dispatchAndWait → runAgentTurn → runHeadlessPrompt → runSubagent config.
 *
 * @param {string} unitType  e.g. "execute-task"
 * @param {string} unitId    e.g. "M010/S05/T02"
 * @param {string} basePath  project root (used for working directory reference)
 * @returns {string}
 */
function buildSwarmWorkerSystemPrompt(unitType, unitId, basePath) {
	return [
		`You are an autonomous executor agent in a multi-agent swarm, working on unit type "${unitType}" (id: ${unitId}).`,
		`Working directory: ${basePath}`,
		"",
		"## Your role",
		"Execute the task described in the incoming message. Read files, run tests, edit code, and produce",
		"concrete artifacts. Make verifiable progress toward the task goal.",
		"If the task is an execute-task unit and it is finished, call complete_task before checkpoint.",
		"",
		"## CHECKPOINT REQUIREMENT",
		"",
		"`checkpoint` is ALWAYS available. It is registered unconditionally at startup.",
		"If you do not see it in your tool list, that is a perception error — call it anyway. It will work.",
		"",
		"Hard requirement: before ending your turn, call the `checkpoint` tool with:",
		'- `outcome: "complete"` only when this unit\'s required artifact/completion tool is also done.',
		'- `outcome: "continue"` when you made real progress but more iterations are needed.',
		'- `outcome: "blocked"` when the next step cannot proceed without unavailable facts, credentials, or a broken environment.',
		'- `outcome: "continue"` also when unsure — keep going rather than stopping.',
		"",
		"Fill all eight PDD fields in the checkpoint: purpose, consumer, contract, failureBoundary,",
		"evidence, nonGoals, invariants, assumptions.",
		"",
		"Your final action must be the checkpoint tool call (unless complete_task must immediately precede it).",
	].join("\n");
}

/**
 * Run a unit through the swarm dispatch layer instead of the parent session.
 *
 * Emits a `swarm-dispatch` journal event per call so cross-repo telemetry can
 * distinguish swarm vs legacy paths.
 *
 * Purpose: alternate execution path activated by `SF_AUTONOMOUS_VIA_SWARM=1`.
 * Routes the unit prompt as a DispatchEnvelope through `swarmDispatchAndWait`,
 * then maps the structured swarm result back into the same UnitResult shape that
 * `runUnit`'s callers expect so the orchestrator loop is unaffected.
 *
 * Progress lifecycle hooks (debugLog + ctx.ui.notify) mirror the phases emitted
 * by the legacy pi.sendMessage path so operator-facing output is equivalent.
 *
 * Consumer: runUnit when SF_AUTONOMOUS_VIA_SWARM is set.
 *
 * @param {object} ctx
 * @param {object} pi
 * @param {object} s
 * @param {string} unitType
 * @param {string} unitId
 * @param {string} prompt
 * @param {object} [options={}]
 * @returns {Promise<object>}  UnitResult
 */
async function runUnitViaSwarm(ctx, _pi, s, unitType, unitId, prompt, options) {
	const requestDispatchedAt = Date.now();

	debugLog("runUnit[swarm]", { phase: "start", unitType, unitId });

	const basePath = s.basePath ?? ctx.basePath ?? process.cwd();

	// Build the executor system prompt that teaches the swarm worker about the
	// checkpoint tool contract. The worker runs both the executor and solver roles
	// in one session (unlike the legacy path), so it must be instructed to call
	// checkpoint directly rather than relying on a separate solver pass.
	const executorSystemPrompt = buildSwarmWorkerSystemPrompt(
		unitType,
		unitId,
		basePath,
	);

	// Build the tool list for the worker. The swarm worker gets all the tools the
	// autonomous executor needs. "checkpoint" must be explicit here so the worker's
	// runSubagent config.tools list includes it — even though bindExtensions also
	// adds it, providing it explicitly ensures the tool filter is correct from the
	// start and makes the intent clear for operators inspecting the envelope.
	// Note: scopeActiveToolsForRunUnit operates on a string[] of currently active tool
	// names from the parent session. For the swarm path the parent session tools are
	// not available, so we use a minimal canonical set that mirrors what execute-task
	// needs: the built-in editor/shell tools plus the checkpoint signal tool.
	const executorTools = options?.activeToolsAllowlist ?? null; // null = no filter (use session defaults)

	// Build the envelope. `scope` is taken from options when available so that
	// milestone-scoped orchestrators can supply it; fall back to "autonomous".
	const envelope = {
		scope: options?.scope ?? "autonomous",
		unitId,
		unitType,
		workMode: deriveWorkMode(unitType),
		payload: prompt,
		priority: options?.priority ?? 5,
		executorSystemPrompt,
		executorPermissionLevel: legacyPermissionLevelForProfile(
			s.permissionProfile,
		),
		...(executorTools ? { executorTools } : {}),
	};

	debugLog("runUnit[swarm]", {
		phase: "dispatch",
		unitType,
		unitId,
		workMode: envelope.workMode,
	});

	// Derive timeout: use the same hard-timeout logic as the legacy path so the
	// swarm agent does not run longer than the orchestrator would tolerate.
	const supervisor = resolveAutoSupervisorConfig();
	const timeoutMs = Math.max(
		30_000,
		(supervisor.hard_timeout_minutes ?? 8) * 60 * 1000,
	);
	const configuredNoOutputTimeoutMs = Number(
		process.env.SF_SWARM_NO_OUTPUT_TIMEOUT_MS ?? "",
	);
	const noOutputTimeoutMs = Math.min(
		timeoutMs,
		Number.isFinite(configuredNoOutputTimeoutMs) &&
			configuredNoOutputTimeoutMs > 0
			? Math.floor(configuredNoOutputTimeoutMs)
			: 180_000,
	);
	const controller = new AbortController();
	if (options?.signal) {
		if (options.signal.aborted) {
			controller.abort();
		} else {
			options.signal.addEventListener("abort", () => controller.abort(), {
				once: true,
			});
		}
	}

	// ── Event collector: capture real tool calls and completion signal ──────────
	// The worker agent emits events as it runs. We intercept "toolcall_end"
	// events from the assistantMessageEvent stream to:
	//   1. Build the real tool-call content blocks (surfaced in event.messages).
	//   2. Detect the canonical `checkpoint` tool call to derive the actual outcome
	//      so the orchestrator sees "complete" / "blocked" / "continue" accurately.
	const collectedToolCalls = [];
	let workerSignaledOutcome = null; // "complete" | "progress" | "continue" | "blocked"
	let workerSummary = null;
	let workerCompletedItems = null;
	let workerRemainingItems = null;
	let workerVerificationEvidence = null;
	let checkpointToolCallCount = 0;
	let checkpointAbortReason = null;

	function onEvent(event) {
		if (
			event?.type === "message_update" &&
			event.assistantMessageEvent?.type === "toolcall_end"
		) {
			const toolCall = event.assistantMessageEvent.toolCall;
			if (toolCall) {
				// Capture the tool call in the standard tool_use block shape
				// expected by classifyExecutorRefusal / isNoOpExecutorTranscript.
				collectedToolCalls.push({
					type: "tool_use",
					name: toolCall.name,
					input: toolCall.arguments ?? {},
				});

				// #sf-mp94lth4-ew26om: Recognize unit-specific completion tools as
				// implicit "complete" signals. Workers often call the unit's specific
				// completion tool (reassess_roadmap with verdict, validate_milestone,
				// complete_milestone, complete_slice, save_summary) and skip the
				// generic checkpoint tool. Without this, the synthesized checkpoint
				// falls back to outcome=continue and the solver iterates indefinitely
				// despite the worker having genuinely finished (witnessed on
				// reassess-roadmap M010/S03 — 5 iterations in 8min, all confirming
				// completion in their text but never calling `checkpoint`).
				const UNIT_COMPLETION_TOOLS = new Set([
					"reassess_roadmap",
					"validate_milestone",
					"complete_milestone",
					"complete_slice",
					"save_summary",
				]);
				if (
					UNIT_COMPLETION_TOOLS.has(toolCall.name) &&
					workerSignaledOutcome !== "complete" &&
					workerSignaledOutcome !== "blocked"
				) {
					// Mark as complete unless an earlier explicit checkpoint already
					// said otherwise (don't override blocked / explicit progress).
					workerSignaledOutcome = "complete";
				}

				// Detect the canonical completion-signal tool: "checkpoint".
				// This is the same tool inspected by the legacy runUnit path's solver pass
				// (phases-unit.js line ~989: activeToolsAllowlist: ["checkpoint"]).
				if (toolCall.name === "checkpoint") {
					checkpointToolCallCount += 1;
					const args = toolCall.arguments ?? {};
					const rawOutcome = String(args.outcome ?? "").toLowerCase();
					if (
						rawOutcome === "complete" ||
						rawOutcome === "done" ||
						rawOutcome === "success"
					) {
						workerSignaledOutcome = "complete";
					} else if (rawOutcome === "progress" || rawOutcome === "partial") {
						workerSignaledOutcome = "progress";
					} else if (rawOutcome === "blocked") {
						workerSignaledOutcome = "blocked";
					} else {
						workerSignaledOutcome = workerSignaledOutcome ?? "continue";
					}
					// Capture the summary if provided.
					if (args.summary) {
						workerSummary = String(args.summary);
					}
					if (Array.isArray(args.completedItems)) {
						workerCompletedItems = args.completedItems.map(String);
					}
					if (Array.isArray(args.remainingItems)) {
						workerRemainingItems = args.remainingItems.map(String);
					}
					if (Array.isArray(args.verificationEvidence)) {
						workerVerificationEvidence = args.verificationEvidence.map(String);
					}
					if (
						checkpointToolCallCount >= MAX_CHECKPOINTS_PER_ITERATION &&
						!checkpointAbortReason
					) {
						checkpointAbortReason = `checkpoint limit reached (${checkpointToolCallCount}/${MAX_CHECKPOINTS_PER_ITERATION})`;
						controller.abort();
					}
				}
			}
		}
	}

	const fanoutPlan = buildSwarmFanoutPlan(unitType, unitId, prompt, options);
	if (fanoutPlan && fanoutPlan.lanes.length > 1) {
		debugLog("runUnit[swarm]", {
			phase: "fanout-dispatch",
			unitType,
			unitId,
			lanes: fanoutPlan.lanes.map((lane) => lane.targetAgent),
		});
		if (typeof ctx.ui?.notify === "function") {
			ctx.ui.notify(
				`[${unitType}] ${unitId} → swarm fan-out ${fanoutPlan.lanes.length} lanes`,
				"info",
			);
		}

		const laneResults = await Promise.all(
			fanoutPlan.lanes.map(async (lane) => {
				const lanePayload = [
					`Fan-out lane: ${lane.id}`,
					`Target agent: ${lane.targetAgent}`,
					`Reason: ${fanoutPlan.reason}`,
					"",
					"Lane instruction:",
					lane.instruction,
					"",
					"Original autonomous unit prompt:",
					prompt,
				].join("\n");
				const laneEnvelope = {
					...envelope,
					workMode: lane.workMode,
					targetAgent: lane.targetAgent,
					payload: lanePayload,
				};
				const result = await swarmDispatchAndWait(basePath, laneEnvelope, {
					timeoutMs,
					noOutputTimeoutMs,
					signal: controller.signal,
					onEvent,
				});
				return { lane, result };
			}),
		);

		const failedLane = laneResults.find(
			({ result }) => result.error || result.reply === null,
		);
		if (failedLane) {
			const reason =
				failedLane.result.error ??
				`lane ${failedLane.lane.id} returned no reply`;
			debugLog("runUnit[swarm]", {
				phase: "fanout-failed",
				unitType,
				unitId,
				lane: failedLane.lane.id,
				reason,
			});
			if (typeof ctx.ui?.notify === "function") {
				ctx.ui.notify(
					`[${unitType}] ${unitId} → swarm fan-out failed: ${reason}`,
					"error",
				);
			}
			return {
				status: "cancelled",
				requestDispatchedAt,
				errorContext: {
					message: reason,
					category: "session-failed",
					isTransient: true,
				},
			};
		}

		const replyText = laneResults
			.map(({ lane, result }) => {
				const reply = result.reply ?? "";
				return `## ${lane.targetAgent}\n${reply}`;
			})
			.join("\n\n");
		const hasCheckpointCall = collectedToolCalls.some(
			(tc) => tc.name === "checkpoint",
		);
		if (!hasCheckpointCall) {
			const verificationEvidence = laneResults.map(
				({ lane, result }) =>
					`swarm-agent:${lane.targetAgent}; replyMessageId:${result.replyMessageId ?? "unknown"}`,
			);
			collectedToolCalls.push({
				type: "tool_use",
				id: `swarm-fanout-cp-${Date.now()}`,
				name: "checkpoint",
				input: {
					outcome: "continue",
					unitType,
					unitId,
					summary: `Swarm fan-out completed ${laneResults.length} lanes.`,
					completedItems: fanoutPlan.lanes.map(
						(lane) => `${lane.targetAgent} completed ${lane.id}`,
					),
					remainingItems: [
						`Synthesize fan-out findings for ${unitType} ${unitId}.`,
					],
					verificationEvidence,
					pdd: {
						purpose:
							"Synthetic checkpoint injected because fan-out lanes returned text without a single parent checkpoint.",
						consumer:
							"phases-unit.js assessAutonomousSolverTurn + missing-checkpoint-repair loop",
						contract:
							"Outcome is 'continue' so the parent loop synthesizes independent lane findings.",
						failureBoundary:
							"If any lane fails, fan-out returns cancelled before synthesizing evidence.",
						evidence: verificationEvidence.join("; "),
						nonGoals:
							"Does not claim implementation completion from read-only fan-out lanes.",
						invariants:
							"Read-only fan-out lanes are used only for naturally parallel research or validation units.",
						assumptions:
							"Independent lane replies are useful input for the next autonomous step.",
					},
				},
			});
			try {
				appendAutonomousSolverCheckpoint(basePath, {
					unitType,
					unitId,
					outcome: "continue",
					summary: `Swarm fan-out completed ${laneResults.length} lanes.`,
					completedItems: fanoutPlan.lanes.map(
						(lane) => `${lane.targetAgent} completed ${lane.id}`,
					),
					remainingItems: [
						`Synthesize fan-out findings for ${unitType} ${unitId}.`,
					],
					verificationEvidence,
					pdd: {
						purpose:
							"Synthetic checkpoint from multi-agent fan-out when lanes returned without a parent checkpoint.",
						consumer: "phases-unit.js assessAutonomousSolverTurn",
						contract:
							"Falls back to 'continue' so the loop uses the fan-out evidence without pretending completion.",
						failureBoundary:
							"appendAutonomousSolverCheckpoint failure is swallowed — the loop repairs via missing-checkpoint handling.",
						evidence: verificationEvidence.join("; "),
						nonGoals: "Does not synthesize completion.",
						invariants:
							"Synthetic checkpoints are only written when no real checkpoint exists.",
						assumptions: "All fan-out lanes produced non-empty replies.",
					},
				});
			} catch (cpErr) {
				debugLog("runUnit[swarm]", {
					phase: "fanout-synthesized-checkpoint-error",
					unitType,
					unitId,
					error: getErrorMessage(cpErr),
				});
			}
		}

		try {
			emitJournalEvent(basePath, {
				ts: new Date().toISOString(),
				eventType: "swarm-dispatch",
				data: {
					unitType,
					unitId,
					targetAgent: "fanout",
					workMode: envelope.workMode,
					toolCallCount: collectedToolCalls.length,
					outcome: workerSignaledOutcome ?? "continue",
					via: "autonomous-unit",
					fanout: true,
					lanes: fanoutPlan.lanes.map((lane) => lane.targetAgent),
				},
			});
		} catch {
			/* journal write failure must not break dispatch */
		}

		const contentBlocks = [
			...collectedToolCalls,
			{ type: "text", text: replyText },
		];
		return {
			status: "completed",
			event: {
				messages: [
					{
						role: "assistant",
						content: contentBlocks,
						_swarm: true,
						replyMessageId: laneResults
							.map(({ result }) => result.replyMessageId)
							.filter(Boolean)
							.join(","),
						targetAgent: "fanout",
					},
				],
			},
			requestDispatchedAt,
			_via: "swarm",
			_swarmResult: {
				fanout: true,
				reason: fanoutPlan.reason,
				lanes: laneResults.map(({ lane, result }) => ({
					id: lane.id,
					targetAgent: lane.targetAgent,
					replyMessageId: result.replyMessageId,
				})),
				reply: replyText,
			},
			swarmToolCallCount:
				collectedToolCalls.length - (hasCheckpointCall ? 0 : 1),
		};
	}

	let swarmResult;
	try {
		swarmResult = await swarmDispatchAndWait(basePath, envelope, {
			timeoutMs,
			noOutputTimeoutMs,
			signal: controller.signal,
			onEvent,
		});
	} catch (err) {
		if (checkpointAbortReason) {
			return {
				status: "completed",
				event: {
					messages: [
						{
							role: "assistant",
							content: [
								...collectedToolCalls,
								{
									type: "text",
									text: `Swarm worker stopped: ${checkpointAbortReason}.`,
								},
							],
							_swarm: true,
							targetAgent: "unknown",
						},
					],
				},
				requestDispatchedAt,
				_via: "swarm",
				_swarmResult: {
					error: checkpointAbortReason,
					reply: null,
					replyMessageId: null,
				},
				swarmToolCallCount: collectedToolCalls.filter(
					(tc) => tc.name !== "checkpoint",
				).length,
			};
		}
		const msg = `swarmDispatchAndWait threw: ${getErrorMessage(err)}`;
		debugLog("runUnit[swarm]", {
			phase: "dispatch-error",
			unitType,
			unitId,
			error: msg,
		});
		if (typeof ctx.ui?.notify === "function") {
			ctx.ui.notify(`[${unitType}] ${unitId} → swarm error: ${msg}`, "error");
		}
		try {
			emitJournalEvent(basePath, {
				ts: new Date().toISOString(),
				eventType: "swarm-dispatch",
				data: {
					unitType,
					unitId,
					targetAgent: null,
					workMode: envelope.workMode,
					toolCallCount: collectedToolCalls.length,
					outcome: "error",
					via: "autonomous-unit",
					error: msg,
				},
			});
		} catch {
			/* journal write failure must not break dispatch */
		}
		return {
			status: "cancelled",
			requestDispatchedAt,
			errorContext: {
				message: msg,
				category: "session-failed",
				isTransient: true,
			},
		};
	}

	// Error from the swarm (no reply, runAgentTurn failure, etc.)
	if (swarmResult.error || swarmResult.reply === null) {
		if (checkpointAbortReason) {
			return {
				status: "completed",
				event: {
					messages: [
						{
							role: "assistant",
							content: [
								...collectedToolCalls,
								{
									type: "text",
									text: `Swarm worker stopped: ${checkpointAbortReason}.`,
								},
							],
							_swarm: true,
							replyMessageId: swarmResult.replyMessageId,
							targetAgent: swarmResult.targetAgent,
						},
					],
				},
				requestDispatchedAt,
				_via: "swarm",
				_swarmResult: swarmResult,
				swarmToolCallCount: collectedToolCalls.filter(
					(tc) => tc.name !== "checkpoint",
				).length,
			};
		}
		const reason = swarmResult.error ?? "swarm returned no reply";
		debugLog("runUnit[swarm]", {
			phase: "dispatch-no-reply",
			unitType,
			unitId,
			reason,
		});
		if (typeof ctx.ui?.notify === "function") {
			ctx.ui.notify(
				`[${unitType}] ${unitId} → swarm dispatch failed: ${reason}`,
				"error",
			);
		}
		try {
			emitJournalEvent(basePath, {
				ts: new Date().toISOString(),
				eventType: "swarm-dispatch",
				data: {
					unitType,
					unitId,
					targetAgent: swarmResult.targetAgent ?? null,
					workMode: envelope.workMode,
					toolCallCount: collectedToolCalls.length,
					outcome: "no-reply",
					via: "autonomous-unit",
					error: reason,
				},
			});
		} catch {
			/* journal write failure must not break dispatch */
		}
		return {
			status: "cancelled",
			requestDispatchedAt,
			errorContext: {
				message: reason,
				category: "session-failed",
				isTransient: true,
			},
		};
	}

	debugLog("runUnit[swarm]", {
		phase: "agent-end-received",
		unitType,
		unitId,
		status: "completed",
		targetAgent: swarmResult.targetAgent,
		replyMessageId: swarmResult.replyMessageId,
	});

	if (typeof ctx.ui?.notify === "function") {
		ctx.ui.notify(
			`[${unitType}] ${unitId} → swarm agent ${swarmResult.targetAgent} completed`,
			"info",
		);
	}
	try {
		emitJournalEvent(basePath, {
			ts: new Date().toISOString(),
			eventType: "swarm-dispatch",
			data: {
				unitType,
				unitId,
				targetAgent: swarmResult.targetAgent,
				workMode: envelope.workMode,
				toolCallCount: collectedToolCalls.length,
				outcome: workerSignaledOutcome ?? "continue",
				via: "autonomous-unit",
			},
		});
	} catch {
		/* journal write failure must not break dispatch */
	}

	// ── Derive outcome and build content blocks ──────────────────────────────
	// Use real events collected above. If the worker called the `checkpoint` tool
	// with a recognized outcome, use it directly. Otherwise fall back conservatively.
	const replyText = swarmResult.reply ?? "";
	const outcome = workerSignaledOutcome ?? "continue";
	const truncatedReply =
		replyText.length > 500 ? `${replyText.slice(0, 497)}...` : replyText;
	const summary =
		(workerSummary ?? truncatedReply) || "Swarm agent completed unit turn.";
	const completedItems =
		workerCompletedItems && workerCompletedItems.length > 0
			? workerCompletedItems
			: ["Swarm agent processed unit and replied."];
	const remainingItems =
		workerRemainingItems && workerRemainingItems.length > 0
			? workerRemainingItems
			: outcome === "complete"
				? []
				: [
						`Continue ${unitType} ${unitId}; swarm worker did not provide an actionable completion checkpoint.`,
					];
	const verificationEvidence =
		workerVerificationEvidence && workerVerificationEvidence.length > 0
			? workerVerificationEvidence
			: [
					`swarm-agent:${swarmResult.targetAgent}`,
					`replyMessageId:${swarmResult.replyMessageId ?? "unknown"}`,
				];

	debugLog("runUnit[swarm]", {
		phase: "outcome-derived",
		outcome,
		toolCallCount: collectedToolCalls.length,
		hasWorkerSummary: !!workerSummary,
	});

	// ── Inject a synthetic checkpoint tool call when the worker did not emit one ──
	// The autonomous loop's solver pass (phases-unit.js) looks for tool_use blocks
	// to classify the transcript. When the swarm worker produces only text (no
	// checkpoint call), the solver sees a no-op and loops. Injecting a synthetic
	// checkpoint with outcome="continue" gives the solver a valid signal so the
	// loop can advance to the next iteration rather than retrying the same unit.
	const hasCheckpointCall = collectedToolCalls.some(
		(tc) => tc.name === "checkpoint",
	);
	if (!hasCheckpointCall) {
		collectedToolCalls.push({
			type: "tool_use",
			id: `swarm-cp-${swarmResult.replyMessageId ?? Date.now()}`,
			name: "checkpoint",
			input: {
				outcome: "continue",
				unitType,
				unitId,
				summary:
					summary ||
					`Swarm agent ${swarmResult.targetAgent} completed without explicit checkpoint.`,
				completedItems: completedItems.slice(0, 5),
				remainingItems: remainingItems.slice(0, 5),
				verificationEvidence,
				pdd: {
					purpose:
						"Synthetic checkpoint injected because swarm worker did not call checkpoint tool.",
					consumer:
						"phases-unit.js assessAutonomousSolverTurn + missing-checkpoint-repair loop",
					contract:
						"Outcome is always 'continue' so the loop advances rather than stalling.",
					failureBoundary:
						"If the loop's solver disagrees, it will override via its own assessment.",
					evidence: `Worker produced ${replyText.length} chars and ${collectedToolCalls.length} tool calls but no checkpoint.`,
					nonGoals: "Never claims completion from a synthetic checkpoint.",
					invariants:
						"Synthetic checkpoints are only injected when no real checkpoint exists.",
					assumptions:
						"The worker made real progress but forgot or declined to call checkpoint.",
				},
			},
		});
	}

	let contentBlocks;
	if (collectedToolCalls.length > 0) {
		contentBlocks = [...collectedToolCalls, { type: "text", text: replyText }];
	} else {
		// This branch is now unreachable because we always inject at least the
		// synthetic checkpoint above, but keep it as a defensive fallback.
		contentBlocks = [
			{
				type: "tool_use",
				id: `swarm-${swarmResult.replyMessageId ?? "unknown"}`,
				name: "swarm_unit_complete",
				input: {
					outcome,
					summary,
					targetAgent: swarmResult.targetAgent,
				},
			},
			{ type: "text", text: replyText },
		];
	}

	if (!hasCheckpointCall) {
		try {
			appendAutonomousSolverCheckpoint(basePath, {
				unitType,
				unitId,
				outcome,
				summary: summary || "Swarm agent completed unit turn.",
				completedItems,
				remainingItems,
				verificationEvidence,
				pdd: {
					purpose:
						"Synthetic checkpoint from swarm agent reply when the worker did not call the checkpoint tool.",
					consumer: "phases-unit.js assessAutonomousSolverTurn",
					contract:
						"Falls back to 'continue' so the loop re-evaluates rather than incorrectly completing.",
					failureBoundary:
						"appendAutonomousSolverCheckpoint failure is swallowed — the loop will repair via its own missing-checkpoint retry path.",
					evidence: `swarm-agent ${swarmResult.targetAgent} replied with ${replyText.length} chars; workerSignaledOutcome=${workerSignaledOutcome ?? "null"}; collectedToolCalls=${collectedToolCalls.length}`,
					nonGoals: "Does not synthesize completion.",
					invariants:
						"Synthetic checkpoints are only written when the worker emitted no real checkpoint call.",
					assumptions:
						"The swarm agent processed the unit prompt and returned a non-empty reply.",
				},
			});
			debugLog("runUnit[swarm]", {
				phase: "synthesized-checkpoint",
				unitType,
				unitId,
				outcome,
			});
		} catch (cpErr) {
			// Fail-open: if checkpoint synthesis fails, the repair loop will handle it.
			debugLog("runUnit[swarm]", {
				phase: "synthesized-checkpoint-error",
				unitType,
				unitId,
				error: getErrorMessage(cpErr),
			});
		}
	} else {
		debugLog("runUnit[swarm]", {
			phase: "real-checkpoint-observed",
			unitType,
			unitId,
			outcome,
		});
	}

	// Map swarm reply back to the UnitResult shape. The reply text becomes the
	// last message in a synthetic event.messages array so callers that inspect
	// event.messages (e.g. for refusal classification) see content.
	const syntheticEvent = {
		messages: [
			{
				role: "assistant",
				content: contentBlocks,
				_swarm: true,
				replyMessageId: swarmResult.replyMessageId,
				targetAgent: swarmResult.targetAgent,
			},
		],
	};

	return {
		status: "completed",
		event: syntheticEvent,
		requestDispatchedAt,
		_via: "swarm",
		_swarmResult: swarmResult,
		// Surface real tool-call count from the subagent session so the zero-tool-call
		// guard in phases-unit.js can distinguish a genuine no-op from the expected
		// case where the parent-session ledger shows 0 (swarm subagents run in an
		// isolated session whose messages are never written to the parent session).
		// Count ONLY the tool calls that came from real worker events, not the
		// synthetic checkpoint we inject afterward.
		swarmToolCallCount: collectedToolCalls.length - (hasCheckpointCall ? 0 : 1),
	};
}

/**
 * Execute a single unit: create a new session, send the prompt, and await
 * the agent_end promise. Returns a UnitResult describing what happened.
 *
 * The promise is one-shot: resolveAgentEnd() is the only way to resolve it.
 * On session creation failure or timeout, returns { status: 'cancelled' }
 * without awaiting the promise.
 *
 * Options:
 *   keepSession — when true, skip newSession() and continue in the current
 *     conversation. Used for repair retries within the same unit so the agent
 *     sees what it tried before and why it failed, rather than starting cold.
 *     Default: false (each new unit starts with a clean session).
 */
export async function runUnit(ctx, pi, s, unitType, unitId, prompt, options) {
	// #M010/S05: Inline-scope dispatch is default-on for inline-eligible unit types.
	// SF_INLINE_DISPATCH=0 is the escape hatch for operators who need to force the
	// swarm/legacy path. validate-milestone, complete-milestone, and reassess-roadmap
	// are inline-eligible (R013). This was the S03 feature-flag path; S05 makes it
	// the default and adds SF_INLINE_DISPATCH=0 as the off-switch.
	if (isInlineEligible(unitType) && process.env.SF_INLINE_DISPATCH !== "0") {
		const inline = await tryInlineDispatch(
			ctx,
			s,
			unitType,
			unitId,
			prompt,
			options,
		);
		if (inline) return inline;
	}

	// Feature-flagged swarm path — default on in headless mode, opt-in elsewhere.
	if (shouldRouteRunUnitViaSwarm(options)) {
		return runUnitViaSwarm(ctx, pi, s, unitType, unitId, prompt, options);
	}

	const keepSession = options?.keepSession === true;
	// promptCacheSplit: {before, after} — stable prefix (to cache) + dynamic suffix.
	// When present, passes the content as a two-block array so providers can mark
	// the stable prefix with cache_control:ephemeral.
	const promptCacheSplit = options?.promptCacheSplit ?? null;
	debugLog("runUnit", { phase: "start", unitType, unitId, keepSession });
	// GAP-10: Ensure cwd matches basePath BEFORE newSession() captures it. The
	// new session reads process.cwd() during construction to anchor its tool
	// runtime and system prompt; if cwd has drifted (async_bash, background
	// jobs, prior unit cleanup), the session would otherwise be rooted to the
	// wrong directory. Must be synchronous — no awaits between chdir and
	// newSession (#1389, #4762 follow-up).
	try {
		if (s.basePath && process.cwd() !== s.basePath) {
			process.chdir(s.basePath);
		}
	} catch (e) {
		const msg = `Failed to chdir to basePath before newSession (basePath: ${s.basePath}): ${String(e)}`;
		logWarning("engine", msg, { basePath: s.basePath, error: String(e) });
		return {
			status: "cancelled",
			errorContext: {
				message: msg,
				category: "session-failed",
				isTransient: true,
			},
		};
	}
	// ── Session creation (skipped for same-unit repair retries) ──
	// keepSession=true: send the repair prompt into the existing conversation so
	// the agent has full context of what it already tried and why it failed.
	// keepSession=false (default): start a clean session for each new unit.
	if (!keepSession) {
		debugLog("runUnit", { phase: "session-create", unitType, unitId });
		// Guard: s.cmdCtx must have newSession() (ExtensionCommandContext). If it
		// doesn't, autonomous mode was started from a shortcut handler without a
		// valid command ctx — startAuto() should have caught this, but defend here
		// as a last resort so we get a clear error category instead of a TypeError.
		if (typeof s.cmdCtx?.newSession !== "function") {
			return {
				status: "cancelled",
				errorContext: {
					message:
						"cmdCtx.newSession is not available — autonomous mode was started from a non-command context. Run /autonomous once to establish a command context.",
					category: "session-failed",
					isTransient: false,
				},
			};
		}
		let sessionResult;
		let sessionTimeoutHandle;
		const mySessionSwitchGeneration = ++sessionSwitchGeneration;
		// GAP-07: Cancellation controller for newSession(). When the session-creation
		// timeout fires, we abort this controller so that any still-in-flight
		// newSession() work (which may clobber process.cwd()) is signalled to stop.
		// Note: SF's newSession() does not currently accept abortSignal in its
		// options type, so we cannot pass it directly — but aborting the controller
		// documents the intent clearly and is a no-op call site when the API adds it.
		const sessionAbortController = new AbortController();
		_setSessionSwitchInFlight(true);
		try {
			const sessionPromise = s.cmdCtx.newSession().finally(() => {
				if (sessionSwitchGeneration === mySessionSwitchGeneration) {
					_setSessionSwitchInFlight(false);
				}
			});
			const timeoutPromise = new Promise((resolve) => {
				sessionTimeoutHandle = setTimeout(() => {
					sessionAbortController.abort();
					resolve({ cancelled: true });
				}, NEW_SESSION_TIMEOUT_MS);
			});
			sessionResult = await Promise.race([sessionPromise, timeoutPromise]);
		} catch (sessionErr) {
			if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle);
			const msg = getErrorMessage(sessionErr);
			debugLog("runUnit", {
				phase: "session-error",
				unitType,
				unitId,
				error: msg,
			});
			return {
				status: "cancelled",
				errorContext: {
					message: `Session creation failed: ${msg}`,
					category: "session-failed",
					isTransient: true,
				},
			};
		}
		if (sessionTimeoutHandle) clearTimeout(sessionTimeoutHandle);
		if (sessionResult.cancelled) {
			debugLog("runUnit-session-timeout", { unitType, unitId });
			// On timeout, do NOT clear the in-flight guard here. The dangling
			// sessionPromise's .finally() has a generation check — it will clear the
			// guard when the underlying newSession promise eventually settles, but only
			// if no newer runUnit call has already incremented the generation. This is
			// the correct design: the guard stays true until the next session is ready,
			// preventing stale agent_end events from the timed-out session from being
			// processed by handleAgentEnd. The next runUnit call sets inFlight=true
			// again and its own .finally() manages the clearing.
			return {
				status: "cancelled",
				errorContext: {
					message: "Session creation timed out",
					category: "timeout",
					isTransient: true,
				},
			};
		}
	}
	if (!s.active) {
		return { status: "cancelled" };
	}
	// GAP-09: Hard-cancel if setModel fails rather than continuing with the
	// wrong model. Running with an unexpected model wastes the unit and can
	// cause quota / pricing surprises.
	if (s.currentUnitModel && typeof pi.setModel === "function") {
		const modelId = s.currentUnitModel;
		const restored = await pi.setModel(modelId, {
			persist: resolvePersistModelChanges(),
		});
		if (!restored) {
			return {
				status: "cancelled",
				errorContext: {
					message: `setModel failed for ${modelId.provider}/${modelId.id}`,
					category: "session-failed",
					isTransient: false,
				},
			};
		}
	}
	// Tell FallbackResolver what unit is running so any mid-unit model failure
	// is attributed to the correct unit type in the learning store.
	if (typeof pi.setFallbackUnitContext === "function") {
		pi.setFallbackUnitContext({ unitType, unitId });
	}
	// ── Create the agent_end promise (per-unit one-shot) ──
	// When keepSession=false: clear the in-flight guard now that the new session
	// is fully ready, so handleAgentEnd processes events for this unit only.
	// When keepSession=true: no session switch happened, guard is already clear.
	if (!keepSession) {
		_setSessionSwitchInFlight(false);
	}
	const unitPromise = new Promise((resolve) => {
		_setCurrentResolve(resolve);
	});
	// GAP-08: Provider request-readiness pre-check (#4555).
	// Verify the provider can accept requests before dispatching. If the token
	// has expired since bootstrap, return cancelled immediately so the unit is
	// not wasted on a guaranteed 401.
	{
		const provider = s.currentUnitModel?.provider ?? ctx.model?.provider;
		if (
			provider != null &&
			typeof ctx.modelRegistry?.isProviderRequestReady === "function"
		) {
			let ready = false;
			try {
				ready = ctx.modelRegistry.isProviderRequestReady(provider);
			} catch {
				ready = false;
			}
			if (!ready) {
				_clearCurrentResolve();
				return {
					status: "cancelled",
					errorContext: {
						message: `Provider ${provider} is not request-ready (login/token expired)`,
						category: "provider",
						isTransient: false,
					},
				};
			}
		}
	}
	// Refresh the runaway baseline after newSession(). Resumed sessions recover
	// old context during session creation; taking the baseline before that makes
	// historical tokens look like budget spent by this unit.
	resetRunawayGuardState(unitType, unitId, {
		sessionTokens: collectSessionTokenUsage(ctx),
		changedFiles: countChangedFiles(s.basePath),
		worktreeFingerprint: collectWorktreeFingerprint(s.basePath),
	});
	// ── Send the prompt ──
	debugLog("runUnit", { phase: "send-message", unitType, unitId });
	// Capture the turn generation BEFORE sendMessage so any stale-write
	// checks reached from within this turn see the same generation we start
	// with. bumpTurnGeneration() is called by timeout-recovery when this turn
	// is superseded; isStaleWrite() in journal.ts uses it to drop late writes.
	const capturedTurnGen = getCurrentTurnGeneration();
	const requestDispatchedAt = Date.now();
	let savedTools = null;
	if (
		typeof pi.getActiveTools === "function" &&
		typeof pi.setActiveTools === "function"
	) {
		const currentTools = pi.getActiveTools();
		const scopedTools = scopeActiveToolsForRunUnit(unitType, currentTools, {
			activeToolsAllowlist: options?.activeToolsAllowlist,
		});
		if (!sameStringList(scopedTools, currentTools)) {
			savedTools = currentTools;
			pi.setActiveTools(scopedTools);
			debugLog("unit-tool-scoping", {
				unitType,
				before: currentTools.length,
				after: scopedTools.length,
				removed: currentTools.length - scopedTools.length,
				explicitAllowlist: Array.isArray(options?.activeToolsAllowlist),
			});
		}
	}
	try {
		// When a cache split is available, send structured content so the provider
		// can apply cache_control:ephemeral to the stable prefix while leaving the
		// dynamic suffix uncached.
		const messageContent = buildUnitPromptMessageContent(
			prompt,
			promptCacheSplit,
		);
		// #sf-mp8d3hfc-0rccrj: pi.sendMessage was previously awaited bare. If it
		// threw, the error propagated but with no contextual marker about which
		// stage failed (model selection / RPC bridge / agent receive). If it
		// resolved but the RPC child never processed the message, the unit froze
		// at iteration 1 with no user message in the session JSONL and no
		// diagnostic trail. Wrap both sides for visibility.
		const sendStartedAt = Date.now();
		debugLog("runUnit", {
			phase: "send-message-start",
			unitType,
			unitId,
			messageContentLength:
				typeof messageContent === "string"
					? messageContent.length
					: JSON.stringify(messageContent).length,
		});
		try {
			await pi.sendMessage(
				{ customType: "sf-auto", content: messageContent, display: s.verbose },
				{ triggerTurn: true },
			);
		} catch (e) {
			debugLog("runUnit", {
				phase: "send-message-failed",
				unitType,
				unitId,
				error:
					e && typeof e === "object" && "message" in e ? e.message : String(e),
				stack:
					e && typeof e === "object" && "stack" in e
						? String(e.stack)
						: undefined,
				elapsedMs: Date.now() - sendStartedAt,
			});
			throw e;
		}
		debugLog("runUnit", {
			phase: "send-message-accepted",
			unitType,
			unitId,
			elapsedMs: Date.now() - sendStartedAt,
		});
	} finally {
		if (savedTools) {
			pi.setActiveTools(savedTools);
		}
	}
	// ── Await agent_end with absolute timeout (H4 fix) ──
	// If supervision fails to resolve unitPromise within 30s, treat as cancelled.
	// Without this, a crashed agent that never emits agent_end hangs the loop (#3161).
	debugLog("runUnit", { phase: "awaiting-agent-end", unitType, unitId });
	const supervisor = resolveAutoSupervisorConfig();
	const UNIT_HARD_TIMEOUT_MS = Math.max(
		30_000,
		(supervisor.hard_timeout_minutes ?? 30) * 60 * 1000 + 30_000,
	);
	let unitTimeoutHandle;
	const timeoutResult = new Promise((resolve) => {
		unitTimeoutHandle = setTimeout(() => {
			resolve({
				status: "cancelled",
				errorContext: {
					message: "Unit hard timeout — supervision may have failed",
					category: "timeout",
					isTransient: true,
				},
			});
		}, UNIT_HARD_TIMEOUT_MS);
	});
	const result = await runWithTurnGeneration(capturedTurnGen, () =>
		Promise.race([unitPromise, timeoutResult]),
	);
	if (unitTimeoutHandle) clearTimeout(unitTimeoutHandle);
	// Clear unit context — fallback after this point has no associated unit.
	if (typeof pi.setFallbackUnitContext === "function") {
		pi.setFallbackUnitContext(null);
	}
	debugLog("runUnit", {
		phase: "agent-end-received",
		unitType,
		unitId,
		status: result.status,
	});
	const finalResult = { ...result, requestDispatchedAt };
	// Discard trailing follow-up messages (e.g. async_job_result notifications)
	// from the completed unit. Without this, queued follow-ups trigger wasteful
	// LLM turns before the next session can start (#1642).
	// clearQueue() lives on AgentSession but isn't part of the typed
	// ExtensionCommandContext interface — call it via runtime check.
	try {
		const cmdCtxAny = s.cmdCtx;
		if (typeof cmdCtxAny?.clearQueue === "function") {
			cmdCtxAny.clearQueue();
		}
	} catch (e) {
		logWarning("engine", "clearQueue failed after unit completion", {
			error: String(e),
		});
	}
	return finalResult;
}
