/**
 * HeartbeatManager — owns the always-on heartbeat lifecycle.
 *
 * Heartbeats are intentionally decoupled from the wake/sleep cycle so the
 * onboarding service sees an accurate liveness signal even during long
 * sleeps (notably the 2-hour overnight interval). The runtime loop is a
 * single recursive `setTimeout` whose cadence adapts to `agentState.busy`:
 *
 *   - busy === true  -> 30s   (preserves dashboard precision during work)
 *   - busy === false -> 5min  (idle keep-alive)
 *
 * Every heartbeat (startup, runtime tick, subscription gate, nudge)
 * funnels through one private `sendHeartbeat()` method that owns the
 * mutex, payload build, and full response handling (subscription,
 * billing, shared keys, credential bundle, pending commands, ack
 * confirm/requeue). This eliminates the previously scattered duplicate
 * response-handling blocks in init/onWake/sendHeartbeatForSubscription.
 *
 * The local on-disk heartbeat file is a separate liveness signal
 * consumed by the in-process child watchdog (see heartbeat-watchdog.ts).
 * It is refreshed at the top of every send, so it stays fresh even when
 * cloud heartbeat credentials are absent.
 */
import { OnboardingServiceClient, } from "../onboarding/onboarding-service-client.js";
import { CommandDispatcher } from "./command-dispatcher.js";
import { getCurrentVersion } from "../utils/version-check.js";
import { isOnboardingComplete, loadOwnerConfig, ownerConfigExists, saveConfig } from "../config/index.js";
import { getAgentState } from "./state.js";
import { createCollectDataHandler } from "./data-collector.js";
import { createUpgradeHandler, createUpgradeBetaHandler } from "./upgrade-handler.js";
import { createUsageHandler } from "./usage/usage-handler.js";
import { createUsageBackfillHandler } from "./usage/usage-backfill-handler.js";
import { BillingHandler } from "../billing/billing-handler.js";
import { setSharedAnthropicApiKey } from "../anthropic-credentials.js";
import { setSharedGeminiApiKey } from "../gemini-credentials.js";
import { setSharedOpenAIApiKey } from "../openai-credentials.js";
import { setSharedPhonecallApiKey } from "../phonecall-credentials.js";
import { setSharedFinancialToolApiKey } from "../financial-tool-credentials.js";
import { setSharedSerpApiKey } from "../serpapi-credentials.js";
import { setSharedTosCredentials } from "../tos-credentials.js";
import { resetArchiveConfigCache } from "./image-archive.js";
import { CredentialBundleHandler } from "./credential-bundle-handler.js";
import { TUNNEL_ROTATION_CAPABILITIES } from "./tunnel-rotation-manager.js";
import { createRdpSessionHandler } from "./rdp-session-command.js";
import { cacheConfigFieldIfMissing } from "./runtime-credentials.js";
import { writeHeartbeatFile } from "./heartbeat-file.js";
import { listPendingUsageReports, markUsageReportsAcked, markUsageReportsInflight, markUsageReportsRejected, releaseInflightUsageReports, releaseStaleInflightUsageReports, } from "./usage/usage-report-queue.js";
import { logger } from "../logger.js";
import { checkDisk } from "./disk-monitor.js";
import { setLlmProviderRoutes } from "./model-provider.js";
/** Cadence while the agent is processing a wake cycle. */
const BUSY_HEARTBEAT_INTERVAL_MS = 30_000;
/**
 * Cadence while the agent is idle (sleeping between wake cycles or
 * waiting on input). Sized so two missed idle heartbeats + slack stay
 * inside the server's `HEARTBEAT_ONLINE_MS` threshold (10 min) — see
 * `services/onboarding/src/lib/db.ts` for the corresponding constant.
 */
