@yemi33/minions 0.1.2356 → 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.
@@ -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) {
@@ -575,6 +580,18 @@ function _findExistingPrdForPlan(planFile, prdDir) {
575
580
  return null;
576
581
  }
577
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
+
578
595
  async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
579
596
  const plansDir = PLANS_DIR;
580
597
  if (!fs.existsSync(plansDir)) fs.mkdirSync(plansDir, { recursive: true });
@@ -591,7 +608,7 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
591
608
  // ── Reconciliation: check if a plan already exists for a meeting in this run ──
592
609
  const meetingIds = _findMeetingsInRun(run);
593
610
  if (meetingIds.length > 0) {
594
- const existingPlanFile = _findExistingPlanForMeeting(meetingIds, plansDir);
611
+ const existingPlanFile = _findExistingPlanForMeeting(meetingIds, plansDir, slug);
595
612
  if (existingPlanFile) {
596
613
  log('info', `Pipeline ${run.pipelineId}: reconciling plan stage — adopting existing plan "${existingPlanFile}"`);
597
614
 
@@ -686,9 +703,11 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
686
703
  });
687
704
  if (result.missingRuntime) {
688
705
  log('warn', `Pipeline plan LLM skipped: ${result.text || result.stderr || 'runtime unavailable'} — falling back to raw meeting context`);
689
- } else if (result.text) {
706
+ } else if (result.text && _looksLikePlanContent(result.text)) {
690
707
  content = result.text;
691
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`);
692
711
  }
693
712
  } catch (e) { log('warn', `Pipeline plan LLM failed: ${e.message} — falling back to raw meeting context`); }
694
713
 
@@ -701,6 +720,14 @@ async function executePlanStage(stage, stageState, run, config, pipeline = {}) {
701
720
  if (stage.description) content += stage.description + '\n';
702
721
  }
703
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
+
704
731
  const filename = `${slug}-${dateStamp()}.md`;
705
732
  const filePath = shared.uniquePath(path.join(plansDir, filename));
706
733
  safeWrite(filePath, content);
@@ -1021,7 +1048,7 @@ function isStageComplete(stage, stageState, run, config) {
1021
1048
  if (prIds.length === 0) return true; // nothing to merge
1022
1049
  const projects = shared.getProjects(config);
1023
1050
  for (const project of projects) {
1024
- const prs = safeJson(shared.projectPrPath(project)) || [];
1051
+ const prs = safeJsonArr(shared.projectPrPath(project));
1025
1052
  for (const prId of prIds) {
1026
1053
  const pr = shared.findPrRecord(prs, prId, project);
1027
1054
  if (pr && pr.status !== PR_STATUS.MERGED && pr.status !== PR_STATUS.ABANDONED) return false;
@@ -1173,7 +1200,7 @@ async function discoverPipelineWork(config) {
1173
1200
  if (meetingIds.length > 0) {
1174
1201
  const plansDir = PLANS_DIR;
1175
1202
  if (fs.existsSync(plansDir)) {
1176
- const existingPlan = _findExistingPlanForMeeting(meetingIds, plansDir);
1203
+ const existingPlan = _findExistingPlanForMeeting(meetingIds, plansDir, slugify(nextPlanStage.title || 'pipeline-plan'));
1177
1204
  if (existingPlan) {
1178
1205
  updateRunStage(pipeline.id, activeRun.runId, stage.id, {
1179
1206
  status: PIPELINE_STATUS.COMPLETED, completedAt: ts(),
@@ -1207,7 +1234,34 @@ async function discoverPipelineWork(config) {
1207
1234
  stageState.status = PIPELINE_STATUS.FAILED;
1208
1235
  anyFailed = true;
1209
1236
  } else if (depsReady) {
1210
- 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
+ }
1211
1265
  updateRunStage(pipeline.id, activeRun.runId, stage.id, { ...result, startedAt: ts() });
1212
1266
  Object.assign(stageState, result, { startedAt: ts() });
1213
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
  }
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
  /**
@@ -9174,6 +9213,7 @@ module.exports = {
9174
9213
  exec,
9175
9214
  execAsync,
9176
9215
  execSilent,
9216
+ isUncPath,
9177
9217
  shellSafeGh,
9178
9218
  shellSafeGit,
9179
9219
  removeStaleIndexLock,