/**
 * Unit supervision timers — soft timeout warning, idle watchdog,
 * hard timeout, and context-pressure monitor.
 *
 * Originally extracted from dispatchNextUnit() in auto.ts (now deleted — replaced by autoLoop).
 * via startUnitSupervision() and torn down by the caller via clearUnitTimeout().
 */
import { saveActivityLog } from "./activity-log.js";
import { resolveAgentEndCancelled } from "./auto/resolve.js";
import { detectWorkingTreeActivity } from "./auto-supervisor.js";
import { recoverTimedOutUnit } from "./auto-timeout-recovery.js";
import {
	clearInFlightTools,
	getInFlightToolCount,
	getOldestInFlightToolStart,
	getToolCallCountSnapshot,
	getTotalToolCallCount,
	hasInteractiveToolInFlight,
} from "./auto-tool-tracking.js";
import {
	computeBudgets,
	resolveExecutorContextWindow,
} from "./context-budget.js";
import { getErrorMessage } from "./error-utils.js";
import { resolveAutoSupervisorConfig } from "./preferences.js";
import { writeRunawayRecoveryArtifact } from "./runaway-recovery.js";
import { recordSelfFeedback } from "./self-feedback.js";
import { getMilestoneSlices, getSliceTasks, isDbAvailable } from "./sf-db.js";
import {
	collectSessionTokenUsage,
	collectWorktreeFingerprint,
	countChangedFiles,
	evaluateRunawayGuard,
	resolveRunawayGuardConfig,
} from "./uok/auto-runaway-guard.js";
import { closeoutUnit } from "./uok/auto-unit-closeout.js";
import {
	readUnitRuntimeRecord,
	writeUnitRuntimeRecord,
} from "./uok/unit-runtime.js";
import { logError, logWarning } from "./workflow-logger.js";
/**
 * Set up all four supervision timers for the current unit:
 * 1. Soft timeout warning (wrapup)
 * 2. Idle watchdog (progress polling, stuck tool detection)
 * 3. Hard timeout (pause + recovery)
 * 4. Context-pressure monitor (continue-here)
 */
/**
 * Parse a task estimate string (e.g. "30m", "2h", "1h30m") into minutes.
 * Returns null if the string cannot be parsed.
 */
