// engine/worktree-gc.js — orphan-worktree garbage collection
//
// W-mpbqhstz001lf518 (closes yemi33/minions#2627). Two callers, one shared
// decision surface:
//
//   1. On dispatch end (engine.js onAgentClose): remove the dispatch's
//      worktree once the agent process has truly exited and all sidecar
//      reads (keep_processes, managed_spawn) are done. Skip when the
//      worktree was borrowed from the pool, returned to the pool, or is
//      anchored by live keep_processes PIDs / managed_spawn cwds.
//
//   2. On engine boot (engine/cli.js): scan each project's worktree root
//      for orphan W-* dirs whose dispatch is not in dispatch.json (active
//      or pending) AND that are not pool entries. Such dirs are leftovers
//      from a prior crash that block retries with
//      `fatal: 'work/W-…' is already used by worktree at …`.
//
// All git ops route through `shared.removeWorktree` (which already runs
// `git worktree remove --force` + `git worktree prune`). All git ops run
// OUTSIDE `mutateJsonFileLocked` callbacks — per the lock-callback
// contract in CLAUDE.md ("do not run network calls, git commands, process
// kills, or `await` while holding a file lock").

const fs = require('fs');
const path = require('path');

const shared = require('./shared');
const worktreePool = require('./worktree-pool');

let keepProcessSweep = null;
function _keepProcessSweep() {
  if (!keepProcessSweep) keepProcessSweep = require('./keep-process-sweep');
  return keepProcessSweep;
}

let managedSpawn = null;
function _managedSpawn() {
  if (!managedSpawn) managedSpawn = require('./managed-spawn');
  return managedSpawn;
}

const _noopLog = () => {};

// ── Stuck-dir escalation (Layer 4 of W-mq5o6bvy000x7191) ─────────────────────
// In-memory ring of paths that have failed removal N consecutive times. After
// escalation we (a) write a dedup'd inbox note, (b) suppress repeat per-tick
// warns for `worktreeStuckSuppressMs`, and (c) reduce retry cadence to
// `worktreeStuckSlowRetryMs`. The ring also remembers the last attempt time so
// the slow-cadence gate is precise. Memory only — dies with the engine; a
// fresh boot starts fresh and is re-escalated on the same path after N more
// failures, which is the desired behavior.
const _stuckPaths = new Map(); // resolved path -> { failures, escalatedAt, suppressedUntil, lastAttempt, lastError, lastNoteDate }

function _stuckRecord(resolvedPath) {
  let rec = _stuckPaths.get(resolvedPath);
  if (!rec) { rec = { failures: 0, escalatedAt: 0, suppressedUntil: 0, lastAttempt: 0, lastError: '', lastNoteDate: '' }; _stuckPaths.set(resolvedPath, rec); }
  return rec;
}

function _isStuckPathSuppressed(resolvedPath, now = Date.now()) {
  const rec = _stuckPaths.get(resolvedPath);
  if (!rec || !rec.suppressedUntil) return false;
  return now < rec.suppressedUntil;
}

function _shouldSlowRetry(resolvedPath, slowRetryMs, now = Date.now()) {
  const rec = _stuckPaths.get(resolvedPath);
  if (!rec || !rec.escalatedAt) return true; // not escalated → eligible
  return (now - rec.lastAttempt) >= slowRetryMs;
}

function _markStuckSuccess(resolvedPath, opts = {}) {
  const rec = _stuckPaths.get(resolvedPath);
  if (!rec) return false;
  const wasEscalated = rec.escalatedAt > 0;
  _stuckPaths.delete(resolvedPath);
  if (wasEscalated) {
    try { shared.bumpWorktreeGcMetric('recoveredAfterEscalation'); } catch { /* optional */ }
    try {
      const writer = typeof opts.writeToInbox === 'function' ? opts.writeToInbox : shared.writeToInbox;
      const basename = path.basename(resolvedPath);
      // W-mq6f2fe0000557fa — when the recovery is the direct result of an
      // auto-reap kill, write a distinct slug + body so operators can see
      // which orphan-sweep escalations were resolved by the engine itself
      // vs. naturally by an external holder release.
      const viaReap = opts.recoveryReason === 'holder-reap';
      const reapedPids = Array.isArray(opts.reapedPids) ? opts.reapedPids : [];
      const slug = viaReap
        ? `worktree-recovered-${basename}-via-holder-reap`
        : `worktree-recovered-${basename}`;
      const bodyLines = viaReap
        ? [
          `# Worktree recovered via holder-reap: ${basename}`,
          '',
          `Previously stuck worktree dir **${resolvedPath}** was successfully removed`,
          `after the engine auto-killed the orphan spawn-agent holder(s)`,
          `${reapedPids.length > 0 ? `(PID${reapedPids.length > 1 ? 's' : ''} ${reapedPids.join(', ')})` : ''}`.trim(),
          `under the \`engine.autoReapOrphanWorktreeHolders\` flag (W-mq6f2fe0000557fa).`,
          `Suppression cleared.`,
        ]
        : [
          `# Worktree recovered: ${basename}`,
          '',
          `Previously stuck worktree dir **${resolvedPath}** has been successfully removed`,
          `after escalation. Suppression cleared.`,
        ];
      writer('engine', slug, bodyLines.join('\n'));
    } catch { /* note best-effort */ }
  }
  return wasEscalated;
}

