// engine/spawn-phase-watchdog.js — W-mq0e2dae000a003d
//
// Per-tick check that kills agent processes wedged in the spawn/MCP-init
// phase. The classic failure mode is a Copilot or Claude child that emits
// startup-only events (MCP server load, skill registration, hook firing)
// and then sits idle indefinitely — no `assistant.*`, no `tool.*`, no
// `result`, no completion-report. The agent never makes progress so the
// hard timeout (5h) eventually kills it, wasting a worker slot for hours.
//
// Detection contract:
//
//   1. Process must be a fresh spawn (not re-attached after engine
//      restart — `procInfo.reattached` is false/undefined). Re-attached
//      processes already lost their early live-output and can look
//      pre-wedged after the engine reads them mid-flight.
//
//   2. Time since spawn must exceed `spawnPhaseGraceMs` (default 2min).
//      Short grace because a healthy startup ships a real event within
//      seconds; 2min gives slow MCP servers and corp-network DNS plenty
//      of headroom.
//
//   3. live-output.log tail must contain ZERO non-startup events. We tail
//      ~16KB and parse JSON lines; any non-startup type means the runtime
//      handed control to the task and the watchdog stands down.
//
//   4. Per-process CPU seconds must be <= `spawnPhaseMaxCpuSeconds`
//      (default 5). A genuinely busy startup will burn CPU on tokenizer
//      bootstrap, MCP IPC, or model warmup; a wedged child sits in epoll.
//      The CPU gate is fail-open — if we can't sample, we DO NOT kill.
//
// On fire: kill the tracked process tree, complete the dispatch with
// SPAWN_PHASE_STALL, mark agent-retryable so the next tick re-spawns the
// agent with a fresh runtime invocation (resolves transient MCP wedges).

const fs = require('fs');
const path = require('path');
const shared = require('./shared');
const queries = require('./queries');
const { ENGINE_DEFAULTS, FAILURE_CLASS } = shared;
const AGENTS_DIR = queries.AGENTS_DIR;
const log = (level, msg) => shared.log ? shared.log(level, msg) : console.log(`[${level}] ${msg}`);

// Lazy require to break circular dep with dispatch.js (which lazy-requires engine.js).
function dispatch() { return require('./dispatch'); }

// ── Per-runtime startup-only event filters ──────────────────────────────────
//
// Anything NOT in these sets is treated as "real task activity" and the
// watchdog stands down. These match the runtimes/*.js KNOWN_EVENT_TYPES
// startup section — keep in sync.

const COPILOT_STARTUP_TYPES = new Set([
  'session.mcp_server_status_changed',
  'session.mcp_servers_loaded',
  'session.skills_loaded',
  'session.tools_updated',
  'session.info',
]);

// Claude stream-json prints {"type":"system","subtype":"init|hook_started|hook_response"}
// during startup before any assistant/user/tool_use/result events fire.
const CLAUDE_STARTUP_SUBTYPES = new Set(['init', 'hook_started', 'hook_response']);

// Header line that engine.js writes at spawn: "[<iso>] pid: <n>"
const HEADER_LINE_RE = /^\[[^\]]+\]\s+(?:pid:|spawn-failed)/;

/**
 * Inspect a tail of `live-output.log` and decide whether the runtime has
 * emitted any real task progress events yet.
 *
 * Returns true when we found at least one non-startup event. Returns
 * false when the tail is empty, header-only, or contains ONLY recognized
 * startup events.
 *
 * Conservative on parse errors: a line that fails JSON.parse is treated
 * as a real progress signal (could be a `[engine-system]` notice or
 * codex plain-text output), so the watchdog stands down. Only structured
 * JSON whose `type` we positively recognize as startup is filtered out.
 */
