/**
 * engine/dispatch.js — Dispatch queue management: add, complete, mutate, alerts.
 * Extracted from engine.js for modularity. No logic changes.
 */

const fs = require('fs');
const path = require('path');
const shared = require('./shared');
const queries = require('./queries');
const { setCooldown, setCooldownFailure } = require('./cooldown');
const dispatchEvents = require('./dispatch-events');

const { safeJsonArr, mutateWorkItems,
  mutatePullRequests, getProjects, projectPrPath, log, ts, dateStamp,
  sidecarDispatchPrompt, deleteDispatchPromptSidecar,
  WI_STATUS, WORK_TYPE, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS, PR_STATUS } = shared;
const { getConfig, INBOX_DIR } = queries;

const MINIONS_DIR = shared.MINIONS_DIR;

// Dispatch types that do not push code to a PR branch. These remain dispatchable
// against `_contextOnly: true` PRs because casting a review vote, posting a
// comment, asking a question, or running a read-only explore never mutates the
// PR's source branch. Auto-discovery (engine.js#discoverFromPrs) is still
// gated on `_contextOnly` separately — only explicit WIs (dashboard, watches,
// CC) can target a context-only PR, and only via these types.
const NON_MUTATING_DISPATCH_TYPES = new Set([
  WORK_TYPE.REVIEW,
  WORK_TYPE.ASK,
  WORK_TYPE.EXPLORE,
]);

// Lazy require to break circular dependency with engine.js
let _lifecycle = null;
function lifecycle() { if (!_lifecycle) _lifecycle = require('./lifecycle'); return _lifecycle; }

// ─── Dispatch Mutation ───────────────────────────────────────────────────────

/**
 * Walk pending + active dispatch entries and snapshot {id → prompt} for items
 * that have a string prompt. Used to diff before/after a mutation so we only
 * re-check items the mutator actually touched (#1167).
 */
function _snapshotPrompts(dispatch) {
  const snap = new Map();
  for (const list of [dispatch.pending, dispatch.active]) {
    if (!Array.isArray(list)) continue;
    for (const item of list) {
      if (item && item.id && typeof item.prompt === 'string') snap.set(item.id, item.prompt);
    }
  }
  return snap;
}

/**
 * Sidecar oversized prompts only for items the mutator added or modified.
 * Keeps dispatch.json safe from bloat (#1167) without paying O(n) on every
 * unrelated mutation (e.g. status flips, completion-marking).
 */
function _sidecarChangedPrompts(dispatch, prevSnap) {
  const threshold = ENGINE_DEFAULTS.maxDispatchPromptBytes;
  for (const list of [dispatch.pending, dispatch.active]) {
    if (!Array.isArray(list)) continue;
    for (const item of list) {
      if (!item || typeof item.prompt !== 'string') continue;
      const prev = item.id ? prevSnap.get(item.id) : undefined;
      if (prev === item.prompt) continue; // untouched — already validated on insert
      sidecarDispatchPrompt(item, threshold);
    }
  }
}

// SQL-backed mutator (Phase 9.4 SQL-only). mutator receives a
// `{ pending, active, completed, review }` object, mutates in place
// (or returns a replacement), and the changes land transactionally in
// the dispatches table. The diff-then-apply trick in dispatch-store
// preserves every field via the `data` JSON column, so new dispatch
// fields the engine adds in future commits don't need any migration —
// they round-trip automatically. SQLite failures propagate; the CLI
// shim in bin/minions.js guarantees node:sqlite is loadable.
function mutateDispatch(mutator) {
  const store = require('./dispatch-store');
  const { wrote, result } = store.applyDispatchMutation((dispatch) => {
    dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
    dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
    dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
    dispatch.review = Array.isArray(dispatch.review) ? dispatch.review : [];
    const prevSnap = _snapshotPrompts(dispatch);
    const next = mutator(dispatch) ?? dispatch;
    // Prompt-size guard: only scan items whose prompt changed (or new items),
    // so a 100-item status-flip doesn't re-byte-count every prompt.
    _sidecarChangedPrompts(next, prevSnap);
    return next;
  });
  if (wrote) {
    try { require('./queries').invalidateDispatchCache(); } catch {}
    try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
    // Mirror back to dispatch.json for tests + tools that fs.readFileSync
    // the file directly. SQL is the source of truth; the JSON file is
    // regenerated from SQL on every successful mutation, never independently
    // mutated. Cheap to delete once those direct-JSON readers are gone.
    try { store._mirrorJsonFromSql(); } catch { /* best-effort */ }
  }
  return result;
}

// ─── Add to Dispatch ─────────────────────────────────────────────────────────

function getDispatchProjectKey(project) {
  if (!project) return '';
  if (project.name) return String(project.name).toLowerCase();
  return project.localPath ? path.resolve(project.localPath).toLowerCase() : '';
}

function getPrDispatchTargetKey(entry) {
  if (!entry?.meta?.pr || !entry?.meta?.project) return null;
  const project = entry.meta.project;
  const projectKey = getDispatchProjectKey(project);
  if (!projectKey) return null;
  const prKey = shared.getCanonicalPrId(project, entry.meta.pr, entry.meta.pr?.url || '');
  if (!prKey) return null;
  return `${projectKey}:${prKey}`;
}

function getPrDispatchDedupeKey(entry) {
  if (!entry?.type) return null;
  const targetKey = getPrDispatchTargetKey(entry);
  if (!targetKey) return null;
  const type = entry.type === WORK_TYPE.FIX ? WORK_TYPE.FIX : entry.type;
  return `${targetKey}:${type}`;
}

function getBranchDispatchLockKey(entry) {
  const branch = entry?.meta?.branch || entry?.meta?.pr?.branch || '';
  if (!branch) return null;
  const normalizedBranch = shared.sanitizeBranch(branch);
  if (!normalizedBranch) return null;
  const projectKey = getDispatchProjectKey(entry?.meta?.project) || 'default';
  return `${projectKey}:${normalizedBranch}`;
}