// W-mq6f2fe0000557fa — gate: is this holder an unambiguous orphan spawn-agent
// process we can safely kill? Requires ALL of:
//   (a) cmdline contains 'spawn-agent.js' (only minions agent wrappers qualify)
//   (b) cmdline references the worktree basename (extra confirmation it's
//       OUR spawn-agent and not an unrelated process that happens to mention
//       the path)
//   (c) ageMs > agentTimeoutMs * 2 (the worktree was definitely abandoned by
//       its dispatch; agentTimeout default is 5h, so 10h)
//
// The orphan-sweep code path already guarantees no live dispatch row claims
// the path — we'd never be here otherwise — so the "no live dispatch" check
// is implicit, not duplicated here.
function _isSafeReapHolder(holder, basename, minAgeMs) {
  if (!holder || !holder.pid || !holder.cmdline) return false;
  const cmd = String(holder.cmdline);
  if (!/spawn-agent\.js/i.test(cmd)) return false;
  if (basename && !cmd.includes(basename)) return false;
  const age = Number(holder.ageMs) || 0;
  if (age <= 0) return false; // unknown age — refuse to kill
  if (age < minAgeMs) return false;
  return true;
}

function _maybeEscalateStuck(resolvedPath, errorMsg, opts = {}) {
  const cfgEng = (opts.config && opts.config.engine) || {};
  const threshold = cfgEng.worktreeStuckThreshold || shared.ENGINE_DEFAULTS.worktreeStuckThreshold || 10;
  const suppressMs = cfgEng.worktreeStuckSuppressMs || shared.ENGINE_DEFAULTS.worktreeStuckSuppressMs || (60 * 60 * 1000);
  const now = Date.now();
  const rec = _stuckRecord(resolvedPath);
  rec.failures++;
  rec.lastAttempt = now;
  rec.lastError = errorMsg || rec.lastError || 'unknown';
  if (rec.failures < threshold) return { escalated: false, alreadyEscalated: false, holders: [], reapedPids: [] };

  const alreadyEscalated = rec.escalatedAt > 0;
  if (!alreadyEscalated) {
    rec.escalatedAt = now;
    rec.suppressedUntil = now + suppressMs;
    try { shared.bumpWorktreeGcMetric('stuckEscalated'); } catch { /* optional */ }
  } else if (now >= rec.suppressedUntil) {
    // Re-arm the suppression window on each post-escalation failure tick.
    rec.suppressedUntil = now + suppressMs;
  }

  // W-mq6f2fe0000557fa — orphan-sweep enrichment + opt-in auto-reap. Scans
  // and reap-attempts run on EVERY escalated tick (no per-day dedup) so that
  // a fresh holder appearing after the first escalation still gets killed.
  // The inbox NOTE is still dedup'd per UTC day below.
  let holders = [];
  let reapedPids = [];
  const isOrphanSweep = opts.reason === 'orphan-sweep';
  if (isOrphanSweep) {
    try {
      const findHoldersFn = typeof opts.findProcessesWithCwdInside === 'function'
        ? opts.findProcessesWithCwdInside
        : shared.findProcessesWithCwdInside;
      const scanTimeoutMs = cfgEng.orphanHolderScanTimeoutMs
        ?? shared.ENGINE_DEFAULTS.orphanHolderScanTimeoutMs
        ?? 5000;
      holders = findHoldersFn(resolvedPath, { timeoutMs: scanTimeoutMs }) || [];
    } catch { holders = []; }

    if (cfgEng.autoReapOrphanWorktreeHolders === true && holders.length > 0) {
      const basename = path.basename(resolvedPath);
      const agentTimeoutMs = Number(cfgEng.agentTimeout)
        || Number(shared.ENGINE_DEFAULTS.agentTimeout)
        || (5 * 60 * 60 * 1000);
      const minAgeMs = agentTimeoutMs * 2;
      const killFn = typeof opts.killImmediate === 'function'
        ? opts.killImmediate
        : shared.killImmediate;
      for (const h of holders) {
        if (!_isSafeReapHolder(h, basename, minAgeMs)) continue;
        try {
          killFn({ pid: h.pid });
          reapedPids.push(h.pid);
        } catch { /* fail-open — keep trying other holders */ }
      }
      if (reapedPids.length > 0) {
        try { shared.bumpWorktreeGcMetric('orphanHoldersReaped'); } catch { /* optional */ }
      }
    }
  }

  // Dedup inbox note per UTC day.
  const today = new Date(now).toISOString().slice(0, 10);
  if (rec.lastNoteDate === today) {
    return { escalated: !alreadyEscalated, alreadyEscalated, holders, reapedPids };
  }
  rec.lastNoteDate = today;

  try {
    const writer = typeof opts.writeToInbox === 'function' ? opts.writeToInbox : shared.writeToInbox;
    const basename = path.basename(resolvedPath);
    const bodyParts = [
      `# Worktree stuck: ${basename}`,
      '',
      `Worktree dir **${resolvedPath}** has failed removal ${rec.failures} consecutive times.`,
      `Last error: \`${rec.lastError}\``,
      '',
      '## Suggested manual recovery (Windows)',
      '',
      'Identify the lock holder:',
      '',
      '```powershell',
      `Get-Process | Where-Object { $_.Path -like '${resolvedPath.replace(/'/g, "''")}*' }`,
      '```',
      '',
      'If `handle.exe` from Sysinternals is installed:',
      '',
      '```',
      `handle.exe -p <pid> ${resolvedPath}`,
      '```',
      '',
      'Once the holder is gone, force-remove:',
      '',
      '```powershell',
      `Remove-Item -Recurse -Force "${resolvedPath}"`,
      '```',
      '',
      `The engine will continue to retry this path at the slow cadence`,
      `(\`ENGINE_DEFAULTS.worktreeStuckSlowRetryMs\`, default 30min). When the`,
      `holder finally releases, a \`worktree-recovered-${basename}\` note is`,
      `written here to clear this alert.`,
      '',
      `Per-tick warn spam is suppressed for the next`,
      `\`ENGINE_DEFAULTS.worktreeStuckSuppressMs\` (default 60min).`,
    ];

    // W-mq6f2fe0000557fa — append the holder section for orphan-sweep callers.
    if (isOrphanSweep) {
      bodyParts.push('', '## Live holders');
      bodyParts.push('');
      if (holders.length === 0) {
        bodyParts.push(
          '_No holder process found via cwd scan._',
          '',
          'If the dir is still stuck, the holder may be a non-process resource (',
          'antivirus scan-in-progress, OneDrive sync, indexer); try again after',
          '~60s, or run `handle.exe` interactively for a deeper search.'
        );
      } else {
        for (const h of holders) {
          const tail = String(h.cmdline || '').replace(/\r?\n/g, ' ').slice(0, 300);
          const ageStr = h.ageMs ? `${Math.floor(h.ageMs / 1000)}s` : 'unknown';
          bodyParts.push(`- **PID ${h.pid}** (age: ${ageStr})`);
          bodyParts.push('  ```');
          bodyParts.push(`  ${tail}`);
          bodyParts.push('  ```');
        }
        bodyParts.push('');
        if (cfgEng.autoReapOrphanWorktreeHolders === true) {
          if (reapedPids.length > 0) {
            bodyParts.push(
              `## Auto-reap`,
              '',
              `Engine auto-killed ${reapedPids.length} unambiguous orphan`,
              `spawn-agent process${reapedPids.length === 1 ? '' : 'es'}: PID${reapedPids.length === 1 ? '' : 's'} ${reapedPids.join(', ')}.`,
              `If the kill released the cwd lock, the next sweep tick will`,
              `successfully remove the worktree and emit a`,
              `\`worktree-recovered-${basename}-via-holder-reap\` recovery note.`
            );
          } else {
            bodyParts.push(
              `## Auto-reap`,
              '',
              `\`engine.autoReapOrphanWorktreeHolders\` is ON but no holder`,
              `qualified for auto-kill (cmdline must match \`spawn-agent.js\`,`,
              `reference the worktree basename, and process age must exceed`,
              `\`engine.agentTimeout * 2\`). Manual intervention may be needed.`
            );
          }
        } else {
          bodyParts.push(
            `_To auto-kill orphan spawn-agent processes holding this worktree,_`,
            `_flip \`engine.autoReapOrphanWorktreeHolders\` ON in dashboard Settings._`
          );
        }
      }
    }

    writer('engine', `worktree-stuck-${basename}`, bodyParts.join('\n'));
  } catch { /* note best-effort */ }

  return { escalated: !alreadyEscalated, alreadyEscalated, holders, reapedPids };
}

