/**
 * WakeCycleRunner — encapsulates the inner session-switch loop, Claude
 * stream processing, error classification / retry logic, transcript
 * sanitization, and post-cycle transcript memory indexing.
 *
 * Extracted from loop.ts so the main loop is a thin orchestrator.
 */
import { processAgentStream, SdkTerminalError, StreamSilenceTimeoutError } from "./stream-handler.js";
import { sanitizeTranscript } from "./image-pruner.js";
import { indexWakeCycle, countFileLines } from "./transcript/transcript-indexer.js";
import { OnboardingServiceClient } from "../onboarding/onboarding-service-client.js";
import { getClaudeTranscriptDirForProfileDir } from "../utils/claude-transcripts.js";
import { getConfigDir } from "../config/index.js";
import { notifyOwner } from "../obs/notify.js";
import { logger } from "../logger.js";
import { t, resolveLocale } from "../i18n/index.js";
import { scheduleRestart } from "../utils/restart.js";
import { runGracefulShutdown } from "./state.js";
import path from "path";
import { randomUUID } from "node:crypto";
const AUTH_PATTERNS = /\b(401|402|403|authentication|billing|unauthorized|forbidden|api.?key)\b/i;
// Fatal environment/installation problems that won't fix themselves on retry.
// In particular: the Claude Agent SDK immediately exits with code 1 when its
// bundled CLI / native binary is missing (see claude/cli-resolver.ts). Looping
// on that only produces EPIPE storms, so classify it as fatal and let the
// operator reinstall or pin the SDK version. Note: we intentionally do NOT
// match a bare "process exited with code 1" — many transient SDK errors also
// exit with code 1 and should remain retryable.
const FATAL_ENV_PATTERNS = /Cannot find module.*claude-agent-sdk|claude-agent-sdk.*cli\.js|ENOENT.*claude-agent-sdk|claude-agent-sdk.*ENOENT/i;
const RETRYABLE_PATTERNS = /\b(ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|ECONNRESET|UND_ERR_SOCKET|UND_ERR_CONNECT_TIMEOUT|fetch failed|socket hang up|429|529|rate.?limit|overloaded|throttl|timeout|50[0234]|service.?unavailable|bad.?gateway|internal.?server)\b/i;
function resolveIndexableTranscript(transcriptInfo, fallbackPath) {
    if (transcriptInfo.kind === "none")
        return null;
    const path = transcriptInfo.sessionFilePath ?? fallbackPath;
    if (!path)
        return null;
    return { path, format: transcriptInfo.kind };
}
const PROVIDER_QUOTA_PATTERNS = /\b(too.?many.?tokens.?per.?day|daily.?token|daily.?quota|quota.?exceeded|insufficient.?quota)\b/i;
// NOTE: do not wrap this whole alternation in `\b...\b` — the `.{0,80}` runs in
// some branches make the trailing `\b` unreliable (regex anchors `exceeds?` at
// a non-word-boundary position). Use targeted boundaries inside each branch.
const PROMPT_TOO_LONG_PATTERNS = new RegExp([
    "\\bprompt.?is.?too.?long\\b",
    "\\btoo.?many.?tokens\\b",
    "\\bcontext.?length.?exceeded\\b",
    "\\bmax.?tokens.?exceeded\\b",
    "\\binput.{0,5}too.?long\\b",
    // Pi / OpenAI style: "Your input exceeds the context window of this model"
    "input.{0,80}exceeds?.{0,80}context.?window",
    // OpenAI/Anthropic style: "context window exceeded" / "context window has been exceeded ..."
    "context.?window.{0,80}exceeded",
].join("|"), "i");
const CORRUPT_TRANSCRIPT_PATTERNS = /text content blocks must be non-empty|thinking.*blocks.*cannot be modified|redacted_thinking.*blocks.*cannot be modified|unexpected.*tool_use_id|not valid JSON|no low surrogate|surrogate/i;
const TOO_MANY_IMAGES_PATTERN = /too many images|dimension limit.*many.image|many.image.*dimension limit/i;
const INVALID_JSON_BODY_PATTERN = /\b400\s+Invalid JSON body\b|\bInvalid JSON body\b/i;
// SDK 0.3.144+ surfaces an explicit `model_not_found` code (previously folded
// into a generic invalid_request / 400). It means the current provider can't
// serve the requested model, so switching provider is the actual fix.
const MODEL_NOT_FOUND_PATTERN = /\bmodel_not_found\b|\bmodel not found\b/i;
const PROMPT_TOO_LONG_REASONS = new Set([
    "prompt_too_long",
    "rapid_refill_breaker",
]);
const TOO_MANY_IMAGES_REASONS = new Set(["image_error"]);
/**
 * Maximum number of synthetic "keep waiting for subagents" continuations we
 * will inject into a single wake cycle when the model ends its turn while
 * async subagents are still running. Bounded so a stuck subagent can't keep
 * the cycle alive forever.
 */