function findActivePrOrBranchLock(dispatch, item) {
  const active = dispatch.active || [];

  // PR-target dedup is FIX-only: a FIX shouldn't stack on top of another FIX
  // for the same PR, but a REVIEW + FIX pair targeting the same PR is the
  // normal review-then-fix flow and must not be dedup'd here.
  if (item?.type === WORK_TYPE.FIX) {
    const prTargetKey = getPrDispatchTargetKey(item);
    if (prTargetKey) {
      const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
      if (existing) return { existing, reason: `active PR dispatch ${prTargetKey}` };
    }
  }

  // Branch-lock applies to EVERY type, not just FIX (W-mphll3py0006234e —
  // issue #2817). Any dispatch that carries meta.branch is, by definition,
  // claiming ownership of that branch for the duration of its run — if two
  // such dispatches overlap on the same branch they race the eventual
  // `git push` and the spawn-time stale-HEAD guard fails whichever push
  // lost the race. Mirrors the dispatch-loop's `lockedBranches` mutex
  // (engine.js ~6577-6731) at queue-time so maintenance-class dispatches
  // (setup / test / docs / implement / verify / decompose / review …) get
  // the same coordination FIX already had.
  const branchLockKey = getBranchDispatchLockKey(item);
  if (!branchLockKey) return null;
  const existing = active.find(d => getBranchDispatchLockKey(d) === branchLockKey);
  if (!existing) return null;
  return { existing, reason: `active branch dispatch ${branchLockKey}` };
}

function addToDispatch(item) {
  item.id = item.id || `${item.agent || 'unassigned'}-${item.type}-${shared.uid()}`;
  item.created_at = ts();
  item.meta = item.meta && typeof item.meta === 'object' ? item.meta : {};
  if (!item.meta.completionReportPath) {
    item.meta.completionReportPath = shared.dispatchCompletionReportPath(item.id);
  }
  let added = false;
  mutateDispatch((dispatch) => {
    // Walked once per addToDispatch — all three dedup checks below find against the same set.
    const queued = [...dispatch.pending, ...(dispatch.active || [])];

    // Dedup: skip if same work item ID is already pending or active
    const wiId = item.meta?.source === 'central-work-item-fanout' ? null : item.meta?.item?.id;
    if (wiId) {
      const existing = queued.find(d => d.meta?.item?.id === wiId);
      if (existing) {
        log('info', `Dedup: skipping ${item.id} — work item ${wiId} already in ${existing.id}`);
        return dispatch;
      }
    }
    // Also dedup by dispatchKey
    if (item.meta?.dispatchKey) {
      const existing = queued.find(d => d.meta?.dispatchKey === item.meta.dispatchKey);
      if (existing) {
        log('info', `Dedup: skipping ${item.id} — dispatchKey ${item.meta.dispatchKey} already in ${existing.id}`);
        return dispatch;
      }
    }
    const prDedupeKey = getPrDispatchDedupeKey(item);
    if (prDedupeKey) {
      const existing = queued.find(d => getPrDispatchDedupeKey(d) === prDedupeKey);
      if (existing) {
        log('info', `Dedup: skipping ${item.id} — PR dispatch ${prDedupeKey} already in ${existing.id}`);
        return dispatch;
      }
    }
    const activeLock = findActivePrOrBranchLock(dispatch, item);
    if (activeLock) {
      log('info', `Dedup: skipping ${item.id} — ${activeLock.reason} already in ${activeLock.existing.id}`);
      return dispatch;
    }
    dispatch.pending.push(item);
    added = true;
    return dispatch;
  });
  if (added) {
    log('info', `Queued dispatch: ${item.id} (${item.type} → ${item.agent})`);
    // W-mph8xt88000ke0fc — Stamp the dispatch cooldown ONLY when the item
    // actually survives the queue-time dedup gauntlet. Discover-side blocks
    // (discoverFromPrs build-failure / review-feedback / merge-conflict,
    // discoverFromWorkItems, discoverCentralWorkItems) used to call
    // setCooldown(key) inline the instant a candidate item was built,
    // BEFORE addToDispatch ran the prDedupeKey / workItem-id / dispatchKey
    // dedup checks. The losing candidate left an orphan cooldown that
    // blocked re-dispatch of that cause for `cooldownMinutes || 30` minutes
    // even though no agent ever ran (live repro:
    // ado:office/iss/constellation#5227109 on 2026-05-22 — build-fix and
    // human-fix landed within 38 ms; only the human-fix ran but the orphan
    // build-fix cooldown blocked the next 24 min of retries).
    //
    // Callers opt in by setting `item.meta.cooldownKey`. When absent (e.g.
    // lifecycle.js auto-redispatches that intentionally bypass the cooldown
    // ladder), no stamp fires — same as the pre-fix behavior for those
    // paths.
    if (item.meta?.cooldownKey) setCooldown(item.meta.cooldownKey);
  }
  return item.id;
}

// ─── Pre-Dispatch Acceptance Criteria Gate (P-a2d6b9c7, Ripley §3) ──────────
//
// Cheap-LLM validation gate that runs *before* queue insertion so
// impossible/ambiguous work items are routed to a review queue rather than
// burning a full agent run. On by default via ENGINE_DEFAULTS.enablePreDispatchEval
// (P-d2a9f6e5); per-project disable available via the dashboard config UI.
//
// Wired from engine.js discoverWork(); kept as a separate async wrapper so
// the existing synchronous addToDispatch() call sites are unaffected.

function _persistInvalidWorkItem(item, evaluation) {
  const meta = item?.meta;
  const itemId = meta?.item?.id;
  if (!itemId) return;
  let wiPath;
  try { wiPath = lifecycle().resolveWorkItemPath(meta); } catch { wiPath = null; }
  if (!wiPath) return;
  try {
    mutateWorkItems(wiPath, (items) => {
      if (!Array.isArray(items)) return items;
      const idx = items.findIndex(w => w && w.id === itemId);
      if (idx === -1) return items;
      items[idx]._preDispatchEval = {
        valid: false,
        reason: evaluation.reason || '',
        evaluatedAt: ts(),
      };
      return items;
    }, { skipWriteIfUnchanged: true });
  } catch (e) {
    log('warn', `pre-dispatch-eval: failed to persist reason on ${itemId}: ${e.message}`);
  }
}

function _routeToReviewQueue(item, evaluation) {
  item.id = item.id || `${item.agent || 'unassigned'}-${item.type}-${shared.uid()}`;
  item.created_at = ts();
  item.meta = item.meta && typeof item.meta === 'object' ? item.meta : {};
  item._preDispatchEval = {
    valid: false,
    reason: evaluation.reason || '',
    evaluatedAt: ts(),
  };
  mutateDispatch((dispatch) => {
    dispatch.review = Array.isArray(dispatch.review) ? dispatch.review : [];
    // Dedup against the review queue itself so repeated discovery passes don't
    // flood the queue with duplicates of the same WI.
    const wiId = item.meta?.item?.id;
    if (wiId && dispatch.review.some(d => d?.meta?.item?.id === wiId)) {
      return dispatch;
    }
    dispatch.review.push(item);
    return dispatch;
  });
}

