/**
 * In-process child watchdog.
 *
 * Spawns a small standalone Node child process whose only job is to
 * detect that the main agent's event loop has stopped writing to its
 * heartbeat file, and SIGKILL the parent if it has. pm2 then auto-
 * restarts the agent.
 *
 * Why a separate child process (rather than a worker_thread, a
 * setInterval, or an OS-level cron):
 *
 *  - worker_threads share the parent's event loop scheduler for some
 *    primitives (and in any case can't kill the parent if the parent
 *    is wedged inside synchronous native code).
 *  - setInterval inside the same process won't fire when the event
 *    loop is blocked — which is exactly the failure mode we want to
 *    recover from.
 *  - cron / launchd would work, but `crontab` itself triggers a one-
 *    time macOS SecurityAgent prompt on Sequoia ("Terminal would like
 *    to administer your computer"), and the spawnSync we used to
 *    install the entry blocked the agent's startup for 13+ minutes
 *    on the first install. A child process spawned from inside the
 *    running agent doesn't need any new OS permission.
 *
 * The child is implemented via `node -e <inline script>` so it has
 * zero on-disk footprint, ships inside the bundle automatically, and
 * is identical between dev (tsx) and production (esbuild bundle).
 */
import { spawn } from "node:child_process";
import { logger } from "../logger.js";
import { getHeartbeatFilePath, writeHeartbeatFile } from "./heartbeat-file.js";
/**
 * Default staleness threshold before the watchdog SIGKILLs the parent.
 *
 * The runtime heartbeat loop (see heartbeat-manager.ts) refreshes the
 * heartbeat file every 5 minutes when idle and every 30s when busy.
 * 15 minutes covers two missed idle ticks (10 min) plus a 5-minute
 * slack budget for transient stalls — a real wedge still recovers in
 * well under a quarter hour.
 */
const DEFAULT_WATCHDOG_THRESHOLD_MS = 15 * 60 * 1000;
/**
 * Inline script run inside the child via `node -e`.
 *
 * Intentionally written as a single string with no external imports
 * beyond Node built-ins (`fs`, `process`). It receives its config via
 * argv:
 *
 *   argv[1] = parent pid (number)
 *   argv[2] = heartbeat file path (string)
 *   argv[3] = staleness threshold in ms (number)
 *   argv[4] = poll interval in ms (number)
 *
 * Behavior:
 *   - Every `intervalMs` it stat()s the heartbeat file and reads the
 *     epoch-ms timestamp inside.
 *   - If the heartbeat is older than `thresholdMs`, it logs to stderr
 *     and `process.kill(parentPid, "SIGKILL")` the parent.
 *   - It also probes `process.kill(parentPid, 0)` every tick to detect
 *     a parent that died on its own (e.g. graceful shutdown), and
 *     exits cleanly when that happens.
 *   - It listens for SIGTERM so the parent can stop it gracefully.
 *   - It does NOT log on every tick — only on state changes — to keep
 *     stdout/stderr small under pm2.
 */