// Test-only reset hook. Production code never calls this.
function _resetStuckPathsForTesting() { _stuckPaths.clear(); }

// W-mq6f2fe0000557fa — post-auto-reap retry. Called only when
// `_maybeEscalateStuck` reports `reapedPids.length > 0`. Clears the per-path
// failure cooldown (otherwise the post-3-strike suppression in
// `shared.removeWorktree` silently skips the retry), waits briefly for the
// OS to release the killed processes' file handles, then attempts ONE more
// removal. On success, writes the recovered-via-holder-reap note via
// `_markStuckSuccess` with the reaped-pid attribution.
function _postReapRetry(wtPath, gitRoot, parentDir, resolvedPath, _removeWorktree, opts, projStats, result, log, reapedPids) {
  try { shared.clearWorktreeFailureCache(resolvedPath); } catch { /* optional */ }
  // Brief settle window for OS to release file handles from killed processes.
  try {
    const sleepFn = typeof opts.sleepSyncFn === 'function'
      ? opts.sleepSyncFn
      : (ms) => {
        try {
          require('child_process').execFileSync(process.execPath, ['-e', `setTimeout(()=>process.exit(0), ${Number(ms) || 0})`], { stdio: 'ignore' });
        } catch { /* timeout / spawn fail — proceed anyway */ }
      };
    sleepFn(2000);
  } catch { /* sleep failures should not block the retry */ }

  try {
    const removed = _removeWorktree(wtPath, gitRoot, parentDir);
    if (removed) {
      // Down-count the failure we recorded just before; this dispatch
      // ultimately succeeded after the auto-reap.
      if (projStats) { projStats.failed = Math.max(0, projStats.failed - 1); projStats.evicted++; }
      if (result) { result.failed = Math.max(0, result.failed - 1); result.evicted++; }
      _markStuckSuccess(resolvedPath, {
        writeToInbox: opts.writeToInbox,
        recoveryReason: 'holder-reap',
        reapedPids,
      });
      try { shared.bumpWorktreeGcMetric('recoveredViaHolderReap'); } catch { /* optional */ }
      if (typeof log === 'function') {
        log('info', `worktree-gc: removed ${wtPath} after auto-reaping holder(s) ${reapedPids.join(', ')}`);
      }
      return true;
    }
  } catch (retryErr) {
    if (typeof log === 'function') {
      log('warn', `worktree-gc: post-reap retry threw for ${wtPath}: ${retryErr && retryErr.message}`);
    }
  }
  return false;
}