/**
 * Async wrapper around addToDispatch that consults the pre-dispatch validator
 * when ENGINE_DEFAULTS.enablePreDispatchEval is true. Validator failures are
 * fail-open (the item still queues) — this gate must never wedge dispatch.
 *
 * @param {object} item - dispatch item (same shape addToDispatch expects).
 * @param {object} [opts]
 * @param {object} [opts.config] - engine config; defaults to queries.getConfig().
 * @param {Function} [opts.validate] - injection point for tests; defaults to
 *   require('./pre-dispatch-eval').validateAcceptanceCriteria.
 * @returns {Promise<string|null>} dispatch id when queued; null when routed to review.
 */
async function addToDispatchWithValidation(item, opts = {}) {
  const config = opts.config || queries.getConfig();
  const enabled = config?.engine?.enablePreDispatchEval ?? ENGINE_DEFAULTS.enablePreDispatchEval;
  if (!enabled) return addToDispatch(item);

  const wi = item?.meta?.item;
  const criteria = wi && (wi.acceptance_criteria || wi.acceptanceCriteria);
  const hasCriteria = Array.isArray(criteria) && criteria.length > 0;
  // P-d2a9f6e5: also let the validator run when criteria are absent but the
  // work item carries a rich description — defense-in-depth for noop dispatches
  // authored from description-only WIs (same incident class as P-a4f7c2d8).
  // The threshold lives in pre-dispatch-eval.js (DESCRIPTION_MIN_CHARS); we
  // duplicate the bypass check here so we don't even spin up the validator
  // when there's nothing usable to evaluate.
  const { DESCRIPTION_MIN_CHARS } = require('./pre-dispatch-eval');
  const description = ((wi && wi.description) || '').trim();
  const hasUsableDescription = description.length >= DESCRIPTION_MIN_CHARS;
  if (!hasCriteria && !hasUsableDescription) {
    return addToDispatch(item);
  }

  const validate = typeof opts.validate === 'function'
    ? opts.validate
    : require('./pre-dispatch-eval').validateAcceptanceCriteria;

  let evaluation;
  try {
    evaluation = await validate(wi, { engineConfig: config?.engine });
  } catch (e) {
    log('warn', `pre-dispatch-eval: validator threw — failing open: ${e.message}`);
    return addToDispatch(item);
  }

  if (!evaluation || evaluation.valid !== false) return addToDispatch(item);

  _persistInvalidWorkItem(item, evaluation);
  _routeToReviewQueue(item, evaluation);
  log('warn', `pre-dispatch-eval: blocked work item ${wi.id} — ${evaluation.reason || 'criteria not actionable'}`);
  return null;
}


function _resolveDispatchProject(projectRef, config) {
  if (!projectRef) return null;
  const projects = getProjects(config);
  const resolved = shared.resolveProjectSource(projectRef, projects, { allowCentral: false });
  if (resolved.project) return resolved.project;
  return projectRef;
}

function _isPrBackedDispatch(entry) {
  return !!(entry?.meta?.pr && entry.meta?.project);
}

function getStalePrDispatchReason(entry, config) {
  if (!_isPrBackedDispatch(entry)) return '';
  const project = _resolveDispatchProject(entry.meta.project, config);
  if (!project) return 'missing project metadata';

  const tracked = shared.findPrRecord(queries.getPrs(project), entry.meta.pr, project);
  const prLabel = entry.meta.pr?.id || entry.meta.pr?.url || entry.id;
  if (!tracked) return `PR ${prLabel} is no longer tracked`;
  if (tracked.status !== PR_STATUS.ACTIVE) return `PR ${tracked.id || prLabel} is ${tracked.status || 'missing status'}`;
  // Combined gate (W-mq5v612m001g1545 + #3126):
  //   1. `entry.meta?.source !== 'work-item'` — blocks any non-work-item dispatch
  //      (e.g. legacy `pr`/`pr-human-feedback` discovery paths) from running
  //      against a context-only PR. Auto-discovery in engine.js#discoverFromPrs
  //      already skips _contextOnly PRs at the source, so this is defense-in-depth.
  //   2. `!NON_MUTATING_DISPATCH_TYPES.has(entry.type)` — even an explicit
  //      work-item dispatch is dropped if it's a mutating type (fix, implement,
  //      test, verify, decompose, docs). Only review/ask/explore — which never
  //      push to the PR's source branch — are allowed through.
  if (tracked._contextOnly
      && (entry.meta?.source !== 'work-item' || !NON_MUTATING_DISPATCH_TYPES.has(entry.type))) {
    return `PR ${tracked.id || prLabel} is context-only`;
  }

  const queuedBranch = entry.meta.branch || entry.meta.pr?.branch || '';
  const trackedBranch = tracked.branch || '';
  if (queuedBranch && trackedBranch && shared.sanitizeBranch(queuedBranch) !== shared.sanitizeBranch(trackedBranch)) {
    return `PR ${tracked.id || prLabel} branch changed from ${queuedBranch} to ${trackedBranch}`;
  }

  return '';
}

function pruneStalePrDispatches(config = queries.getConfig()) {
  const removed = [];
  mutateDispatch((dispatch) => {
    dispatch.pending = (dispatch.pending || []).filter(entry => {
      const reason = getStalePrDispatchReason(entry, config);
      if (!reason) return true;
      removed.push({ entry, reason });
      return false;
    });
    return dispatch;
  });

  for (const { entry, reason } of removed) {
    try { deleteDispatchPromptSidecar(entry); } catch { /* cleanup best-effort */ }
    log('info', `Dropped stale PR dispatch ${entry.id}: ${reason}`);
  }
  return removed.length;
}

// ─── Retryable Failure Classification ────────────────────────────────────────

