/**
 * engine/timeout.js — Runtime timeout, stale-orphan cleanup, steering, and idle checks.
 *
 * Live-checkout dispatches (project.worktreeMode === 'live') are killed by PID
 * exactly like isolated-mode dispatches — no special handling, no in-place
 * `git reset`/`git clean`, no working-tree cleanup. The engine only ever sends
 * SIGTERM/SIGKILL to the tracked process; the operator owns the checkout.
 */

const fs = require('fs');
const path = require('path');
const shared = require('./shared');
const queries = require('./queries');
const steering = require('./steering');

const { safeRead, safeWrite, safeJson, mutateJsonFileLocked, getProjects, projectWorkItemsPath, log, ts,
  ENGINE_DEFAULTS, ENGINE_DIR, WI_STATUS, WORK_TYPE, DISPATCH_RESULT, AGENT_STATUS, FAILURE_CLASS } = shared;
const { getDispatch, getAgentStatus } = queries;
const AGENTS_DIR = queries.AGENTS_DIR;
const MINIONS_DIR = shared.MINIONS_DIR;

// Lazy require to break circular dependency with engine.js
// Only needed for engine().activeProcesses and engine().engineRestartGraceUntil — log/ts come from shared.js
let _engine = null;
function engine() { if (!_engine) _engine = require('../engine'); return _engine; }

// Lazy require for dispatch module (also circular via engine)
let _dispatch = null;
function dispatch() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }

// ─── Idle Alert State ────────────────────────────────────────────────────────

let _lastActivityTime = Date.now();
let _idleAlertSent = false;

// ─── Idle Threshold Check ────────────────────────────────────────────────────

function checkIdleThreshold(config) {
  const { isAgentIdle } = require('./routing');
  const thresholdMs = (config.engine?.idleAlertMinutes || 15) * 60 * 1000;
  const agents = Object.keys(config.agents || {});
  const allIdle = agents.every(id => isAgentIdle(id));
  const dispatchData = getDispatch();
  const hasPending = (dispatchData.pending || []).length > 0;

  if (!allIdle || hasPending) {
    _lastActivityTime = Date.now();
    _idleAlertSent = false;
    return;
  }

  const idleMs = Date.now() - _lastActivityTime;
  if (idleMs > thresholdMs && !_idleAlertSent) {
    const mins = Math.round(idleMs / 60000);
    log('warn', `All agents idle for ${mins} minutes — no work sources producing items`);
    _idleAlertSent = true;
  }
}

// ─── Steering Checker ────────────────────────────────────────────────────────

// W-mq066js7000fff1f-c (Gap C): kill-retry escalation ladder. Intervals between
// successive kill attempts after a steering kill issues. Attempt i (1-indexed)
// uses STEERING_KILL_INTERVALS_MS[min(i-1, len-1)]; last value repeats so cap=5
// keeps stepping every 120s. cap=1 = one graceful retry only (then give-up).
const STEERING_KILL_INTERVALS_MS = [30000, 60000, 120000];
// Set of steering-store statuses still "in flight" — Gap G ignores entries that
// have already terminated (delivered/dropped/etc).
const STEERING_ACTIVE_STATUSES = new Set(['queued', 'live_kill', 'deferred', 're_spawning']);

// W-mq066js7000fff1f-c (Gap G): lazy require for engine/steering-store.js.
// Sibling branch -d ships the store; my branch ships independently and no-ops
// when the module is absent. Only swallows MODULE_NOT_FOUND for the exact
// resolution — real syntax/runtime errors propagate so a broken store fails
// visibly instead of silently degrading.
let _steeringStoreLoaded = false;
let _steeringStore = null;
function getSteeringStore() {
  if (_steeringStoreLoaded) return _steeringStore;
  _steeringStoreLoaded = true;
  try {
    _steeringStore = require('./steering-store');
  } catch (err) {
    const isMissing = err && err.code === 'MODULE_NOT_FOUND' && /steering-store/.test(String(err.message || ''));
    if (!isMissing) {
      try { log('warn', `Steering: failed to load engine/steering-store.js: ${err.message}`); } catch {}
    }
    _steeringStore = null;
  }
  return _steeringStore;
}
// Test-only reset.
function _resetSteeringStoreCacheForTest() { _steeringStoreLoaded = false; _steeringStore = null; }
// Test-only direct injection — bypasses the lazy require entirely. Pass `null`
// to simulate the module-absent case without touching require.cache.
function _setSteeringStoreForTest(store) {
  _steeringStoreLoaded = true;
  _steeringStore = store;
}

function _steeringIdForEntry(entry) {
  if (!entry) return null;
  if (entry.id) return String(entry.id);
  const filePath = entry.path || entry;
  if (typeof filePath !== 'string') return null;
  const m = path.basename(filePath).match(/^steering-(\d+)/);
  return m ? m[1] : null;
}

function _safeStoreCall(fn, ...args) {
  const store = getSteeringStore();
  if (!store || typeof store[fn] !== 'function') return null;
  try { return store[fn](...args); }
  catch (err) {
    try { log('warn', `Steering store ${fn} failed: ${err.message}`); } catch {}
    return null;
  }
}

function _clampSteeringDeferredMaxMs(config) {
  const raw = Number(config?.engine?.steeringDeferredMaxMs ?? ENGINE_DEFAULTS.steeringDeferredMaxMs);
  if (!Number.isFinite(raw)) return ENGINE_DEFAULTS.steeringDeferredMaxMs;
  return Math.max(60000, Math.min(14400000, raw));
}

function _clampSteeringMaxKillRetries(config) {
  const raw = Number(config?.engine?.steeringMaxKillRetries ?? ENGINE_DEFAULTS.steeringMaxKillRetries);
  if (!Number.isFinite(raw)) return ENGINE_DEFAULTS.steeringMaxKillRetries;
  return Math.max(1, Math.min(5, Math.round(raw)));
}

function _steeringKillIntervalMs(attemptIdx) {
  const i = Math.max(0, Math.min(STEERING_KILL_INTERVALS_MS.length - 1, attemptIdx));
  return STEERING_KILL_INTERVALS_MS[i];
}

function _appendLiveOutputLine(agentId, line) {
  try {
    const liveLogPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
    fs.appendFileSync(liveLogPath, line.endsWith('\n') ? line : `${line}\n`);
  } catch { /* optional */ }
}

