/**
 * agent-stream-launch.ts — Phase B B6 PID-1 supervisor (with PID-file
 * spawn guard added 2026-05-18 per olam-driver-runner-fix Phase A1
 * finding + Phase C4 mitigation).
 *
 * Runs as PID 1 inside the devbox container. Forks driver + codex
 * children; forwards SIGTERM with 25s drain grace; surfaces child
 * exits to stderr (visible via docker logs).
 *
 * Spawn guard (Phase C4, 2026-05-18):
 *   - PID-file at `/tmp/olam-supervisor-${WORLD_ID}.pid`. On startup:
 *     atomic `open(path, 'wx')` create-if-not-exists. On EEXIST: read
 *     stored PID + check `process.kill(pid, 0)`. If alive: log + exit 0
 *     (no-op spawn; existing supervisor stays in charge). If dead:
 *     unlink + retry the atomic create.
 *   - On SIGTERM: unlink the PID-file before draining children.
 *   - Closes the gap originally documented in this file (line ~9 pre-
 *     C4) where "host-cp serialization will prevent double-spawn"
 *     turned out unreliable across host-cp restarts. Verified
 *     reproducible per `olam-frost-oak-9854-devbox` showing 7
 *     supervisors over a ~7-hour window.
 *
 * Surviving demo-cut simplifications:
 *   - NO cgroup memory polling / warning chunks.
 *   - NO health-probe endpoint.
 *   - NO selective bearer env scrubbing for lookouts.
 *   - Codex/driver auto-restart with simple exp-backoff cap 30s.
 *   - Driver crash surfaces via stderr only.
 *
 * Source: docs/design/olam-plan-chat-agent-runtime.md `lifecycle` +
 * `supervision` + `sigterm-drain` sections + olam-driver-runner-fix
 * Phase A1 + C4.
 */
import { fork } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const DRAIN_GRACE_MS = 25_000;
const BACKOFF_SCHEDULE_MS = [1_000, 2_000, 4_000, 8_000, 16_000, 30_000];
const BACKOFF_CAP_MS = 30_000;
const FAILURE_WINDOW_MS = 60_000;
const FAILURE_DISABLE_THRESHOLD = 5;
/**
 * Spawn all configured children + install SIGTERM/uncaughtException/
 * unhandledRejection handlers. Returns a handle for graceful shutdown.
 *
 * The supervisor process never exits on its own — it stays up until
 * drain() is called (typically via signal handler).
 */