function isRetryableFailureReason(reason = '', failureClass = '') {
  // FAILURE_CLASS-based classification takes precedence when available
  if (failureClass) {
    const neverRetry = new Set([
      FAILURE_CLASS.CONFIG_ERROR,
      FAILURE_CLASS.PERMISSION_BLOCKED,
      FAILURE_CLASS.AUTH, // W-mpcuc8i80003a7b3 — git/network credential failure; mechanical retry won't fix missing az / GCM creds
      FAILURE_CLASS.WORKTREE_PREFLIGHT, // pre-spawn worktree validation — recompute will produce the same failure
      FAILURE_CLASS.WORKTREE_DIRTY,     // #2996: reused worktree was dirty and could not be auto-healed — non-retryable for this dispatch attempt; the engine quarantined the worktree so the next discovery cycle creates a fresh one
      FAILURE_CLASS.WORKTREE_DIVERGENT, // #2996: reused worktree's local branch had unpushed commits — engine quarantined + backed up the local ref; non-retryable for this dispatch (next discovery creates fresh worktree on origin/<branch>)
      FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED, // W-mq5n1zx5: quarantine rename couldn't release the worktree dir even after retry + force-remove fallback. Non-retryable at the dispatch level so the per-agent retry counter isn't bumped (environmental, not the agent's fault); the WI auto-recovery loop in engine.js#discoverFromWorkItems re-queues without touching _retriesByAgent.
      FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR, // W-mp6k7ywi000fa33c — keep-pids cwd is not a real git worktree; re-running won't fix the structural issue
      FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA, // W-mp7i902u000l991f — keep-pids.json failed shape validation; re-running with the same wrong file won't fix it
      FAILURE_CLASS.INVALID_MANAGED_SPAWN, // W-mpbhxg3b000u8411 — managed-spawn.json failed validation; re-running with the same wrong file won't fix it
      FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED, // W-mpbhxg3b000u8411 — healthcheck timed out; agent must fix the spec or the service it spawned
      FAILURE_CLASS.INJECTION_FLAGGED, // F5 (W-mpeklod3000we69c) — agent spotted a prompt-injection attempt in spliced untrusted content; a human must review the source before re-dispatch
      FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, // P-a3f9b204 — live-checkout refused to spawn because operator localPath is dirty; mechanical retry won't fix it (operator must commit/stash/discard)
    ]);
    if (neverRetry.has(failureClass)) return false;
  }
  const r = String(reason || '').toLowerCase();
  if (!r) return true; // unknown error from tool exit — keep retryable
  const nonRetryable = [
    'no playbook rendered',
    'failed to render',
    'no target project available',
    'no plan files found',
    'plan file not found',
    'invalid filename',
    'invalid file path',
    'missing required',
    'validation failed',
    'auth failure',
    'authentication failed',
    'authentication failure',
    'unauthorized',
    'invalid api key',
    'please log in',
    'budget-exceeded',
    'budget exceeded',
    'budget cap exceeded',
    'max-budget-usd',
    'cost limit',
  ];
  return !nonRetryable.some(s => r.includes(s));
}

function normalizeRetryableDecision(value) {
  if (typeof value === 'boolean') return value;
  if (typeof value === 'string') {
    const normalized = value.trim().toLowerCase();
    if (['true', 'yes', '1'].includes(normalized)) return true;
    if (['false', 'no', '0'].includes(normalized)) return false;
  }
  return undefined;
}

function isCompletedWorkItemForFailure(item) {
  return !!item && (
    item.status === WI_STATUS.DONE ||
    (!!item.completedAt && (!!item._pr || !!item._prUrl))
  );
}

// W-mpbhxg3b000u8411 — Failure classes that should force-demote a `done` work
// item back to `failed`. These are the hard sidecar-acceptance and
// healthcheck rejections fired in engine.js's onAgentClose AFTER
// runPostCompletionHooks has already flipped the WI to DONE based on the
// completion-report's verdict:"success". The agent's "I'm done" claim is
// structurally false when the sidecar didn't pass validation — so the
// dispatch must be allowed to demote the WI rather than silently ignoring the
// rejection (incident W-mpbg0jpt0007f7fe). PR-shipped WIs are still
// protected: the demotion only fires when the WI has no _pr/_prUrl, since
// these acceptance gates never run for spawns that produced a PR.
const FORCE_DEMOTE_FAILURE_CLASSES = new Set([
  FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR,
  FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA,
  FAILURE_CLASS.INVALID_MANAGED_SPAWN,
  FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED,
  // W-mq0e2dae000a003d: spawn-phase wedges should never claim success,
  // but include here as defense-in-depth so a stray completion-report
  // can't sneak through.
  FAILURE_CLASS.SPAWN_PHASE_STALL,
]);

function readLiveWorkItem(meta) {
  const itemId = meta?.item?.id;
  if (!itemId) return null;
  const wiPath = lifecycle().resolveWorkItemPath(meta);
  if (!wiPath) return null;
  const items = safeJsonArr(wiPath);
  return items.find(i => i.id === itemId) || null;
}

function writeFailedAgentReport(item, reason, resultSummary, failureClass) {
  if (!item?.id) {
    log('warn', 'Cannot write failed agent report without dispatch id');
    return;
  }
  const agentId = item.agent || 'engine';
  const itemId = item.meta?.item?.id || '';
  const title = item.meta?.item?.title || item.task || item.id;
  const metadata = {
    dispatchId: item.id,
    sourceItem: itemId || null,
    result: DISPATCH_RESULT.ERROR,
    failureClass: failureClass || null,
  };
  const content = [
    `# Agent Failed: ${title}`,
    '',
    `**Agent:** ${agentId}`,
    `**Dispatch:** \`${item.id}\``,
    itemId ? `**Work Item:** \`${itemId}\`` : '',
    `**Type:** ${item.type || 'unknown'}`,
    `**Result:** ${DISPATCH_RESULT.ERROR}`,
    failureClass ? `**Failure Class:** ${failureClass}` : '',
    reason ? `**Reason:** ${reason}` : '',
    '',
    resultSummary ? `## Summary\n${resultSummary}` : '## Summary\n(no agent summary captured)',
  ].filter(Boolean).join('\n');
  shared.writeToInbox(agentId, `agent-failure-${item.id}`, content, null, metadata);
}