function runtimeSupportsMidRunSessionId(info) {
  if (typeof info?.midRunSessionId === 'boolean') return info.midRunSessionId;
  if (typeof info?.runtime?.capabilities?.midRunSessionId === 'boolean') return info.runtime.capabilities.midRunSessionId;
  if (info?.runtimeName) {
    try {
      const { resolveRuntime } = require('./runtimes');
      const runtime = resolveRuntime(info.runtimeName);
      if (typeof runtime.capabilities?.midRunSessionId === 'boolean') return runtime.capabilities.midRunSessionId;
    } catch {
      return true;
    }
  }
  return true;
}

function rememberDeferredSteering(info, steerEntry) {
  const existing = new Set(Array.isArray(info._deferredSteeringFiles) ? info._deferredSteeringFiles : []);
  if (steerEntry?.path) existing.add(steerEntry.path);
  info._deferredSteeringFiles = Array.from(existing);
  // Stamp per-entry deferred timestamp for the Gap B stranded-sweep. Map keyed
  // by file path so multiple deferred messages each track their own clock.
  if (steerEntry?.path) {
    if (!info._deferredSteeringQueuedAt || typeof info._deferredSteeringQueuedAt !== 'object') {
      info._deferredSteeringQueuedAt = {};
    }
    if (!info._deferredSteeringQueuedAt[steerEntry.path]) {
      info._deferredSteeringQueuedAt[steerEntry.path] = Date.now();
    }
  }
}

function deferSteeringUntilCheckpoint(id, info, steerEntry) {
  log('info', `Steering: no mid-run resumable checkpoint for ${info.agentId} (${id}) — queued until checkpoint`);
  rememberDeferredSteering(info, steerEntry);
  // W-mq066js7000fff1f-a (Gap D): mark the delivery-state row as
  // 'deferred' so the dashboard can show the queued-for-checkpoint
  // disposition. Heuristic ack still progresses to 'acknowledged'
  // once the resumed turn produces output evidence.
  if (steerEntry?.steerId) {
    try {
      const store = require('./steering-store');
      store.updateStatus(steerEntry.steerId, 'deferred', { dispatchId: id, runtime: info?.runtimeName || null });
    } catch { /* best-effort */ }
  }
  try {
    const liveLogPath = path.join(AGENTS_DIR, info.agentId, 'live-output.log');
    fs.appendFileSync(liveLogPath, `\n[steering] Message received. This runtime has not emitted a resumable checkpoint for the current run yet, so the message is queued until the agent reaches a resumable checkpoint or the next dispatch.\n`);
  } catch { /* optional */ }
}

// W-mq066js7000fff1f-c (Gap B): per-tick sweep over `_deferredSteeringQueuedAt`
// entries that are still in `_deferredSteeringFiles` (source-of-truth) and have
// no sessionId after `engine.steeringDeferredMaxMs`. Each match: append
// `[steering-warn]` to live-output, mark `_steeringStranded: true` on the matching
// dispatch active row, and `store.updateStatus(id, 'stranded')` (no-op if store
// absent). `info._deferredSteeringStrandedFiles` (Set) guards against re-firing
// the same warning every tick while the file remains stranded.
function checkDeferredStranded(activeProcesses, config) {
  const maxMs = _clampSteeringDeferredMaxMs(config);
  const now = Date.now();
  for (const [dispatchId, info] of activeProcesses) {
    if (!info || info.sessionId) continue;
    const queuedAt = info._deferredSteeringQueuedAt;
    if (!queuedAt || typeof queuedAt !== 'object') continue;
    const deferredSet = new Set(Array.isArray(info._deferredSteeringFiles) ? info._deferredSteeringFiles : []);
    if (deferredSet.size === 0) continue;
    if (!info._deferredSteeringStrandedFiles) info._deferredSteeringStrandedFiles = new Set();
    const stranded = info._deferredSteeringStrandedFiles;
    for (const filePath of deferredSet) {
      if (stranded.has(filePath)) continue;
      const ts = Number(queuedAt[filePath]);
      if (!Number.isFinite(ts) || now - ts <= maxMs) continue;
      const entryId = _steeringIdForEntry({ path: filePath });
      const ageMin = Math.round((now - ts) / 60000);
      _appendLiveOutputLine(info.agentId,
        `\n[steering-warn] Steering message ${entryId || path.basename(filePath)} has been queued for ${ageMin}m without a resumable checkpoint (no sessionId emitted). It will be delivered on the next dispatch.`);
      log('warn', `Steering: ${info.agentId} (${dispatchId}) deferred steering ${entryId || filePath} stranded after ${ageMin}m — no sessionId`);
      stranded.add(filePath);
      try {
        dispatch().mutateDispatch((data) => {
          for (const row of data.active || []) {
            if (row && row.id === dispatchId) row._steeringStranded = true;
          }
          return data;
        });
      } catch (err) {
        try { log('warn', `Steering: mutateDispatch _steeringStranded failed for ${dispatchId}: ${err.message}`); } catch {}
      }
      if (entryId) _safeStoreCall('updateStatus', entryId, 'stranded', { last_error: 'no-session-after-max-defer' });
    }
  }
}

