@yemi33/minions 0.1.2355 → 0.1.2357

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1122,6 +1122,13 @@ function syncPrsFromOutput(output, agentId, meta, config, opts = {}) {
1122
1122
  const projScope = shared.getProjectPrScope(p);
1123
1123
  if (projScope && projScope === urlScope) return p;
1124
1124
  }
1125
+ // W-mrchrfe100067d3a — a PR scoped to one of a project's allowlisted
1126
+ // additionalRepoScopes (e.g. a backport PR opened against
1127
+ // yemi33/minions from the minions-opg checkout) belongs to that
1128
+ // project too, even though it isn't the project's primary scope.
1129
+ for (const p of projects) {
1130
+ if (shared.getProjectAdditionalPrScopes(p).includes(urlScope)) return p;
1131
+ }
1125
1132
  }
1126
1133
  const evidenceText = `${outputText}\n${evidenceUrl}`;
1127
1134
  for (const p of projects) {
@@ -3047,6 +3054,71 @@ function clearBuildFixIneffective(target) {
3047
3054
  return true;
3048
3055
  }
3049
3056
 
3057
+ // #764 — PR-level circuit breaker for the WORKTREE_PREFLIGHT
3058
+ // branch-held-externally failure class (engine.js `_failBranchHeldByExternalWorktree`).
3059
+ // That failure already stamps the WI `agentRetryable: false`, which stops the
3060
+ // immediate quarantine-recovery-loop storm for that one dispatch instance, but
3061
+ // it carries no memory at the PR level — the human-feedback / review / build /
3062
+ // conflict fix dispatch pollers see the PR is still `pendingFix` (or has
3063
+ // coalesced feedback) and re-dispatch a brand-new work item next cooldown
3064
+ // window, forever, until a human manually releases the branch. Mirrors the
3065
+ // `_buildFixIneffective` shape (see `recordBuildFixIneffective` above) so the
3066
+ // dashboard/pause-inspection conventions stay consistent, but is keyed on the
3067
+ // holder path + branch rather than a head SHA — the signal that should clear
3068
+ // this pause is "the holder no longer holds the branch", not "a new commit
3069
+ // landed" (see `engine.js#isWorktreeHeldPauseSuppressed`, which performs that
3070
+ // live re-check and calls `clearWorktreeHeldPause` when the holder has moved
3071
+ // on).
3072
+ function recordWorktreeHeldPause(target, { holderPath, isProjectRoot, branch, dispatchItem } = {}) {
3073
+ if (!target) return null;
3074
+ const now = ts();
3075
+ const prior = target._worktreeHeldPaused && typeof target._worktreeHeldPaused === 'object'
3076
+ ? target._worktreeHeldPaused : null;
3077
+ target._worktreeHeldPaused = {
3078
+ holderPath: holderPath || null,
3079
+ isProjectRoot: !!isProjectRoot,
3080
+ branch: branch || null,
3081
+ since: prior?.since || now,
3082
+ lastAt: now,
3083
+ dispatchId: dispatchItem?.id || null,
3084
+ reason: isProjectRoot
3085
+ ? `Branch ${branch || '(unknown)'} is held by the project root (${holderPath}). PR-driven review/fix automation is paused until the root releases the branch.`
3086
+ : `Branch ${branch || '(unknown)'} is held by an external worktree at ${holderPath} the engine can't reach. PR-driven review/fix automation is paused until that worktree releases the branch.`,
3087
+ };
3088
+ if (!prior) {
3089
+ try {
3090
+ const prNumber = target.prNumber || shared.getPrNumber(target) || target.id || 'unknown';
3091
+ const noteBody = `# Worktree-held pause — PR automation stopped: ${target.id || prNumber}\n\n`
3092
+ + `**PR:** ${target.url || target.id || '(unknown)'}\n`
3093
+ + `**Branch:** ${branch || target.branch || '(unknown)'}\n`
3094
+ + `**Holder:** ${holderPath || '(unknown)'}${isProjectRoot ? ' (project root)' : ' (external worktree)'}\n\n`
3095
+ + `A WORKTREE_PREFLIGHT dispatch failed because this branch is checked out ${isProjectRoot ? 'at the project root' : 'in an external worktree'} the engine can't reach. `
3096
+ + `Review/human-feedback/build-fix/merge-conflict automation for this PR is now paused so the poller stops re-dispatching against the same unresolved cause every cooldown window (issue #764).\n\n`
3097
+ + `**Recovery:** ${isProjectRoot ? `\`git -C ${holderPath} checkout <mainBranch>\` to release the branch` : 'finish or remove the external worktree holding this branch'} — the engine auto-clears this pause the next time it detects the holder no longer holds the branch.\n`;
3098
+ shared.writeToInbox(
3099
+ 'engine',
3100
+ `worktree-held-paused-${prNumber}`,
3101
+ noteBody,
3102
+ null,
3103
+ { wi: dispatchItem?.meta?.item?.id || null, pr: target.id || null },
3104
+ );
3105
+ } catch (err) {
3106
+ log('warn', `worktree-held-paused inbox alert for ${target.id || '(unknown)'}: ${err.message}`);
3107
+ }
3108
+ }
3109
+ return target._worktreeHeldPaused;
3110
+ }
3111
+
3112
+ // Clears the `_worktreeHeldPaused` circuit breaker (issue #764) so PR-driven
3113
+ // review/fix dispatch can resume. Returns true when a record was actually
3114
+ // removed so callers (the dashboard resume endpoint, engine.js's live
3115
+ // re-check) can distinguish "cleared" from "nothing to clear".
3116
+ function clearWorktreeHeldPause(target) {
3117
+ if (!target?._worktreeHeldPaused) return false;
3118
+ delete target._worktreeHeldPaused;
3119
+ return true;
3120
+ }
3121
+
3050
3122
  function clearPrNoOpFixAttempt(target, cause) {
3051
3123
  if (!target?._noOpFixes || !target._noOpFixes[cause]) return;
3052
3124
  delete target._noOpFixes[cause];
@@ -6611,16 +6683,25 @@ function pruneScopeMismatchDuplicatePrs(config) {
6611
6683
  const projects = shared.getProjects(config) || [];
6612
6684
  if (projects.length === 0) return { pruned: 0, relocated: 0, scanned: 0 };
6613
6685
 
6614
- // Map project name -> canonical scope so we can find which project IS the
6615
- // correctly-scoped owner for a given mismatch record.
6686
+ // Map project name -> canonical scope(s) so we can find which project IS
6687
+ // the correctly-scoped owner for a given mismatch record. Includes
6688
+ // additionalRepoScopes (W-mrchrfe100067d3a) so a stale `_invalidProjectScope`
6689
+ // stamp left over from before a project opted into a secondary scope (or
6690
+ // any record from another host that hasn't been re-normalized yet) maps
6691
+ // back to the project that now allowlists it, instead of relocating to a
6692
+ // nonexistent project or getting stuck unresolved.
6616
6693
  const scopeByProject = new Map();
6617
6694
  const projectByScope = new Map();
6618
6695
  for (const p of projects) {
6619
6696
  if (!p || !p.name) continue;
6620
6697
  const sc = shared.getProjectPrScope(p);
6621
- if (!sc) continue;
6622
- scopeByProject.set(p.name, sc);
6623
- projectByScope.set(sc, p);
6698
+ if (sc) {
6699
+ scopeByProject.set(p.name, sc);
6700
+ if (!projectByScope.has(sc)) projectByScope.set(sc, p);
6701
+ }
6702
+ for (const additionalScope of shared.getProjectAdditionalPrScopes(p)) {
6703
+ if (!projectByScope.has(additionalScope)) projectByScope.set(additionalScope, p);
6704
+ }
6624
6705
  }
6625
6706
 
6626
6707
  // Index all PRs by canonical id across all projects (post-_scope decoration).
@@ -6995,6 +7076,13 @@ module.exports = {
6995
7076
  // Issue #671 — exported so dashboard.js can clear the build-fix-ineffective
6996
7077
  // ("infra issue suspected") pause from the resume endpoint.
6997
7078
  clearBuildFixIneffective,
7079
+ // Issue #764 — PR-level circuit breaker for WORKTREE_PREFLIGHT
7080
+ // branch-held-externally failures. Exported so engine.js can stamp the
7081
+ // pause at dispatch-failure time and auto-clear it via the live re-check,
7082
+ // and so dashboard.js can expose a manual clear endpoint + unit tests can
7083
+ // exercise both paths directly.
7084
+ recordWorktreeHeldPause,
7085
+ clearWorktreeHeldPause,
6998
7086
  // W-mpx44p05000ze8d8 — exported so engine/ado.js + engine/github.js can
6999
7087
  // drop stale `_noOpFixes` records during their PR status poll, and so
7000
7088
  // unit tests can exercise the sweep helper directly.
@@ -128,6 +128,21 @@
128
128
  * (signature `(absPath:string) => boolean`, defaulting to fs.existsSync).
129
129
  * Production callers omit both and the helper falls back to
130
130
  * `require('./shared').shellSafeGit` and `fs.existsSync` respectively.
131
+ *
132
+ * `checkLiveCheckoutDirty` / `checkLiveCheckoutStaleBase` (W-mrcifup2000c218a):
133
+ * ALLOCATION-TIME pre-dispatch counterparts to the dirty / stale-base checks
134
+ * above. Both are cheap, read-only, non-mutating probes engine.js calls
135
+ * BEFORE a live-mode work item is dispatched (in the same allocation loop
136
+ * that already gates on `live_checkout_busy`), so a dirty tree or a
137
+ * contaminated local base leaves the item pending
138
+ * (`_pendingReason: 'live_checkout_dirty'` / `'live_checkout_stale_base'`)
139
+ * instead of burning a retry (dirty, pre-fix behavior) or failing
140
+ * non-retryably on the first hit (stale-base, pre-fix behavior). The
141
+ * in-spawn guards documented above (inside `prepareLiveCheckout`, surfaced by
142
+ * spawnAgent) remain in place as a defense-in-depth fallback for the race
143
+ * window between this probe and the actual git operations inside spawnAgent
144
+ * — they should now fire rarely, as the exception path rather than the
145
+ * common path.
131
146
  */
132
147
 
133
148
  'use strict';
@@ -1205,6 +1220,130 @@ async function prepareLiveCheckout(opts = {}) {
1205
1220
  return { ok: true, branch: branchName, created: true, originalRef, originalRefType };
1206
1221
  }
1207
1222
 
1223
+ /**
1224
+ * checkLiveCheckoutDirty — W-mrcifup2000c218a
1225
+ *
1226
+ * Cheap, read-only, NON-MUTATING pre-dispatch probe for a dirty live-checkout
1227
+ * working tree. Mirrors prepareLiveCheckout's Step 1 dirty check (same
1228
+ * `git status --porcelain=v1 -b` command, same porcelain parsing, same
1229
+ * auto-cleanable-artifact allowance) but never deletes anything and never
1230
+ * throws on a git failure — callers use this at ALLOCATION time (before a WI
1231
+ * is dispatched) to decide whether to leave the item pending
1232
+ * (`_pendingReason: 'live_checkout_dirty'`) instead of spawning it only to
1233
+ * have prepareLiveCheckout refuse it after the fact.
1234
+ *
1235
+ * Intentionally permissive on ambiguity: if the `git status` probe itself
1236
+ * fails (transient error), or every dirty entry is an auto-cleanable build
1237
+ * artifact (which prepareLiveCheckout's spawn-time auto-clean step will
1238
+ * delete before it ever re-checks dirtiness), this reports `dirty:false` so
1239
+ * allocation does not block on a condition that resolves itself inside the
1240
+ * spawn path. The in-spawn guard (engine.js ~line 2617) remains the
1241
+ * authoritative check for the race window between this probe and the actual
1242
+ * git operations inside spawnAgent.
1243
+ *
1244
+ * @param {object} opts
1245
+ * @param {string} opts.localPath operator checkout root (git cwd)
1246
+ * @param {object} [opts.gitOpts] extra execFile opts merged into cwd
1247
+ * @param {boolean} [opts.skipDirtyCheck] #522: opt out entirely (GVFS repos)
1248
+ * @param {function} [opts._git] test seam; defaults to shellSafeGit
1249
+ * @returns {Promise<{dirty:boolean, dirtyFiles?:string[], branchInfo?:string, autoCleanable?:boolean, error?:string}>}
1250
+ */
1251
+ async function checkLiveCheckoutDirty(opts = {}) {
1252
+ const { localPath, gitOpts, skipDirtyCheck, _git } = opts;
1253
+ if (skipDirtyCheck) return { dirty: false };
1254
+ if (!localPath || typeof localPath !== 'string') return { dirty: false };
1255
+ const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
1256
+ const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
1257
+ let statusRaw;
1258
+ try {
1259
+ statusRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
1260
+ } catch (e) {
1261
+ // A probe failure is not proof of a dirty tree — don't block allocation
1262
+ // on it. spawnAgent's own dirty check (with its LIVE_CHECKOUT_FAILED
1263
+ // classification) is the authoritative fallback for a real git failure.
1264
+ return { dirty: false, error: e.message };
1265
+ }
1266
+ const lines = (typeof statusRaw === 'string' ? statusRaw : '')
1267
+ .split(/\r?\n/)
1268
+ .map((line) => line.replace(/\s+$/, ''))
1269
+ .filter((line) => line.length > 0);
1270
+ const branchInfo = lines.find((line) => line.startsWith('## ')) || '';
1271
+ const dirtyFiles = lines.filter((line) => !line.startsWith('## '));
1272
+ if (dirtyFiles.length === 0) return { dirty: false, branchInfo };
1273
+ const cleanablePaths = _collectAutoCleanablePaths(dirtyFiles);
1274
+ if (cleanablePaths) return { dirty: false, branchInfo, autoCleanable: true };
1275
+ return { dirty: true, dirtyFiles, branchInfo };
1276
+ }
1277
+
1278
+ /**
1279
+ * checkLiveCheckoutStaleBase — W-mrcifup2000c218a
1280
+ *
1281
+ * Cheap, read-only, NON-MUTATING, fetch-free pre-dispatch probe for the
1282
+ * STALE-BASE GUARD condition (W-mr98op8w000ma4ad). Mirrors prepareLiveCheckout's
1283
+ * Step 4d divergence check (same `git rev-list --count origin/<mainRef>..<mainRef>`
1284
+ * comparison) so allocation-time callers can detect a contaminated local base
1285
+ * BEFORE dispatching, instead of burning a non-retryable terminal failure after
1286
+ * the fact. Never fetches, never resets, never mutates anything.
1287
+ *
1288
+ * Scope mirrors prepareLiveCheckout exactly: only relevant when a NEW branch
1289
+ * would be forked (an existing local `branchName` checkout is unaffected by
1290
+ * this class of contamination), when HEAD sits on `mainRef` itself, and when
1291
+ * `origin/<mainRef>` is present locally to compare against. `allowNonMainBase`
1292
+ * (PR-targeted / shared-branch continuations) short-circuits to `stale:false`
1293
+ * — those dispatches intentionally build on a non-main branch and opt out of
1294
+ * this guard, same as prepareLiveCheckout.
1295
+ *
1296
+ * @param {object} opts
1297
+ * @param {string} opts.localPath operator checkout root (git cwd)
1298
+ * @param {string} opts.mainRef project base branch name
1299
+ * @param {object} [opts.gitOpts] extra execFile opts merged into cwd
1300
+ * @param {boolean} [opts.allowNonMainBase] opt out (PR-targeted/shared-branch)
1301
+ * @param {function} [opts._git] test seam; defaults to shellSafeGit
1302
+ * @returns {Promise<{stale:boolean, ahead?:number, mainRef?:string, localMainSha?:string, originMainSha?:string}>}
1303
+ */
1304
+ async function checkLiveCheckoutStaleBase(opts = {}) {
1305
+ const { localPath, mainRef, gitOpts, allowNonMainBase, _git } = opts;
1306
+ if (allowNonMainBase) return { stale: false };
1307
+ if (!localPath || typeof localPath !== 'string') return { stale: false };
1308
+ if (!mainRef || typeof mainRef !== 'string') return { stale: false };
1309
+ const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
1310
+ const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
1311
+ let curBranch = '';
1312
+ try {
1313
+ const raw = await git(['symbolic-ref', '-q', '--short', 'HEAD'], baseOpts);
1314
+ curBranch = (typeof raw === 'string' ? raw : '').trim();
1315
+ } catch {
1316
+ // Detached HEAD or probe error — out of scope for this guard (the
1317
+ // mid-operation/detached-HEAD preflight is a separate concern, still
1318
+ // handled in-spawn only).
1319
+ return { stale: false };
1320
+ }
1321
+ if (!curBranch || curBranch !== mainRef) return { stale: false };
1322
+ let originMainSha = '';
1323
+ try {
1324
+ const originRaw = await git(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${mainRef}`], baseOpts);
1325
+ originMainSha = (typeof originRaw === 'string' ? originRaw : '').trim();
1326
+ } catch {
1327
+ return { stale: false }; // no remote-tracking ref → ambiguous, skip (local-only/no-remote repo)
1328
+ }
1329
+ if (!originMainSha) return { stale: false };
1330
+ let aheadCount = -1;
1331
+ try {
1332
+ const cntRaw = await git(['rev-list', '--count', `refs/remotes/origin/${mainRef}..${mainRef}`], baseOpts);
1333
+ aheadCount = parseInt((typeof cntRaw === 'string' ? cntRaw : '').trim(), 10);
1334
+ if (Number.isNaN(aheadCount)) aheadCount = -1;
1335
+ } catch {
1336
+ aheadCount = -1; // rev-list failed → transient/ambiguous; don't block on it
1337
+ }
1338
+ if (aheadCount <= 0) return { stale: false };
1339
+ let localMainSha = '';
1340
+ try {
1341
+ const localRaw = await git(['rev-parse', '--verify', '--quiet', `refs/heads/${mainRef}`], baseOpts);
1342
+ localMainSha = (typeof localRaw === 'string' ? localRaw : '').trim();
1343
+ } catch { /* best-effort — informational only */ }
1344
+ return { stale: true, ahead: aheadCount, mainRef, localMainSha, originMainSha };
1345
+ }
1346
+
1208
1347
  /**
1209
1348
  * restoreLiveCheckoutAtDispatchEnd — P-d9e6b2c4
1210
1349
  *
@@ -1723,6 +1862,8 @@ async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
1723
1862
 
1724
1863
  module.exports = {
1725
1864
  prepareLiveCheckout,
1865
+ checkLiveCheckoutDirty,
1866
+ checkLiveCheckoutStaleBase,
1726
1867
  restoreLiveCheckoutAtDispatchEnd,
1727
1868
  resolveLiveCheckoutAutoStash,
1728
1869
  performLiveCheckoutAutoStash,
@@ -8,7 +8,7 @@ const fs = require('fs');
8
8
  const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
- const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
11
+ const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR, isPrdArchived } = shared;
12
12
  const routing = require('./routing');
13
13
  const http = require('http');
14
14
  const { shouldRunNow } = require('./scheduler');
@@ -501,11 +501,16 @@ function _findMeetingsInRun(run) {
501
501
  return meetings;
502
502
  }
503
503
 
504
- // Check if a plan already exists for a given meeting (created manually via dashboard)
505
- function _findExistingPlanForMeeting(meetingIds, plansDir) {
504
+ // Check if a plan already exists for a given meeting (created manually via
505
+ // dashboard, or previously by this same pipeline stage). `pipelineSlugPrefix`
506
+ // (optional) is the slug a pipeline plan stage writes its files under
507
+ // (`slugify(stage.title)`) — matching on it catches pre-existing pipeline
508
+ // plans that predate the `**Source Meeting:**` marker below.
509
+ function _findExistingPlanForMeeting(meetingIds, plansDir, pipelineSlugPrefix) {
506
510
  const files = safeReadDir(plansDir).filter(f => f.endsWith('.md'));
507
- // Build slug prefixes for both pipeline and dashboard naming conventions
511
+ // Build slug prefixes for dashboard, and (optionally) pipeline naming conventions
508
512
  const slugPrefixes = [];
513
+ if (pipelineSlugPrefix) slugPrefixes.push(pipelineSlugPrefix);
509
514
  for (const mid of meetingIds) {
510
515
  const mtg = safeJson(path.join(MEETINGS_DIR, mid + '.json'));
511
516
  if (mtg?.title) {
@@ -537,8 +542,26 @@ function _canonicalPlanName(planFile) {
537
542
  return String(planFile || '').replace(/^(.*-\d{4}-\d{2}-\d{2})-\d+(\.md)$/i, '$1$2');
538
543
  }
539
544
 
545
+ // PRD top-level statuses that indicate a genuine, materialized plan-to-prd
546
+ // conversion (mirrors PLAN_STATUS — a PRD's status field is drawn from the
547
+ // same enum as a plan's). 'archived' is intentionally NOT in PLAN_STATUS (it's
548
+ // an in-place-archive-only status stamped by dashboard.js's _archivePrdPostProcess,
549
+ // not a plan lifecycle state) but is a real status a converted-then-archived PRD
550
+ // can carry — checked separately below via isPrdArchived so an already-archived
551
+ // conversion isn't mistaken for a stub and re-triggered.
552
+ const _VALID_EXISTING_PRD_STATUSES = new Set(Object.values(PLAN_STATUS));
553
+
540
554
  // Check if a PRD already exists for a given plan file (plan already converted).
541
555
  // Matches by canonical name so a collision-bumped plan still finds its PRD.
556
+ //
557
+ // A matching `source_plan` alone is not proof of a successful conversion — a
558
+ // crashed/interrupted plan-to-prd write, or a fragment-looking LLM response
559
+ // (the same garbage-content failure mode fixed for plan stages in PR #750),
560
+ // can leave a stub PRD JSON with `source_plan` set but no recognized `status`
561
+ // and no `missing_features`. Treating that stub as "already converted" would
562
+ // permanently skip re-running plan-to-prd for a plan that never actually got
563
+ // a usable PRD. Require both a recognized top-level status AND an array
564
+ // `missing_features` field before counting the PRD as an existing conversion.
542
565
  function _findExistingPrdForPlan(planFile, prdDir) {
543
566
  if (!fs.existsSync(prdDir)) return null;
544
567
  const prdFiles = safeReadDir(prdDir).filter(f => f.endsWith('.json'));
@@ -547,11 +570,28 @@ function _findExistingPrdForPlan(planFile, prdDir) {
547
570
  // safeJsonNoRestore: PRDs are terminal artifacts — never restore archived
548
571
  // PRDs from a stale .backup sidecar (W-mouptdh1000h9f39).
549
572
  const prd = safeJsonNoRestore(path.join(prdDir, pf));
550
- if (prd?.source_plan && _canonicalPlanName(path.basename(String(prd.source_plan))) === planKey) return pf;
573
+ if (!prd?.source_plan || _canonicalPlanName(path.basename(String(prd.source_plan))) !== planKey) continue;
574
+ if (!(_VALID_EXISTING_PRD_STATUSES.has(prd.status) || isPrdArchived(prd)) || !Array.isArray(prd.missing_features)) {
575
+ log('warn', `Pipeline plan-to-prd: found PRD "${pf}" for plan "${planFile}" but it lacks a valid status/missing_features (garbage/incomplete conversion) — treating plan-to-prd as not yet done`);
576
+ continue;
577
+ }
578
+ return pf;
551
579
  }
552
580
  return null;
553
581
  }
554
582
 
583
+ // A well-formed plan (LLM path or fallback path) always starts with a markdown
584
+ // title and has substantive body content. Guards against `maxTurns: 1` LLM
585
+ // calls that spend their single turn on tool-oriented preamble/narration
586
+ // instead of the plan itself (W-mrbn0eex000265d7) — that kind of response is a
587
+ // short mid-task fragment, not a plan, and must not be written verbatim.
588
+ function _looksLikePlanContent(content) {
589
+ if (!content) return false;
590
+ const trimmed = content.trim();
591
+ if (trimmed.length < 200) return false;
592
+ return trimmed.startsWith('#');
593
+ }
594
+
555
595
  async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
556
596
  const plansDir = PLANS_DIR;
557
597
  if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true });
@@ -568,7 +608,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
568
608
  // ── Reconciliation: check if a plan already exists for a meeting in this run ──
569
609
  const meetingIds = _findMeetingsInRun(run);
570
610
  if (meetingIds.length > 0) {
571
- const existingPlanFile = _findExistingPlanForMeeting(meetingIds, plansDir);
611
+ const existingPlanFile = _findExistingPlanForMeeting(meetingIds, plansDir, slug);
572
612
  if (existingPlanFile) {
573
613
  log('info', `Pipeline ${run.pipelineId}: reconciling plan stage — adopting existing plan "${existingPlanFile}"`);
574
614
 
@@ -663,9 +703,11 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
663
703
  });
664
704
  if (result.missingRuntime) {
665
705
  log('warn', `Pipeline plan LLM skipped: ${result.text || result.stderr || 'runtime unavailable'} — falling back to raw meeting context`);
666
- } else if (result.text) {
706
+ } else if (result.text && _looksLikePlanContent(result.text)) {
667
707
  content = result.text;
668
708
  log('info', `Pipeline plan: LLM generated ${content.length} chars from meeting context`);
709
+ } else if (result.text) {
710
+ log('warn', `Pipeline plan LLM response looked like a fragment, not a plan (${result.text.length} chars, doesn't start with a title) — discarding and falling back to raw meeting context`);
669
711
  }
670
712
  } catch (e) { log('warn', `Pipeline plan LLM failed: ${e.message} — falling back to raw meeting context`); }
671
713
 
@@ -678,6 +720,14 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
678
720
  if (stage.description) content += stage.description + '\n';
679
721
  }
680
722
 
723
+ // Stamp every source meeting so a future/duplicate invocation for this run
724
+ // can find this plan via `_findExistingPlanForMeeting`'s content scan — the
725
+ // reconciliation guard above only matches on this literal marker or a slug
726
+ // prefix, and generated content (LLM or fallback) never included it before.
727
+ if (meetingIds.length > 0) {
728
+ content += '\n\n---\n\n' + meetingIds.map(mid => `**Source Meeting:** ${mid}`).join('\n') + '\n';
729
+ }
730
+
681
731
  const filename = `${slug}-${dateStamp()}.md`;
682
732
  const filePath = shared.uniquePath(path.join(plansDir, filename));
683
733
  safeWrite(filePath, content);
@@ -998,7 +1048,7 @@ function isStageComplete(stage, stageState, run, config) {
998
1048
  if (prIds.length === 0) return true; // nothing to merge
999
1049
  const projects = shared.getProjects(config);
1000
1050
  for (const project of projects) {
1001
- const prs = safeJson(shared.projectPrPath(project)) || [];
1051
+ const prs = safeJsonArr(shared.projectPrPath(project));
1002
1052
  for (const prId of prIds) {
1003
1053
  const pr = shared.findPrRecord(prs, prId, project);
1004
1054
  if (pr && pr.status !== PR_STATUS.MERGED && pr.status !== PR_STATUS.ABANDONED) return false;
@@ -1150,7 +1200,7 @@ async function discoverPipelineWork(config) {
1150
1200
  if (meetingIds.length > 0) {
1151
1201
  const plansDir = PLANS_DIR;
1152
1202
  if (fs.existsSync(plansDir)) {
1153
- const existingPlan = _findExistingPlanForMeeting(meetingIds, plansDir);
1203
+ const existingPlan = _findExistingPlanForMeeting(meetingIds, plansDir, slugify(nextPlanStage.title || 'pipeline-plan'));
1154
1204
  if (existingPlan) {
1155
1205
  updateRunStage(pipeline.id, activeRun.runId, stage.id, {
1156
1206
  status: PIPELINE_STATUS.COMPLETED, completedAt: ts(),
@@ -1184,7 +1234,34 @@ async function discoverPipelineWork(config) {
1184
1234
  stageState.status = PIPELINE_STATUS.FAILED;
1185
1235
  anyFailed = true;
1186
1236
  } else if (depsReady) {
1187
- const result = await executeStage(stage, activeRun, pipeline, config);
1237
+ // W-mrbn0eex000265d7: persist RUNNING BEFORE the (potentially slow,
1238
+ // up-to-120s LLM-backed) executeStage call resolves. Plan stages in
1239
+ // particular kick off an LLM call; the stage's persisted status
1240
+ // previously stayed PENDING for the whole call, so a concurrent or
1241
+ // retried invocation of discoverPipelineWork (overlapping tick, or
1242
+ // resumed run after an engine restart mid-await) would see PENDING
1243
+ // again and re-execute the stage, writing a second duplicate plan
1244
+ // file. Marking RUNNING first means a re-entrant call takes the
1245
+ // RUNNING branch above (isStageComplete) instead of re-executing.
1246
+ updateRunStage(pipeline.id, activeRun.runId, stage.id, { status: PIPELINE_STATUS.RUNNING, startedAt: ts() });
1247
+ stageState.status = PIPELINE_STATUS.RUNNING;
1248
+ // W-mrbn0eex000265d7 (review follow-up): the RUNNING status above is
1249
+ // already persisted, so if executeStage throws instead of resolving
1250
+ // (e.g. a lock-timeout in mutateWorkItems, an fs error), an uncaught
1251
+ // rejection would propagate out of discoverPipelineWork to the
1252
+ // engine.js tick-phase catch, which only logs a warning — leaving
1253
+ // this stage's persisted status stuck at RUNNING forever with no
1254
+ // follow-up transition (previously it stayed PENDING and simply
1255
+ // retried next tick). Catch here and route the failure through the
1256
+ // normal FAILED path so dependents fail fast instead of the run
1257
+ // stalling silently.
1258
+ let result;
1259
+ try {
1260
+ result = await executeStage(stage, activeRun, pipeline, config);
1261
+ } catch (e) {
1262
+ log('warn', `Pipeline ${pipeline.id}: stage ${stage.id} threw during execution: ${e.message}`);
1263
+ result = { status: PIPELINE_STATUS.FAILED, error: e.message };
1264
+ }
1188
1265
  updateRunStage(pipeline.id, activeRun.runId, stage.id, { ...result, startedAt: ts() });
1189
1266
  Object.assign(stageState, result, { startedAt: ts() });
1190
1267
  if (result.status === PIPELINE_STATUS.RUNNING) {
@@ -12,7 +12,7 @@ const queries = require('./queries');
12
12
  const { wrapUntrusted, buildSource } = require('./untrusted-fence');
13
13
  const { MINIONS_BRAND_URL } = require('./comment-format');
14
14
 
15
- const { safeJson, safeRead, getProjects, log, ts, dateStamp, truncateTextBytes, ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT, getProjectOrg } = shared;
15
+ const { safeJsonArr, safeRead, getProjects, log, ts, dateStamp, truncateTextBytes, ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT, getProjectOrg } = shared;
16
16
  const { getConfig, getDispatch, getNotes, getPrs, getKnowledgeBaseIndex, AGENTS_DIR } = queries;
17
17
 
18
18
  const MINIONS_DIR = shared.MINIONS_DIR;
@@ -1060,11 +1060,12 @@ function renderPlaybook(type, vars) {
1060
1060
  // ─── Playbook Section Validator ──────────────────────────────────────────────
1061
1061
 
1062
1062
  // Required structural section patterns — warn (do not throw) when absent.
1063
- // Pluralisation: /^## Tools?\b/m matches both '## Tool' and '## Tools'.
1063
+ // Only '## Your Task' is checked: it's the one section every playbook template
1064
+ // actually implements. '## Tools' / '## Constraints' were removed (#775) —
1065
+ // no template in playbooks/ defines those headers, so the checks always
1066
+ // failed and produced 100%-failure-rate warn-level noise in engine/log.json.
1064
1067
  const _REQUIRED_PROMPT_SECTIONS = [
1065
1068
  { pattern: /^## Your Task\b/m, label: '## Your Task' },
1066
- { pattern: /^## Tools?\b/m, label: '## Tools' },
1067
- { pattern: /^## Constraints\b/m, label: '## Constraints' },
1068
1069
  ];
1069
1070
 
1070
1071
  /**
@@ -1266,7 +1267,7 @@ function buildAgentContext(agentId, config, project) {
1266
1267
  }
1267
1268
  // Also check central pull-requests.json
1268
1269
  try {
1269
- const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json')) || [];
1270
+ const centralPrs = safeJsonArr(path.join(MINIONS_DIR, 'pull-requests.json'));
1270
1271
  for (const pr of centralPrs.filter(pr => pr.status === PR_STATUS.ACTIVE)) {
1271
1272
  if (!allPrs.some(p => p.id === pr.id)) allPrs.push({ ...pr, _project: 'central' });
1272
1273
  }
@@ -64,7 +64,33 @@ function _readJsonArrayFallback(scope) {
64
64
 
65
65
  // Content-hash fingerprint per scope: same-length swaps (timestamps,
66
66
  // reviewStatus enums) collide on (mtime, size) but never on SHA-1.
67
- const _lastMirrorHashByScope = new Map();
67
+ //
68
+ // W-mrcg7j9k000ja233 (#759): this used to be an in-memory-only Map, which
69
+ // resets to empty on every process restart. Persist it in SQL instead
70
+ // (`pr_mirror_hashes`) so a restart can't blank out the "does JSON already
71
+ // agree with SQL" trust state — that reset, combined with a restart landing
72
+ // mid-flight of another process's mirror write, is exactly what let a stale
73
+ // JSON snapshot get treated as canonical and silently wipe SQL rows for a
74
+ // scope (see _resyncScopeIfJsonDiverged / _hydrateScopeFromJson below).
75
+ function _getLastMirrorHash(db, scope) {
76
+ try {
77
+ const row = db.prepare('SELECT hash FROM pr_mirror_hashes WHERE scope = ?').get(scope);
78
+ return row ? row.hash : null;
79
+ } catch { return null; }
80
+ }
81
+
82
+ function _setLastMirrorHash(db, scope, hash) {
83
+ try {
84
+ db.prepare(`
85
+ INSERT INTO pr_mirror_hashes (scope, hash, updated_at) VALUES (?, ?, ?)
86
+ ON CONFLICT(scope) DO UPDATE SET hash = excluded.hash, updated_at = excluded.updated_at
87
+ `).run(scope, hash, Date.now());
88
+ } catch { /* best-effort — a missed persisted-hash write just means the next resync re-checks */ }
89
+ }
90
+
91
+ function _forgetLastMirrorHash(db, scope) {
92
+ try { db.prepare('DELETE FROM pr_mirror_hashes WHERE scope = ?').run(scope); } catch { /* best-effort */ }
93
+ }
68
94
 