function markCompletedSourceWorkItem(item) {
  const meta = item?.meta;
  const itemId = meta?.item?.id;
  if (!itemId) return false;
  const wiPath = lifecycle().resolveWorkItemPath(meta);
  if (!wiPath) return false;
  let found = false;
  let terminal = false;
  mutateWorkItems(wiPath, items => {
    if (!Array.isArray(items)) return items;
    const wi = items.find(i => i.id === itemId);
    if (!wi) return items;
    found = true;
    if (wi.status === WI_STATUS.DONE) {
      terminal = true;
      return items;
    }
    wi.status = WI_STATUS.DONE;
    delete wi.failReason;
    delete wi.failedAt;
    delete wi._retryCount;
    if (!wi.completedAt) wi.completedAt = ts();
    if (item.agent && !wi.dispatched_to) wi.dispatched_to = item.agent;
    terminal = true;
    return items;
  });
  if (!found) {
    throw new Error(`source work item ${itemId} not found`);
  }
  return terminal;
}

// ─── Complete Dispatch ───────────────────────────────────────────────────────

function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', resultSummary = '', opts = {}) {
  const { processWorkItemFailure = true, processWorkItemSuccess = false, failureClass } = opts;
  const agentRetryable = normalizeRetryableDecision(opts.agentRetryable ?? opts.retryable);
  let item = null;
  let completedSourceWorkItem = false;
  // Pre-spawn failures (e.g. worktree creation) move the item from pending →
  // completed without it ever entering active. Track this so the human-feedback
  // dedupe-clear below can skip clearing the completed entry — otherwise the
  // engine immediately re-queues a fix that has no chance of succeeding (#2454).
  let wasPending = false;

  mutateDispatch((dispatch) => {
    // Check active list first
    let idx = dispatch.active.findIndex(d => d.id === id);
    if (idx >= 0) {
      item = dispatch.active.splice(idx, 1)[0];
    } else {
      // Also check pending list (e.g., worktree failure before spawn)
      idx = dispatch.pending.findIndex(d => d.id === id);
      if (idx >= 0) {
        item = dispatch.pending.splice(idx, 1)[0];
        wasPending = true;
      }
    }

    if (!item) return dispatch;
    item.completed_at = ts();
    item.result = result;
    if (reason) item.reason = reason;
    if (resultSummary) item.resultSummary = resultSummary;
    if (failureClass && result === DISPATCH_RESULT.ERROR) item.failureClass = failureClass;
    item.meta = item.meta && typeof item.meta === 'object' ? item.meta : {};
    if (opts.completionReportPath && !item.meta.completionReportPath) {
      item.meta.completionReportPath = opts.completionReportPath;
    }
    if (opts.structuredCompletion && typeof opts.structuredCompletion === 'object') {
      item.structuredCompletion = opts.structuredCompletion;
      if (opts.structuredCompletion._path && !item.meta.completionReportPath) {
        item.meta.completionReportPath = opts.structuredCompletion._path;
      }
    }
    if (processWorkItemSuccess && result === DISPATCH_RESULT.SUCCESS && item.meta?.item?.id) {
      if (item.agent) item.meta._agentId = item.agent;
      completedSourceWorkItem = markCompletedSourceWorkItem(item);
    }
    // Drop prompt (and sidecar file, if any) — completed entries don't need
    // replayable content and it would accumulate forever (#1167).
    try { deleteDispatchPromptSidecar(item); } catch { /* best-effort */ }
    delete item.prompt;
    delete item._promptFile;
    delete item._promptBytes;
    if (dispatch.completed.length >= 100) {
      dispatch.completed = dispatch.completed.slice(-100);
    }
    dispatch.completed.push(item);
    return dispatch;
  });

  if (item) {
    log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
    // W-mpob4nyk0006580e — broadcast terminal state immediately after the
    // `result` field is committed to dispatch.json. This is the single
    // source of truth for the live-view banner; subscribers (dashboard SSE
    // handler at handleAgentLiveStream) push it to clients so the banner
    // mirrors `dispatch.result` exactly — no log-parsing heuristics that can
    // disagree with the engine's verdict.
    try {
      dispatchEvents.emitDispatchTerminal(dispatchEvents.buildTerminalEvent({
        dispatchId: id,
        agentId: item.agent,
        result,
        reason,
        failureClass,
        structuredCompletion: opts.structuredCompletion || item.structuredCompletion || null,
        resultSummary,
      }));
    } catch (e) { log('warn', 'emit dispatch.terminal: ' + e.message); }

    if (result === DISPATCH_RESULT.ERROR) {
      writeFailedAgentReport(item, reason, resultSummary, failureClass);
    }

    // Update source work item status on failure + auto-retry with backoff
    const retryableFailure = agentRetryable !== undefined ? agentRetryable : isRetryableFailureReason(reason, failureClass);
    let completedWorkItemFailure = false;
    let liveWi = null;
    if (processWorkItemFailure && result === DISPATCH_RESULT.ERROR && item.meta?.item?.id) {
      // If the live item cannot be resolved, keep the existing retry path.
      try {
        liveWi = readLiveWorkItem(item.meta);
        completedWorkItemFailure = isCompletedWorkItemForFailure(liveWi);
      } catch (e) { log('warn', 'read live work item before retry: ' + e.message); }
    }
    if (result === DISPATCH_RESULT.ERROR && item.meta?.dispatchKey && retryableFailure && !completedWorkItemFailure) {
      setCooldownFailure(item.meta.dispatchKey);
    }

    if (processWorkItemFailure && result === DISPATCH_RESULT.ERROR && item.meta?.item?.id) {
      if (completedWorkItemFailure) {
        // W-mpbhxg3b000u8411 — sidecar acceptance / healthcheck rejections fire
        // AFTER runPostCompletionHooks has already flipped the WI to DONE based
        // on the agent's completion-report verdict. The completion-report's
        // success claim is structurally false when the sidecar didn't pass
        // validation, so demote DONE → FAILED with the dispatch's failure
        // class. PR-shipped WIs are explicitly NOT demoted — refuse to unship
        // merged work even if a stale acceptance error somehow arrives late.
        const isPrShipped = !!liveWi && (!!liveWi._pr || !!liveWi._prUrl);
        const isForceDemote = failureClass && FORCE_DEMOTE_FAILURE_CLASSES.has(failureClass) && !isPrShipped;
        if (isForceDemote) {
          try {
            const wiPath = lifecycle().resolveWorkItemPath(item.meta);
            if (wiPath) {
              const classSuffix = ` [${failureClass.toUpperCase().replace(/-/g, '_')}]`;
              const demoteReason = `Non-retryable failure: ${reason || failureClass}${classSuffix}`;
              mutateWorkItems(wiPath, items => {
                const wi = items.find(i => i.id === item.meta.item.id);
                if (wi) {
                  wi.status = WI_STATUS.FAILED;
                  wi.failReason = demoteReason;
                  wi.failedAt = ts();
                  wi._failureClass = failureClass;
                  wi._lastDispatchResult = DISPATCH_RESULT.ERROR;
                  delete wi.completedAt;
                  delete wi._noop;
                  delete wi._noopReason;
                }
              });
              log('warn', `Demoted WI ${item.meta.item.id} from DONE → FAILED${classSuffix} — sidecar acceptance gate rejected after completion-report claimed success`);
            }
          } catch (e) {
            // P-bf06-force-demote-observability — write failures during
            // sidecar-rejection force-demote used to log at warn level only,
            // so they were invisible to dashboards / inbox alerting. Surface
            // them at error level with the full stack AND a synthetic
            // agent-failure inbox alert so the failed write is debuggable.
            // NOTE: do NOT call lifecycle().syncPrdItemStatus from this catch
            // — the success-path PRD sync at line 688 owns that contract and
            // adding it here would double-write PRD status under conflicting
            // assumptions.
            log('error', `force-demote on ${failureClass} failed: ${e.message}\n${e.stack}`, { stack: e.stack, failureClass });
            try {
              // Pass a shimmed dispatch id so writeToInbox's slug-based dedupe
              // (see shared.writeToInbox prefix collision check) doesn't drop
              // this alert against the prior agent-failure note that the
              // ERROR branch already wrote at line 550.
              const demoteItem = { ...item, id: `${item.id}-demote-write` };
              writeFailedAgentReport(demoteItem, `force-demote write failed: ${e.message}`, e.stack || '', 'dispatch-demote-write-failed');
            } catch (reportErr) {
              log('error', `force-demote write failed-agent-report itself failed: ${reportErr.message}`);
            }
          }
        } else {
          log('info', `Dispatch error for ${item.meta.item.id} ignored — work item is already completed`);
        }
      } else {
        let retries = (item.meta.item._retryCount || 0);
        try {
          const wi = queries.getWorkItems().find(i => i.id === item.meta.item.id);
          if (wi) retries = wi._retryCount || 0;
        } catch (e) { log('warn', 'read retry count: ' + e.message); }
        const maxRetries = ENGINE_DEFAULTS.maxRetries;
        const withinSafetyCap = retries < maxRetries;
        if (retryableFailure && withinSafetyCap) {
          log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/${maxRetries}${failureClass ? ' [' + failureClass + ']' : ''}`);
          lifecycle().updateWorkItemStatus(item.meta, WI_STATUS.PENDING, '');
          // Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
          if (item.meta?.dispatchKey) {
            try {
              mutateDispatch((dp) => {
                dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== item.meta.dispatchKey) : [];
                return dp;
              });
            } catch (e) { log('warn', 'clear dispatch for retry: ' + e.message); }
          }
          // Increment retry counter on the source work item
          try {
            const wiPath = lifecycle().resolveWorkItemPath(item.meta);
            if (wiPath) {
              mutateWorkItems(wiPath, items => {
                const wi = items.find(i => i.id === item.meta.item.id);
                if (wi && wi.status !== WI_STATUS.PAUSED && wi.status !== WI_STATUS.DONE && !wi.completedAt) {
                  wi._retryCount = retries + 1;
                  wi.status = WI_STATUS.PENDING;
                  wi._lastRetryReason = reason || '';
                  wi._lastRetryAt = ts();
                  // W-mpg6isvy000xca4d — surface the previous failure class so the
                  // next spawn can swap in a runtime-appropriate fallback model
                  // when the previous attempt was bounced for MODEL_UNAVAILABLE
                  // (overloaded_error / 503). Empty string clears any stale
                  // value from an earlier failure cycle.
                  wi._lastFailureClass = failureClass || '';
                  // W-mpmwxn1j — Bump per-agent retry count so the next dispatch
                  // can reassign to a different eligible agent once the same
                  // agent hits the threshold. Skip when no agent is resolvable
                  // (anonymous failures shouldn't corrupt the map shape).
                  const failedAgent = item.agent || wi.dispatched_to;
                  if (failedAgent) shared.bumpAgentRetryCount(wi, failedAgent);
                  delete wi.failReason;
                  delete wi.failedAt;
                  delete wi.dispatched_at;
                  delete wi.dispatched_to;
                  delete wi._pendingReason;
                }
              });
            }
          } catch (e) { log('warn', 'increment retry counter: ' + e.message); }
        } else {
          // Human-readable labels for each failure class — used as fallback when reason is empty
          const CLASS_LABELS = {
            [FAILURE_CLASS.EMPTY_OUTPUT]: 'agent produced no output \u2014 likely crashed on startup',
            [FAILURE_CLASS.BUILD_FAILURE]: 'build/test/lint failure in output',
            [FAILURE_CLASS.MERGE_CONFLICT]: 'merge conflict',
            [FAILURE_CLASS.DEPENDENCY_MERGE_SETUP]: 'dependency merge setup failed',
            [FAILURE_CLASS.MAX_TURNS]: 'reached max turn limit',
            [FAILURE_CLASS.TIMEOUT]: 'timed out waiting for agent',
            [FAILURE_CLASS.SPAWN_ERROR]: 'agent process failed to start',
            [FAILURE_CLASS.NETWORK_ERROR]: 'network or API error',
            [FAILURE_CLASS.OUT_OF_CONTEXT]: 'context window exhausted',
            [FAILURE_CLASS.CONFIG_ERROR]: 'configuration error',
            [FAILURE_CLASS.PERMISSION_BLOCKED]: 'permission or auth failure',
            [FAILURE_CLASS.AUTH]: 'ADO/git authentication failed (missing or expired credentials)',
            [FAILURE_CLASS.WORKTREE_PREFLIGHT]: 'worktree preflight rejected (nested in project root or rootDir collapsed to drive root)',
            [FAILURE_CLASS.WORKTREE_DIRTY]: 'reused worktree had uncommitted edits and could not be auto-healed (#2996) — engine quarantined the dir so the next dispatch creates a fresh worktree',
            [FAILURE_CLASS.WORKTREE_DIVERGENT]: 'reused worktree had unpushed local commits ahead of origin (#2996) — engine backed up the local ref and quarantined the dir so the next dispatch starts from origin/<branch>',
            [FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED]: 'quarantine rename was blocked by the OS (Windows EBUSY/EPERM) even after retry + force-remove fallback — environmental, not the agent\'s fault; auto-recovery loop will re-queue without bumping per-agent retries',
            [FAILURE_CLASS.INVALID_KEEP_PROCESSES_WORKDIR]: 'keep_processes cwd is not a real git worktree (rerun in a `git worktree add` directory)',
            [FAILURE_CLASS.INVALID_KEEP_PROCESSES_SCHEMA]: 'keep-pids.json failed shape validation (wrong keys/types/values — see inbox alert for the canonical shape)',
            [FAILURE_CLASS.INVALID_MANAGED_SPAWN]: 'managed-spawn.json failed validation (bad schema, workdir, or allowlist — see inbox alert)',
            [FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED]: 'managed-spawn spec(s) failed healthcheck within timeout (failing PIDs killed; surviving siblings stay alive)',
            [FAILURE_CLASS.INJECTION_FLAGGED]: 'agent flagged a prompt-injection attempt in spliced untrusted content — human review of the listed sources required before re-dispatch',
            [FAILURE_CLASS.MODEL_UNAVAILABLE]: 'requested model returned overloaded/503 — fallback model swapped in for retry',
            [FAILURE_CLASS.UNKNOWN]: 'unknown error',
          };
          const classLabel = failureClass ? (CLASS_LABELS[failureClass] || failureClass) : '';
          const effectiveReason = reason || classLabel || 'Unknown error';
          const classSuffix = failureClass ? ` [${failureClass.toUpperCase().replace(/-/g, '_')}]` : '';
          const finalReason = !retryableFailure
            ? `Non-retryable failure: ${effectiveReason}${classSuffix}`
            : (reason || `Failed after ${maxRetries} retries${classSuffix}`);
          lifecycle().updateWorkItemStatus(item.meta, WI_STATUS.FAILED, finalReason);
          // Surface blocked dependents in logs without creating failure inbox noise.
          try {
            const config = getConfig();
            const failedId = item.meta.item.id;
            const blockedItems = [];
            const allItems = queries.getWorkItems(config);
            allItems.filter(w => w.status === WI_STATUS.PENDING && (w.depends_on || []).includes(failedId))
              .forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));

            log('warn', `Work item ${failedId} failed: ${finalReason}` +
              (blockedItems.length > 0 ? `; blocked dependents: ${blockedItems.map(line => line.replace(/^- `([^`]+)`.*/, '$1')).join(', ')}` : '; no downstream items blocked'));
          } catch (e) { log('warn', 'summarize failure dependents: ' + e.message); }
        }
      }
    }

    if (completedSourceWorkItem) {
      try {
        lifecycle().syncPrdItemStatus(item.meta.item.id, WI_STATUS.DONE, item.meta.item?.sourcePlan);
        log('info', `Work item ${item.meta.item.id} → ${WI_STATUS.DONE}`);
      } catch (e) { log('warn', 'manual completion PRD sync: ' + e.message); }
    }

    // Restore pendingFix on failed human-feedback fix so engine re-dispatches on next tick
    if (result === DISPATCH_RESULT.ERROR && item.meta?.source === 'pr-human-feedback') {
      const prId = item.meta.pr?.id;
      const project = item.meta.project;
      if (prId && project) {
        try {
          const prsPath = projectPrPath(project);
          let restored = false;
          mutatePullRequests(prsPath, prs => {
            const target = shared.findPrRecord(prs, { id: prId }, project);
            if (target?.humanFeedback) {
              target.humanFeedback.pendingFix = true;
              restored = true;
            }
          });
          if (restored) log('info', `Restored pendingFix=true on ${prId} after failed human-feedback fix`);
          else log('info', `Skipped pendingFix restore for ${prId} — PR is no longer tracked`);
        } catch (e) { log('warn', `restore pendingFix: ${e.message}`); }
      }
      // Clear completed dispatch entry so dedup doesn't block re-dispatch.
      // SKIP this clear when the failure happened before spawn (e.g. worktree
      // creation failure, #2454) — otherwise the engine sees no recent dispatch
      // history, clears the cooldown as "stale", and re-queues immediately on
      // the very next tick, looping forever for structural failures like a
      // stale-locked worktree entry that the agent can't fix on its own.
      if (item.meta?.dispatchKey && !wasPending) {
        try {
          mutateDispatch((dp) => {
            dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== item.meta.dispatchKey) : [];
            return dp;
          });
        } catch (e) { log('warn', 'clear human-feedback dispatch for retry: ' + e.message); }
      } else if (item.meta?.dispatchKey && wasPending) {
        log('info', `Preserving completed dispatch entry for ${item.meta.dispatchKey} (pre-spawn failure) — cooldown will throttle retry`);
      }
    }
  }
}