// W-mq066js7000fff1f-c (Gap C): runs the kill-retry escalation ladder for an
// already-steered process whose `_steeringAt` is set but which hasn't exited.
// Caller is `checkSteering` per-tick loop; `info` is the `activeProcesses`
// record. Returns true if any rung fired (caller should `continue` and not
// scan for new steering messages for this dispatch).
function _runSteeringKillLadder(id, info, config) {
  if (!info._steeringAt || info._steeringGaveUp) return false;
  const cap = _clampSteeringMaxKillRetries(config);
  const attempts = Number(info._steeringKillAttempts) || 0;
  // Reference time: anchor on `_steeringAt` for attempt 0 (the bookkeeping
  // sequence), then on `_steeringLastRetryAt` for subsequent rungs.
  const refTime = attempts === 0 ? info._steeringAt : (info._steeringLastRetryAt || info._steeringAt);
  const wait = _steeringKillIntervalMs(attempts);
  if (Date.now() - refTime <= wait) return false;

  // attempts < cap → run kill attempt (i=0 graceful, else platform escalation).
  if (attempts < cap) {
    if (attempts === 0) {
      log('warn', `Steering: ${info.agentId} (${id}) didn't exit ${Math.round(wait / 1000)}s after kill — retrying gracefully`);
      try { shared.killGracefully(info.proc, 5000); }
      catch (err) { try { log('warn', `Steering kill retry (graceful) failed for ${info.agentId}: ${err.message}`); } catch {} }
    } else {
      log('warn', `Steering: ${info.agentId} (${id}) survived attempt ${attempts} — escalating (attempt ${attempts + 1}/${cap})`);
      _escalatePlatformKill(info);
    }
    info._steeringKillAttempts = attempts + 1;
    info._steeringLastRetryAt = Date.now();
    return true;
  }

  // attempts === cap → give up: log + non-actionable inbox notice.
  const minutes = Math.round((Date.now() - info._steeringAt) / 60000);
  log('error', `Steering: ${info.agentId} (${id}) did not exit after ${cap} kill attempts (${minutes}m since kill) — giving up`);
  _appendLiveOutputLine(info.agentId,
    `\n[steering-stuck] Process did not exit after ${cap} kill attempts over ~${minutes}m. The engine is giving up automatic escalation; operator intervention may be required.`);
  try {
    // System notification body is framed as non-actionable: prefix `[engine-system]`
    // so an agent that re-consumes it from the inbox treats it as metadata. The
    // primary signal is the live-output line above (operator-facing) and the
    // give-up log; this inbox write is a belt-and-suspenders notification.
    steering.writeSteeringMessage(info.agentId,
      `[engine-system] Steering kill escalation gave up after ${cap} attempts (~${minutes}m). This is an automated notification — do not act on it; the engine has surfaced this dispatch for operator attention.`,
      { source: 'engine' });
  } catch (err) {
    try { log('warn', `Steering: writeSteeringMessage for give-up failed: ${err.message}`); } catch {}
  }
  info._steeringGaveUp = true;
  info._steeringKillAttempts = attempts + 1;
  info._steeringLastRetryAt = Date.now();
  return true;
}

function _escalatePlatformKill(info) {
  const pid = info?.proc?.pid;
  if (!pid) return;
  if (process.platform === 'win32') {
    try { shared.exec(`taskkill /F /T /PID ${pid}`, { timeout: 3000 }); }
    catch { /* may already be dead */ }
    return;
  }
  // Unix: collect descendant PIDs (deepest first), SIGKILL each, then a final
  // `pkill -KILL -P <pid>` sweep for anything pgrep missed.
  const descendants = _collectDescendantPids(pid);
  for (const child of descendants) {
    try { process.kill(child, 'SIGKILL'); } catch { /* gone */ }
  }
  try { shared.exec(`pkill -KILL -P ${pid}`, { timeout: 3000 }); }
  catch { /* children may already be dead */ }
  try { process.kill(pid, 'SIGKILL'); } catch { /* parent may already be dead */ }
}

function _collectDescendantPids(rootPid) {
  // Returns descendant PIDs ordered deepest-first so leaves die before parents
  // (otherwise grandchildren get re-parented to init and disappear from pgrep -P
  // <pid>). BFS the tree, then reverse.
  const visited = new Set();
  const queue = [rootPid];
  const order = [];
  while (queue.length) {
    const current = queue.shift();
    if (visited.has(current)) continue;
    visited.add(current);
    let out = '';
    try { out = String(shared.exec(`pgrep -P ${current}`, { timeout: 2000 }) || ''); }
    catch { /* no children or pgrep missing */ continue; }
    const children = out.split(/\s+/).map(s => Number(s)).filter(n => Number.isFinite(n) && n > 0);
    for (const child of children) {
      if (visited.has(child)) continue;
      order.push(child);
      queue.push(child);
    }
  }
  return order.reverse();
}

