@yemi33/minions 0.1.2357 → 0.1.2359

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.
@@ -310,25 +310,53 @@ function addToDispatch(item) {
310
310
  function _persistInvalidWorkItem(item, evaluation) {
311
311
  const meta = item?.meta;
312
312
  const itemId = meta?.item?.id;
313
- if (!itemId) return;
313
+ if (!itemId) return { stuck: false };
314
314
  let wiPath;
315
315
  try { wiPath = lifecycle().resolveWorkItemPath(meta); } catch { wiPath = null; }
316
- if (!wiPath) return;
316
+ if (!wiPath) return { stuck: false };
317
+ // W-mrcrh5hj0017d22f — repeat-rejection cap. Compare against the
318
+ // description snapshot from the LAST rejection so an operator edit to the
319
+ // WI resets the counter (it deserves a fresh evaluation), while an
320
+ // unchanged, permanently-unactionable description accumulates toward
321
+ // the cap instead of being silently re-evaluated forever.
322
+ const maxRejections = queries.getConfig()?.engine?.preDispatchEvalMaxRejections
323
+ ?? ENGINE_DEFAULTS.preDispatchEvalMaxRejections;
324
+ const currentDescription = String((meta.item && meta.item.description) || '');
325
+ let stuck = false;
317
326
  try {
318
327
  mutateWorkItems(wiPath, (items) => {
319
328
  if (!Array.isArray(items)) return items;
320
329
  const idx = items.findIndex(w => w && w.id === itemId);
321
330
  if (idx === -1) return items;
322
- items[idx]._preDispatchEval = {
331
+ const wi = items[idx];
332
+ const prior = wi._preDispatchEval;
333
+ const sameDescription = prior && prior.description === currentDescription;
334
+ const rejectCount = (sameDescription ? (prior.rejectCount || 0) : 0) + 1;
335
+ wi._preDispatchEval = {
323
336
  valid: false,
324
337
  reason: evaluation.reason || '',
325
338
  evaluatedAt: ts(),
339
+ rejectCount,
340
+ description: currentDescription,
326
341
  };
342
+ if (rejectCount >= maxRejections) {
343
+ stuck = true;
344
+ wi.status = WI_STATUS.FAILED;
345
+ wi._failureClass = FAILURE_CLASS.PRE_DISPATCH_EVAL_STUCK;
346
+ wi.failReason = `pre-dispatch-eval rejected this work item ${rejectCount} times in a row ` +
347
+ `with an unchanged description — last reason: ${evaluation.reason || 'criteria not actionable'}`;
348
+ wi.failedAt = ts();
349
+ wi._pendingReason = 'pre_dispatch_eval_stuck';
350
+ }
327
351
  return items;
328
352
  }, { skipWriteIfUnchanged: true });
329
353
  } catch (e) {
330
354
  log('warn', `pre-dispatch-eval: failed to persist reason on ${itemId}: ${e.message}`);
331
355
  }
356
+ if (stuck) {
357
+ log('warn', `pre-dispatch-eval: ${itemId} rejected ${maxRejections}+ times with unchanged description — flipped to failed (PRE_DISPATCH_EVAL_STUCK) instead of re-evaluating forever`);
358
+ }
359
+ return { stuck };
332
360
  }
333
361
 
334
362
  function _routeToReviewQueue(item, evaluation) {
@@ -455,8 +483,12 @@ async function addToDispatchWithValidation(item, opts = {}) {
455
483
 
456
484
  if (!evaluation || evaluation.valid !== false) return addToDispatch(item);
457
485
 
458
- _persistInvalidWorkItem(item, evaluation);
459
- _routeToReviewQueue(item, evaluation);
486
+ const { stuck } = _persistInvalidWorkItem(item, evaluation);
487
+ // W-mrcrh5hj0017d22f: once the WI has been flipped to failed for
488
+ // repeat-rejection, don't also push it into the review queue — the
489
+ // failed status already stops future discovery ticks from re-evaluating
490
+ // it, and a review-queue entry would just be a stale duplicate signal.
491
+ if (!stuck) _routeToReviewQueue(item, evaluation);
460
492
  log('warn', `pre-dispatch-eval: blocked work item ${wi.id} — ${evaluation.reason || 'criteria not actionable'}`);
461
493
  return null;
462
494
  }
@@ -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);
@@ -945,13 +957,8 @@ function isStageComplete(stage, stageState, run, config) {
945
957
 
946
958
  switch (stage.type) {
947
959
  case STAGE_TYPE.TASK: {
948
- // Check root + all project work-items.json (WIs may be moved to project paths)
949
- const wiPath = CENTRAL_WI_PATH;
950
- const workItems = safeJsonArr(wiPath);
951
- const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
952
- return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
953
- }, []);
954
- const all = [...workItems, ...allProjectWi];
960
+ // Check central + all project work items (WIs may live in project scopes)
961
+ const all = _allWorkItems();
955
962
  const ids = artifacts.workItems || [];
956
963
  if (ids.length === 0) return false;
957
964
  return ids.every(id => {
@@ -970,12 +977,7 @@ function isStageComplete(stage, stageState, run, config) {
970
977
  }
971
978
  case STAGE_TYPE.PLAN: {
972
979
  // Plan stage completion: PRD conversion done + all materialized work items done
973
- const wiPath = CENTRAL_WI_PATH;
974
- const workItems = safeJsonArr(wiPath);
975
- const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
976
- return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
977
- }, []);
978
- const all = [...workItems, ...allProjectWi];
980
+ const all = _allWorkItems();
979
981
 
980
982
  // Check if plan-to-prd work item is done
981
983
  const prdWiIds = artifacts.workItems || [];
@@ -1138,13 +1140,20 @@ async function discoverPipelineWork(config) {
1138
1140
  // Collect output
1139
1141
  let output = '';
1140
1142
  if (stage.type === STAGE_TYPE.TASK) {
1141
- const wiPath = CENTRAL_WI_PATH;
1142
- const workItems = safeJsonArr(wiPath);
1143
- const projWi = shared.getProjects(config).reduce((acc, p) => acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []), []);
1144
- const allWi = [...workItems, ...projWi];
1143
+ const allWi = _allWorkItems();
1145
1144
  output = (stageState.artifacts?.workItems || []).map(id => {
1146
1145
  const wi = allWi.find(w => w.id === id);
1147
- 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 || '';
1148
1157
  }).join('\n');
1149
1158
  } else if (stage.type === STAGE_TYPE.MEETING) {
1150
1159
  const { getMeeting } = require('./meeting');
package/engine/shared.js CHANGED
@@ -3497,6 +3497,17 @@ const ENGINE_DEFAULTS = {
3497
3497
  // requested/completed PRDs still flow through the normal validator path so a
3498
3498
  // stale PRD reopen can't smuggle in unvetted items.
3499
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,
3500
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".
3501
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).
3502
3513
 
@@ -4958,6 +4969,7 @@ const FAILURE_CLASS = {
4958
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).
4959
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.
4960
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.
4961
4973
  UNKNOWN: 'unknown', // Unclassified failure
4962
4974
  };
4963
4975
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2357",
3
+ "version": "0.1.2359",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"