const MAX_SUBAGENT_CONTINUATIONS = 3;
/**
 * How many seconds we ask the model to wait between `mcp__visionclaw__wait`
 * calls while it's holding open the turn for async subagents. Short enough
 * that newly-completed task_notifications are acknowledged promptly.
 */
const SUBAGENT_CONTINUATION_WAIT_SECONDS = 20;
function getTerminalReason(err) {
    return err instanceof SdkTerminalError ? err.terminalReason : undefined;
}
function classifyError(err) {
    const msg = err instanceof Error ? err.message : String(err);
    const status = err.status ??
        err.statusCode;
    const terminalReason = getTerminalReason(err);
    if (terminalReason === "aborted_streaming")
        return "retryable";
    if (terminalReason === "blocking_limit")
        return "fatal";
    if (terminalReason === "model_error")
        return "retryable";
    // model_not_found → next provider. Must precede the status===400 branch
    // below, since the API may still tag this error with a 400 status that would
    // otherwise classify it as retryable and waste the retry budget.
    if (MODEL_NOT_FOUND_PATTERN.test(msg))
        return "provider_failed";
    // Auth/quota errors → provider_failed (try next provider)
    if (status === 401 || status === 402 || status === 403)
        return "provider_failed";
    if (PROVIDER_QUOTA_PATTERNS.test(msg))
        return "provider_failed";
    if (status === 400 ||
        status === 429 ||
        status === 529 ||
        (status !== undefined && status >= 500))
        return "retryable";
    if (FATAL_ENV_PATTERNS.test(msg))
        return "fatal";
    if (AUTH_PATTERNS.test(msg))
        return "provider_failed";
    if (RETRYABLE_PATTERNS.test(msg))
        return "retryable";
    return "retryable";
}
function isPromptTooLong(err) {
    const terminalReason = getTerminalReason(err);
    if (terminalReason && PROMPT_TOO_LONG_REASONS.has(terminalReason))
        return true;
    const msg = err instanceof Error ? err.message : String(err);
    return PROMPT_TOO_LONG_PATTERNS.test(msg);
}
function isCorruptTranscript(err) {
    const msg = err instanceof Error ? err.message : String(err);
    return CORRUPT_TRANSCRIPT_PATTERNS.test(msg);
}
function isTooManyImages(err) {
    const terminalReason = getTerminalReason(err);
    if (terminalReason && TOO_MANY_IMAGES_REASONS.has(terminalReason))
        return true;
    const msg = err instanceof Error ? err.message : String(err);
    return TOO_MANY_IMAGES_PATTERN.test(msg);
}
function isInvalidJsonBody(err) {
    const msg = err instanceof Error ? err.message : String(err);
    return INVALID_JSON_BODY_PATTERN.test(msg);
}
function deriveTranscriptPath(sessionId) {
    const dir = getClaudeTranscriptDirForProfileDir(getConfigDir());
    return path.join(dir, `${sessionId}.jsonl`);
}
function generateWakeCycleId() {
    return `wc_${randomUUID().replaceAll("-", "")}`;
}
const MAX_RETRIES_BEFORE_NOTIFY = 5;
const BASE_DELAY_MS = 5_000;
const MAX_DELAY_MS = 5 * 60_000;
const FATAL_SLEEP_MS = 5 * 60_000;
/** Max times to retry the LLM call on stream silence timeout before aborting. */
const MAX_SILENCE_RETRIES = 3;
/** Escalating silence timeouts (ms) per attempt: 5min, 10min, 15min. */
const SILENCE_TIMEOUT_SCHEDULE_MS = [300_000, 600_000, 900_000];
/** Decreasing effort levels per attempt to reduce thinking overhead on retries. */
const EFFORT_SCHEDULE = {
    coding: ["high", "medium", "low"],
    general: ["medium", "low", "low"],
};
export class WakeCycleRunner {
    deps;
    invalidJsonBodyRecoveryCount = 0;
    constructor(deps) {
        this.deps = deps;
    }
    async recordHealthEvent(event) {
        const { config } = this.deps;
        if (!config.onboardingServiceUrl || !config.visionclawServiceApiKey) {
            logger.warn(`Unable to record health event ${event.eventType}: onboarding service is not configured`);
            return;
        }
        const client = new OnboardingServiceClient(config.onboardingServiceUrl, config.agentName, config.visionclawServiceApiKey);
        const response = await client.heartbeat({
            busy: true,
            provider: config.provider,
            model: config.model,
            healthEvents: [event],
        });
        if (!response) {
            logger.warn(`Unable to record health event ${event.eventType}: heartbeat returned no response`);
        }
    }
    /**
     * Execute one full wake cycle: the session-switch inner loop, stream
     * processing, error recovery, and post-cycle transcript indexing.
     *
     * @param contentBlocks  The multimodal user-message blocks for this cycle.
     * @param trigger        Whether this wake was from a message or heartbeat.
     * @param messages       Inbound messages (empty for heartbeat).
     * @param calendarSummary Calendar summary string for session-switch context.
     * @param consecutiveErrors Current error counter from previous cycles.
     * @returns Updated consecutive error count.
     */
    async run(contentBlocks, trigger, messages, calendarSummary, consecutiveErrors) {
        const { config, ownerConfig, session, activityTracker, interruptHandler, messageQueue } = this.deps;
        // --- Capture transcript start lines for post-cycle indexing ---
        // A wake cycle can switch sessions, so anchor by transcript path instead
        // of using a single start line captured from whichever session was active
        // at cycle entry.
        const wakeCycleId = generateWakeCycleId();
        const wakeCycleTimestampStart = new Date().toISOString();
        const transcriptAnchors = new Map();
        const ensureTranscriptAnchor = async (activeTranscript) => {
            if (!activeTranscript || transcriptAnchors.has(activeTranscript.path))
                return;
            transcriptAnchors.set(activeTranscript.path, await countFileLines(activeTranscript.path));
        };
        await ensureTranscriptAnchor(resolveIndexableTranscript(session.activeSession.getTranscriptInfo(), session.activeSession.getTranscriptPath()));
        // Session switch loop: if the agent calls switch_session, we close the
        // current stream, switch sessions, and re-enter with a new stream.
        let didSwitch = false;
        let switchForwardMessage = "";
        let pendingMemoAck = true;
        let silenceCompactionDone = false;
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite switch loop
        wakeLoop: while (true) {
            const activeSession = session.activeSession;
            try {
                await activeSession.warmUp();
                await ensureTranscriptAnchor(resolveIndexableTranscript(activeSession.getTranscriptInfo(), activeSession.getTranscriptPath()));
                let messageContent;
                if (didSwitch) {
                    const switchMemos = session.consumeMemosForActive();
                    const switchParts = [];
                    switchParts.push(`[Session Switch] time=${new Date().toLocaleString()} active_session=${session.activeMode}`);
                    if (switchMemos) {
                        switchParts.push("");
                        switchParts.push(switchMemos);
                    }
                    const codingStatus = session.getCodingSessionStatus();
                    if (codingStatus && session.activeMode === "general") {
                        switchParts.push("");
                        switchParts.push(`## Active Coding Task`);
                        switchParts.push(codingStatus);
                    }
                    if (calendarSummary) {
                        switchParts.push("");
                        switchParts.push("## Upcoming Calendar Events");
                        switchParts.push(calendarSummary);
                    }
                    if (switchForwardMessage) {
                        switchParts.push("");
                        switchParts.push(switchForwardMessage);
                    }
                    messageContent = [
                        { type: "text", text: switchParts.join("\n") },
                    ];
                    didSwitch = false;
                    switchForwardMessage = "";
                    pendingMemoAck = true;
                }
                else {
                    messageContent =
                        contentBlocks;
                }
                // Proactively sanitize before sending — prevents a wasted API call
                // when the transcript already has known corruption.
                const transcriptInfo = activeSession.getTranscriptInfo();
                const tp = transcriptInfo.kind === "claude"
                    ? transcriptInfo.sessionFilePath ?? activeSession.getTranscriptPath()
                    : null;
                if (tp) {
                    try {
                        await sanitizeTranscript(tp);
                    }
                    catch {
                        // best-effort; reactive repair in the catch block below will retry
                    }
                }
                let streamResult = {
                    toolTimeout: null,
                    finishCalled: false,
                };
                let subagentContinuations = 0;
                // turnLoop: each iteration drains one streamed turn (including all
                // silence-timeout retries for that attempt). We may iterate a bounded
                // number of extra times to inject a "keep waiting for subagents"
                // continuation when the model accidentally ended its turn while async
                // Agent subagents were still running.
                // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional: we break out via labeled break below
                turnLoop: while (true) {
                    for (let silenceAttempt = 1; silenceAttempt <= MAX_SILENCE_RETRIES; silenceAttempt++) {
                        const silenceTimeoutMs = SILENCE_TIMEOUT_SCHEDULE_MS[silenceAttempt - 1] ?? SILENCE_TIMEOUT_SCHEDULE_MS[SILENCE_TIMEOUT_SCHEDULE_MS.length - 1];
                        const effortSchedule = EFFORT_SCHEDULE[session.activeMode];
                        const effort = effortSchedule[silenceAttempt - 1];
                        const queryStream = activeSession.sendAndStream(messageContent, { effort });
                        if (pendingMemoAck) {
                            session.ackMemosForActive();
                            pendingMemoAck = false;
                        }
                        try {
                            streamResult = await processAgentStream(queryStream, activeSession, activityTracker, session.dualSessionEnabled ? session : undefined, config, silenceTimeoutMs);
                            break; // success — exit retry loop
                        }
                        catch (streamErr) {
                            if (!(streamErr instanceof StreamSilenceTimeoutError)) {
                                throw streamErr; // not a silence timeout — propagate to outer handler
                            }
                            if (silenceAttempt < MAX_SILENCE_RETRIES) {
                                const nextTimeoutMs = SILENCE_TIMEOUT_SCHEDULE_MS[silenceAttempt] ?? SILENCE_TIMEOUT_SCHEDULE_MS[SILENCE_TIMEOUT_SCHEDULE_MS.length - 1];
                                const nextEffort = effortSchedule[silenceAttempt];
                                logger.system(`Stream silence timeout after ${silenceTimeoutMs / 1000}s (attempt ${silenceAttempt}/${MAX_SILENCE_RETRIES}) — retrying LLM call with ${nextTimeoutMs / 1000}s timeout and effort=${nextEffort}...`);
                                // Re-send the same messageContent on next iteration
                                continue;
                            }
                            // All retries exhausted.
                            logger.err(`Stream silence timeout: all ${MAX_SILENCE_RETRIES} attempts exhausted — aborting`);
                            const locale = resolveLocale(ownerConfig.language);
                            void notifyOwner(t("llm.retry_exhausted", locale, String(MAX_SILENCE_RETRIES), SILENCE_TIMEOUT_SCHEDULE_MS.map((ms) => `${ms / 1000}s`).join("→")), config).catch((err) => {
                                logger.warn(`Failed to send LLM retry notification: ${err instanceof Error ? err.message : String(err)}`);
                            });
                            // Try provider fallback before compaction
                            const { providerFallback } = this.deps;
                            if (providerFallback.hasPriorityList) {
                                const previousProvider = config.provider;
                                const switched = providerFallback.disableAndSwitch("silence_exhausted");
                                if (switched) {
                                    logger.system(`Silence exhaustion triggered provider fallback: ${previousProvider} → ${config.provider}`);
                                    consecutiveErrors = 0;
                                    continue wakeLoop;
                                }
                                // All providers exhausted from silence — sleep and reset
                                void notifyOwner(t("llm.providers_exhausted", resolveLocale(ownerConfig.language)), config).catch((_e) => { });
                                await providerFallback.sleepAndReset();
                                consecutiveErrors = 0;
                                continue wakeLoop;
                            }
                            if (!silenceCompactionDone && activeSession.getSessionId()) {
                                silenceCompactionDone = true;
                                logger.system("Stream silence retries exhausted — forcing compaction before retry");
                                try {
                                    await activeSession.requestCompaction();
                                    consecutiveErrors = 0;
                                    continue wakeLoop;
                                }
                                catch (compactErr) {
                                    logger.warn(`Compaction after repeated silence timeouts failed: ${compactErr instanceof Error ? compactErr.message : String(compactErr)}`);
                                }
                            }
                            messageQueue.enqueue({
                                id: `silence_interrupted_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
                                channel: "system",
                                sender: "system",
                                text: `Your previous wake cycle was interrupted due to ${MAX_SILENCE_RETRIES} consecutive LLM stream silence timeouts (${SILENCE_TIMEOUT_SCHEDULE_MS.map((ms) => `${ms / 1000}s`).join("→")}). Check your task status and continue where you left off.`,
                                attachments: [],
                                timestamp: new Date().toISOString(),
                            });
                            logger.system("Enqueued interrupted-task recovery message for next wake cycle");
                            throw streamErr; // propagate so the outer handler records the error
                        }
                    }
                    // --- Accidental end-of-turn with running subagents ---
                    // The Claude Agent SDK's async `Agent` tool returns a launch ack
                    // immediately; the real result arrives later as a `task_notification`.
                    // If the model produces some text and stops while any subagent is
                    // still running, the turn's [result] closes the SDK stream and any
                    // later task_notifications are dropped. Re-send a short synthetic
                    // prompt telling the model to keep waiting — bounded so a truly
                    // stuck subagent can't loop the cycle forever.
                    //
                    // Hang guards layered here (in addition to the cap below):
                    //   - Skip when a tool timeout fired (watchdog already killed something).
                    //   - Skip when the agent explicitly called `finish`.
                    //   - Skip when the outer loop has been asked to stop (SIGINT,
                    //     /stop, etc.) so Ctrl-C isn't delayed by another round-trip.
                    //   - Each continuation turn is itself bounded by the stream-silence
                    //     timeout + retries and by ToolCallWatchdog on any inner tool
                    //     call (including `wait`).
                    if (!streamResult.toolTimeout &&
                        !streamResult.finishCalled &&
                        !session.isStopRequested() &&
                        subagentContinuations < MAX_SUBAGENT_CONTINUATIONS) {
                        const runningSubagents = activityTracker.getActiveSubagents();
                        if (runningSubagents.length > 0) {
                            subagentContinuations++;
                            const names = runningSubagents
                                .map((s) => s.description ?? s.subagentType ?? s.toolUseId)
                                .join(", ");
                            logger.system(`Turn ended with ${runningSubagents.length} subagent(s) still running — injecting continuation #${subagentContinuations}/${MAX_SUBAGENT_CONTINUATIONS} (${names})`);
                            const continuationText = [
                                `You ended your turn while ${runningSubagents.length} asynchronous Agent subagent${runningSubagents.length === 1 ? " is" : "s are"} still running: ${names}.`,
                                "",
                                "These subagents have not completed yet. Do NOT end your turn again until every one of them has completed.",
                                "",
                                `Hold the turn open by calling \`mcp__visionclaw__wait(seconds=${SUBAGENT_CONTINUATION_WAIT_SECONDS})\` in a loop. Between waits, acknowledge any newly completed subagent in one short sentence. Only once every subagent has completed should you synthesize the final report and notify the user.`,
                                "",
                                `This is continuation ${subagentContinuations} of ${MAX_SUBAGENT_CONTINUATIONS}. If any subagent is still running after the last continuation, report the partial results to the user instead of waiting indefinitely.`,
                            ].join("\n");
                            messageContent = [
                                { type: "text", text: continuationText },
                            ];
                            continue turnLoop;
                        }
                    }
                    break turnLoop;
                } // end turnLoop
                // --- Programmatic fallback when continuation cap was exhausted ---
                // If we burned through all continuations and subagents are *still*
                // running, the model never compiled the final report. Send the user
                // a direct notification so they're not left hanging — this doesn't
                // depend on model compliance.
                if (subagentContinuations >= MAX_SUBAGENT_CONTINUATIONS) {
                    const stillRunning = activityTracker.getActiveSubagents();
                    if (stillRunning.length > 0) {
                        const completed = activityTracker
                            .getSubagents()
                            .filter((s) => s.status === "completed" || s.status === "failed");
                        const lines = [];
                        lines.push(`⚠️ I launched subagents for your request but couldn't compile the final report after ${MAX_SUBAGENT_CONTINUATIONS} attempts.`);
                        if (completed.length > 0) {
                            lines.push("");
                            lines.push(`Completed (${completed.length}):`);
                            for (const s of completed) {
                                const label = s.description ?? s.subagentType ?? s.toolUseId;
                                const icon = s.status === "completed" ? "✅" : "❌";
                                lines.push(`  ${icon} ${label}`);
                            }
                        }
                        lines.push("");
                        lines.push(`Still running (${stillRunning.length}):`);
                        for (const s of stillRunning) {
                            const label = s.description ?? s.subagentType ?? s.toolUseId;
                            lines.push(`  ⏳ ${label}`);
                        }
                        lines.push("");
                        lines.push("The results may still arrive. You can ask me to check and compile them later.");
                        logger.warn(`Subagent continuation cap reached with ${stillRunning.length} subagent(s) still running — sending fallback notification`);
                        void notifyOwner(lines.join("\n"), config).catch((err) => {
                            logger.warn(`Failed to send subagent fallback notification: ${err instanceof Error ? err.message : String(err)}`);
                        });
                    }
                }
                const toolTimeout = streamResult.toolTimeout;
                if (toolTimeout) {
                    const elapsedSec = Math.round(toolTimeout.elapsedMs / 1000);
                    const timeoutMsgParts = [];
                    if (toolTimeout.smartAlarm) {
                        timeoutMsgParts.push(`Your previous tool call was aborted by the watchdog after ${elapsedSec}s:`, `  Tool: ${toolTimeout.toolName}`, `  Input: ${toolTimeout.toolInput ? (toolTimeout.toolInput.length > 300 ? toolTimeout.toolInput.substring(0, 300) + "\u2026" : toolTimeout.toolInput) : "(unknown)"}`, `  Duration: ${elapsedSec}s`, `  Reason: ${toolTimeout.smartAlarmReason ?? "took too long and appeared stuck"}`, "", "The tool call did not return a result. You should either:", "  1. Retry the same operation (the issue may have been transient), or", "  2. Take an alternative approach (e.g. different URL, smaller payload, different command).", "Do NOT silently ignore this — inform the user if the failed operation was part of their request.");
                    }
                    else {
                        timeoutMsgParts.push(`Tool call timeout: ${toolTimeout.toolName} (${toolTimeout.toolUseId}) was killed after ${elapsedSec}s.`);
                        if (toolTimeout.browserRestarted) {
                            timeoutMsgParts.push("Browser (Chrome/CDP) has been restarted.");
                        }
                        timeoutMsgParts.push("The query was aborted. You may retry the operation or take an alternative approach.");
                    }
                    const timeoutMsg = timeoutMsgParts.join("\n");
                    messageQueue.enqueue({
                        id: `timeout_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
                        channel: "system",
                        sender: "system",
                        text: timeoutMsg,
                        attachments: [],
                        timestamp: new Date().toISOString(),
                    });
                    logger.system(`Enqueued timeout context message for next wake cycle`);
                }
                if (activeSession.hasOrphanedInjections) {
                    interruptHandler.requeueIfOrphaned();
                }
                try {
                    await activeSession.maybeCompact();
                }
                catch (compactErr) {
                    logger.warn(`Post-query compaction failed: ${compactErr instanceof Error ? compactErr.message : String(compactErr)}`);
                }
                const switchResult = session.executePendingSwitch();
                if (switchResult) {
                    didSwitch = true;
                    switchForwardMessage = switchResult.forwardMessage;
                    logger.system(`Re-entering wake cycle for ${switchResult.target} session`);
                    continue;
                }
                this.deps.providerFallback.clearNetworkFailures();
                this.invalidJsonBodyRecoveryCount = 0;
                consecutiveErrors = 0;
            }
            catch (err) {
                activeSession.closeInput();
                if (session.isStopRequested()) {
                    logger.system("Agent stopped (stop requested)");
                }
                else {
                    const errorResult = await this.handleError(err, consecutiveErrors, session);
                    if (errorResult.kind === "continue_with_reset") {
                        consecutiveErrors = 0;
                        continue;
                    }
                    if (errorResult.kind === "continue_preserve_count") {
                        consecutiveErrors = errorResult.consecutiveErrors;
                        continue;
                    }
                    consecutiveErrors = errorResult.consecutiveErrors;
                }
            }
            break;
        } // end switch loop
        // --- Post-wake-cycle transcript indexing (best-effort) ---
        // Usage reports are no longer enqueued immediately here. Instead,
        // indexWakeCycle extracts and stores tool call facts, and the background
        // UsageReporter periodically converts them into usage.db queue items.
        const postCycleTranscriptInfo = session.activeSession.getTranscriptInfo();
        const postCycleTranscript = resolveIndexableTranscript(postCycleTranscriptInfo, session.activeSession.getTranscriptPath());
        const postCycleStartLine = postCycleTranscript
            ? transcriptAnchors.get(postCycleTranscript.path)
            : undefined;
        const wakeCycleTimestampEnd = new Date().toISOString();
        const wakeCycleOutcome = session.isStopRequested()
            ? "stopped"
            : consecutiveErrors === 0
                ? didSwitch
                    ? "switched"
                    : "completed"
                : "error";
        // Always index wake cycles — including stopped/error cycles — so that
        // usage metering is consistent for all cycles. The outcome field records
        // what happened.
        if (postCycleTranscript && postCycleStartLine !== undefined) {
            const wakeCycleEndLine = await countFileLines(postCycleTranscript.path);
            void indexWakeCycle({
                wakeCycleId,
                sessionType: session.activeMode,
                sourceFile: postCycleTranscript.path,
                sourceFormat: postCycleTranscript.format,
                startLine: postCycleStartLine,
                endLine: wakeCycleEndLine,
                timestampStart: wakeCycleTimestampStart,
                timestampEnd: wakeCycleTimestampEnd,
                messages,
                trigger,
                config,
                outcome: wakeCycleOutcome,
            }).catch((indexErr) => {
                logger.warn(`Transcript indexing failed: ${indexErr instanceof Error ? indexErr.message : String(indexErr)}`);
            });
        }
        return { consecutiveErrors };
    }
    /**
     * Handle an error from the session switch loop. Returns:
     *  - `continue_with_reset` for recovery paths that should retry immediately
     *    with a clean error counter (compaction/sanitization succeeded)
     *  - `continue_preserve_count` for normal retryable failures that should
     *    keep accumulating consecutiveErrors across retries
     *  - `done` when the wake cycle should exit
     */
    async handleError(err, consecutiveErrors, session) {
        const { config } = this.deps;
        const errMsg = err instanceof Error ? err.message : String(err);
        const category = classifyError(err);
        consecutiveErrors++;
        logger.err(`Agent loop error (${category}, attempt ${consecutiveErrors}): ${errMsg}`, { stack: err instanceof Error ? err.stack : undefined });
        // NOTE: a `terminal_reason === "aborted_streaming"` triggered by our own
        // requestStop() is handled before we get here — the caller only invokes
        // handleError when !session.isStopRequested(). So we don't need to
        // re-check that case here; any aborted_streaming seen in this path is an
        // SDK-side stream abort we should surface and retry.
        if (category !== "provider_failed" && isPromptTooLong(err) && session.activeSession.getSessionId()) {
            logger.system("Prompt too long — forcing compaction before retry");
            try {
                await session.activeSession.requestCompaction();
                return { kind: "continue_with_reset" };
            }
            catch (compactErr) {
                logger.warn(`Emergency compaction failed: ${compactErr instanceof Error ? compactErr.message : String(compactErr)}`);
            }
        }
        if (category !== "provider_failed" && isCorruptTranscript(err)) {
            const transcriptInfo = session.activeSession.getTranscriptInfo();
            const sessionId = session.activeSession.getSessionId();
            const tp = transcriptInfo.kind === "claude"
                ? transcriptInfo.sessionFilePath ?? session.activeSession.getTranscriptPath() ?? (sessionId ? deriveTranscriptPath(sessionId) : null)
                : null;
            if (tp) {
                logger.system("Corrupt transcript detected — attempting sanitization before retry");
                try {
                    const fixed = await sanitizeTranscript(tp);
                    if (fixed) {
                        return { kind: "continue_with_reset" };
                    }
                    logger.warn("Transcript sanitizer found nothing to fix — error may require manual intervention");
                }
                catch (sanitizeErr) {
                    logger.warn(`Transcript sanitization failed: ${sanitizeErr instanceof Error ? sanitizeErr.message : String(sanitizeErr)}`);
                }
            }
        }
        if (category !== "provider_failed" && isTooManyImages(err) && session.activeSession.getSessionId()) {
            logger.system("Too many images — forcing compaction before retry");
            try {
                await session.activeSession.requestCompaction();
                return { kind: "continue_with_reset" };
            }
            catch (compactErr) {
                logger.warn(`Emergency compaction for too-many-images failed: ${compactErr instanceof Error ? compactErr.message : String(compactErr)}`);
            }
        }
        if (category !== "provider_failed" && isInvalidJsonBody(err) && session.activeSession.getSessionId()) {
            this.invalidJsonBodyRecoveryCount++;
            const recoveryCount = this.invalidJsonBodyRecoveryCount;
            logger.system(`Invalid JSON body from provider — recovery attempt ${recoveryCount}/3 (likely context/payload too large)`);
            if (recoveryCount === 1) {
                logger.system("Invalid JSON body from provider — forcing compaction before retry");
                try {
                    const before = session.activeSession.getUsageSnapshot();
                    const beforePath = session.activeSession.getTranscriptPath();
                    const startedAt = Date.now();
                    await session.activeSession.requestCompaction();
                    const after = session.activeSession.getUsageSnapshot();
                    const durationMs = Date.now() - startedAt;
                    logger.system(`Invalid-json-body compaction finished in ${durationMs}ms` +
                        ` pre=${before ? `${Math.round(before.usedPct * 1000) / 10}%/${before.usedInputTokens}` : "unknown"}` +
                        ` post=${after ? `${Math.round(after.usedPct * 1000) / 10}%/${after.usedInputTokens}` : "unknown"}` +
                        ` transcript=${beforePath ?? "unknown"}`);
                    if (before && after && after.usedInputTokens >= before.usedInputTokens) {
                        logger.warn("Invalid-json-body compaction ineffective: post-compaction input tokens did not decrease");
                    }
                    return { kind: "continue_preserve_count", consecutiveErrors };
                }
                catch (compactErr) {
                    logger.warn(`Emergency compaction for invalid-json-body failed: ${compactErr instanceof Error ? compactErr.message : String(compactErr)}`);
                }
            }
            if (recoveryCount === 2) {
                const oldSessionId = session.activeSession.getSessionId();
                logger.warn(`Invalid JSON body repeated after compaction; resetting ${session.activeSession.mode} session before retry`);
                session.activeSession.resetForNewSession("Previous session was reset automatically after repeated provider `400 Invalid JSON body` errors. " +
                    "This usually means the provider rejected the context/payload even after compaction. Continue from memory and the current user request; avoid reloading large prior tool outputs unless needed.");
                logger.system(`Invalid-json-body recovery reset session: old=${oldSessionId ?? "unknown"} new=${session.activeSession.getSessionId() ?? "pending"}`);
                return { kind: "continue_preserve_count", consecutiveErrors };
            }
            logger.err("Invalid JSON body persisted after compaction and session reset — scheduling controlled restart fallback");
            void this.recordHealthEvent({
                eventType: "invalid_json_body_recovery_failed",
                severity: "critical",
                message: `${config.agentName}: repeated Invalid JSON body recovery failed; scheduling controlled restart`,
                details: {
                    code: "INVALID_JSON_BODY_RECOVERY_FAILED",
                    consecutiveInvalidJsonBodyErrors: recoveryCount,
                    actionsAttempted: ["compaction", "session_reset", "controlled_restart"],
                    activeSession: session.activeSession.mode,
                    provider: config.provider,
                    model: config.model,
                },
            });
            scheduleRestart(3_000, runGracefulShutdown);
            return { kind: "done", consecutiveErrors: 0 };
        }
        // --- Provider fallback ---
        const { providerFallback } = this.deps;
        if (category === "provider_failed" && providerFallback.hasPriorityList) {
            return this.handleProviderFailed(providerFallback, "auth", errMsg, consecutiveErrors);
        }
        if (category === "retryable" && providerFallback.hasPriorityList) {
            if (providerFallback.recordNetworkFailure()) {
                return this.handleProviderFailed(providerFallback, "network", errMsg, consecutiveErrors);
            }
        }
        if (category === "fatal" ||
            consecutiveErrors >= MAX_RETRIES_BEFORE_NOTIFY) {
            const sleepMin = FATAL_SLEEP_MS / 60_000;
            const notice = category === "fatal"
                ? `Agent hit a fatal error: ${errMsg}. Sleeping ${sleepMin}m before retrying.`
                : `Agent failed ${consecutiveErrors} times in a row: ${errMsg}. Sleeping ${sleepMin}m before retrying.`;
            try {
                await notifyOwner(notice, config);
            }
            catch (notifyErr) {
                logger.warn(`Failed to send error notification to owner: ${notifyErr instanceof Error ? notifyErr.message : String(notifyErr)}`);
            }
            consecutiveErrors = 0;
            await new Promise((r) => setTimeout(r, FATAL_SLEEP_MS));
        }
        else {
            const delay = Math.min(BASE_DELAY_MS * 2 ** (consecutiveErrors - 1), MAX_DELAY_MS);
            logger.system(`Retrying in ${(delay / 1000).toFixed(0)}s (attempt ${consecutiveErrors})...`);
            await new Promise((r) => setTimeout(r, delay));
            return { kind: "continue_preserve_count", consecutiveErrors };
        }
        return { kind: "done", consecutiveErrors };
    }
    /**
     * Handle a provider failure by disabling the current provider and
     * switching to the next one. If all are exhausted, notify the owner,
     * sleep, and reset.
     */
    async handleProviderFailed(fallback, reason, _errMsg, _consecutiveErrors) {
        const { config, ownerConfig } = this.deps;
        const switched = fallback.disableAndSwitch(reason);
        if (switched) {
            return { kind: "continue_with_reset" };
        }
        // All providers exhausted
        const locale = resolveLocale(ownerConfig.language);
        void notifyOwner(t("llm.providers_exhausted", locale), config).catch((err) => {
            logger.warn(`Failed to send providers-exhausted notification: ${err instanceof Error ? err.message : String(err)}`);
        });
        await fallback.sleepAndReset();
        return { kind: "continue_with_reset" };
    }
}
//# sourceMappingURL=wake-cycle-runner.js.map