function checkSteering(config) {
  const activeProcesses = engine().activeProcesses;
  // Gap B: stranded-deferred sweep runs BEFORE the per-dispatch scan so a
  // stranded info still records its warning even if it has no new inbox file.
  try { checkDeferredStranded(activeProcesses, config); }
  catch (err) { try { log('warn', `Steering: checkDeferredStranded failed: ${err.message}`); } catch {} }

  for (const [id, info] of activeProcesses) {
    // Gap A (W-mq066js7000fff1f-b, master): scan agents/<id>/steering-ack/
    // for any ack files the agent has dropped since the last tick. Each
    // <id>.ack removes its matching inbox file (lookup via frontmatter
    // steerId), so unread/pending iteration below naturally skips messages
    // already acknowledged via the explicit contract.
    let ackedFromDir = [];
    try {
      ackedFromDir = steering.ackSteeringFromAckDir(info.agentId);
    } catch (err) {
      log('warn', `Steering ack-dir scan failed for ${info.agentId}: ${err.message}`);
    }
    if (ackedFromDir.length > 0) {
      const ackedPaths = new Set(ackedFromDir.map(a => a.inboxPath).filter(Boolean));
      // Drop the acked inbox files from the per-process pending/deferred
      // bookkeeping so the legacy stdout-heuristic ack pass (engine.js
      // pruneAckedSteeringFiles) does not redundantly re-scan them.
      if (Array.isArray(info._pendingSteeringFiles) && info._pendingSteeringFiles.length > 0) {
        info._pendingSteeringFiles = info._pendingSteeringFiles.filter(
          entry => !ackedPaths.has(entry?.path || entry)
        );
        if (info._pendingSteeringFiles.length === 0) delete info._pendingSteeringFiles;
      }
      if (Array.isArray(info._deferredSteeringFiles) && info._deferredSteeringFiles.length > 0) {
        info._deferredSteeringFiles = info._deferredSteeringFiles.filter(
          p => !ackedPaths.has(p)
        );
        if (info._deferredSteeringFiles.length === 0) delete info._deferredSteeringFiles;
      }
      try {
        const liveLogPath = path.join(AGENTS_DIR, info.agentId, 'live-output.log');
        const lines = ackedFromDir
          .map(a => `[steering-ack] ${a.steerId}`)
          .join('\n');
        fs.appendFileSync(liveLogPath, `\n${lines}\n`);
      } catch { /* observability-only */ }
      for (const acked of ackedFromDir) {
        log('info', `Steering ack: ${info.agentId} acknowledged steer-${acked.steerId} via ack-file`);
      }
    }

    // Gap C (this PR): if a steering kill is in-flight, run the escalation
    // ladder. The ladder owns all retry side effects and the give-up exit,
    // and replaces the old one-shot STEERING_KILL_RETRY_MS retry path.
    if (info._steeringAt && !info._steeringGaveUp) {
      const fired = _runSteeringKillLadder(id, info, config);
      if (fired) continue;
    }

    // Skip if already being steered (prevents double-kill race)
    if (info._steeringMessage || info._steeringAt) continue;

    const alreadyPending = new Set([
      ...(info._pendingSteeringFiles || []).map(entry => entry.path || entry),
      ...(info._deferredSteeringFiles || []),
    ]);
    const unread = steering.listUnreadSteeringMessages(info.agentId);
    for (const empty of unread.filter(entry => !entry.message.trim())) {
      shared.safeUnlink(empty.path);
    }
    const steerEntry = unread.find(entry => entry.message.trim() && !alreadyPending.has(entry.path));
    if (!steerEntry) continue; // ENOENT/no agents/<id>/inbox/steering-*.md message
    const message = steerEntry.message.trim();

    if (!runtimeSupportsMidRunSessionId(info)) {
      deferSteeringUntilCheckpoint(id, info, steerEntry);
      continue;
    }

    const sessionId = info.sessionId;
    if (!sessionId) {
      // No session to resume for a runtime that should have emitted one — kill
      // agent and leave message unread in inbox for retry. Previously this
      // silently skipped for up to 5m then deleted the message (#627).
      log('info', `Steering: no sessionId for ${info.agentId} (${id}) — killing and keeping unread message in inbox`);

      // Append to live output so user sees confirmation in the dashboard
      try {
        const liveLogPath = path.join(AGENTS_DIR, info.agentId, 'live-output.log');
        fs.appendFileSync(liveLogPath, `\n[steering] Message received but no session to resume. Killing agent — your message will be delivered on retry.\n`);
      } catch { /* optional */ }

      shared.killImmediate(info.proc);
      info._steeringAt = Date.now();
      info._steeringNoSession = true;
      continue;
    }

    log('info', `Steering: killing ${info.agentId} (${id}) for session resume with human message`);

    // Set steering state BEFORE kill — close event may fire synchronously on some platforms
    info._steeringMessage = message;
    info._steeringSessionId = sessionId;
    info._steeringEntry = steerEntry;
    info._steeringAt = Date.now();

    // W-mq066js7000fff1f-a (Gap D): transition the delivery-state row
    // to 'live_kill' — captures that the engine killed the live agent
    // process to deliver this message via session resume. Best-effort.
    if (steerEntry?.steerId) {
      try {
        const store = require('./steering-store');
        store.updateStatus(steerEntry.steerId, 'live_kill', { dispatchId: id, runtime: info?.runtimeName || null });
      } catch { /* best-effort */ }
    }

    shared.killImmediate(info.proc);
  }
}

// W-mq066js7000fff1f-c (Gap G): when engine.js#onAgentClose's "No conversation
// found" branch is about to unlink the session.json, any steering-store entries
// still in flight against that purged sessionId would silently strand. This
// helper drops them with `status='dropped'` + a `[steering-failed]` live-output
// line so the human knows to re-send. Caller MUST invoke this BEFORE the unlink.
// No-ops cleanly when the steering-store module is absent (-d branch unmerged).
function dropSteeringForPurgedSession(agentId, sessionId, liveOutputPath) {
  if (!agentId || !sessionId) return { dropped: [], skipped: true };
  const store = getSteeringStore();
  if (!store || typeof store.listForAgent !== 'function') return { dropped: [], skipped: true };
  let entries = [];
  try { entries = store.listForAgent(agentId) || []; }
  catch (err) {
    try { log('warn', `Steering: listForAgent for ${agentId} failed: ${err.message}`); } catch {}
    return { dropped: [], skipped: true };
  }
  const dropped = [];
  for (const entry of entries) {
    if (!entry || !STEERING_ACTIVE_STATUSES.has(String(entry.status || ''))) continue;
    const entrySessionId = entry._steeringSessionId || entry.sessionId || entry.session_id;
    if (entrySessionId !== sessionId) continue;
    const entryId = entry.id || _steeringIdForEntry(entry);
    if (!entryId) continue;
    _safeStoreCall('updateStatus', entryId, 'dropped', { last_error: 'session-purged' });
    const line = `\n[steering-failed] Session ${sessionId} was purged by runtime; message ${entryId} dropped, please re-send.\n`;
    if (liveOutputPath) {
      try { fs.appendFileSync(liveOutputPath, line); }
      catch { /* optional */ }
    } else {
      _appendLiveOutputLine(agentId, line);
    }
    log('warn', `Steering: dropped message ${entryId} for ${agentId} (session ${sessionId} purged)`);
    dropped.push(entryId);
  }
  return { dropped, skipped: false };
}

// ─── Timeout Checker ─────────────────────────────────────────────────────────

function trackedProcessPid(procInfo) {
  const pid = Number(procInfo?.proc?.pid || procInfo?.pid || 0);
  return Number.isFinite(pid) && pid > 0 ? pid : null;
}

function isTrackedProcessAlive(procInfo) {
  if (!procInfo) return false;
  const proc = procInfo.proc;
  if (proc && Object.prototype.hasOwnProperty.call(proc, 'exitCode') && proc.exitCode !== null) {
    return false;
  }

  const pid = trackedProcessPid(procInfo);
  if (!pid) return !!proc && proc.killed !== true;
  try {
    process.kill(pid, 0);
    return true;
  } catch {
    return false;
  }
}

// Read the on-disk PID for a dispatch ID, or null if missing/invalid. Shared by
// `isOsPidAliveForDispatch` and the recycled-PID command-line cross-check below
// so both paths agree on which integer PID to interrogate.
// P-f6-tmp-toctou: walks both legacy flat layout (engine/tmp/pid-<safeId>.pid)
// and the per-dispatch dir layout (engine/tmp/dispatch-<safeId>-*/pid-<safeId>.pid).
function _readDispatchPid(itemId) {
  const pidPath = shared.findDispatchPidFile(itemId);
  if (!pidPath) return null;
  let raw;
  try { raw = fs.readFileSync(pidPath, 'utf8'); }
  catch { return null; }
  const pid = parseInt(String(raw).trim(), 10);
  return Number.isFinite(pid) && pid > 0 ? pid : null;
}