export function runSupervisor(opts) {
    const { children: childConfigs, childEnv, forkImpl = fork, processRef = process, } = opts;
    const states = new Map();
    for (const cfg of childConfigs) {
        states.set(cfg.personaKey, { child: null, attempt: 0, failures: [], disabled: false });
    }
    let isDraining = false;
    let pendingExitCode = 0;
    let drainResolve = null;
    const drainPromise = new Promise((resolve) => {
        drainResolve = resolve;
    });
    function spawnChild(cfg) {
        const state = states.get(cfg.personaKey);
        if (!state || state.disabled || isDraining)
            return;
        const env = { ...process.env, ...childEnv };
        // eslint-disable-next-line no-console
        console.error(`[supervisor] forking ${cfg.personaKey} from ${cfg.modulePath}`);
        const child = forkImpl(cfg.modulePath, [], {
            env,
            stdio: ['ignore', 'inherit', 'inherit', 'ipc'],
        });
        state.child = child;
        child.on('exit', (code, signal) => {
            state.child = null;
            // eslint-disable-next-line no-console
            console.error(`[supervisor] ${cfg.personaKey} exited code=${code} signal=${signal}`);
            if (isDraining)
                return;
            if (code === 0 && signal === null) {
                // Clean exit — don't restart (rare for long-lived runners).
                return;
            }
            // Track failure timestamp; if 5+ within 60s, disable.
            const now = Date.now();
            state.failures = state.failures.filter((t) => now - t < FAILURE_WINDOW_MS);
            state.failures.push(now);
            if (state.failures.length >= FAILURE_DISABLE_THRESHOLD) {
                state.disabled = true;
                // eslint-disable-next-line no-console
                console.error(`[supervisor] ${cfg.personaKey} disabled (${state.failures.length} failures in ${FAILURE_WINDOW_MS}ms)`);
                return;
            }
            // Schedule restart with exp-backoff.
            const delay = BACKOFF_SCHEDULE_MS[Math.min(state.attempt, BACKOFF_SCHEDULE_MS.length - 1)] ?? BACKOFF_CAP_MS;
            state.attempt += 1;
            setTimeout(() => {
                if (isDraining || state.disabled)
                    return;
                state.attempt = Math.max(0, state.attempt - 1); // decay attempt after delay elapsed
                spawnChild(cfg);
            }, delay);
        });
    }
    function activeChildren() {
        const out = [];
        for (const state of states.values()) {
            if (state.child)
                out.push(state.child);
        }
        return out;
    }
    async function drain(exitCode = 0) {
        if (isDraining) {
            return drainPromise;
        }
        isDraining = true;
        pendingExitCode = exitCode;
        // eslint-disable-next-line no-console
        console.error(`[supervisor] draining children (exit code ${exitCode})`);
        for (const child of activeChildren()) {
            try {
                child.kill('SIGTERM');
            }
            catch {
                // ignore
            }
        }
        const deadline = Date.now() + DRAIN_GRACE_MS;
        while (activeChildren().length > 0 && Date.now() < deadline) {
            await new Promise((r) => setTimeout(r, 250));
        }
        for (const child of activeChildren()) {
            try {
                child.kill('SIGKILL');
            }
            catch {
                // ignore
            }
        }
        // eslint-disable-next-line no-console
        console.error(`[supervisor] drain complete; exiting code ${pendingExitCode}`);
        if (drainResolve)
            drainResolve();
        // Caller decides whether to actually call process.exit() — keeps the
        // function testable + lets the main() entry point handle it.
    }
    processRef.on('SIGTERM', () => {
        void drain(pendingExitCode);
    });
    processRef.on('SIGINT', () => {
        void drain(pendingExitCode);
    });
    processRef.on('uncaughtException', (err) => {
        // eslint-disable-next-line no-console
        console.error('[supervisor] uncaughtException — draining with exit code 1:', err);
        pendingExitCode = 1;
        void drain(1);
    });
    processRef.on('unhandledRejection', (reason) => {
        // eslint-disable-next-line no-console
        console.error('[supervisor] unhandledRejection — draining with exit code 1:', reason);
        pendingExitCode = 1;
        void drain(1);
    });
    // Spawn initial children.
    for (const cfg of childConfigs) {
        spawnChild(cfg);
    }
    return {
        drain,
        draining: () => isDraining,
        activeChildCount: () => activeChildren().length,
    };
}
/**
 * Phase C4 spawn guard. Atomic create-if-not-exists on a PID file at
 * `/tmp/olam-supervisor-${worldId}.pid`. If the file exists and the
 * stored PID is alive: outcome='existing-alive' (caller should no-op
 * exit). If the file exists but the PID is dead: unlink + retry,
 * outcome='stale-cleared'. Otherwise: outcome='acquired'.
 *
 * `process.kill(pid, 0)` is a liveness probe (no signal sent; throws if
 * the PID doesn't exist or we don't own it). EPERM is treated as alive
 * (process exists but we can't signal it — still don't double-spawn).
 *
 * Exported for unit testing; main() invokes at startup.
 */
export function acquireSupervisorLock(worldId) {
    const pidPath = `/tmp/olam-supervisor-${worldId}.pid`;
    const tryAcquire = () => {
        try {
            const fd = openSync(pidPath, 'wx');
            writeFileSync(fd, String(process.pid));
            return { outcome: 'acquired', pidPath };
        }
        catch (err) {
            if (err.code === 'EEXIST')
                return null;
            throw err;
        }
    };
    // First attempt: atomic create.
    const first = tryAcquire();
    if (first)
        return first;
    // EEXIST path: read existing PID + liveness check.
    let existingPid;
    try {
        existingPid = Number.parseInt(readFileSync(pidPath, 'utf8').trim(), 10);
    }
    catch {
        // Read failed; assume stale + try unlink.
        existingPid = NaN;
    }
    if (!Number.isFinite(existingPid) || existingPid <= 0) {
        if (existsSync(pidPath))
            unlinkSync(pidPath);
        const retry = tryAcquire();
        if (retry)
            return retry;
        throw new Error(`spawn-guard: failed to acquire ${pidPath} after stale cleanup`);
    }
    // Liveness probe.
    try {
        process.kill(existingPid, 0);
        // No throw → process exists.
        return { outcome: 'existing-alive', existingPid, pidPath };
    }
    catch (err) {
        const code = err.code;
        if (code === 'EPERM') {
            // Process exists but we can't signal it; still don't double-spawn.
            return { outcome: 'existing-alive', existingPid, pidPath };
        }
        // ESRCH or similar: stale.
        if (existsSync(pidPath))
            unlinkSync(pidPath);
        const retry = tryAcquire();
        if (retry)
            return { outcome: 'stale-cleared', stalePid: existingPid, pidPath };
        throw new Error(`spawn-guard: failed to acquire ${pidPath} after stale-PID cleanup`);
    }
}
/**
 * Phase A2 — emit a single startup line naming the bundle this supervisor
 * is running. host-cp parses it (key=value pairs) to correlate world
 * staleness reports with the operator's host dist.
 *
 * Format (one line, stderr — picked up via `docker logs`):
 *   `[supervisor] bundle bundle_entry=<path> bundle_sha256=<hex> bundle_mtime=<ISO>`
 *
 * When the entry path is unreadable, emits a degraded line with
 * `bundle_sha256=unknown bundle_mtime=unknown` instead of throwing —
 * supervisor must continue to start even if the introspection probe
 * fails (e.g. file deleted mid-launch).
 */