const IDLE_HEARTBEAT_INTERVAL_MS = 5 * 60_000;
const MAX_USAGE_REPORTS_PER_HEARTBEAT = 10;
const MAX_HEALTH_EVENTS_PER_HEARTBEAT = 10;
const MAX_USAGE_REPORT_PAYLOAD_BYTES = 256 * 1024;
const USAGE_REPORT_INFLIGHT_TIMEOUT_MS = 10 * 60_000;
/**
 * Apply the heartbeat response's `sharedKeys` block.
 *
 * Two things happen for each present field:
 *
 *   1. The tier-2 in-memory shared variable is updated via the relevant
 *      `setSharedXyzApiKey` setter, so resolvers see the new value for
 *      the rest of this process.
 *   2. The value is written to `config.json` **only if the slot is
 *      currently empty** (via `cacheConfigFieldIfMissing`). This seeds
 *      offline-restart survival when the agent has never had a value
 *      for that field, without ever clobbering an existing value set
 *      by per-agent bundle apply, `reconfigure`, or operator edits.
 *
 * Contrast with `credentialBundle` apply (see `runtime-credentials.ts`),
 * which uses `cacheConfigField` and overwrites unconditionally because
 * the per-agent bundle is authoritative for that agent.
 *
 * Field absence, null, and empty strings are treated as "no credential to
 * apply", not "delete this key". Server-issued shared keys therefore never
 * clear in-memory or on-disk local values.
 */