// Last-resort liveness check via the on-disk PID file (engine/tmp/pid-<safeId>.pid).
// Used by orphan detection to avoid false-positive kills when the engine has lost the
// tracked process handle (engine restart, never-tracked spawn, etc.) but the OS-level
// child process is still alive and healthy. The safeId here mirrors engine.js spawn
// (id.replace(/[:\\/*?"<>|]/g, '-')) — same pattern engine/cli.js uses to re-attach.
function isOsPidAliveForDispatch(itemId) {
  const pid = _readDispatchPid(itemId);
  if (!pid) return false;
  try { process.kill(pid, 0); return true; }
  catch { return false; }
}

function readFileTail(filePath, maxBytes) {
  const fd = fs.openSync(filePath, 'r');
  try {
    const stat = fs.fstatSync(fd);
    const tailSize = Math.min(stat.size, maxBytes);
    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);
  }
}

function parseProcessExitCode(logText) {
  if (!logText) return null;
  const exitPattern = /(?:^|\n)\[process-exit\]\s+(?:code=)?(-?\d+|spawn-failed)(?=\s|$)/g;
  let lastMatch = null;
  let m;
  while ((m = exitPattern.exec(logText)) !== null) lastMatch = m;
  if (!lastMatch) return null;
  return lastMatch[1] === 'spawn-failed' ? -1 : parseInt(lastMatch[1], 10);
}

function terminalResultIndicatesError(obj) {
  const subtype = String(obj?.subtype || '');
  const terminalReason = String(obj?.terminal_reason || obj?.terminalReason || '');
  return obj?.is_error === true ||
    /^error/i.test(subtype) ||
    /max[_-]?turns|error|fail|cancel|timeout/i.test(terminalReason);
}

function parseTerminalResultFallbackExitCode(logText) {
  if (!logText) return null;
  let exitCode = null;
  for (const line of String(logText).split(/\r?\n/)) {
    const trimmed = line.trim();
    if (!trimmed || !trimmed.includes('"result"') ||
        (!trimmed.includes('terminal_reason') && !trimmed.includes('terminalReason'))) continue;

    try {
      const obj = JSON.parse(trimmed);
      if (obj?.type === 'result' && (obj.terminal_reason || obj.terminalReason) && terminalResultIndicatesError(obj)) {
        exitCode = 1;
      }
      continue;
    } catch { /* fall through to regex fallback for diagnostic-prefixed JSON */ }

    if (/"type"\s*:\s*"result"/.test(trimmed) &&
        /"terminal_?reason"\s*:\s*"[^"]*(?:max[_-]?turns|error|fail|cancel|timeout)[^"]*"/i.test(trimmed)) {
      exitCode = 1;
    }
  }
  return exitCode;
}