function hasRealTaskActivity(tailText, runtimeName) {
  if (!tailText) return false;
  const lines = String(tailText).split(/\r?\n/);
  for (const rawLine of lines) {
    const line = rawLine.trim();
    if (!line) continue;
    if (HEADER_LINE_RE.test(line)) continue;
    // Bracketed engine sentinels like `[process-exit] code=0` shouldn't
    // reach this code path (timeout.js would have completed first), but
    // if they do, treat them as real activity so we never race timeout.
    if (/^\[(?:process-exit|engine-system|steering-)/.test(line)) return true;
    // Try to parse JSON. Non-JSON lines (codex plain text, partial flushes)
    // count as real activity — only positively-identified startup events
    // are filtered out.
    let evt;
    try { evt = JSON.parse(line); }
    catch { return true; }
    if (!evt || typeof evt !== 'object') continue;
    const type = typeof evt.type === 'string' ? evt.type : null;
    if (!type) {
      // Object with no `type` — unusual; treat as activity to stay safe.
      return true;
    }
    if (runtimeName === 'copilot') {
      if (COPILOT_STARTUP_TYPES.has(type)) continue;
      // Anything else from Copilot is real (assistant.*, tool.*, result,
      // session.task_complete, user.message, function).
      return true;
    }
    if (runtimeName === 'claude' || !runtimeName) {
      // Claude stream-json: only {type:'system', subtype:'init|hook_*'} is startup.
      if (type === 'system') {
        const sub = typeof evt.subtype === 'string' ? evt.subtype : '';
        if (CLAUDE_STARTUP_SUBTYPES.has(sub)) continue;
        // Other `system` subtypes (e.g. errors, banners) — treat as activity.
        return true;
      }
      return true;
    }
    // Unknown runtime — be conservative.
    return true;
  }
  return false;
}

/**
 * Tick-loop hook. Iterates active processes, applies the four detection
 * gates, kills + completes any that wedge.
 *
 * @param {Map<string, object>} activeProcesses  Engine's tracked-process map
 * @param {object} config                        Live engine config
 * @param {object} [opts]                        Test seams
 * @param {() => number} [opts.now]              Clock injector
 * @param {(pid:number) => number|null} [opts.cpuSampler]  CPU-sec sampler
 * @param {(p:string) => string|null} [opts.tailReader]   live-output tail reader
 * @param {(proc:object) => void} [opts.killer]  killImmediate injector
 * @param {(dispatchId, result, reason, summary, opts) => void} [opts.completer]
 */
function checkSpawnPhaseStalls(activeProcesses, config, opts = {}) {
  if (!activeProcesses || typeof activeProcesses.entries !== 'function') return;
  const enabled = config?.engine?.spawnPhaseWatchdogEnabled
    ?? ENGINE_DEFAULTS.spawnPhaseWatchdogEnabled;
  if (enabled === false) return;

  const graceMs = Math.max(1000, Number(config?.engine?.spawnPhaseGraceMs)
    || ENGINE_DEFAULTS.spawnPhaseGraceMs);
  const maxCpuSec = Math.max(0, Number(config?.engine?.spawnPhaseMaxCpuSeconds
    ?? ENGINE_DEFAULTS.spawnPhaseMaxCpuSeconds));

  const now = (opts.now || Date.now)();
  const cpuSampler = opts.cpuSampler || ((pid) => shared.getProcessCpuSeconds(pid));
  const tailReader = opts.tailReader || _defaultTailReader;
  const killer = opts.killer || ((proc) => shared.killImmediate(proc));
  const completer = opts.completer || ((id, result, reason, summary, o) => dispatch().completeDispatch(id, result, reason, summary, o));

  for (const [id, procInfo] of activeProcesses.entries()) {
    if (!procInfo) continue;
    // Skip re-attached processes — we have no reliable spawn timestamp
    // for their startup phase, and their live-output may be mid-stream.
    if (procInfo.reattached) continue;
    // Skip processes that have already been promoted past spawn phase
    // by a prior watchdog tick (single-shot per spawn).
    if (procInfo._spawnPhaseCleared) continue;
    // Skip processes that the steering pipeline has already killed —
    // a re-spawn will track itself.
    if (procInfo._steeringAt) continue;
    const proc = procInfo.proc;
    if (!proc) continue;
    // If the process already exited, let timeout.js / onAgentClose handle it.
    if (Object.prototype.hasOwnProperty.call(proc, 'exitCode') && proc.exitCode !== null) continue;

    const startedAtMs = procInfo.startedAt ? Date.parse(procInfo.startedAt) : NaN;
    if (!Number.isFinite(startedAtMs)) continue;
    const elapsed = now - startedAtMs;
    if (elapsed < graceMs) continue;

    const agentId = procInfo.agentId;
    if (!agentId) continue;

    const liveLogPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
    const tail = tailReader(liveLogPath);

    if (hasRealTaskActivity(tail, procInfo.runtimeName)) {
      // Promote out of spawn phase — never re-check this process again.
      procInfo._spawnPhaseCleared = true;
      continue;
    }

    // Still in spawn phase past the grace. Now gate on CPU.
    const pid = proc.pid;
    if (!pid) continue;
    const cpuSec = cpuSampler(pid);
    if (cpuSec == null) {
      // Fail-open: can't sample → don't kill. Try again next tick.
      continue;
    }
    if (cpuSec > maxCpuSec) {
      // Process is doing work; just slow to emit. Stand down.
      continue;
    }

    // All four gates passed — this is a wedge. Kill + complete.
    const elapsedSec = Math.round(elapsed / 1000);
    const reason = `Spawn-phase stall: no task events after ${elapsedSec}s (CPU ${cpuSec.toFixed(2)}s ≤ ${maxCpuSec}s)`;
    log('warn', `spawn-phase-watchdog: killing ${agentId} (${id}) — ${reason}`);

    // Write a structured inbox note BEFORE killing so the dispatch
    // completion's failure-report doesn't overwrite our diagnostic.
    try {
      const noteContent = [
        `# Spawn-phase stall: ${agentId}`,
        '',
        `**Dispatch:** \`${id}\``,
        `**Agent:** ${agentId}`,
        `**Runtime:** ${procInfo.runtimeName || '(unknown)'}`,
        `**PID:** ${pid}`,
        `**Elapsed since spawn:** ${elapsedSec}s`,
        `**CPU seconds:** ${cpuSec.toFixed(2)} (threshold ${maxCpuSec})`,
        `**Grace window:** ${Math.round(graceMs / 1000)}s`,
        '',
        '## Tail of live-output.log',
        '```',
        (tail || '(empty)').slice(-2000),
        '```',
        '',
        'The runtime emitted only startup-only events (MCP init, hook fires) ' +
        'and burned no CPU past the grace window. Engine treated this as a wedged ' +
        'startup and killed the child; the dispatch is marked retryable so the next ' +
        'tick will respawn with a fresh runtime invocation.',
      ].join('\n');
      shared.writeToInbox('engine', `spawn-phase-stall-${id}`, noteContent, null, {
        dispatchId: id, agent: agentId, failureClass: FAILURE_CLASS.SPAWN_PHASE_STALL,
      });
    } catch (e) { log('warn', `spawn-phase-watchdog: inbox note: ${e.message}`); }

    try { killer(proc); }
    catch (e) { log('warn', `spawn-phase-watchdog: kill: ${e.message}`); }

    try {
      completer(id, shared.DISPATCH_RESULT.ERROR, reason, '', {
        failureClass: FAILURE_CLASS.SPAWN_PHASE_STALL,
        agentRetryable: true,
        processWorkItemFailure: true,
      });
    } catch (e) { log('warn', `spawn-phase-watchdog: completeDispatch: ${e.message}`); }

    // Clear the engine's tracking so the next discovery tick can rebuild.
    try { activeProcesses.delete(id); } catch { /* defensive */ }
  }
}

function _defaultTailReader(filePath) {
  try {
    const stat = fs.statSync(filePath);
    if (!stat.size) return '';
    const maxBytes = 16384;
    const tailSize = Math.min(stat.size, maxBytes);
    const fd = fs.openSync(filePath, 'r');
    try {
      const buf = Buffer.alloc(tailSize);
      fs.readSync(fd, buf, 0, tailSize, Math.max(0, stat.size - tailSize));
      return buf.toString('utf8');
    } finally { fs.closeSync(fd); }
  } catch { return null; }
}

module.exports = {
  checkSpawnPhaseStalls,
  hasRealTaskActivity, // exported for testing
  COPILOT_STARTUP_TYPES, // exported for testing
  CLAUDE_STARTUP_SUBTYPES, // exported for testing
};