/**
 * Decide whether a dispatch-end worktree should be GC'd.
 *
 * Inputs are explicit so callers can supply mocks. Returns
 *   { gc: boolean, reason: string }
 * where `reason` is a short tag suitable for logging.
 *
 * Skip rules (any one wins, in priority order):
 *   - no-worktree-path         — worktreePath empty / null
 *   - missing-on-disk          — worktreePath does not exist (already gone)
 *   - pool-member              — worktree-pool currently owns this path
 *                                (covers borrowed + idle + returned states;
 *                                authoritative over the legacy in-memory
 *                                borrowedFromPool/returnedToPool flags
 *                                because the pool can evict between borrow
 *                                and dispatch-end — see PR #2627 review)
 *   - keep-processes-anchored  — agent declared keep_processes PIDs that are live
 *   - managed-spawn-anchored   — agent placed managed_spawn services with cwd
 *                                inside (or equal to) the worktree (checked
 *                                BOTH against in-memory `managedSpawnSpawnedCount`
 *                                for services this dispatch just spawned AND
 *                                against the global state file for legacy
 *                                services that recordManagedBatch persisted)
 *
 * Otherwise: `{ gc: true, reason: 'orphan' }`.
 */
function shouldGcDispatchWorktree(opts) {
  const {
    worktreePath,
    agentId = '',
    // Belt+suspenders: defensive override when caller wants to forcibly
    // protect this worktree regardless of pool/anchor state.
    forceSkip = false,
    // In-memory hint from the dispatch-end caller: when > 0, services were
    // spawned by THIS dispatch but recordManagedBatch may have failed to
    // persist them; skip GC unconditionally to prevent yanking their cwd.
    managedSpawnSpawnedCount = 0,
    // Test injection points — production callers leave undefined.
    isPoolMember = null,
    listManagedSpecs = null,
    getActiveAnchorPidsForAgent = null,
    fileExists = null,
  } = opts || {};

  if (!worktreePath) return { gc: false, reason: 'no-worktree-path' };
  const exists = typeof fileExists === 'function'
    ? !!fileExists(worktreePath)
    : fs.existsSync(worktreePath);
  if (!exists) return { gc: false, reason: 'missing-on-disk' };
  if (forceSkip) return { gc: false, reason: 'force-skip' };

  // Pool ownership is authoritative: the pool's on-disk state file tells us
  // whether ANY pool entry (idle, borrowed, just-returned) currently claims
  // this path. Subsumes the legacy in-memory borrowedFromPool/returnedToPool
  // flags and covers the pool-return-throw eviction case correctly.
  try {
    const fn = typeof isPoolMember === 'function'
      ? isPoolMember
      : (wt) => worktreePool.isPoolMember(wt);
    if (fn(worktreePath)) return { gc: false, reason: 'pool-member' };
  } catch (_e) { /* pool readable optional */ }

  // In-memory managed-spawn signal: this dispatch's own spawn batch.
  // `managedSpawnSpawnedCount > 0` means recordManagedBatch was called (or
  // attempted) — the cwd of those services lives inside this worktree, so
  // even if the state-file persistence failed we must protect them.
  if (Number(managedSpawnSpawnedCount) > 0) {
    return { gc: false, reason: 'managed-spawn-anchored' };
  }

  // keep_processes anchor check: any live PID for this agent → skip.
  if (agentId) {
    try {
      const fn = typeof getActiveAnchorPidsForAgent === 'function'
        ? getActiveAnchorPidsForAgent
        : _keepProcessSweep().getActiveAnchorPidsForAgent;
      const res = fn(agentId);
      if (res && res.pids && res.pids.size > 0) {
        return { gc: false, reason: 'keep-processes-anchored' };
      }
    } catch (_e) { /* sidecar reader optional */ }
  }

  // managed_spawn anchor check: any LIVE spec (from any agent) whose cwd
  // is == or under the worktree → skip. Covers the "completed dispatch
  // with long-running managed-spawn services survives boot" case.
  try {
    const fn = typeof listManagedSpecs === 'function'
      ? listManagedSpecs
      : _managedSpawn().listManagedSpecs;
    const specs = fn() || [];
    const wtAbs = path.resolve(worktreePath);
    const wtPrefix = wtAbs + path.sep;
    for (const rec of specs) {
      if (!rec || typeof rec.cwd !== 'string' || rec.cwd.length === 0) continue;
      let cwdAbs;
      try { cwdAbs = path.resolve(rec.cwd); } catch { continue; }
      if (cwdAbs === wtAbs || cwdAbs.startsWith(wtPrefix)) {
        return { gc: false, reason: 'managed-spawn-anchored' };
      }
    }
  } catch (_e) { /* sidecar reader optional */ }

  return { gc: true, reason: 'orphan' };
}

/**
 * Run shouldGcDispatchWorktree + (if eligible) `shared.removeWorktree`.
 *
 * Returns `{ outcome, reason, removed }`:
 *   - outcome: 'gc'|'skip'|'gc-failed'
 *   - reason:  short tag from shouldGcDispatchWorktree, or 'remove-failed'
 *   - removed: boolean — true when removeWorktree returned truthy
 */