function applySharedKeysFromHeartbeat(_config, _channelManager, sharedKeys) {
    if (!sharedKeys)
        return false;
    let updated = false;
    const seedIfMissing = (field, value) => {
        cacheConfigFieldIfMissing(field, value, {
            logLabel: `seeded shared ${field} in config.json (first-time)`,
        });
    };
    const gemini = sharedKeys.geminiApiKey?.trim();
    if (gemini) {
        updated = setSharedGeminiApiKey(gemini) || updated;
        seedIfMissing("geminiApiKey", gemini);
    }
    const serp = sharedKeys.serpApiKey?.trim();
    if (serp) {
        updated = setSharedSerpApiKey(serp) || updated;
        seedIfMissing("serpApiKey", serp);
    }
    const phonecall = sharedKeys.phonecallApiKey?.trim();
    if (phonecall) {
        updated = setSharedPhonecallApiKey(phonecall) || updated;
        seedIfMissing("phonecallApiKey", phonecall);
    }
    const financialTool = sharedKeys.financialToolApiKey?.trim();
    if (financialTool) {
        updated = setSharedFinancialToolApiKey(financialTool) || updated;
        seedIfMissing("financialToolApiKey", financialTool);
    }
    // TOS credentials are an atomic pair: only persist when both halves
    // are present, otherwise we risk a half-written credential that fails
    // auth in confusing ways. The in-memory setter accepts partials so the
    // archive subsystem can react to a partial update, but disk seeding
    // requires both fields.
    const tosId = sharedKeys.tosAccessKeyId?.trim();
    const tosSecret = sharedKeys.tosAccessKeySecret?.trim();
    if (tosId || tosSecret) {
        const tosUpdated = setSharedTosCredentials(tosId, tosSecret);
        if (tosUpdated) {
            resetArchiveConfigCache();
            updated = true;
        }
        if (tosId && tosSecret) {
            seedIfMissing("tosAccessKeyId", tosId);
            seedIfMissing("tosAccessKeySecret", tosSecret);
        }
    }
    const anthropic = sharedKeys.anthropicApiKey?.trim();
    if (anthropic) {
        updated = setSharedAnthropicApiKey(anthropic) || updated;
        seedIfMissing("anthropicApiKey", anthropic);
    }
    // 9Router fields have no in-memory shared resolver today; the client
    // factory reads them straight from `config.json`. Seeding them when
    // missing gives a 9Router-provisioned agent its first usable value
    // without overriding any explicit configuration.
    const ninerouterApi = sharedKeys.ninerouterApiKey?.trim();
    if (ninerouterApi) {
        seedIfMissing("ninerouterApiKey", ninerouterApi);
    }
    const ninerouterBase = sharedKeys.ninerouterBaseUrl?.trim();
    if (ninerouterBase) {
        seedIfMissing("ninerouterBaseUrl", ninerouterBase);
    }
    const openai = sharedKeys.openaiApiKey?.trim();
    if (openai) {
        updated = setSharedOpenAIApiKey(openai) || updated;
        seedIfMissing("openaiApiKey", openai);
    }
    return updated;
}
export class HeartbeatManager {
    client = null;
    commandDispatcher;
    config;
    channelManager = null;
    _lastSubscriptionStatus = "skip";
    _lastBillingPlans = [];
    _lastBillingMessage;
    _lastUsageStatus;
    _lastLlmProviderPriority;
    _lastCardAvailable = false;
    /**
     * Owns all server-issued credential orchestration: signature tracking,
     * bundle materialisation, restart coalescing. HeartbeatManager hands
     * it each heartbeat response and reads its `appliedSignature` when
     * building the next payload.
     */
    credentialBundle;
    providerFallback = null;
    // ── Runtime loop state ──
    /** Recursive setTimeout handle. `null` when the loop is not armed. */
    runtimeTimer = null;
    /** Provider for current `agentState.busy`. Set by `startRuntime`. */
    getBusy = null;
    /**
     * Mutex tail — every `sendHeartbeat` awaits this then replaces it
     * with the new in-flight promise so concurrent callers serialise.
     * Initialised to a resolved promise so the first send doesn't block.
     */
    sendMutex = Promise.resolve();
    pendingHealthEvents = [];
    constructor(config, options) {
        this.config = config;
        this.credentialBundle = new CredentialBundleHandler({
            // Read live so post-sync renames (see runSend) propagate to
            // tunnel-name validation on the very next bundle apply.
            getAgentName: () => this.config.agentName,
            getConfig: () => this.config,
            restartTunnel: options?.restartTunnel,
            obsPort: options?.obsPort,
            onBedrockChanged: () => {
                if (this.config.provider !== "bedrock")
                    return;
                try {
                    getAgentState().sessionManager?.discardAllWarmQueries();
                }
                catch {
                    // AgentState may not exist during the startup heartbeat.
                }
            },
        });
        this.commandDispatcher = new CommandDispatcher();
        this.commandDispatcher.registerHandler("collect_data", createCollectDataHandler(config));
        this.commandDispatcher.registerHandler("upgrade", createUpgradeHandler());
        this.commandDispatcher.registerHandler("upgrade_beta", createUpgradeBetaHandler());
        this.commandDispatcher.registerHandler("usage", createUsageHandler());
        this.commandDispatcher.registerHandler("usage_backfill", createUsageBackfillHandler(config));
        this.commandDispatcher.registerHandler("create_rdp_session", createRdpSessionHandler());
    }
    /** Build a base heartbeat payload including owner info for server-side backfill. */
    buildPayload(busy) {
        const payload = {
            version: getCurrentVersion(),
            model: this.config.model,
            provider: this.config.provider,
            uptime: Math.round(process.uptime()),
            busy,
            onboardingComplete: isOnboardingComplete(),
        };
        // Include owner info so the server can backfill if a "Start Over" reset
        // cleared the fields while the agent was mid-onboarding. Also forward
        // the Telegram chat ID so the server can store it on the agent row
        // and reach the owner directly when the agent is offline.
        if (ownerConfigExists()) {
            try {
                const owner = loadOwnerConfig();
                if (owner.ownerEmail)
                    payload.ownerEmail = owner.ownerEmail;
                if (owner.ownerName)
                    payload.ownerName = owner.ownerName;
                if (owner.telegramChatId !== undefined) {
                    payload.ownerChatId = String(owner.telegramChatId);
                }
            }
            catch {
                /* best-effort */
            }
        }
        // Always include the applied signature when we have one. The server
        // compares it to its current credential signature and only ships
        // back a `credentialBundle` on mismatch — so steady-state payloads
        // pay just the signature string (a few dozen bytes).
        const sig = this.credentialBundle.appliedSignature;
        if (sig) {
            payload.appliedCredentialSignature = sig;
        }
        if (this.config.machineSerial)
            payload.machineSerial = this.config.machineSerial;
        payload.clientCapabilities = [...TUNNEL_ROTATION_CAPABILITIES];
        payload.tunnelRuntimeStatus = this.credentialBundle.tunnelRotation.getRuntimeStatus();
        const rotationStatus = this.credentialBundle.tunnelRotation.currentStatus;
        if (rotationStatus) {
            payload.tunnelRotationStatus = rotationStatus;
        }
        // Provider fallback state — lets the dashboard show which provider is
        // active and which are temporarily disabled due to errors.
        // Always send disabledProviders (including []) so the server can
        // detect the transition from non-empty to empty and emit a single
        // recovery event, rather than preserving the stale non-empty value.
        if (this.providerFallback) {
            payload.activeProvider = this.providerFallback.activeProvider;
            payload.disabledProviders = this.providerFallback.disabledProviders;
        }
        return payload;
    }
    /**
     * The single place every heartbeat goes through.
     *
     * - Always refreshes the on-disk heartbeat file (cheap, even when no
     *   onboarding-service client is configured).
     * - Mutex-serialises concurrent callers (busy ticks, idle ticks,
     *   subscription gate polls, nudges) so command acks cannot be
     *   double-submitted and response handlers cannot interleave.
     * - In `strict` mode, errors are rethrown after re-queuing acks so
     *   `init()` can surface auth failures and disable the client. In
     *   non-strict mode errors are caught and acks are requeued for the
     *   next attempt, identical to the previous best-effort behaviour.
     * - Does NOT wake the main loop. The `notify` command handler in
     *   loop.ts is responsible for waking after it enqueues the message;
     *   waking from inside `sendHeartbeat` would race that handler.
     */
    async sendHeartbeat(options) {
        // Acquire the mutex by waiting on the previous tail and installing
        // a new one. Even if `prev` rejected we still want to proceed —
        // `await prev` swallowing nothing here would allow an unhandled
        // rejection from a previous send to propagate out of an unrelated
        // call, so guard with `.catch(() => undefined)`.
        const prev = this.sendMutex;
        let release;
        this.sendMutex = new Promise((resolve) => {
            release = resolve;
        });
        try {
            await prev.catch(() => undefined);
            return await this.runSend(options);
        }
        finally {
            release();
        }
    }
    /**
     * Body of a single heartbeat send, executed under the mutex. Split
     * out from `sendHeartbeat` so the locking shell is a thin wrapper.
     */
    async runSend(options) {
        // Refresh the local liveness file before any network work. This is
        // unconditional and runs even when no client is configured, so the
        // child watchdog (when enabled) sees a heartbeat regardless.
        writeHeartbeatFile();
        if (!this.client)
            return null;
        const acks = this.commandDispatcher.drainAcks();
        releaseStaleInflightUsageReports(USAGE_REPORT_INFLIGHT_TIMEOUT_MS);
        const candidateUsageRows = listPendingUsageReports(MAX_USAGE_REPORTS_PER_HEARTBEAT);
        const usageRows = [];
        let usagePayloadBytes = 2; // account for JSON array brackets `[]`
        for (const row of candidateUsageRows) {
            const rowBytes = Buffer.byteLength(JSON.stringify(row.report), "utf8") + 1; // +1 for comma
            if (usagePayloadBytes + rowBytes > MAX_USAGE_REPORT_PAYLOAD_BYTES)
                break;
            usageRows.push(row);
            usagePayloadBytes += rowBytes;
        }
        const usageReportIds = usageRows.map((row) => row.wakeCycleId);
        if (usageReportIds.length > 0)
            markUsageReportsInflight(usageReportIds);
        const payload = this.buildPayload(options.busy);
        if (acks.length > 0)
            payload.commandAcks = acks;
        if (usageRows.length > 0)
            payload.usageReports = usageRows.map((row) => row.report);
        // Drain health events into a local batch (capped at server max).
        // On delivery failure we prepend them back so they retry next tick.
        const healthBatch = this.pendingHealthEvents.splice(0, MAX_HEALTH_EVENTS_PER_HEARTBEAT);
        if (healthBatch.length > 0)
            payload.healthEvents = healthBatch;
        let resp;
        try {
            resp = options.strict
                ? await this.client.heartbeatStrict(payload)
                : await this.client.heartbeat(payload);
        }
        catch (err) {
            // Put undelivered acks back so the next heartbeat can retry them.
            if (acks.length > 0)
                this.commandDispatcher.requeueAcks(acks);
            if (usageReportIds.length > 0)
                releaseInflightUsageReports(usageReportIds, "heartbeat_failed");
            if (healthBatch.length > 0)
                this.pendingHealthEvents.unshift(...healthBatch);
            if (options.strict) {
                // Strict callers (init) want auth failures to bubble up so they
                // can disable the client and notify the owner.
                throw err;
            }
            // Non-strict: best-effort, swallow.
            return null;
        }
        // The non-strict client swallows network/HTTP errors and returns
        // `null` instead of throwing, so we must treat a null response as
        // "delivery failed" and requeue acks. Without this, an offline
        // server window silently drops every command ack drained during
        // it. (heartbeatStrict only ever returns null when the server
        // reports a non-auth non-2xx, which is the same delivery-failed
        // case — strict callers care about auth, not delivery.)
        if (resp === null) {
            if (acks.length > 0)
                this.commandDispatcher.requeueAcks(acks);
            if (usageReportIds.length > 0)
                releaseInflightUsageReports(usageReportIds, "heartbeat_failed");
            if (healthBatch.length > 0)
                this.pendingHealthEvents.unshift(...healthBatch);
            return null;
        }
        // ── Response side-effects (single source of truth) ──
        // `resp` is non-null past the guard above.
        if (resp.subscriptionStatus) {
            this._lastSubscriptionStatus = resp.subscriptionStatus;
        }
        if (resp.billingPlans) {
            this._lastBillingPlans = resp.billingPlans;
        }
        if (resp.billingMessage !== undefined) {
            this._lastBillingMessage = resp.billingMessage;
        }
        if (resp.usageStatus) {
            this._lastUsageStatus = resp.usageStatus;
        }
        if (resp.ackedUsageReportIds) {
            markUsageReportsAcked(resp.ackedUsageReportIds);
        }
        if (resp.rejectedUsageReports) {
            markUsageReportsRejected(resp.rejectedUsageReports);
        }
        if (resp.llmProviderRoutes) {
            setLlmProviderRoutes(resp.llmProviderRoutes);
        }
        if (resp.llmProviderPriority) {
            this._lastLlmProviderPriority = resp.llmProviderPriority;
            // Forward priority changes to the fallback manager so it can
            // detect ordering changes and reset all provider states.
            if (this.providerFallback) {
                this.providerFallback.setPriorityList(resp.llmProviderPriority);
            }
        }
        if (resp.cardAvailable !== undefined) {
            this._lastCardAvailable = resp.cardAvailable;
            try {
                const state = getAgentState();
                if (state.cardAvailable !== resp.cardAvailable) {
                    state.cardAvailable = resp.cardAvailable;
                    logger.system(`Virtual card: ${resp.cardAvailable ? "available" : "not configured"}`);
                    // The Claude provider pre-warms a subprocess with the system
                    // prompt and MCP tool list baked in (both gate on cardAvailable).
                    // Without discarding the warm child, the next turn would consume
                    // a stale subprocess that either has a `manage_card` tool the
                    // server-side card no longer backs, or lacks the tool even
                    // though a card just became available. Discarding forces the
                    // next turn to rebuild prompt + tools from the new state.
                    // (OpenAI rebuilds on every sendAndStream, so this is a no-op
                    // there but cheap to call.)
                    try {
                        state.sessionManager?.discardAllWarmQueries();
                    }
                    catch {
                        // Best-effort; SessionManager may not yet be wired.
                    }
                }
            }
            catch {
                // Agent state not yet initialized (e.g. during startup heartbeat
                // inside `init()`). loop.ts seeds AgentState.cardAvailable from
                // `heartbeat.lastCardAvailable` before the first warm-up, so the
                // initial value is correct without needing this discard path.
            }
        }
        if (applySharedKeysFromHeartbeat(this.config, this.channelManager, resp.sharedKeys)) {
            logger.debug("Shared keys updated from heartbeat");
        }
        // Reconcile local agentName with the server's canonical value BEFORE
        // touching the credential bundle. The bundle's tunnel cert is issued
        // against the server-side canonical name, and `applyRuntimeCredentials`
        // validates the on-disk tunnel filename against our `agentName`. If
        // we applied the bundle first, a freshly-renamed agent would reject
        // its own valid credentials on the very heartbeat that delivered them.
        //
        // The server is the source of truth for the slug; if our local
        // config.agentName has drifted (e.g. an admin renamed the agent),
        // move the old value into displayName (only if displayName isn't
        // already set, to preserve any user-configured custom label) and
        // overwrite agentName with the server's value.
        //
        // We also rebuild the OnboardingServiceClient so subsequent
        // heartbeat URLs use the new canonical slug. Auth still works
        // pre-rebuild because the server's verifyAgentApiKey falls back to
        // a key-only lookup, but rebuilding eliminates the ambiguity.
        if (resp.agentName && resp.agentName !== this.config.agentName) {
            const previousAgentName = this.config.agentName;
            const previousDisplayName = this.config.displayName;
            this.config.displayName ??= previousAgentName;
            this.config.agentName = resp.agentName;
            try {
                saveConfig(this.config);
                logger.system(`Heartbeat: synced agentName "${previousAgentName}" -> "${resp.agentName}"` +
                    (previousDisplayName === undefined
                        ? ` (moved old value to displayName)`
                        : ` (displayName already set to "${previousDisplayName}", left untouched)`));
                if (this.config.onboardingServiceUrl && this.config.visionclawServiceApiKey) {
                    this.client = new OnboardingServiceClient(this.config.onboardingServiceUrl, this.config.agentName, this.config.visionclawServiceApiKey);
                }
            }
            catch (err) {
                // Roll back the in-memory mutation so a failed save doesn't
                // leave us with config that disagrees with disk. Note: the
                // CredentialBundleHandler reads agentName via getter, so a
                // rollback here automatically reverts its validation target too.
                this.config.agentName = previousAgentName;
                this.config.displayName = previousDisplayName;
                logger.warn(`Heartbeat: failed to persist agentName sync: ${err instanceof Error ? err.message : String(err)}`);
            }
        }
        // Apply credential bundle AFTER the agentName sync so tunnel-name
        // validation runs against the now-canonical name. The handler
        // delegates to applyRuntimeCredentials, which is the single owner of
        // bundle-sourced in-memory and on-disk credential state (including
        // SerpAPI / Gemini — see `applySerpApiCredential` /
        // `applyGeminiCredential` in runtime-credentials.ts; those update the
        // tier-1 in-memory store via the `setBundleXyzApiKey` setters AND
        // the tier-3 on-disk cache via `cacheConfigField`). All reads should
        // go through the resolvers (`resolveSerpApiKey`, `resolveGeminiApiKey`)
        // which prefer the in-memory bundle tier; we therefore do NOT mirror
        // the values back onto `this.config` here, since that would race
        // with the single-field atomic writes performed by runtime-credentials
        // and would re-introduce the conflation between the per-agent
        // override slot and the cache slot.
        this.credentialBundle.handle(resp.credentialBundle);
        // Acks were delivered successfully — clear their completed-set entries.
        if (acks.length > 0)
            this.commandDispatcher.confirmAcksDelivered(acks);
        if (resp.pendingCommands && resp.pendingCommands.length > 0) {
            this.commandDispatcher.dispatch(resp.pendingCommands);
        }
        return resp;
    }
    /**
     * Initialize the heartbeat client and validate the API key.
     * Should be called once at startup. If validation fails, heartbeats
     * are silently disabled.
     */
    async init(channelManager, telegramChatId, justUpgraded) {
        this.channelManager = channelManager;
        // Queue synthetic ack for a just-completed upgrade
        if (justUpgraded?.commandId !== undefined) {
            this.commandDispatcher.queueAck({
                commandId: justUpgraded.commandId,
                status: "completed",
                result: JSON.stringify({
                    message: `Upgrade completed: ${justUpgraded.fromVersion} → ${justUpgraded.toVersion}`,
                }),
            });
            logger.system(`Queued auto-ack for upgrade command ${justUpgraded.commandId} (${justUpgraded.fromVersion} → ${justUpgraded.toVersion})`);
        }
        if (!this.config.onboardingServiceUrl || !this.config.visionclawServiceApiKey) {
            // No client to validate; still refresh the local heartbeat file so
            // the watchdog sees a fresh timestamp from process start.
            await this.sendHeartbeat({ busy: false });
            return;
        }
        this.client = new OnboardingServiceClient(this.config.onboardingServiceUrl, this.config.agentName, this.config.visionclawServiceApiKey);
        // Register billing handler for bundle purchases.
        // Subscription payment is handled by the subscription gate (subscription-gate.ts),
        // not via a command — the gate reads plans from the heartbeat response directly.
        if (telegramChatId) {
            const billing = new BillingHandler(channelManager, telegramChatId, this.client);
            this.commandDispatcher.registerHandler("bundle_purchase", billing.handleBundlePurchase);
        }
        // The startup heartbeat carries whatever signature we have on disk
        // (or none, on a fresh install). The server returns a
        // `credentialBundle` only on signature mismatch, so the steady-state
        // restart costs nothing extra; first installs and recovery cases
        // get the full bundle automatically.
        try {
            await this.sendHeartbeat({ busy: false, strict: true });
            logger.system("Runtime heartbeat: initial key validation succeeded");
        }
        catch (err) {
            logger.err(`Runtime heartbeat: API key validation failed: ${err instanceof Error ? err.message : String(err)}`);
            try {
                if (telegramChatId) {
                    await channelManager.sendMessage("telegram", String(telegramChatId), `⚠️ Onboarding service rejected my API key. Runtime heartbeat disabled.`);
                }
            }
            catch {
                // best-effort notification
            }
            this.client = null;
        }
    }
    /** Whether a heartbeat client is active. */
    get enabled() {
        return this.client !== null;
    }
    /** The subscription status from the most recent heartbeat response. */
    get lastSubscriptionStatus() {
        return this._lastSubscriptionStatus;
    }
    /** Billing plans from the most recent heartbeat response (populated when expired). */
    get lastBillingPlans() {
        return this._lastBillingPlans;
    }
    /** Billing message from the most recent heartbeat response (populated when expired). */
    get lastBillingMessage() {
        return this._lastBillingMessage;
    }
    get lastUsageStatus() {
        return this._lastUsageStatus;
    }
    get lastLlmProviderPriority() {
        return this._lastLlmProviderPriority;
    }
    /** Runtime-only card availability from the most recent heartbeat response. */
    get lastCardAvailable() {
        return this._lastCardAvailable;
    }
    /** Wire the provider fallback manager for heartbeat reporting and priority change detection. */
    setProviderFallback(fb) {
        this.providerFallback = fb;
    }
    /**
     * Request a Stripe Billing Portal session.
     * Returns subscription info + portal URL, or null if unavailable.
     */
    async getBillingPortal() {
        if (!this.client)
            return null;
        return this.client.getBillingPortal();
    }
    /**
     * Send a single heartbeat and return the subscription status from the response.
     * Used by the subscription gate for fast-polling (10s interval).
     */
    async sendHeartbeatForSubscription() {
        if (!this.client)
            return this._lastSubscriptionStatus;
        await this.sendHeartbeat({ busy: false });
        return this._lastSubscriptionStatus;
    }
    async sendHeartbeatForUsageGate() {
        if (!this.client)
            return this._lastUsageStatus;
        await this.sendHeartbeat({ busy: false });
        return this._lastUsageStatus;
    }
    /**
     * Arm the always-on runtime heartbeat loop.
     *
     * Called once at process startup, after `agentState` is initialised so
     * the `getBusy` provider can read the live busy flag. Does NOT fire
     * an immediate heartbeat — `init()` already sent the strict startup
     * heartbeat, and an immediate runtime tick would just duplicate it.
     * State-change latency is covered by `nudge()`, not by an immediate
     * fire.
     *
     * Calling `startRuntime` while the loop is already armed is a no-op
     * apart from refreshing the `getBusy` reference; this makes the
     * subscription gate's `stop -> poll -> start` pattern safe even if
     * something inside the gate accidentally calls `startRuntime` again.
     */
    startRuntime(getBusy) {
        this.getBusy = getBusy;
        // Synchronously refresh the local heartbeat file so a freshly armed
        // watchdog (when enabled) sees a current timestamp from the moment
        // the runtime loop is in charge.
        writeHeartbeatFile();
        if (this.runtimeTimer !== null)
            return;
        this.scheduleNextTick();
    }
    /**
     * Stop the runtime heartbeat loop and wait for any in-flight send to
     * finish. Used by graceful shutdown and by the subscription gate
     * before it takes over with its own 10s polling cadence.
     *
     * IMPORTANT: clearing the timer is not enough on its own — a tick
     * that is already mid-send would still re-arm itself when it
     * completes (see `runRuntimeTick`). We null `getBusy` so the
     * `runRuntimeTick` re-arm guard short-circuits, and `nudge()`
     * (which keys off `getBusy`) becomes a no-op until `startRuntime`
     * is called again. `startRuntime` is the only caller that brings
     * `getBusy` back, so the lifecycle is start/stop-symmetric.
     */
    async stopRuntime() {
        if (this.runtimeTimer !== null) {
            clearTimeout(this.runtimeTimer);
            this.runtimeTimer = null;
        }
        this.getBusy = null;
        // Drain any in-flight send so the caller can rely on a quiet
        // post-stop state (no surprise heartbeats firing during gate
        // polling or during graceful shutdown).
        await this.sendMutex.catch(() => undefined);
    }
    /**
     * Fire an out-of-band heartbeat right now via the same mutex path
     * the runtime loop uses. Used by the main loop on busy<->idle
     * transitions to flatten dashboard latency without waiting for the
     * next scheduled tick (up to 5min in the idle case).
     */
    nudge() {
        if (!this.getBusy)
            return;
        void this.sendHeartbeat({ busy: this.getBusy() }).catch(() => {
            /* best-effort */
        });
    }
    scheduleNextTick() {
        const interval = this.getBusy?.() === true
            ? BUSY_HEARTBEAT_INTERVAL_MS
            : IDLE_HEARTBEAT_INTERVAL_MS;
        this.runtimeTimer = setTimeout(() => {
            // Detach from the timer slot before sending so a re-entrant
            // `startRuntime` (e.g. subscription gate teardown racing the
            // tick fire) sees `runtimeTimer === null` and re-arms cleanly.
            this.runtimeTimer = null;
            void this.runRuntimeTick();
        }, interval);
    }
    async runRuntimeTick() {
        try {
            await this.sendHeartbeat({ busy: this.getBusy?.() === true });
        }
        catch {
            // sendHeartbeat already logs / requeues on error in non-strict mode;
            // belt-and-suspenders catch in case future strict ticks slip in.
        }
        void this.runDiskCheck().catch(() => { });
        // If the loop was stopped (or about to be stopped) during the
        // send, don't re-arm.
        if (this.getBusy === null)
            return;
        this.scheduleNextTick();
    }
    async runDiskCheck() {
        const result = await checkDisk(this.config);
        if (!result)
            return;
        if (result.healthEvents.length > 0) {
            this.pendingHealthEvents.push(...result.healthEvents);
            this.nudge();
        }
    }
}
//# sourceMappingURL=heartbeat-manager.js.map