function checkTimeouts(config) {
  const activeProcesses = engine().activeProcesses;
  const engineRestartGraceUntil = engine().engineRestartGraceUntil;
  const engineRestartGraceExempt = engine().engineRestartGraceExempt;
  const { completeDispatch } = dispatch();
  // P-a7b2c1d9: fenced ```completion stdout parser (formerly destructured here)
  // deliberately dropped from this destructure. The recovery path uses the
  // sidecar (parseCompletionReportFile) as the sole structured-completion
  // source. The stdout parser added no new signal on the timeout/exit-recovery
  // path — only a disagreement risk between the sidecar and the stdout block.
  // Removing this caller unblocks the completion-fallback-parsers deprecation
  // sweep window in docs/deprecated.json (its `counter==0 ⇒ parser unused`
  // claim is now logically sound).
  const { runPostCompletionHooks, parseAgentOutput, parseCompletionReportFile, detectNonTerminalResultSummary } = require('./lifecycle');

  const timeout = config.engine?.agentTimeout || ENGINE_DEFAULTS.agentTimeout;
  const defaultStaleOrphanTimeout = config.engine?.heartbeatTimeout || ENGINE_DEFAULTS.heartbeatTimeout;
  const runtimeResumeHeartbeatTimeout = config.engine?.resumeHeartbeatTimeout || ENGINE_DEFAULTS.resumeHeartbeatTimeout || defaultStaleOrphanTimeout;

  // Optional per-type stale-orphan timeouts: merge ENGINE_DEFAULTS ← config overrides.
  const perTypeStaleOrphanTimeouts = { ...ENGINE_DEFAULTS.heartbeatTimeouts, ...(config.engine?.heartbeatTimeouts || {}) };

  // 1. Check tracked processes for hard timeout (supports per-item deadline from fan-out)
  for (const [id, info] of activeProcesses.entries()) {
    const itemTimeout = info.meta?.deadline ? Math.max(0, info.meta.deadline - new Date(info.startedAt).getTime()) : timeout;
    const elapsed = Date.now() - new Date(info.startedAt).getTime();
    if (elapsed > itemTimeout) {
      log('warn', `Agent ${info.agentId} (${id}) hit hard timeout after ${Math.round(elapsed / 1000)}s — killing`);
      dispatch().updateAgentStatus(id, AGENT_STATUS.TIMED_OUT, `Hard timeout after ${Math.round(elapsed / 1000)}s`);
      shared.killGracefully(info.proc, 5000);
    }
  }

  // 2. Stale-orphan check — for ALL active dispatch items (catches lost process handles after restart).
  //    Silence is not a failure for tracked live processes once a runtime has emitted output:
  //    long CLI commands can legitimately produce no stdout/stderr for extended periods.
  //    The exception is a resumed runtime that has not produced its first stdout/stderr
  //    heartbeat after spawn; that is the "alive but stuck in --resume" failure mode.
  const dispatchData = getDispatch();
  const deadItems = [];
  const legacyAnnotationClears = new Set();

  function completeFromOutput(item, liveLogPath, processExitCode, detectedLogText, hasProcess) {
    const isSuccess = processExitCode === 0;
    log('info', `Agent ${item.agent} (${item.id}) completed via output detection (exit code ${processExitCode}, ${isSuccess ? 'success' : 'error'})`);

    // Extract output text for the output.log — read full file for complete parsing
    const outputLogPath = path.join(AGENTS_DIR, item.agent, 'output.log');
    try {
      const fullLog = safeRead(liveLogPath) || detectedLogText;
      const { text } = shared.parseStreamJsonOutput(fullLog);
      safeWrite(outputLogPath, `# Output for dispatch ${item.id}\n# Exit code: ${processExitCode}\n# Completed: ${ts()}\n# Detected via output scan\n\n## Result\n${text || '(no text)'}\n`);
    } catch (e) { log('warn', 'parse output result: ' + e.message); }

    const fullLogForHooks = safeRead(liveLogPath) || detectedLogText;
    let completionDetection = null;
    let outputResultSummary = '';
    try {
      const runtimeName = item.meta?.runtimeName || item.runtimeName || 'claude';
      outputResultSummary = parseAgentOutput(fullLogForHooks, runtimeName).resultSummary || '';
      const gateSummary = outputResultSummary || (!fullLogForHooks.includes('"type":') ? fullLogForHooks : '');
      // P-a7b2c1d9: pass `null` for structuredCompletion. By the time
      // checkTimeouts runs, the sidecar `completion.json` (parseCompletionReportFile)
      // is the documented source of truth; the fenced ```completion stdout
      // parser is intentionally bypassed here.
      completionDetection = isSuccess
        ? detectNonTerminalResultSummary(gateSummary, null, parseCompletionReportFile(item), { detectPhantom: true })
        : null;
    } catch (e) { log('warn', 'completion summary gate: ' + e.message); }

    completeDispatch(item.id, completionDetection ? DISPATCH_RESULT.ERROR : (isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR),
      completionDetection ? completionDetection.reason : (isSuccess ? 'Completed (detected from output)' : `Exited with code ${processExitCode} (detected from output)`),
      outputResultSummary,
      completionDetection ? { processWorkItemFailure: false } : {});

    // Run post-completion hooks via shared helper (async — fire and forget in timeout context).
    // Pass the actual exit code so autoRecovery (PR-created-but-failed) still works correctly.
    // detectPhantom: true mirrors the line 310 detectNonTerminalResultSummary call —
    // when the timeout path completes a dispatch via the [process-exit] sentinel,
    // we have no guarantee the runtime emitted a result event. Propagating
    // detectPhantom downstream lets enforcePrAttachmentContract route phantom
    // hard-fails through the _phantomRetryCount budget instead of bypassing
    // the retry counter entirely (P-d9a3e6f4).
    // P-d2a8f6c1: hand the per-spawn nonce to lifecycle for trust-boundary
    // validation. Read it from activeProcesses before the hasProcess branch
    // below deletes the entry.
    const expectedNonce = activeProcesses.get(item.id)?._completionNonce || null;
    const completionNonceRequired = config?.engine?.completionNonceRequired ?? ENGINE_DEFAULTS.completionNonceRequired;
    runPostCompletionHooks(item, item.agent, processExitCode, fullLogForHooks, config, { detectPhantom: true, expectedNonce, completionNonceRequired }).catch(e => log('warn', 'post-completion hooks: ' + e.message));

    if (hasProcess) {
      shared.killImmediate(activeProcesses.get(item.id)?.proc);
      activeProcesses.delete(item.id);
    }
  }

  for (const item of (dispatchData.active || [])) {
    if (!item.agent) continue;

    // Per-type stale-orphan timeout: look up work type from dispatch item, fall back to default.
    const workType = item.workType || item.meta?.item?.type;
    const staleOrphanTimeout = (workType && perTypeStaleOrphanTimeouts[workType]) || defaultStaleOrphanTimeout;

    const procInfo = activeProcesses.get(item.id);
    const hasProcess = !!procInfo;
    let processAlive = isTrackedProcessAlive(procInfo);
    const liveLogPath = path.join(AGENTS_DIR, item.agent, 'live-output.log');
    let lastActivity = item.started_at ? new Date(item.started_at).getTime() : 0;

    // live-output.log mtime is used for stale-orphan cleanup, completion recovery,
    // and the resume first-output watchdog. It is not a general output-silence
    // timeout for live tracked processes.
    try {
      const stat = fs.statSync(liveLogPath);
      lastActivity = Math.max(lastActivity, stat.mtimeMs);
    } catch { /* optional */ }

    const silentMs = Date.now() - lastActivity;
    const silentSec = Math.round(silentMs / 1000);

    // Check if the agent actually completed by looking for the [process-exit] sentinel.
    //
    // The sentinel is written synchronously by spawn-agent.js's proc.on('close') handler
    // BEFORE spawn-agent itself exits, in the form:
    //   "\n[process-exit] code=<N>\n"        — normal exit (any exit code)
    //   "\n[process-exit] spawn-failed\n"    — synchronous spawn() throw before runFile returned
    //
    // This sentinel is the single source of truth for "process is gone" + "what was the
    // exit code". We rely on the actual exit code — NOT a "subtype":"success" substring
    // match — to decide success/error. Substring-matching `subtype:"success"` was the
    // false-positive vector for #1792: a resumed --resume turn emits subtype:"success"
    // even when the agent did no real work, while the OS exit code can still be 1, so
    // the dispatch was being marked SUCCESS for a no-op resumed session. Exit code from
    // the [process-exit] sentinel reflects what the OS actually reported.
    //
    // We tail 64KB — process-exit is always the last non-empty line of the file.
    // No time cap: a stuck dispatch whose process has exited must always be detected (#716).
    // completedViaOutput detection is gated on a [process-exit] code=N sentinel;
    // a "type":"result" event alone can race engine.js's close handler (#1792).
    try {
      let liveLogTail;
      try {
        liveLogTail = readFileTail(liveLogPath, 65536); // 64KB
      } catch { /* ENOENT or read failure — liveLogTail stays undefined */ }

      // Parse the LAST [process-exit] sentinel — code=N or "spawn-failed".
      // Use the global regex with a manual loop so we always pick up the latest occurrence,
      // not the first (defends against logs that somehow contain stale sentinel lines).
      const processExitCode = parseProcessExitCode(liveLogTail);

      if (processExitCode !== null) {
        // Guard: only complete (and kill) if the tracked process is actually dead.
        // The sentinel can land in live-output.log while the child is still alive
        // (race with the engine's stdout-forwarder appendFileSync writes, or a
        // premature 'close' event on Windows). Without this guard, completeFromOutput
        // taskkill /F /T's a still-working agent — surfaces as "phantom completion"
        // mid-multi-turn session. Confirmed 2026-05-11: agents on both Copilot and
        // Claude died at turn boundaries with claude/copilot.exe still actively
        // making API calls, with the sentinel + claude events interleaved in the log.
        if (processAlive) {
          log('warn', `${item.id}: [process-exit] code=${processExitCode} sentinel found in live-output.log but tracked process (pid=${trackedProcessPid(procInfo) || '?'}) is still alive — treating sentinel as stale/premature, skipping completion`);
        } else {
          completeFromOutput(item, liveLogPath, processExitCode, liveLogTail, hasProcess);
          continue; // Skip orphan/hung detection — we handled it
        }
      }
      // Note: we DO NOT trigger on `"type":"result"` alone. There is a ~1s race between
      // claude CLI emitting the result event and spawn-agent.js writing [process-exit] —
      // engine.js's onAgentClose handler fires within that window for tracked processes
      // and handles completion correctly. Triggering on result-event here would race the
      // close handler and risk marking SUCCESS based on subtype before the actual exit
      // code is known (#1792).
    } catch (e) { log('warn', 'output completion detection: ' + e.message); }

    // Blocking tool annotations are no longer needed: live tracked processes are allowed to
    // be quiet regardless of which command/tool is running.
    if (item._blockingToolCall) {
      legacyAnnotationClears.add(item.id);
    }

    // Skip recently-steered agents — they're being killed and re-spawned
    if (procInfo?._steeringAt && Date.now() - procInfo._steeringAt < 60000) continue;

    if (processAlive) {
      if (procInfo?._runtimeResumeAwaitingFirstOutput) {
        const resumeStartedAt = Number(procInfo._runtimeResumeAt || 0);
        const resumeHeartbeatAt = Math.max(lastActivity, resumeStartedAt);
        const resumeSilentMs = Date.now() - resumeHeartbeatAt;
        if (resumeSilentMs > runtimeResumeHeartbeatTimeout) {
          const resumeSilentSec = Math.round(resumeSilentMs / 1000);
          const reason = `Runtime resume stalled — no output heartbeat for ${resumeSilentSec}s`;
          log('warn', `Runtime resume stalled: ${item.agent} (${item.id}) — no output heartbeat for ${resumeSilentSec}s; killing and retrying fresh`);
          dispatch().updateAgentStatus(item.id, AGENT_STATUS.TIMED_OUT, reason);
          try { fs.appendFileSync(liveLogPath, `\n[runtime-resume-timeout] ${reason}. Killing this resume attempt and retrying with a fresh session.\n`); } catch { /* optional */ }
          // Clear the cached session so retry does not re-enter the same stuck --resume path.
          try { shared.safeUnlink(path.join(AGENTS_DIR, item.agent, 'session.json')); } catch {}
          activeProcesses.delete(item.id);
          shared.killGracefully(procInfo.proc, 5000);
          deadItems.push({ item, reason, failureClass: FAILURE_CLASS.TIMEOUT });
        }
      }
      continue;
    }

    // Capture live-output.log file state for orphan diagnostics
    // (#W-mo248lkjwgsu original, #W-mo25loq8kjer pid annotation).
    // Four distinguishable failure modes:
    //   logExists=false                         → spawn call itself threw, no log ever written
    //   logExists=true pidPresent=false         → engine stub written but spawn died before emitting pid line
    //   logExists=true pidPresent=true silent   → process spawned (pid recorded) but no recent output
    //   logExists=true pidPresent=true size>pid → process handle was lost after output was written
    //
    // The pid line `[<iso>] pid: <N>` is stamped by engine.js immediately after runFile() returns.
    // Its presence → the child process was actually spawned; absence → spawn itself failed or the
    // appendFileSync on the pid line threw (rare).
    let _logState = 'logExists=false logSize=0 pidPresent=false';
    try {
      const lst = fs.statSync(liveLogPath);
      // Read only the head (4KB) — pid line is written right after the stub, always near the top.
      // Avoids loading the full log just for a diagnostic annotation.
      let pidPresent = false;
      try {
        const fd = fs.openSync(liveLogPath, 'r');
        const headSize = Math.min(lst.size, 4096);
        const headBuf = Buffer.alloc(headSize);
        fs.readSync(fd, headBuf, 0, headSize, 0);
        fs.closeSync(fd);
        // Match `] pid: <digits>` — agnostic to ISO timestamp format at the start.
        pidPresent = /\]\s+pid:\s+\d+/.test(headBuf.toString('utf8'));
      } catch { /* read failure — pidPresent stays false */ }
      _logState = `logExists=true logSize=${lst.size} pidPresent=${pidPresent}`;
    } catch { /* ENOENT — keep default */ }

    const confirmedDeadAtRestart = engineRestartGraceExempt?.has(item.id);
    const reattachedProcessEnded = !!procInfo?.reattached && !processAlive;
    const canReapDeadProcess = confirmedDeadAtRestart || reattachedProcessEnded;
    if (!processAlive && (canReapDeadProcess || silentMs > staleOrphanTimeout) && (Date.now() > engineRestartGraceUntil || canReapDeadProcess)) {
      // Last-resort PID check: lost tracked handle but OS process may still be alive.
      if (isOsPidAliveForDispatch(item.id)) {
        // Cross-check the PID's command line: a recycled OS PID can be alive
        // under an unrelated process (Windows reuses PIDs aggressively). Without
        // this cmdline gate, the dispatch is parked indefinitely as 'active'
        // pinned to a process that has nothing to do with the agent.
        const livePid = _readDispatchPid(item.id);
        if (livePid && !shared.isProcessCommandLineMatchingAgent(livePid)) {
          log('warn', `Orphan check: ${item.agent} (${item.id}) — PID ${livePid} alive but command line doesn't match agent, treating as dead`);
          processAlive = false;
        } else {
          log('info', `Orphan check: ${item.agent} (${item.id}) silent ${silentSec}s but OS PID is alive — keeping [${_logState}]`);
          continue;
        }
      }
      // Final safety scan: the normal 64KB tail scan can miss a clean exit if
      // later runtime payloads or diagnostics push the sentinel outside the tail.
      // Before declaring an orphan, inspect the full log and route terminal exits
      // through the same completion path.
      try {
        const fullLog = safeRead(liveLogPath);
        const processExitCode = parseProcessExitCode(fullLog);
        if (processExitCode !== null) {
          completeFromOutput(item, liveLogPath, processExitCode, fullLog, hasProcess);
          continue;
        }
        const terminalResultExitCode = parseTerminalResultFallbackExitCode(fullLog);
        if (terminalResultExitCode !== null) {
          log('info', `Agent ${item.agent} (${item.id}) completed via stale terminal result fallback (exit code ${terminalResultExitCode})`);
          completeFromOutput(item, liveLogPath, terminalResultExitCode, fullLog, hasProcess);
          continue;
        }
      } catch (e) { log('warn', 'orphan final output completion scan: ' + e.message); }

      // No tracked process AND no recent output past stale-orphan timeout AND (grace period expired OR confirmed-dead at restart)
      // OR a re-attached process whose OS PID has since exited (no close event will fire for fake restart handles) → orphaned
      log('warn', `Orphan detected: ${item.agent} (${item.id}) — no live process tracked, silent for ${silentSec}s [logExists/logSize=${_logState}]`);
      dispatch().updateAgentStatus(item.id, AGENT_STATUS.TIMED_OUT, `Orphaned — no process, silent for ${silentSec}s`);
      // Clear session so retry starts fresh
      try { shared.safeUnlink(path.join(AGENTS_DIR, item.agent, 'session.json')); } catch {}
      deadItems.push({ item, reason: `Orphaned — no process, silent for ${silentSec}s` });
      activeProcesses.delete(item.id);
    }
  }

  // Clean up dead items
  for (const { item, reason, failureClass } of deadItems) {
    completeDispatch(item.id, DISPATCH_RESULT.ERROR, reason, '', failureClass ? { failureClass } : {});
  }

  // Clear legacy blocking-tool annotations; process liveness no longer depends on tool parsing.
  if (legacyAnnotationClears.size > 0) {
    const { mutateDispatch: mutateDispatchFn } = dispatch();
    mutateDispatchFn((dp) => {
      for (const activeItem of dp.active) {
        if (legacyAnnotationClears.has(activeItem.id)) delete activeItem._blockingToolCall;
      }
    });
  }

  // Agent status is now derived from dispatch.json at read time (getAgentStatus).
  // No reconcile sweep needed — dispatch IS the source of truth.

  // Reconcile: find work items stuck in "dispatched" with no matching active dispatch
  const activeKeys = new Set((dispatchData.active || []).map(d => d.meta?.dispatchKey).filter(Boolean));
  const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
  for (const project of getProjects(config)) {
    allWiPaths.push(projectWorkItemsPath(project));
  }
  for (const wiPath of allWiPaths) {
    // skipWriteIfUnchanged is critical — checkTimeouts runs every tick, and
    // without this gate the file's mtime updates on every call (no real
    // change), tripping the cli.js work-items.json watcher → triggering tick
    // → calling checkTimeouts → ... a 5-6s loop of "File change detected".
    mutateJsonFileLocked(wiPath, (items) => {
      if (!items || !Array.isArray(items)) return items;
      let changed = false;
      for (const item of items) {
        if (item.status !== WI_STATUS.DISPATCHED) continue;
        // Never revert completed items
        if (item.completedAt || item.status === WI_STATUS.DONE) continue;
        // Skip recently-steered agents (being killed and re-spawned)
        const steerInfo = activeProcesses.get(item.id) || [...activeProcesses.values()].find(p => p.agentId && item.dispatched_to === p.agentId);
        if (steerInfo?._steeringAt && Date.now() - steerInfo._steeringAt < 60000) continue;
        // Check if any active dispatch references this item
        const projectNames = getProjects(config).map(p => p.name);
        const possibleKeys = [
          `central-work-${item.id}`,
          ...projectNames.map(p => `work-${p}-${item.id}`),
        ];
        const isActive = possibleKeys.some(k => activeKeys.has(k)) ||
          (dispatchData.active || []).some(d => d.meta?.item?.id === item.id);
        if (!isActive) {
          const retries = (item._retryCount || 0);
          const maxRetries = ENGINE_DEFAULTS.maxRetries;
          if (retries < maxRetries) {
            log('info', `Reconcile: work item ${item.id} agent died — auto-retry ${retries + 1}/${maxRetries}`);
            item.status = WI_STATUS.PENDING;
            item._retryCount = retries + 1;
            // W-mpmwxn1j — bump per-agent retry count BEFORE deleting
            // dispatched_to so the next dispatch can reassign away from the
            // agent that died. Anonymous (no dispatched_to) failures don't bump.
            if (item.dispatched_to) shared.bumpAgentRetryCount(item, item.dispatched_to);
            delete item.dispatched_at;
            delete item.dispatched_to;
            delete item._pendingReason;
            delete item.failReason;
          } else {
            log('warn', `Reconcile: work item ${item.id} failed after ${retries} retries — marking as failed`);
            item.status = WI_STATUS.FAILED;
            item.failReason = `Agent died or was killed (${maxRetries} retries exhausted)`;
            item.failedAt = ts();
            delete item._pendingReason;
          }
          changed = true;
        }
      }
      return items;
    }, { defaultValue: [], skipWriteIfUnchanged: true });
  }
}

// ─── Exports ─────────────────────────────────────────────────────────────────

module.exports = {
  checkTimeouts,
  checkSteering,
  checkIdleThreshold,
  dropSteeringForPurgedSession,
  isOsPidAliveForDispatch,
  parseProcessExitCode, terminalResultIndicatesError, parseTerminalResultFallbackExitCode, // exported for testing
  readFileTail, runtimeSupportsMidRunSessionId, // exported for testing
  // exported for testing
  rememberDeferredSteering, checkDeferredStranded, _runSteeringKillLadder, _collectDescendantPids,
  _resetSteeringStoreCacheForTest, _setSteeringStoreForTest,
};