const CHILD_SCRIPT = `
"use strict";
const fs = require("node:fs");

const parentPid = Number(process.argv[1]);
const heartbeatFile = process.argv[2];
const thresholdMs = Number(process.argv[3]);
const intervalMs = Number(process.argv[4]);

if (!Number.isFinite(parentPid) || parentPid <= 0) process.exit(2);
if (!heartbeatFile) process.exit(2);
if (!Number.isFinite(thresholdMs) || thresholdMs <= 0) process.exit(2);
if (!Number.isFinite(intervalMs) || intervalMs <= 0) process.exit(2);

const log = (msg) => {
  const stamp = new Date().toISOString();
  process.stderr.write("[watchdog " + stamp + "] " + msg + "\\n");
};

let killing = false;
let stopped = false;

process.on("SIGTERM", () => {
  stopped = true;
  log("SIGTERM received, exiting");
  process.exit(0);
});
process.on("SIGINT", () => {
  stopped = true;
  process.exit(0);
});

log("watchdog started: parentPid=" + parentPid + " threshold=" + thresholdMs + "ms interval=" + intervalMs + "ms");

function parentAlive() {
  try {
    process.kill(parentPid, 0);
    return true;
  } catch (err) {
    // ESRCH = no such process; EPERM = process exists but we can't signal.
    // EPERM still means alive.
    return err && err.code === "EPERM";
  }
}

function readHeartbeatMs() {
  try {
    const raw = fs.readFileSync(heartbeatFile, "utf-8").trim();
    const n = Number(raw);
    return Number.isFinite(n) && n > 0 ? n : null;
  } catch (err) {
    if (err && err.code === "ENOENT") return null;
    log("read error: " + (err && err.message ? err.message : String(err)));
    return null;
  }
}

function tick() {
  if (stopped) return;

  if (!parentAlive()) {
    log("parent " + parentPid + " gone, exiting");
    process.exit(0);
  }

  const hbMs = readHeartbeatMs();
  if (hbMs === null) {
    // No heartbeat file yet (early startup) or unreadable — skip this tick.
    return;
  }

  const ageMs = Date.now() - hbMs;
  if (ageMs > thresholdMs && !killing) {
    killing = true;
    log("STALE heartbeat: age=" + ageMs + "ms > threshold=" + thresholdMs + "ms — SIGKILLing parent " + parentPid);
    try {
      process.kill(parentPid, "SIGKILL");
    } catch (err) {
      log("SIGKILL failed: " + (err && err.message ? err.message : String(err)));
    }
    // Stay alive briefly so pm2 can observe the parent exit; then quit.
    setTimeout(() => process.exit(0), 5000).unref();
  }
}

setInterval(tick, intervalMs).unref();
// Run first check on next tick, after the unref()'d interval is set up.
setImmediate(tick);

// Without something keeping the loop alive, .unref()'d timers would let
// us exit immediately. We want to live as long as our parent does, so
// hold a ref via stdin.
process.stdin.resume();
`;
let activeChild = null;
/**
 * Start the child watchdog. Returns a stop() function the caller can
 * use during graceful shutdown to prevent the watchdog from killing
 * a parent that is about to exit cleanly anyway.
 *
 * Idempotent: calling twice returns the same handle and does not
 * spawn a second child.
 *
 * Disabled by default. Set the env var
 * `VISIONCLAW_ENABLE_HEARTBEAT_WATCHDOG=1` to opt in. While disabled,
 * this function returns a no-op handle without spawning anything.
 */
export function startHeartbeatWatchdog(opts = {}) {
    if (process.env.VISIONCLAW_ENABLE_HEARTBEAT_WATCHDOG !== "1") {
        return { stop: () => undefined, pid: null };
    }
    if (activeChild && !activeChild.killed && activeChild.exitCode === null) {
        return {
            stop: stopActiveChild,
            pid: activeChild.pid ?? null,
        };
    }
    const thresholdMs = opts.thresholdMs ?? DEFAULT_WATCHDOG_THRESHOLD_MS;
    const intervalMs = opts.intervalMs ?? 60_000;
    const heartbeatFile = getHeartbeatFilePath();
    const parentPid = process.pid;
    try {
        // Initialize the watched file inside the opt-in path so enabling the
        // watchdog cannot trip over a stale timestamp from a previous run.
        writeHeartbeatFile();
        const child = spawn(process.execPath, [
            "-e",
            CHILD_SCRIPT,
            String(parentPid),
            heartbeatFile,
            String(thresholdMs),
            String(intervalMs),
        ], {
            // We want the child to:
            //   - keep running if our controlling terminal goes away (pm2 case),
            //   - NOT block our event loop on its stdio,
            //   - still inherit stderr so its log lines land in pm2's stderr file.
            stdio: ["pipe", "ignore", "inherit"],
            detached: false,
            windowsHide: true,
        });
        activeChild = child;
        child.on("exit", (code, signal) => {
            if (activeChild === child)
                activeChild = null;
            if (signal === "SIGTERM" || signal === "SIGINT")
                return; // expected
            logger.warn(`Heartbeat watchdog child exited unexpectedly (code=${code ?? "null"}, signal=${signal ?? "null"})`);
        });
        child.on("error", (err) => {
            logger.warn(`Heartbeat watchdog child error: ${err instanceof Error ? err.message : String(err)}`);
        });
        logger.system(`Heartbeat watchdog started (pid=${child.pid ?? "?"}, threshold=${Math.round(thresholdMs / 1000)}s, interval=${Math.round(intervalMs / 1000)}s)`);
        return { stop: stopActiveChild, pid: child.pid ?? null };
    }
    catch (err) {
        logger.warn(`Failed to start heartbeat watchdog: ${err instanceof Error ? err.message : String(err)}`);
        return { stop: () => undefined, pid: null };
    }
}
function stopActiveChild() {
    const child = activeChild;
    if (!child)
        return;
    activeChild = null;
    try {
        // SIGTERM lets the child print its goodbye log and exit(0). If it's
        // already dead this is a no-op.
        child.kill("SIGTERM");
    }
    catch {
        /* best effort */
    }
}
//# sourceMappingURL=heartbeat-watchdog.js.map