// ─── Inbox Alert ─────────────────────────────────────────────────────────────

function writeInboxAlert(slug, content) {
  const safeSlug = shared.safeSlugComponent(slug, 100);
  const alertKey = `engine-alert-${safeSlug}-${dateStamp()}`;
  const file = path.join(INBOX_DIR, `${alertKey}.md`);
  // Atomic dedupe: openSync(_, 'wx') creates exclusively, fails with EEXIST if
  // the file is already there. Closes the TOCTOU window that the previous
  // safeReadDir+safeWrite pattern left open between two racing writers.
  let fd;
  try {
    if (!fs.existsSync(INBOX_DIR)) fs.mkdirSync(INBOX_DIR, { recursive: true });
    fd = fs.openSync(file, 'wx');
  } catch (e) {
    if (e && e.code === 'EEXIST') {
      log('info', `Alert ${alertKey} already exists, skipping duplicate`);
      return false;
    }
    log('warn', 'write inbox alert: ' + e.message);
    throw e;
  }
  try {
    fs.writeSync(fd, typeof content === 'string' ? content : JSON.stringify(content, null, 2));
  } finally {
    try { fs.closeSync(fd); } catch { /* best-effort close */ }
  }
  return true;
}

// ─── Agent Worker Status ────────────────────────────────────────────────────