export function parseEstimateMinutes(estimate) {
	if (!estimate || typeof estimate !== "string") return null;
	const trimmed = estimate.trim();
	if (!trimmed) return null;
	let totalMinutes = 0;
	let matched = false;
	// Match hours component
	const hoursMatch = trimmed.match(/(\d+)\s*h/i);
	if (hoursMatch) {
		totalMinutes += Number(hoursMatch[1]) * 60;
		matched = true;
	}
	// Match minutes component
	const minutesMatch = trimmed.match(/(\d+)\s*m/i);
	if (minutesMatch) {
		totalMinutes += Number(minutesMatch[1]);
		matched = true;
	}
	return matched ? totalMinutes : null;
}
export function startUnitSupervision(sctx) {
	const {
		s,
		ctx,
		pi,
		unitType,
		unitId,
		prefs,
		buildSnapshotOpts,
		buildRecoveryContext,
		pauseAuto,
	} = sctx;
	const supervisor = resolveAutoSupervisorConfig();
	// Scale timeouts based on task estimate annotations (#2243).
	// If the task has an est: annotation, use it to extend the hard and soft timeouts
	// so longer tasks don't get prematurely timed out.
	let taskEstimate = sctx.taskEstimate;
	if (!taskEstimate && unitType === "task" && isDbAvailable()) {
		// Look up the task estimate from the DB (#2243).
		try {
			if (s.currentMilestoneId) {
				const slices = getMilestoneSlices(s.currentMilestoneId);
				for (const slice of slices) {
					const tasks = getSliceTasks(s.currentMilestoneId, slice.id);
					const task = tasks.find((t) => t.id === unitId);
					if (task?.estimate) {
						taskEstimate = task.estimate;
						break;
					}
				}
			}
		} catch (err) {
			// Non-fatal — fall through with no estimate
			logWarning("timer", `operation failed: ${getErrorMessage(err)}`);
		}
	}
	const estimateMinutes = taskEstimate
		? parseEstimateMinutes(taskEstimate)
		: null;
	const MAX_TIMEOUT_SCALE = 6; // Cap at 6x (60min task). Prevents 2h+ tasks from creating 120min+ timeout windows.
	const timeoutScale =
		estimateMinutes && estimateMinutes > 0
			? Math.min(MAX_TIMEOUT_SCALE, Math.max(1, estimateMinutes / 10))
			: 1;
	const softTimeoutMs =
		(supervisor.soft_timeout_minutes ?? 0) * 60 * 1000 * timeoutScale;
	const idleTimeoutMs = (supervisor.idle_timeout_minutes ?? 0) * 60 * 1000; // idle not scaled — idle is idle
	const hardTimeoutMs =
		(supervisor.hard_timeout_minutes ?? 0) * 60 * 1000 * timeoutScale;
	const runawayConfig = resolveRunawayGuardConfig(supervisor);
	// ── 1. Soft timeout warning ──
	s.wrapupWarningHandle = setTimeout(() => {
		s.wrapupWarningHandle = null;
		if (!s.active || !s.currentUnit) return;
		writeUnitRuntimeRecord(
			s.basePath,
			unitType,
			unitId,
			s.currentUnit.startedAt,
			{
				phase: "wrapup-warning-sent",
				wrapupWarningSent: true,
			},
		);
		// Only trigger a new turn if no tools are currently in flight.
		// Triggering during active tool calls causes tool results to be skipped
		// with "Skipped due to queued user message", leading to provider errors (#3512).
		const softTrigger = getInFlightToolCount() === 0;
		pi.sendMessage(
			{
				customType: "sf-auto-wrapup",
				display: s.verbose,
				content: [
					"**TIME BUDGET WARNING — keep going only if progress is real.**",
					"This unit crossed the soft time budget.",
					"If you are making progress, continue. If not, switch to wrap-up mode now:",
					"1. rerun the minimal required verification",
					"2. write or update the required durable artifacts",
					"3. mark task or slice state on disk correctly",
					"4. leave precise resume notes if anything remains unfinished",
				].join("\n"),
			},
			{ triggerTurn: softTrigger },
		);
	}, softTimeoutMs);
	// ── 2. Idle watchdog (shared 15s tick — also runs context-pressure check) ──
	if (s.continueHereHandle) {
		clearInterval(s.continueHereHandle);
		s.continueHereHandle = null;
	}
	const executorContextWindow = resolveExecutorContextWindow(
		ctx.modelRegistry,
		prefs,
		ctx.model?.contextWindow,
	);
	const continueHereThreshold = computeBudgets(
		executorContextWindow,
	).continueThresholdPercent;
	s.idleWatchdogHandle = setInterval(async () => {
		try {
			if (!s.active || !s.currentUnit) return;
			// Read runtime record ONCE and share it between both checks.
			const runtime = readUnitRuntimeRecord(s.basePath, unitType, unitId);
			// ── 4. Context-pressure continue-here monitor
			// ── 2a. Context-pressure / continue-here check ──
			// Runs first so it fires even when the agent is still making progress.
			if (s.cmdCtx && runtime && !runtime.continueHereFired) {
				const contextUsage = s.cmdCtx.getContextUsage();
				if (
					contextUsage &&
					contextUsage.percent != null &&
					contextUsage.percent >= continueHereThreshold
				) {
					writeUnitRuntimeRecord(
						s.basePath,
						unitType,
						unitId,
						s.currentUnit.startedAt,
						{
							continueHereFired: true,
						},
					);
					if (s.verbose) {
						ctx.ui.notify(
							`Context at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%) — sending wrap-up signal.`,
							"info",
						);
					}
					// Only trigger a new turn if no tools are currently in flight (#3512).
					const contextTrigger = getInFlightToolCount() === 0;
					pi.sendMessage(
						{
							customType: "sf-auto-wrapup",
							display: s.verbose,
							content: [
								"**CONTEXT BUDGET WARNING — wrap up this unit now.**",
								`Context window is at ${contextUsage.percent}% (threshold: ${continueHereThreshold}%).`,
								"The next unit needs a fresh context to work effectively. Wrap up now:",
								"1. Finish any in-progress file writes",
								"2. Write or update the required durable artifacts (summary, checkboxes)",
								"3. Mark task state on disk correctly",
								"4. Leave precise resume notes if anything remains unfinished",
								"Do NOT start new sub-tasks or investigations.",
							].join("\n"),
						},
						{ triggerTurn: contextTrigger },
					);
				}
			}
			// ── 2b. Runaway guard ──
			// This catches active loops that keep using tools/tokens, so the idle
			// watchdog would otherwise treat them as healthy progress forever.
			if (runtime) {
				const decision = evaluateRunawayGuard(
					unitType,
					unitId,
					{
						toolCalls: getTotalToolCallCount(),
						sessionTokens: collectSessionTokenUsage(ctx),
						elapsedMs: Date.now() - s.currentUnit.startedAt,
						changedFiles: countChangedFiles(s.basePath),
						worktreeFingerprint: collectWorktreeFingerprint(s.basePath),
						topTools: getToolCallCountSnapshot(),
					},
					runawayConfig,
				);
				if (decision.action === "warn") {
					writeUnitRuntimeRecord(
						s.basePath,
						unitType,
						unitId,
						s.currentUnit.startedAt,
						{
							phase: decision.final
								? "runaway-final-warning-sent"
								: "runaway-warning-sent",
							lastProgressAt: Date.now(),
							lastProgressKind: decision.final
								? "runaway-final-warning"
								: "runaway-warning",
						},
					);
					if (s.verbose) {
						ctx.ui.notify(
							`Runaway guard ${decision.final ? "final warning" : "warning"} for ${unitType} ${unitId}.`,
							"warning",
						);
					}
					pi.sendMessage(
						{
							customType: "sf-auto-runaway-guard",
							display: s.verbose,
							content: decision.message,
						},
						{ triggerTurn: getInFlightToolCount() === 0 },
					);
					return;
				}
				if (decision.action === "fail") {
					if (getInFlightToolCount() > 0) return;
					await closeoutUnit(
						ctx,
						s.basePath,
						s.currentUnit.type,
						s.currentUnit.id,
						s.currentUnit.startedAt,
						buildSnapshotOpts(),
					);
					writeUnitRuntimeRecord(
						s.basePath,
						unitType,
						unitId,
						s.currentUnit.startedAt,
						{
							phase: "failed-silent-worker",
							status: "failed",
							lastProgressAt: Date.now(),
							lastProgressKind: "runaway-guard-fail",
							runawayGuardFail: decision.metadata,
						},
					);
					const unitParts = unitId.split("/");
					recordSelfFeedback(
						{
							kind: "runaway-loop:silent-worker-failure",
							severity: "high",
							summary: decision.reason,
							evidence: JSON.stringify(decision.metadata, null, 2),
							suggestedFix:
								"LLM session never produced an assistant message — check session-manager.ts:1086-1096 (silent _persist skip) and verify the model/provider is responding. The dispatcher will attempt retry within maxRetries; if persistent, transitions to blocked.",
							occurredIn: {
								unitType,
								milestone: unitParts[0],
								slice: unitParts[1],
								task: unitParts.slice(2).join("/") || undefined,
							},
							source: "detector",
						},
						s.basePath,
					);
					ctx.ui.notify(decision.reason, "error");
					return;
				}
				if (decision.action === "pause") {
					if (getInFlightToolCount() > 0) return;
					await closeoutUnit(
						ctx,
						s.basePath,
						s.currentUnit.type,
						s.currentUnit.id,
						s.currentUnit.startedAt,
						buildSnapshotOpts(),
					);
					writeUnitRuntimeRecord(
						s.basePath,
						unitType,
						unitId,
						s.currentUnit.startedAt,
						{
							phase: "paused",
							lastProgressAt: Date.now(),
							lastProgressKind: "runaway-guard",
							runawayGuardPause: decision.metadata,
						},
					);
					const recoveryArtifact = writeRunawayRecoveryArtifact(
						s.basePath,
						decision.metadata,
					);
					const unitParts = unitId.split("/");
					recordSelfFeedback(
						{
							kind: "runaway-guard-hard-pause",
							severity: "medium",
							summary: decision.reason,
							evidence: JSON.stringify(decision.metadata, null, 2),
							suggestedFix: recoveryArtifact
								? `Resume from ${recoveryArtifact.markdownPath}; use its dirty-file list and resume prompt to split or finish the smallest verifiable unit.`
								: "Review the paused unit's warning responses and runtime metrics to distinguish legitimate scope from loop/churn.",
							occurredIn: {
								unitType,
								milestone: unitParts[0],
								slice: unitParts[1],
								task: unitParts.slice(2).join("/") || undefined,
							},
							source: "detector",
						},
						s.basePath,
					);
					ctx.ui.notify(decision.reason, "warning");
					await pauseAuto(ctx, pi);
					return;
				}
			}
			// ── 2c. Idle watchdog check ──
			if (!runtime) return;
			if (Date.now() - runtime.lastProgressAt < idleTimeoutMs) return;
			// Agent has tool calls currently executing — not idle, just waiting.
			// But only suppress recovery if the tool started recently.
			let stalledToolDetected = false;
			if (getInFlightToolCount() > 0) {
				// User-interactive tools (ask_user_questions, secure_env_collect) block
				// waiting for human input by design — never treat them as stalled (#2676).
				if (hasInteractiveToolInFlight()) {
					writeUnitRuntimeRecord(
						s.basePath,
						unitType,
						unitId,
						s.currentUnit.startedAt,
						{
							lastProgressAt: Date.now(),
							lastProgressKind: "interactive-tool-waiting",
						},
					);
					return;
				}
				const oldestStart = getOldestInFlightToolStart();
				const toolAgeMs = Date.now() - oldestStart;
				if (toolAgeMs < idleTimeoutMs) {
					writeUnitRuntimeRecord(
						s.basePath,
						unitType,
						unitId,
						s.currentUnit.startedAt,
						{
							lastProgressAt: Date.now(),
							lastProgressKind: "tool-in-flight",
						},
					);
					return;
				}
				// Tool has been in-flight longer than idle timeout — treat as hung.
				// Clear the stale entries so subsequent ticks don't re-detect them,
				// and set the flag so the filesystem-activity check below does not
				// override the stall verdict (#2527).
				stalledToolDetected = true;
				clearInFlightTools();
				ctx.ui.notify(
					`Stalled tool detected: a tool has been in-flight for ${Math.round(toolAgeMs / 60000)}min. Treating as hung — attempting idle recovery.`,
					"warning",
				);
			}
			// Check if the agent is producing work on disk.
			// Skip this when a stalled tool was just detected — filesystem changes
			// from earlier in the task should not override the stall verdict (#2527).
			if (!stalledToolDetected && detectWorkingTreeActivity(s.basePath)) {
				writeUnitRuntimeRecord(
					s.basePath,
					unitType,
					unitId,
					s.currentUnit.startedAt,
					{
						lastProgressAt: Date.now(),
						lastProgressKind: "filesystem-activity",
					},
				);
				return;
			}
			if (s.currentUnit) {
				await closeoutUnit(
					ctx,
					s.basePath,
					s.currentUnit.type,
					s.currentUnit.id,
					s.currentUnit.startedAt,
					buildSnapshotOpts(),
				);
			} else {
				saveActivityLog(ctx, s.basePath, unitType, unitId);
			}
			const recovery = await recoverTimedOutUnit(
				ctx,
				pi,
				unitType,
				unitId,
				"idle",
				buildRecoveryContext(),
			);
			if (recovery === "recovered") return;
			// Guard: recoverTimedOutUnit is async — pauseAuto/stopAuto may have
			// set s.currentUnit = null during the await (#2527).
			if (!s.currentUnit) return;
			writeUnitRuntimeRecord(
				s.basePath,
				unitType,
				unitId,
				s.currentUnit.startedAt,
				{
					phase: "paused",
				},
			);
			ctx.ui.notify(
				`Unit ${unitType} ${unitId} made no meaningful progress for ${supervisor.idle_timeout_minutes}min. Pausing autonomous mode.`,
				"warning",
			);
			await pauseAuto(ctx, pi);
		} catch (err) {
			const message = getErrorMessage(err);
			logError("timer", `[idle-watchdog] Unhandled error: ${message}`);
			// Unblock any pending unit promise so the auto-loop is not orphaned.
			resolveAgentEndCancelled({
				message: `Idle watchdog error: ${message}`,
				category: "idle",
				isTransient: true,
			});
			try {
				ctx.ui.notify(`Idle watchdog error: ${message}`, "warning");
			} catch (err) {
				/* best effort */
				logWarning("timer", `notification failed: ${getErrorMessage(err)}`);
			}
		}
	}, 15000);
	// ── 3. Hard timeout ──
	s.unitTimeoutHandle = setTimeout(async () => {
		try {
			s.unitTimeoutHandle = null;
			if (!s.active) return;
			if (s.currentUnit) {
				writeUnitRuntimeRecord(
					s.basePath,
					unitType,
					unitId,
					s.currentUnit.startedAt,
					{
						phase: "timeout",
						timeoutAt: Date.now(),
					},
				);
				await closeoutUnit(
					ctx,
					s.basePath,
					s.currentUnit.type,
					s.currentUnit.id,
					s.currentUnit.startedAt,
					buildSnapshotOpts(),
				);
			} else {
				saveActivityLog(ctx, s.basePath, unitType, unitId);
			}
			const recovery = await recoverTimedOutUnit(
				ctx,
				pi,
				unitType,
				unitId,
				"hard",
				buildRecoveryContext(),
			);
			if (recovery === "recovered") return;
			ctx.ui.notify(
				`Unit ${unitType} ${unitId} exceeded ${supervisor.hard_timeout_minutes}min hard timeout. Pausing autonomous mode.`,
				"warning",
			);
			await pauseAuto(ctx, pi);
		} catch (err) {
			const message = getErrorMessage(err);
			logError("timer", `[hard-timeout] Unhandled error: ${message}`);
			// Unblock any pending unit promise so the auto-loop is not orphaned.
			resolveAgentEndCancelled({
				message: `Hard timeout error: ${message}`,
				category: "timeout",
				isTransient: true,
			});
			try {
				ctx.ui.notify(`Hard timeout error: ${message}`, "warning");
			} catch (err) {
				/* best effort */
				logWarning("timer", `notification failed: ${getErrorMessage(err)}`);
			}
		}
	}, hardTimeoutMs);
}