function gcDispatchWorktreeIfOrphan(opts) {
  const {
    worktreePath,
    gitRoot,
    worktreeRoot,
    log = _noopLog,
    removeWorktree = null,
    // PR #3133 review — when the dispatch-end GC is the caller, the
    // current dispatch's own active row legitimately claims worktreePath
    // (persisted by engine.js at pending→active). Pass `excludeDispatchId`
    // so shared.removeWorktree's live-guard ignores that row and lets the
    // dispatch GC its own orphan worktree. Other live claimants still
    // block the wipe.
    excludeDispatchId = null,
    config = null,
    writeToInbox = null,
  } = opts || {};
  const decision = shouldGcDispatchWorktree(opts);
  if (!decision.gc) {
    return { outcome: 'skip', reason: decision.reason, removed: false };
  }
  if (!gitRoot || !worktreeRoot) {
    return { outcome: 'skip', reason: 'no-git-root', removed: false };
  }
  const _removeFn = typeof removeWorktree === 'function' ? removeWorktree : shared.removeWorktree;
  const _rmOpts = excludeDispatchId ? { excludeDispatchId } : undefined;
  const resolved = (() => { try { return path.resolve(worktreePath); } catch { return worktreePath; } })();
  try {
    const removed = _removeFn(worktreePath, gitRoot, worktreeRoot, _rmOpts);
    if (removed) {
      _markStuckSuccess(resolved, { writeToInbox });
      log('info', `worktree-gc: dispatch-end removed ${path.basename(worktreePath)}`);
      return { outcome: 'gc', reason: decision.reason, removed: true };
    }
    // W-mq5o6bvy000x7191 (Layer 4): track stuck dirs even on soft-false returns.
    const { escalated, alreadyEscalated } = _maybeEscalateStuck(resolved, 'remove returned false', { config, writeToInbox });
    if (!alreadyEscalated && !_isStuckPathSuppressed(resolved)) {
      log('warn', `worktree-gc: dispatch-end remove returned false for ${worktreePath}`);
    }
    return { outcome: 'gc-failed', reason: 'remove-failed', removed: false, stuckEscalated: escalated };
  } catch (gcErr) {
    const { escalated, alreadyEscalated } = _maybeEscalateStuck(resolved, gcErr && gcErr.message, { config, writeToInbox });
    if (!alreadyEscalated && !_isStuckPathSuppressed(resolved)) {
      log('warn', `worktree-gc: dispatch-end remove threw for ${worktreePath}: ${gcErr.message}`);
    }
    return { outcome: 'gc-failed', reason: 'remove-threw', removed: false, stuckEscalated: escalated };
  }
}

/**
 * Walk each project's worktree root and evict orphan W-* dirs.
 *
 *   - `projects`        — array of `{ name, localPath }`
 *   - `dispatchSnap`    — `{ active: [...], pending: [...] }` (work-item dispatch.json)
 *   - `worktreeRootRel` — relative dir under each project's localPath (default '../worktrees')
 *   - `log`             — log(level, msg) function (optional)
 *   - `fs`/`removeWorktree`/`buildWorktreeDirName`/`listManagedSpecs` — test injection points
 *
 * Protection rules (any hit → keep):
 *   1. Dir name matches the wtDirName of any active/pending dispatch in
 *      `dispatchSnap`, computed using THAT DISPATCH'S OWN project name.
 *      We iterate every (dispatch, project) combo so two projects sharing
 *      a parent `worktreeRoot` (monorepo-style) don't cross-evict each
 *      other's dispatches. (PR #2627 review — Issue 2)
 *   2. Normalized abs path is currently in any `worktreePool` entry (idle
 *      or borrowed) — see `extraPoolPaths` for the test override.
 *   3. Worktree contains (or equals) the cwd of any live managed_spawn
 *      spec — matches `engine/cleanup.js`'s tick-time protection so a
 *      completed dispatch with long-running services survives engine
 *      restart. (PR #2627 review — Issue 1)
 *
 * Returns `{ scanned, kept, evicted, failed, perProject }`.
 */