69
95
  const crypto = require('crypto');
70
96
 
@@ -78,7 +104,24 @@ function _fileContentHash(filePath) {
78
104
 
79
105
  function _hydrateScopeFromJson(db, scope) {
80
106
  const jsonItems = _readJsonArrayFallback(scope);
107
+ const beforeCount = db.prepare('SELECT COUNT(*) AS n FROM pull_requests WHERE scope = ?').get(scope).n;
108
+ const beforeIds = db.prepare('SELECT id FROM pull_requests WHERE scope = ?').all(scope).map(r => r.id);
81
109
  db.prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
110
+ // #759 — this DELETE used to be completely silent. Log every hydrate at
111
+ // warn level with before/after counts + the ids that are about to be
112
+ // dropped, so a JSON<->SQL resync that removes rows the operator didn't
113
+ // expect leaves a trace in engine-stdio.log instead of vanishing without
114
+ // explanation.
115
+ try {
116
+ const afterIds = new Set(jsonItems.filter(pr => pr && pr.id).map(pr => String(pr.id)));
117
+ const droppedIds = beforeIds.filter(id => !afterIds.has(id));
118
+ // eslint-disable-next-line no-console
119
+ console.warn(
120
+ `[pull-requests-store] _hydrateScopeFromJson(scope=${scope}): rebuilding SQL from JSON mirror — `
121
+ + `before=${beforeCount} row(s), after=${jsonItems.length} row(s)`
122
+ + (droppedIds.length ? `, dropping id(s): ${droppedIds.join(', ')}` : ''),
123
+ );
124
+ } catch { /* logging must never block the resync */ }
82
125
  if (jsonItems.length === 0) return;
83
126
  const now = Date.now();
84
127
  const ins = db.prepare(`
@@ -107,18 +150,18 @@ function _hydrateScopeFromJson(db, scope) {
107
150
  function _resyncScopeIfJsonDiverged(db, scope) {
108
151
  const jsonPath = _filePathForScope(scope);
109
152
  const currentHash = _fileContentHash(jsonPath);
110
- const lastHash = _lastMirrorHashByScope.get(scope);
153
+ const lastHash = _getLastMirrorHash(db, scope);
111
154
  if (currentHash == null) return;
112
155
  if (lastHash != null && currentHash === lastHash) return;
113
156
  if (lastHash == null) {
114
157
  const sqlHas = db.prepare('SELECT 1 FROM pull_requests WHERE scope = ? LIMIT 1').get(scope);
115
158
  if (sqlHas) {
116
- _lastMirrorHashByScope.set(scope, currentHash);
159
+ _setLastMirrorHash(db, scope, currentHash);
117
160
  return;
118
161
  }
119
162
  }
120
163
  _hydrateScopeFromJson(db, scope);
121
- _lastMirrorHashByScope.set(scope, currentHash);
164
+ _setLastMirrorHash(db, scope, currentHash);
122
165
  }
123
166
 
124
167
  function readPullRequestsForScope(scope) {
@@ -253,7 +296,7 @@ function _applyPullRequestsDiff(db, diff) {
253
296
  return true;
254
297
  }
255
298
 
256
- function applyPullRequestsMutation(scope, mutator) {
299
+ function applyPullRequestsMutation(scope, mutator, options = {}) {
257
300
  const { getDb, withTransaction } = require('./db');
258
301
  const db = getDb();
259
302
 
@@ -270,6 +313,23 @@ function applyPullRequestsMutation(scope, mutator) {
270
313
  : (Array.isArray(next) ? next : before);
271
314
  const diff = _computePullRequestsDiff(scope, beforeSnapshot, after);
272
315
  const wrote = _applyPullRequestsDiff(db, diff);
316
+ // W-mrcg7j9k000ja233 (#759) — mirror the JSON sidecar INSIDE this same
317
+ // SQL write transaction, BEFORE it commits, instead of after (as the
318
+ // caller in shared.js used to do post-return). This mirrors the fix
319
+ // already applied to work-items-store.js (W-mr9vcxvy000cdb4e): SQLite
320
+ // only allows one writer transaction at a time, so mirroring here
321
+ // guarantees the on-disk JSON file reflects this commit by the time it
322
+ // becomes visible to any other reader/writer. Without this ordering, a
323
+ // sibling process (dashboard vs. engine — separate Node processes, each
324
+ // with its own resync state) could observe the freshly committed SQL
325
+ // rows before the mirror file caught up, decide its own cached hash no
326
+ // longer matched the (still-stale) JSON file, and call
327
+ // _hydrateScopeFromJson — which DELETEs + reinserts the whole scope
328
+ // from that stale file, silently dropping the row this transaction just
329
+ // committed.
330
+ if (wrote) {
331
+ try { _mirrorJsonFromSql(scope, options.mirrorPath); } catch { /* best-effort — mirror failure must not block the SQL write */ }
332
+ }
273
333
  return { wrote, result: after };
274
334
  });
275
335
  }
@@ -293,7 +353,7 @@ function _mirrorJsonFromSql(scope, filePath) {
293
353
  const target = filePath || _filePathForScope(scope);
294
354
  shared.safeWrite(target, items);
295
355
  const h = _fileContentHash(target);
296
- if (h != null) _lastMirrorHashByScope.set(scope, h);
356
+ if (h != null) _setLastMirrorHash(db, scope, h);
297
357
  } catch {
298
358
  // Mirror failures are non-fatal — SQL has already committed.
299
359
  }
@@ -312,7 +372,8 @@ function _mirrorJsonFromSql(scope, filePath) {
312
372
  function dropScope(scope) {
313
373
  try {
314
374
  const { getDb } = require('./db');
315
- const result = getDb().prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
375
+ const db = getDb();
376
+ const result = db.prepare('DELETE FROM pull_requests WHERE scope = ?').run(scope);
316
377
  // Mirror the work-items-store.js fix (W-mr96m3hm000fd5d7): don't blindly
317
378
  // forget the mirror-hash. _resyncScopeIfJsonDiverged treats "no recorded
318
379
  // hash" + "zero SQL rows" as a first-install hydrate and rebuilds SQL from
@@ -323,8 +384,8 @@ function dropScope(scope) {
323
384
  // actually gone, e.g. dataMode: 'purge'/'archive').
324
385
  const jsonPath = _filePathForScope(scope);
325
386
  const currentHash = _fileContentHash(jsonPath);
326
- if (currentHash == null) _lastMirrorHashByScope.delete(scope);
327
- else _lastMirrorHashByScope.set(scope, currentHash);
387
+ if (currentHash == null) _forgetLastMirrorHash(db, scope);
388
+ else _setLastMirrorHash(db, scope, currentHash);
328
389
  if (result && result.changes > 0) {
329
390
  try { require('./db-events').emitStateEvent('pull_requests'); } catch { /* optional */ }
330
391
  }