export function logBundleStartup(opts) {
    const { entryPath } = opts;
    // eslint-disable-next-line no-console
    const logger = opts.logger ?? ((msg) => console.error(msg));
    try {
        const buf = readFileSync(entryPath);
        const sha = createHash('sha256').update(buf).digest('hex');
        const mtimeIso = statSync(entryPath).mtime.toISOString();
        logger(`[supervisor] bundle bundle_entry=${entryPath} bundle_sha256=${sha} bundle_mtime=${mtimeIso}`);
    }
    catch (err) {
        const code = err.code ?? 'unknown';
        logger(`[supervisor] bundle bundle_entry=${entryPath} bundle_sha256=unknown bundle_mtime=unknown bundle_error=${code}`);
    }
}
export async function main() {
    // Phase A2 — emit bundle identity before anything else so it lands in
    // `docker logs` even when env validation fails below.
    if (process.argv[1]) {
        logBundleStartup({ entryPath: process.argv[1] });
    }
    const required = ['HOST_CP_URL', 'HOST_CP_BEARER', 'WORLD_ID', 'SESSION_ID'];
    for (const key of required) {
        if (!process.env[key]) {
            // eslint-disable-next-line no-console
            console.error(`[supervisor] missing required env: ${key}`);
            process.exit(1);
        }
    }
    // Phase C4 spawn guard. Closes the zombie-supervisor gap surfaced by
    // olam-driver-runner-fix Phase A1 finding.
    const worldId = process.env.WORLD_ID;
    const guard = acquireSupervisorLock(worldId);
    if (guard.outcome === 'existing-alive') {
        // eslint-disable-next-line no-console
        console.error(`[supervisor] another supervisor is already running for world=${worldId} (PID ${guard.existingPid}); exiting cleanly`);
        process.exit(0);
    }
    if (guard.outcome === 'stale-cleared') {
        // eslint-disable-next-line no-console
        console.error(`[supervisor] cleared stale PID-file (was ${guard.stalePid}); acquired ${guard.pidPath}`);
    }
    // Unlink the PID-file on any exit (clean + signal-induced).
    const cleanupPidFile = () => {
        try {
            if (existsSync(guard.pidPath))
                unlinkSync(guard.pidPath);
        }
        catch {
            // swallow — best-effort cleanup
        }
    };
    process.on('exit', cleanupPidFile);
    process.on('SIGTERM', cleanupPidFile);
    process.on('SIGINT', cleanupPidFile);
    const childEnv = {};
    for (const k of required) {
        childEnv[k] = process.env[k];
    }
    const baseDir = process.env.AGENT_STREAM_DIR ?? dirname(fileURLToPath(import.meta.url));
    const children = [
        { personaKey: 'driver', modulePath: resolve(join(baseDir, 'driver-runner.js')) },
        { personaKey: 'codex', modulePath: resolve(join(baseDir, 'codex-runner.js')) },
    ];
    const handle = runSupervisor({ children, childEnv });
    // Keep the process alive until drain completes; then exit.
    // Drain is triggered by SIGTERM / uncaughtException / unhandledRejection.
    await new Promise((resolve) => {
        const check = setInterval(() => {
            if (handle.draining() && handle.activeChildCount() === 0) {
                clearInterval(check);
                resolve();
            }
        }, 500);
    });
    // pendingExitCode lives on the handle's internal state; we approximate
    // by checking activeChildCount + draining. Production refines this.
    process.exit(0);
}
if (typeof process !== 'undefined' &&
    process.argv[1] &&
    (process.argv[1].endsWith('agent-stream-launch.js') ||
        process.argv[1].endsWith('agent-stream-launch.ts'))) {
    main().catch((err) => {
        // eslint-disable-next-line no-console
        console.error('[supervisor] fatal:', err);
        process.exit(1);
    });
}
//# sourceMappingURL=agent-stream-launch.js.map