function pruneOrphanWorktrees(opts) {
  opts = opts || {};
  const projects = Array.isArray(opts.projects) ? opts.projects : [];
  const dispatchSnap = opts.dispatchSnap || { active: [], pending: [] };
  const worktreeRootRel = typeof opts.worktreeRootRel === 'string' && opts.worktreeRootRel.length > 0
    ? opts.worktreeRootRel
    : (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.worktreeRoot) || '../worktrees';
  const log = typeof opts.log === 'function' ? opts.log : _noopLog;
  const _fs = opts.fs || fs;
  const _removeWorktree = typeof opts.removeWorktree === 'function'
    ? opts.removeWorktree
    : shared.removeWorktree;
  const _buildWorktreeDirName = typeof opts.buildWorktreeDirName === 'function'
    ? opts.buildWorktreeDirName
    : shared.buildWorktreeDirName;
  const _listManagedSpecs = typeof opts.listManagedSpecs === 'function'
    ? opts.listManagedSpecs
    : (() => {
      try { return _managedSpawn().listManagedSpecs(); }
      catch (_e) { return []; }
    });

  // Pool-known paths (idle + borrowed + any stale entry still on disk). The
  // worktree-pool's own pruneStale() runs FIRST in cli boot so this snapshot
  // is post-prune.
  const poolPaths = new Set();
  try {
    const entries = (worktreePool.readPool() || { entries: [] }).entries || [];
    for (const e of entries) {
      if (e && e.path) poolPaths.add(worktreePool._normalizePath(e.path));
    }
  } catch (_e) { /* pool readable optional */ }
  if (Array.isArray(opts.extraPoolPaths)) {
    for (const p of opts.extraPoolPaths) poolPaths.add(worktreePool._normalizePath(p));
  }

  // Cross-project live dir names: compute the expected wtDirName for every
  // active/pending dispatch using THAT DISPATCH'S OWN project name. Each
  // dir name's hash depends on (id, projectName, branchName), so a dir
  // belonging to project B will not appear when we compute names for A.
  // This prevents the cross-project sweep from yanking another project's
  // active worktree when they share a parent `worktreeRoot`. (PR #2627
  // review — Issue 2)
  const globalLiveDirNames = new Set();
  for (const d of [...(dispatchSnap.active || []), ...(dispatchSnap.pending || [])]) {
    if (!d || !d.id || !d.meta || !d.meta.branch) continue;
    const dispatchProject = (d.meta.project && typeof d.meta.project === 'string')
      ? d.meta.project
      : 'default';
    try {
      globalLiveDirNames.add(_buildWorktreeDirName({
        dispatchId: d.id,
        projectName: dispatchProject,
        branchName: d.meta.branch,
      }));
    } catch (_e) { /* defensive */ }
  }

  // Resolved managed-spawn cwds (per-cwd → resolved abs path). Used to
  // reject GC of any dir that contains (or equals) a live service's cwd.
  // (PR #2627 review — Issue 1)
  const managedSpawnCwds = [];
  try {
    for (const rec of (_listManagedSpecs() || [])) {
      if (!rec || typeof rec.cwd !== 'string' || rec.cwd.length === 0) continue;
      try { managedSpawnCwds.push(path.resolve(rec.cwd)); }
      catch (_e) { /* malformed cwd — skip */ }
    }
  } catch (_e) { /* optional */ }

  const result = { scanned: 0, kept: 0, evicted: 0, failed: 0, perProject: {} };

  for (const project of projects) {
    if (!project || !project.localPath) continue;
    let rootDir;
    try { rootDir = path.resolve(String(project.localPath)); } catch { continue; }
    let rootExists = false;
    try { rootExists = _fs.existsSync(rootDir); } catch { rootExists = false; }
    if (!rootExists) continue;
    const wtParent = path.resolve(rootDir, worktreeRootRel);
    let parentExists = false;
    try { parentExists = _fs.existsSync(wtParent); } catch { parentExists = false; }
    if (!parentExists) continue;

    let entries;
    try { entries = _fs.readdirSync(wtParent, { withFileTypes: true }); }
    catch (readErr) {
      log('warn', `worktree-gc: readdir failed for ${wtParent}: ${readErr.message}`);
      continue;
    }

    const projStats = { scanned: 0, kept: 0, evicted: 0, failed: 0 };
    for (const ent of entries) {
      if (!ent || (typeof ent.isDirectory === 'function' && !ent.isDirectory())) continue;
      const name = ent.name || ent;
      if (typeof name !== 'string' || name.length === 0) continue;
      projStats.scanned++;
      result.scanned++;
      const wtPath = path.join(wtParent, name);

      // 1. Live-dispatch protection (cross-project safe).
      if (globalLiveDirNames.has(name)) {
        projStats.kept++; result.kept++;
        continue;
      }
      // 2. Pool membership protection.
      const normPath = worktreePool._normalizePath(wtPath);
      if (poolPaths.has(normPath)) {
        projStats.kept++; result.kept++;
        continue;
      }
      // 3. managed_spawn cwd anchor protection.
      if (managedSpawnCwds.length > 0) {
        const wtPathNorm = path.resolve(wtPath);
        const wtPrefix = wtPathNorm + path.sep;
        let anchored = false;
        for (const cwd of managedSpawnCwds) {
          if (cwd === wtPathNorm || cwd.startsWith(wtPrefix)) { anchored = true; break; }
        }
        if (anchored) {
          projStats.kept++; result.kept++;
          continue;
        }
      }

      try {
        // W-mq5o6bvy000x7191 (Layer 4): respect slow-cadence retry for paths
        // that have escalated past `worktreeStuckThreshold`.
        const wtResolved = path.resolve(wtPath);
        const slowRetryMs = (opts.config?.engine?.worktreeStuckSlowRetryMs)
          ?? shared.ENGINE_DEFAULTS.worktreeStuckSlowRetryMs
          ?? (30 * 60 * 1000);
        if (!_shouldSlowRetry(wtResolved, slowRetryMs)) {
          projStats.kept++; result.kept++;
          continue;
        }
        const suppressed = _isStuckPathSuppressed(wtResolved);
        const removed = _removeWorktree(wtPath, rootDir, wtParent);
        if (removed) {
          projStats.evicted++; result.evicted++;
          _markStuckSuccess(wtResolved, { writeToInbox: opts.writeToInbox });
          log('info', `worktree-gc: boot-evicted orphan ${name} for project ${project.name || 'default'}`);
        } else {
          projStats.failed++; result.failed++;
          const escResult = _maybeEscalateStuck(wtResolved, 'remove returned false', {
            config: opts.config, writeToInbox: opts.writeToInbox,
            reason: 'orphan-sweep',
            findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
            killImmediate: opts.killImmediate,
          });
          if (!escResult.alreadyEscalated && !suppressed) {
            log('warn', `worktree-gc: boot-evict returned false for ${wtPath}`);
          }
          // W-mq6f2fe0000557fa — if auto-reap killed any holders, retry the
          // removal once after a brief settle window. Clear the failure
          // cache entry so the post-reap retry isn't suppressed by the
          // 3-strike cooldown.
          if (escResult.reapedPids && escResult.reapedPids.length > 0) {
            _postReapRetry(wtPath, rootDir, wtParent, wtResolved, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
          }
        }
      } catch (rmErr) {
        projStats.failed++; result.failed++;
        const wtResolved = path.resolve(wtPath);
        const suppressed = _isStuckPathSuppressed(wtResolved);
        const escResult = _maybeEscalateStuck(wtResolved, rmErr && rmErr.message, {
          config: opts.config, writeToInbox: opts.writeToInbox,
          reason: 'orphan-sweep',
          findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
          killImmediate: opts.killImmediate,
        });
        if (!escResult.alreadyEscalated && !suppressed) {
          log('warn', `worktree-gc: boot-evict threw for ${wtPath}: ${rmErr.message}`);
        }
        if (escResult.reapedPids && escResult.reapedPids.length > 0) {
          _postReapRetry(wtPath, rootDir, wtParent, wtResolved, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
        }
      }
    }
    result.perProject[project.name || rootDir] = projStats;
  }
  return result;
}

/**
 * Out-of-root scan: walk `git worktree list --porcelain` for every project,
 * find registered worktrees outside the configured `worktreeRoot`, and reap
 * the unprotected ones. Companion to `pruneOrphanWorktrees`, which only
 * walks `<localPath>/<worktreeRoot>` and is permanently blind to worktrees
 * created under `D:/tmp-*`, `D:/squad-worktrees/*`, etc.
 *
 * Also calls `git worktree prune --expire=now` per project so registry
 * entries whose dirs were manually deleted (`Remove-Item`d) drop out of the
 * git index immediately.
 *
 *   - projects        — array of `{ name, localPath }`
 *   - dispatchSnap    — `{ active: [...], pending: [...] }`
 *   - worktreeRootRel — configured `engine.worktreeRoot` (default '../worktrees')
 *                       used to compute the inside-root set we DO NOT touch
 *                       here (the existing scanner handles it).
 *   - log / fs / removeWorktree / listManagedSpecs / execSilent — injection
 *
 * Protection rules mirror `pruneOrphanWorktrees`:
 *   1. The main checkout (path === projectLocalPath) is always kept.
 *   2. Any path inside the configured `worktreeRoot` is skipped (delegated).
 *   3. Branch matches an active/pending dispatch → keep.
 *   4. Path is in the worktree-pool → keep.
 *   5. managed_spawn cwd anchored → keep.
 *
 * Returns `{ scanned, kept, evicted, failed, prunedRegistry, perProject }`.
 *
 * W-mq5o6bvy000x7191 Layer 3.
 */
function pruneOrphanWorktreesFromGitRegistry(opts) {
  opts = opts || {};
  const projects = Array.isArray(opts.projects) ? opts.projects : [];
  const dispatchSnap = opts.dispatchSnap || { active: [], pending: [] };
  const worktreeRootRel = typeof opts.worktreeRootRel === 'string' && opts.worktreeRootRel.length > 0
    ? opts.worktreeRootRel
    : (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.worktreeRoot) || '../worktrees';
  const log = typeof opts.log === 'function' ? opts.log : _noopLog;
  const _fs = opts.fs || fs;
  const _removeWorktree = typeof opts.removeWorktree === 'function'
    ? opts.removeWorktree
    : shared.removeWorktree;
  const _listManagedSpecs = typeof opts.listManagedSpecs === 'function'
    ? opts.listManagedSpecs
    : (() => {
      try { return _managedSpawn().listManagedSpecs(); }
      catch (_e) { return []; }
    });
  const _execSilent = typeof opts.execSilent === 'function'
    ? opts.execSilent
    : shared.execSilent;
  const _parseWorktreePorcelain = typeof opts.parseWorktreePorcelain === 'function'
    ? opts.parseWorktreePorcelain
    : shared.parseWorktreePorcelain;

  // Branches of active/pending dispatches (with and without `refs/heads/`
  // normalization). Matched case-insensitively to mirror git's behavior on
  // Windows (case-insensitive filesystems).
  const protectedBranches = new Set();
  for (const d of [...(dispatchSnap.active || []), ...(dispatchSnap.pending || [])]) {
    const br = d && d.meta && d.meta.branch;
    if (br && typeof br === 'string') {
      protectedBranches.add(br.toLowerCase());
      protectedBranches.add(br.toLowerCase().replace(/^refs\/heads\//, ''));
    }
  }

  // Pool-known paths (idle + borrowed + stale-but-still-on-disk).
  const poolPaths = new Set();
  try {
    const entries = (worktreePool.readPool() || { entries: [] }).entries || [];
    for (const e of entries) {
      if (e && e.path) poolPaths.add(worktreePool._normalizePath(e.path));
    }
  } catch (_e) { /* pool readable optional */ }
  if (Array.isArray(opts.extraPoolPaths)) {
    for (const p of opts.extraPoolPaths) poolPaths.add(worktreePool._normalizePath(p));
  }

  // managed_spawn cwds.
  const managedSpawnCwds = [];
  try {
    for (const rec of (_listManagedSpecs() || [])) {
      if (!rec || typeof rec.cwd !== 'string' || rec.cwd.length === 0) continue;
      try { managedSpawnCwds.push(path.resolve(rec.cwd)); }
      catch (_e) { /* malformed cwd — skip */ }
    }
  } catch (_e) { /* optional */ }

  const result = { scanned: 0, kept: 0, evicted: 0, failed: 0, prunedRegistry: 0, perProject: {} };
  const _seenAbs = new Set(); // dedup across projects sharing a parent

  for (const project of projects) {
    if (!project || !project.localPath) continue;
    let rootDir;
    try { rootDir = path.resolve(String(project.localPath)); } catch { continue; }
    let rootExists = false;
    try { rootExists = _fs.existsSync(rootDir); } catch { rootExists = false; }
    if (!rootExists) continue;

    // Compute the inside-`worktreeRoot` boundary so we DON'T double-sweep the
    // dirs the existing scanner already handles.
    const wtParentAbs = path.resolve(rootDir, worktreeRootRel);
    const wtParentPrefix = wtParentAbs + path.sep;

    const projStats = { scanned: 0, kept: 0, evicted: 0, failed: 0, prunedRegistry: 0 };
    let raw = '';
    try {
      raw = String(_execSilent('git --no-optional-locks worktree list --porcelain', {
        cwd: rootDir, timeout: 15000, windowsHide: true,
      }) || '');
    } catch (e) {
      log('warn', `worktree-gc: git worktree list failed for ${project.name || rootDir}: ${e.message}`);
      result.perProject[project.name || rootDir] = projStats;
      continue;
    }

    let trees;
    try { trees = _parseWorktreePorcelain(raw); }
    catch (e) {
      log('warn', `worktree-gc: parse worktree list failed for ${project.name || rootDir}: ${e.message}`);
      result.perProject[project.name || rootDir] = projStats;
      continue;
    }

    for (const wt of trees) {
      if (!wt || !wt.path) continue;
      let wtAbs;
      try { wtAbs = path.resolve(wt.path); } catch { continue; }
      // Skip main checkout
      if (wtAbs === rootDir) continue;
      // Skip dirs inside the configured worktreeRoot (handled by sibling scanner)
      if (wtAbs === wtParentAbs || wtAbs.startsWith(wtParentPrefix)) continue;
      if (_seenAbs.has(wtAbs)) continue;
      _seenAbs.add(wtAbs);

      projStats.scanned++;
      result.scanned++;

      // Branch protection
      if (wt.branch && protectedBranches.has(String(wt.branch).toLowerCase())) {
        projStats.kept++; result.kept++; continue;
      }
      // Pool protection
      const normPath = worktreePool._normalizePath(wtAbs);
      if (poolPaths.has(normPath)) {
        projStats.kept++; result.kept++; continue;
      }
      // managed_spawn anchor protection
      if (managedSpawnCwds.length > 0) {
        const wtPrefix = wtAbs + path.sep;
        let anchored = false;
        for (const cwd of managedSpawnCwds) {
          if (cwd === wtAbs || cwd.startsWith(wtPrefix)) { anchored = true; break; }
        }
        if (anchored) { projStats.kept++; result.kept++; continue; }
      }

      // Slow-cadence gate for already-escalated stuck paths
      const slowRetryMs = (opts.config?.engine?.worktreeStuckSlowRetryMs)
        ?? shared.ENGINE_DEFAULTS.worktreeStuckSlowRetryMs
        ?? (30 * 60 * 1000);
      if (!_shouldSlowRetry(wtAbs, slowRetryMs)) {
        projStats.kept++; result.kept++; continue;
      }

      // parentDir for the safety boundary check inside removeWorktree must be
      // the dir *containing* this worktree (not the project's worktreeRoot,
      // since that's not where this dir lives).
      const parentDir = path.dirname(wtAbs);
      const suppressed = _isStuckPathSuppressed(wtAbs);
      try {
        const removed = _removeWorktree(wtAbs, rootDir, parentDir);
        if (removed) {
          projStats.evicted++; result.evicted++;
          _markStuckSuccess(wtAbs, { writeToInbox: opts.writeToInbox });
          try { shared.bumpWorktreeGcMetric('outOfRootEvicted'); } catch { /* optional */ }
          log('info', `worktree-gc: out-of-root evicted ${wtAbs} for project ${project.name || 'default'}`);
        } else {
          projStats.failed++; result.failed++;
          const escResult = _maybeEscalateStuck(wtAbs, 'remove returned false', {
            config: opts.config, writeToInbox: opts.writeToInbox,
            reason: 'orphan-sweep',
            findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
            killImmediate: opts.killImmediate,
          });
          if (!escResult.alreadyEscalated && !suppressed) {
            log('warn', `worktree-gc: out-of-root remove returned false for ${wtAbs}`);
          }
          if (escResult.reapedPids && escResult.reapedPids.length > 0) {
            _postReapRetry(wtAbs, rootDir, parentDir, wtAbs, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
          }
        }
      } catch (rmErr) {
        projStats.failed++; result.failed++;
        const escResult = _maybeEscalateStuck(wtAbs, rmErr && rmErr.message, {
          config: opts.config, writeToInbox: opts.writeToInbox,
          reason: 'orphan-sweep',
          findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
          killImmediate: opts.killImmediate,
        });
        if (!escResult.alreadyEscalated && !suppressed) {
          log('warn', `worktree-gc: out-of-root remove threw for ${wtAbs}: ${rmErr.message}`);
        }
        if (escResult.reapedPids && escResult.reapedPids.length > 0) {
          _postReapRetry(wtAbs, rootDir, parentDir, wtAbs, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
        }
      }
    }

    // Always finish with `git worktree prune --expire=now` to drop registry
    // entries whose dirs were manually removed (e.g. operator `Remove-Item`).
    try {
      _execSilent('git worktree prune --expire=now', {
        cwd: rootDir, timeout: 10000, windowsHide: true,
      });
      // We can't tell how many entries pruned without a second `worktree list`
      // diff; count the call itself for cadence visibility.
      projStats.prunedRegistry = 1;
      result.prunedRegistry++;
      try { shared.bumpWorktreeGcMetric('registryPruned'); } catch { /* optional */ }
    } catch (e) {
      log('warn', `worktree-gc: git worktree prune failed for ${project.name || rootDir}: ${e.message}`);
    }

    result.perProject[project.name || rootDir] = projStats;
  }

  return result;
}

module.exports = {
  shouldGcDispatchWorktree,
  gcDispatchWorktreeIfOrphan,
  pruneOrphanWorktrees,
  pruneOrphanWorktreesFromGitRegistry,
  // exported for testing (W-mq5o6bvy000x7191)
  _resetStuckPathsForTesting,
  _stuckPaths,
};