/**
 * Update the worker-state fields on an active dispatch entry.
 * Uses mutateDispatch() for atomic read-modify-write.
 * @param {string} dispatchId — dispatch entry ID
 * @param {string} status — one of AGENT_STATUS values
 * @param {string} [detail] — optional human-readable detail string
 */
function updateAgentStatus(dispatchId, status, detail) {
  if (!dispatchId || !status) return;
  mutateDispatch((dispatch) => {
    const entry = (dispatch.active || []).find(d => d.id === dispatchId)
      || (dispatch.pending || []).find(d => d.id === dispatchId);
    if (entry) {
      entry.workerState = status;
      entry.workerStateAt = ts();
      if (detail !== undefined) entry.workerStateDetail = detail;
    }
    return dispatch;
  });
}

// ─── Cancel Pending Dispatches for Closed PR ───────────────────────────────

/**
 * Cancel all pending dispatch entries that reference a specific PR.
 * Called when a PR transitions to merged/abandoned/closed — any pending
 * review, fix, or re-review dispatches for that PR are stale and should
 * not be spawned.
 * @param {string} prId — PR identifier (e.g. 'PR-100')
 * @returns {number} count of cancelled entries
 */
function cancelPendingDispatchesForPr(prId) {
  if (!prId) return 0;
  let cancelled = 0;
  mutateDispatch((dispatch) => {
    const before = dispatch.pending.length;
    dispatch.pending = dispatch.pending.filter(d => d.meta?.pr?.id !== prId);
    cancelled = before - dispatch.pending.length;
    return dispatch;
  });
  if (cancelled > 0) {
    log('info', `Cancelled ${cancelled} pending dispatch(es) for closed PR ${prId}`);
  }
  return cancelled;
}

