@yemi33/minions 0.1.2356 → 0.1.2358

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.
@@ -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,
@@ -77,6 +77,23 @@ function getPipelineRuns() {
77
77
  return safeJsonObj(PIPELINE_RUNS_PATH);
78
78
  }
79
79
 
80
+ // W-mrcrh5hj0017d22f: SQL is the canonical (and only) work-items store post
81
+ // Phase 9.4 — the JSON files (central work-items.json + each
82
+ // projects/<name>/work-items.json) are passive, best-effort mirrors that can
83
+ // legitimately lag behind a just-completed write (mirror failures are
84
+ // swallowed by design so they never block the SQL write; see
85
+ // work-items-store.js's `_mirrorJsonFromSql` comments). Reading those JSON
86
+ // files directly (via safeJson/safeJsonArr) can therefore observe a work
87
+ // item whose `status` mirrored to DONE but whose `resultSummary` write
88
+ // hasn't landed in the mirror yet — which silently degraded stage-output
89
+ // template substitution ({{stages.<id>.output}}) to the stage's own work
90
+ // item id instead of its real completion text. Route every work-item read
91
+ // in this module through the SQL-backed store so pipeline state always sees
92
+ // the authoritative record.
93
+ function _allWorkItems() {
94
+ return require('./work-items-store').readAllWorkItems();
95
+ }
96
+
80
97
  function getActiveRun(pipelineId) {
81
98
  const runs = getPipelineRuns();
82
99
  const pipelineRuns = runs[pipelineId] || [];
@@ -338,12 +355,7 @@ function evaluateCondition(condition, ctx) {
338
355
  case 'noFailedItems': {
339
356
  // True when all work items created by the pipeline are done (not failed)
340
357
  if (!run) return false;
341
- const wiPath = CENTRAL_WI_PATH;
342
- const workItems = safeJsonArr(wiPath);
343
- const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
344
- return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
345
- }, []);
346
- const all = [...workItems, ...allProjectWi];
358
+ const all = _allWorkItems();
347
359
  const pipelineWis = all.filter(w => w._pipelineRun === run.runId);
348
360
  if (pipelineWis.length === 0) return true; // no items = nothing failed
349
361
  return pipelineWis.every(w => w.status !== WI_STATUS.FAILED);
@@ -501,11 +513,16 @@ function _findMeetingsInRun(run) {
501
513
  return meetings;
502
514
  }
503
515
 
504
- // Check if a plan already exists for a given meeting (created manually via dashboard)
505
- function _findExistingPlanForMeeting(meetingIds, plansDir) {
516
+ // Check if a plan already exists for a given meeting (created manually via
517
+ // dashboard, or previously by this same pipeline stage). `pipelineSlugPrefix`
518
+ // (optional) is the slug a pipeline plan stage writes its files under
519
+ // (`slugify(stage.title)`) — matching on it catches pre-existing pipeline
520
+ // plans that predate the `**Source Meeting:**` marker below.
521
+ function _findExistingPlanForMeeting(meetingIds, plansDir, pipelineSlugPrefix) {
506
522
  const files = safeReadDir(plansDir).filter(f => f.endsWith('.md'));
507
- // Build slug prefixes for both pipeline and dashboard naming conventions
523
+ // Build slug prefixes for dashboard, and (optionally) pipeline naming conventions
508
524
  const slugPrefixes = [];
525
+ if (pipelineSlugPrefix) slugPrefixes.push(pipelineSlugPrefix);
509
526
  for (const mid of meetingIds) {
510
527
  const mtg = safeJson(path.join(MEETINGS_DIR, mid + '.json'));
511
528
  if (mtg?.title) {
@@ -575,6 +592,18 @@ function _findExistingPrdForPlan(planFile, prdDir) {
575
592
  return null;
576
593
  }
577
594
 
595
+ // A well-formed plan (LLM path or fallback path) always starts with a markdown
596
+ // title and has substantive body content. Guards against `maxTurns: 1` LLM
597
+ // calls that spend their single turn on tool-oriented preamble/narration
598
+ // instead of the plan itself (W-mrbn0eex000265d7) — that kind of response is a
599
+ // short mid-task fragment, not a plan, and must not be written verbatim.
600
+ function _looksLikePlanContent(content) {
601
+ if (!content) return false;
602
+ const trimmed = content.trim();
603
+ if (trimmed.length < 200) return false;
604
+ return trimmed.startsWith('#');
605
+ }
606
+
578
607
  async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
579
608
  const plansDir = PLANS_DIR;
580
609
  if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true });
@@ -591,7 +620,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
591
620
  // ── Reconciliation: check if a plan already exists for a meeting in this run ──
592
621
  const meetingIds = _findMeetingsInRun(run);
593
622
  if (meetingIds.length > 0) {
594
- const existingPlanFile = _findExistingPlanForMeeting(meetingIds, plansDir);
623
+ const existingPlanFile = _findExistingPlanForMeeting(meetingIds, plansDir, slug);
595
624
  if (existingPlanFile) {
596
625
  log('info', `Pipeline ${run.pipelineId}: reconciling plan stage — adopting existing plan "${existingPlanFile}"`);
597
626
 
@@ -686,9 +715,11 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
686
715
  });
687
716
  if (result.missingRuntime) {
688
717
  log('warn', `Pipeline plan LLM skipped: ${result.text || result.stderr || 'runtime unavailable'} — falling back to raw meeting context`);
689
- } else if (result.text) {
718
+ } else if (result.text && _looksLikePlanContent(result.text)) {
690
719
  content = result.text;
691
720
  log('info', `Pipeline plan: LLM generated ${content.length} chars from meeting context`);
721
+ } else if (result.text) {
722
+ 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`);
692
723
  }
693
724
  } catch (e) { log('warn', `Pipeline plan LLM failed: ${e.message} — falling back to raw meeting context`); }
694
725
 
@@ -701,6 +732,14 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
701
732
  if (stage.description) content += stage.description + '\n';
702
733
  }
703
734
 
735
+ // Stamp every source meeting so a future/duplicate invocation for this run
736
+ // can find this plan via `_findExistingPlanForMeeting`'s content scan — the
737
+ // reconciliation guard above only matches on this literal marker or a slug
738
+ // prefix, and generated content (LLM or fallback) never included it before.
739
+ if (meetingIds.length > 0) {
740
+ content += '\n\n---\n\n' + meetingIds.map(mid => `**Source Meeting:** ${mid}`).join('\n') + '\n';
741
+ }
742
+
704
743
  const filename = `${slug}-${dateStamp()}.md`;
705
744
  const filePath = shared.uniquePath(path.join(plansDir, filename));
706
745
  safeWrite(filePath, content);
@@ -918,13 +957,8 @@ function isStageComplete(stage, stageState, run, config) {
918
957
 
919
958
  switch (stage.type) {
920
959
  case STAGE_TYPE.TASK: {
921
- // Check root + all project work-items.json (WIs may be moved to project paths)
922
- const wiPath = CENTRAL_WI_PATH;
923
- const workItems = safeJsonArr(wiPath);
924
- const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
925
- return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
926
- }, []);
927
- const all = [...workItems, ...allProjectWi];
960
+ // Check central + all project work items (WIs may live in project scopes)
961
+ const all = _allWorkItems();
928
962
  const ids = artifacts.workItems || [];
929
963
  if (ids.length === 0) return false;
930
964
  return ids.every(id => {
@@ -943,12 +977,7 @@ function isStageComplete(stage, stageState, run, config) {
943
977
  }
944
978
  case STAGE_TYPE.PLAN: {
945
979
  // Plan stage completion: PRD conversion done + all materialized work items done
946
- const wiPath = CENTRAL_WI_PATH;
947
- const workItems = safeJsonArr(wiPath);
948
- const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
949
- return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
950
- }, []);
951
- const all = [...workItems, ...allProjectWi];
980
+ const all = _allWorkItems();
952
981
 
953
982
  // Check if plan-to-prd work item is done
954
983
  const prdWiIds = artifacts.workItems || [];
@@ -1021,7 +1050,7 @@ function isStageComplete(stage, stageState, run, config) {
1021
1050
  if (prIds.length === 0) return true; // nothing to merge
1022
1051
  const projects = shared.getProjects(config);
1023
1052
  for (const project of projects) {
1024
- const prs = safeJson(shared.projectPrPath(project)) || [];
1053
+ const prs = safeJsonArr(shared.projectPrPath(project));
1025
1054
  for (const prId of prIds) {
1026
1055
  const pr = shared.findPrRecord(prs, prId, project);
1027
1056
  if (pr && pr.status !== PR_STATUS.MERGED && pr.status !== PR_STATUS.ABANDONED) return false;
@@ -1111,13 +1140,20 @@ async function discoverPipelineWork(config) {
1111
1140
  // Collect output
1112
1141
  let output = '';
1113
1142
  if (stage.type === STAGE_TYPE.TASK) {
1114
- const wiPath = CENTRAL_WI_PATH;
1115
- const workItems = safeJsonArr(wiPath);
1116
- const projWi = shared.getProjects(config).reduce((acc, p) => acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []), []);
1117
- const allWi = [...workItems, ...projWi];
1143
+ const allWi = _allWorkItems();
1118
1144
  output = (stageState.artifacts?.workItems || []).map(id => {
1119
1145
  const wi = allWi.find(w => w.id === id);
1120
- return wi?.resultSummary || wi?.title || id;
1146
+ // W-mrcrh5hj0017d22f: never fall back to the stage's own work
1147
+ // item id — a bare id substituted into {{stages.<id>.output}}
1148
+ // produces a nonsensical downstream description (e.g.
1149
+ // "Confirmed bugs:\nPL-mrcqpzit-verify-0-minions-opg") that
1150
+ // pre-dispatch-eval correctly (but silently, forever) rejects.
1151
+ // If the real resultSummary/title text isn't available yet
1152
+ // (WI missing, or a lagged/failed mirror momentarily hides a
1153
+ // just-completed resultSummary), fall back to an empty string
1154
+ // — a blank substitution is harmless where an opaque id is
1155
+ // actively misleading.
1156
+ return wi?.resultSummary || wi?.title || '';
1121
1157
  }).join('\n');
1122
1158
  } else if (stage.type === STAGE_TYPE.MEETING) {
1123
1159
  const { getMeeting } = require('./meeting');
@@ -1173,7 +1209,7 @@ async function discoverPipelineWork(config) {
1173
1209
  if (meetingIds.length > 0) {
1174
1210
  const plansDir = PLANS_DIR;
1175
1211
  if (fs.existsSync(plansDir)) {
1176
- const existingPlan = _findExistingPlanForMeeting(meetingIds, plansDir);
1212
+ const existingPlan = _findExistingPlanForMeeting(meetingIds, plansDir, slugify(nextPlanStage.title || 'pipeline-plan'));
1177
1213
  if (existingPlan) {
1178
1214
  updateRunStage(pipeline.id, activeRun.runId, stage.id, {
1179
1215
  status: PIPELINE_STATUS.COMPLETED, completedAt: ts(),
@@ -1207,7 +1243,34 @@ async function discoverPipelineWork(config) {
1207
1243
  stageState.status = PIPELINE_STATUS.FAILED;
1208
1244
  anyFailed = true;
1209
1245
  } else if (depsReady) {
1210
- const result = await executeStage(stage, activeRun, pipeline, config);
1246
+ // W-mrbn0eex000265d7: persist RUNNING BEFORE the (potentially slow,
1247
+ // up-to-120s LLM-backed) executeStage call resolves. Plan stages in
1248
+ // particular kick off an LLM call; the stage's persisted status
1249
+ // previously stayed PENDING for the whole call, so a concurrent or
1250
+ // retried invocation of discoverPipelineWork (overlapping tick, or
1251
+ // resumed run after an engine restart mid-await) would see PENDING
1252
+ // again and re-execute the stage, writing a second duplicate plan
1253
+ // file. Marking RUNNING first means a re-entrant call takes the
1254
+ // RUNNING branch above (isStageComplete) instead of re-executing.
1255
+ updateRunStage(pipeline.id, activeRun.runId, stage.id, { status: PIPELINE_STATUS.RUNNING, startedAt: ts() });
1256
+ stageState.status = PIPELINE_STATUS.RUNNING;
1257
+ // W-mrbn0eex000265d7 (review follow-up): the RUNNING status above is
1258
+ // already persisted, so if executeStage throws instead of resolving
1259
+ // (e.g. a lock-timeout in mutateWorkItems, an fs error), an uncaught
1260
+ // rejection would propagate out of discoverPipelineWork to the
1261
+ // engine.js tick-phase catch, which only logs a warning — leaving
1262
+ // this stage's persisted status stuck at RUNNING forever with no
1263
+ // follow-up transition (previously it stayed PENDING and simply
1264
+ // retried next tick). Catch here and route the failure through the
1265
+ // normal FAILED path so dependents fail fast instead of the run
1266
+ // stalling silently.
1267
+ let result;
1268
+ try {
1269
+ result = await executeStage(stage, activeRun, pipeline, config);
1270
+ } catch (e) {
1271
+ log('warn', `Pipeline ${pipeline.id}: stage ${stage.id} threw during execution: ${e.message}`);
1272
+ result = { status: PIPELINE_STATUS.FAILED, error: e.message };
1273
+ }
1211
1274
  updateRunStage(pipeline.id, activeRun.runId, stage.id, { ...result, startedAt: ts() });
1212
1275
  Object.assign(stageState, result, { startedAt: ts() });
1213
1276
  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
  }
package/engine/queries.js CHANGED
@@ -72,6 +72,25 @@ function _resetPrEnrichmentCacheForTest() {
72
72
  _prEnrichmentInFlight.clear();
73
73
  }
74
74
 
75
+ // W-mrcs8yqk001o6893 — shared enrichment-fallback resolver. Both the prLinks
76
+ // loop and the wi._pr fallback loop in getPrdInfo() need the SAME defensive
77
+ // behavior when the canonical PR id has no live record in pull-requests.json
78
+ // (e.g. it was merged and then swept by the merged-PR cleanup routine): check
79
+ // the enrichment cache first, else synthesize PR_STATUS.ACTIVE as a
80
+ // placeholder and kick off a fire-and-forget enrollment so the next poll
81
+ // (within PR_ENRICHMENT_TTL_MS) resolves the real status. Previously only the
82
+ // wi._pr loop did this (W-mq5uzmc6001d708f); the prLinks loop hardcoded
83
+ // `pr?.status || PR_STATUS.ACTIVE` with no self-heal, so PRs reached only via
84
+ // pr_links stayed 'active' forever once removed from pull-requests.json.
85
+ function _resolvePrStatusWithEnrichment(pr, canonicalPrId, project, itemId) {
86
+ if (pr?.status) return pr.status;
87
+ if (!canonicalPrId) return PR_STATUS.ACTIVE;
88
+ const cachedStatus = _getCachedPrEnrichment(canonicalPrId);
89
+ if (cachedStatus) return cachedStatus;
90
+ _scheduleAsyncPrEnrollment(canonicalPrId, project, itemId);
91
+ return PR_STATUS.ACTIVE;
92
+ }
93
+
75
94
  /**
76
95
  * Read the first `bytes` and last `bytes` of a file efficiently using byte offsets.
77
96
  * For files <= 2*bytes, reads the whole file. Returns { head, tail } strings.
@@ -572,15 +591,6 @@ function _buildArchiveNotesByWiMap() {
572
591
  return out;
573
592
  }
574
593
 
575
- // Test/maintenance hook — drop the archive notes cache (mirrors the other
576
- // invalidate* helpers). Not normally needed: the cache self-heals via the
577
- // dir-mtime gate + TTL.
578
- function invalidateArchiveNotesByWiCache() {
579
- _archiveNotesByWiCache = null;
580
- _archiveNotesByWiCacheAt = 0;
581
- _archiveNotesByWiCacheMtime = null;
582
- }
583
-
584
594
  // Build a wiId → [filename, ...] map by scanning the inbox + archive dirs.
585
595
  // Archive entries are prefixed with `archive:` so the dashboard renderer can
586
596
  // tell them apart (matches the convention already used by _artifacts.notes).
@@ -2363,7 +2373,18 @@ function getPrdInfo(config) {
2363
2373
  const url = buildPrUrlFromId(prId, pr, projects);
2364
2374
  for (const itemId of (itemIds || [])) {
2365
2375
  if (!prdToPr[itemId]) prdToPr[itemId] = [];
2366
- prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status: pr?.status || PR_STATUS.ACTIVE, _project: pr?._project || '' });
2376
+ // W-mrcs8yqk001o6893 when the pr_links mapping outlives the PR record
2377
+ // (merged-PR sweep removes it from pull-requests.json but pr_links is
2378
+ // never pruned), resolve the item's project so we can self-heal the
2379
+ // status the same way the wi._pr fallback loop below does.
2380
+ let itemProject = null;
2381
+ if (!pr) {
2382
+ const itemSource = (wiById[itemId] || allWiById[itemId] || {}).project
2383
+ || (wiById[itemId] || allWiById[itemId] || {})._source || null;
2384
+ itemProject = itemSource ? shared.resolveProjectSource(itemSource, projects, { allowCentral: false }).project || null : null;
2385
+ }
2386
+ const status = _resolvePrStatusWithEnrichment(pr, prId, itemProject, itemId);
2387
+ prdToPr[itemId].push({ id: prId, url, title: pr?.title || '', status, _project: pr?._project || '' });
2367
2388
  }
2368
2389
  }
2369
2390
  // Fallback: work item _pr field for anything still missing
@@ -2379,17 +2400,8 @@ function getPrdInfo(config) {
2379
2400
  // exists (orphan _pr pointer), use a cached status from a prior async
2380
2401
  // enrollment if available, else synthesize ACTIVE and schedule a
2381
2402
  // fire-and-forget enrollment so the next status poll renders correctly.
2382
- let synthesizedStatus = pr?.status;
2383
- if (!pr && canonicalPrId) {
2384
- const cachedStatus = _getCachedPrEnrichment(canonicalPrId);
2385
- if (cachedStatus) {
2386
- synthesizedStatus = cachedStatus;
2387
- } else {
2388
- synthesizedStatus = PR_STATUS.ACTIVE;
2389
- _scheduleAsyncPrEnrollment(canonicalPrId, project, wi.id);
2390
- }
2391
- }
2392
- prdToPr[wi.id] = [{ id: pr?.id || canonicalPrId || wi._pr, url, title: pr?.title || '', status: synthesizedStatus || PR_STATUS.ACTIVE, _project: project?.name || '' }];
2403
+ const synthesizedStatus = _resolvePrStatusWithEnrichment(pr, canonicalPrId, project, wi.id);
2404
+ prdToPr[wi.id] = [{ id: pr?.id || canonicalPrId || wi._pr, url, title: pr?.title || '', status: synthesizedStatus, _project: project?.name || '' }];
2393
2405
  }
2394
2406
  // Aggregate sub-task PRs to decomposed parent (sub-tasks aren't PRD items but their PRs should show)
2395
2407
  for (const pr of allPrs) {
@@ -3422,7 +3434,7 @@ module.exports = {
3422
3434
  getKnowledgeBaseEntries, getKnowledgeBaseEntriesSnapshot, getKnowledgeBaseIndex,
3423
3435
 
3424
3436
  // Work items & PRD
3425
- getWorkItems, invalidateWorkItemsCache, invalidateArchiveNotesByWiCache, getPrdInfo,
3437
+ getWorkItems, invalidateWorkItemsCache, getPrdInfo,
3426
3438
 
3427
3439
  // W-mq5uzmc6001d708f — test hooks for the defensive PR enrichment cache.
3428
3440
  _resetPrEnrichmentCacheForTest,
package/engine/shared.js CHANGED
@@ -2306,8 +2306,47 @@ function runFile(file, args, opts = {}) {
2306
2306
  return _spawn(file, args, { windowsHide: true, ...opts });
2307
2307
  }
2308
2308
 
2309
+ // True when `p` is a UNC path (`\\server\share\...` or its forward-slash
2310
+ // form `//server/share/...`, e.g. `\\wsl.localhost\Ubuntu\...` / `\\wsl$\...`
2311
+ // for a WSL-hosted project). Windows' cmd.exe — the default shell
2312
+ // `execSync`/`exec` spawn under — refuses a UNC path as its working
2313
+ // directory ("UNC paths are not supported. Defaulting to Windows
2314
+ // directory."), which silently redirects the child's actual cwd elsewhere.
2315
+ function isUncPath(p) {
2316
+ return typeof p === 'string' && (p.startsWith('\\\\') || p.startsWith('//'));
2317
+ }
2318
+
2319
+ // Minimal argv tokenizer for the small, hardcoded `git ...` command strings
2320
+ // built elsewhere in this file — splits on whitespace, honoring
2321
+ // double-quoted segments (the only quoting these callers use, e.g. a branch
2322
+ // name interpolated as `"refs/heads/${branch}"`). Not a general shell
2323
+ // parser; only ever fed strings this codebase constructs itself.
2324
+ function _splitGitCommandLine(cmd) {
2325
+ const argv = [];
2326
+ const re = /"([^"]*)"|(\S+)/g;
2327
+ let m;
2328
+ while ((m = re.exec(cmd)) !== null) argv.push(m[1] !== undefined ? m[1] : m[2]);
2329
+ return argv;
2330
+ }
2331
+
2309
2332
  function execSilent(cmd, opts = {}) {
2310
- return _execSync(cmd, { stdio: 'pipe', windowsHide: true, ...opts });
2333
+ const merged = { stdio: 'pipe', windowsHide: true, ...opts };
2334
+ // W-mrcv3ky7000c2338 / opg#772 — a `git ...` shellout whose `cwd` is a UNC
2335
+ // path (WSL-hosted project, or any other UNC-rooted checkout) would
2336
+ // otherwise run through cmd.exe, which can't chdir into a UNC path and
2337
+ // silently defaults to the Windows directory instead — the subsequent git
2338
+ // command then fails with "fatal: not a git repository", and callers like
2339
+ // cleanup.js's orphan/quarantine worktree-dir reaper have no ground truth
2340
+ // for which worktrees are registered, so quarantined worktree husks are
2341
+ // never reaped (unbounded disk growth). Re-parse into argv and run via
2342
+ // execFileSync (shell:false) instead — that bypasses cmd.exe entirely and
2343
+ // Windows' CreateProcess honors a UNC cwd correctly, exactly like
2344
+ // shared.shellSafeGitSync / shared.removeWorktree already do.
2345
+ if (typeof cmd === 'string' && /^git\b/.test(cmd.trim()) && isUncPath(merged.cwd)) {
2346
+ const argv = _splitGitCommandLine(cmd);
2347
+ return _execFileSync(argv[0], argv.slice(1), merged);
2348
+ }
2349
+ return _execSync(cmd, merged);
2311
2350
  }
2312
2351
 
2313
2352
  /**
@@ -3458,6 +3497,17 @@ const ENGINE_DEFAULTS = {
3458
3497
  // requested/completed PRDs still flow through the normal validator path so a
3459
3498
  // stale PRD reopen can't smuggle in unvetted items.
3460
3499
  preDispatchEvalSkipPrdSourced: true,
3500
+ // W-mrcrh5hj0017d22f — repeat-rejection cap. addToDispatchWithValidation
3501
+ // re-runs the LLM validator on EVERY discovery tick for a WI that's still
3502
+ // `pending` — routing to the review queue (_routeToReviewQueue) dedupes
3503
+ // that queue but never changes the WI's own status, so an item whose
3504
+ // description the validator will never accept (see stage-output
3505
+ // substitution bug above) got re-evaluated forever, once per tick,
3506
+ // invisible to the dashboard. Once the SAME (unchanged) description is
3507
+ // rejected this many consecutive times, the WI is flipped to `failed`
3508
+ // with FAILURE_CLASS.PRE_DISPATCH_EVAL_STUCK instead of being silently
3509
+ // re-queued into review again.
3510
+ preDispatchEvalMaxRejections: 5,
3461
3511
  completionNonceRequired: false, // P-d2a8f6c1 (agent trust boundary F8): when true, a missing `nonce` field in the completion JSON hard-fails the dispatch with failure_class:'completion-nonce-mismatch'. Default false for one release so older agents/runtime caches that haven't picked up the prompt change degrade with a warning instead of breaking. Mismatched nonces hard-fail regardless of this flag. See docs/completion-reports.md → "Trust boundary".
3462
3512
  autoApplyReviewVote: false, // W-mpea9fyb0010febf / P-f7splitgate: master gate for ALL engine platform vote mutations. When true, the engine (a) mirrors live platform vote state into local pull-requests.json reviewStatus, (b) re-applies its own ADO +10 approval when ADO resets votes on target-branch advance (pollPrStatus), (c) reconciles its own prior negative on a verdict flip to APPROVE (ADO resetReviewerNegativeVote / GitHub dismissPriorViewerChangesRequestedReviews), and (d) instructs ADO review agents to cast `az repos pr set-vote`. When false (default), the verdict is recorded in pull-requests.json reviewStatus from the agent's completion ONLY — informational — and Minions never cast, re-cast, or clear a platform vote/review (ADO agents post the verdict as a comment only, parity with GitHub self-approval block).
3463
3513
 
@@ -4919,6 +4969,7 @@ const FAILURE_CLASS = {
4919
4969
  OUTPUT_TRUNCATED: 'output-truncated', // P-8e4c2a17: the agent streamed more stdout than the engine's hard capture cap (engine.js AGENT_OUTPUT_CAP_BYTES, 1MB) BEFORE the terminal `result` event arrived. The result (session id, completion block, final text) lives at the END of the stream, so it fell outside the captured window and parseOutput found nothing — the dispatch would otherwise fail as an opaque UNKNOWN and retry to death. Surfaced loudly with an actionable message; non-retryable (mechanical retry just reproduces the overflow — the agent must reduce output volume or the task must be split).
4920
4970
  VERIFY_MISSING_PR: 'verify-missing-pr', // W-mqsk1ip00006cbae: a verify WI exited done but no PR was attached (neither _prUrl/_pr on the item nor a matching pull-requests.json entry for the plan). Flipped to failed so the plan doesn't silently advance without an E2E PR. Retryable — agent may have phantom-crashed before pushing the branch.
4921
4971
  PROJECT_NOT_FOUND: 'project_not_found', // W-mqv2wsy30002e090: a work item's `project` field names a project that is not configured (resolveConfiguredProject / formatUnknownProjectError → `Project "<name>" not found. Known projects: …`). Stamped at discovery time so the dashboard PR-column renderer surfaces the red failReason snippet like other non-retryable failures instead of burying it in the Agent column. Non-retryable — operator must fix the WI's project field or configure the project.
4972
+ PRE_DISPATCH_EVAL_STUCK: 'pre-dispatch-eval-stuck', // W-mrcrh5hj0017d22f: pre-dispatch-eval rejected the SAME (unchanged) description ENGINE_DEFAULTS.preDispatchEvalMaxRejections times in a row without the WI status ever leaving pending — previously this burned an LLM validation call every discovery tick forever with nothing surfaced to the dashboard (constant-bug-bash's file-fixes stage wedged 16+ consecutive rejections, one/minute, until a human manually cancelled it). Engine now flips the WI to failed with this class so the stalled item is visible and stops silently re-evaluating; non-retryable until the description changes.
4922
4973
  UNKNOWN: 'unknown', // Unclassified failure
4923
4974
  };
4924
4975
 
@@ -9174,6 +9225,7 @@ module.exports = {
9174
9225
  exec,
9175
9226
  execAsync,
9176
9227
  execSilent,
9228
+ isUncPath,
9177
9229
  shellSafeGh,
9178
9230
  shellSafeGit,
9179
9231
  removeStaleIndexLock,