/**
 * Remove dispatch entries matching a predicate from pending/active/completed.
 * For matched active entries, kills the agent process and deletes its
 * pid file + prompt sidecars in engine/tmp/. Lock callback only mutates state;
 * kills and unlinks happen after release.
 *
 * @param {(entry) => boolean} matchFn
 * @returns {number} count of removed entries
 */
function cleanDispatchEntries(matchFn) {
  const tmpDir = path.join(MINIONS_DIR, 'engine', 'tmp');
  let removed = 0;
  const pidsToKill = [];
  const filesToDelete = [];
  const dispatchDirsToRemove = [];
  try {
    // Route through mutateDispatch so the SQL-backed store (Phase 1) and the
    // dispatch.json mirror stay coherent. Writing dispatch.json directly with
    // mutateJsonFileLocked here would leave stale rows in the `dispatches`
    // table; the very next mutateDispatch call (e.g. removeProject's
    // step 7.5 orphan-tagging pass) would then mirror SQL back to JSON and
    // resurrect the entries we just drained.
    mutateDispatch((dispatch) => {
      for (const queue of ['pending', 'active', 'completed']) {
        dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
        const before = dispatch[queue].length;
        if (queue === 'active') {
          for (const d of dispatch[queue]) {
            if (!matchFn(d)) continue;
            // P-f6-tmp-toctou: prefer the dispatch's recorded tmpDir. Fall
            // back to legacy flat layout for active dispatches that pre-date
            // the per-dispatch-dir layout. shared.findDispatchPidFile honors
            // the same resolution order so we agree on which PID to kill.
            const pidPath = shared.findDispatchPidFile(d);
            if (pidPath) {
              try {
                const pid = parseInt(fs.readFileSync(pidPath, 'utf8').trim());
                if (pid) pidsToKill.push(pid);
              } catch { /* PID file may not exist */ }
            }
            if (d.tmpDir && shared.validateDispatchTmpDir(d.tmpDir)) {
              dispatchDirsToRemove.push(d.tmpDir);
            } else {
              // Legacy individual-file cleanup for pre-migration entries.
              filesToDelete.push(path.join(tmpDir, `pid-${d.id}.pid`));
              filesToDelete.push(path.join(tmpDir, `prompt-${d.id}.md`));
              filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md`));
              filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md.tmp`));
            }
          }
        }
        dispatch[queue] = dispatch[queue].filter(d => !matchFn(d));
        removed += before - dispatch[queue].length;
      }
      return dispatch;
    });
  } catch { return 0; }
  // Kill processes outside the lock — taskkill on Windows can take hundreds of ms
  for (const pid of pidsToKill) {
    try {
      const safePid = shared.validatePid(pid);
      if (process.platform === 'win32') {
        const { execFileSync } = require('child_process');
        execFileSync('taskkill', ['/PID', String(safePid), '/F', '/T'], { stdio: 'pipe', timeout: 5000, windowsHide: true });
      } else {
        process.kill(safePid, 'SIGTERM');
      }
    } catch { /* may already be dead */ }
  }
  for (const fp of filesToDelete) {
    try { fs.unlinkSync(fp); } catch { /* may not exist */ }
  }
  for (const dir of dispatchDirsToRemove) {
    shared.removeDispatchTmpDir(dir);
  }
  return removed;
}

/**
 * Cancel pending/queued work items matching a predicate. Done items pass through.
 * Sets status=CANCELLED + _cancelledBy=reason. Returns count cancelled.
 */
function cancelPendingWorkItems(wiPath, matchFn, reason) {
  if (!fs.existsSync(wiPath)) return 0;
  let cancelled = 0;
  try {
    mutateWorkItems(wiPath, items => {
      for (const w of items) {
        if (!matchFn(w)) continue;
        if (w.status !== WI_STATUS.PENDING && w.status !== WI_STATUS.QUEUED) continue;
        w.status = WI_STATUS.CANCELLED;
        if (reason) w._cancelledBy = reason;
        cancelled++;
      }
    });
  } catch { /* file unwritable */ }
  return cancelled;
}

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

module.exports = {
  mutateDispatch,
  addToDispatch,
  addToDispatchWithValidation,
  getPrDispatchDedupeKey,
  isRetryableFailureReason,
  completeDispatch,
  writeInboxAlert,
  updateAgentStatus,
  getStalePrDispatchReason,
  pruneStalePrDispatches,
  cancelPendingDispatchesForPr,
  cleanDispatchEntries,
  cancelPendingWorkItems,
  // exported for testing
  getDispatchProjectKey,
  getPrDispatchTargetKey,
  getBranchDispatchLockKey,
  findActivePrOrBranchLock,
  normalizeRetryableDecision,
  isCompletedWorkItemForFailure,
  NON_MUTATING_DISPATCH_TYPES,
};
