@yemi33/minions 0.1.2277 → 0.1.2279

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.
package/dashboard.js CHANGED
@@ -588,10 +588,18 @@ let _prRefVerifierOverride = null; // test seam
588
588
 
589
589
  // Evict stale entries when the cache exceeds _PR_REF_VERIFY_CACHE_MAX to prevent
590
590
  // unbounded growth. Called after every Map.set() in verifyLoosePrRefIsPr.
591
+ // Two-pass strategy: (1) evict TTL-expired entries; (2) if still over cap,
592
+ // evict the oldest entries (LRU) until the cache is back within bounds.
591
593
  function _evictPrRefVerifyCacheIfNeeded() {
592
594
  if (_prRefVerifyCache.size <= _PR_REF_VERIFY_CACHE_MAX) return;
595
+ // Pass 1: evict TTL-expired entries.
593
596
  const cutoff = Date.now() - _PR_REF_VERIFY_TTL_MS;
594
597
  for (const [k, v] of _prRefVerifyCache) if (v.at < cutoff) _prRefVerifyCache.delete(k);
598
+ // Pass 2: LRU fallback — evict oldest entries if still over cap (e.g. burst of fresh entries).
599
+ if (_prRefVerifyCache.size <= _PR_REF_VERIFY_CACHE_MAX) return;
600
+ const toEvict = _prRefVerifyCache.size - _PR_REF_VERIFY_CACHE_MAX;
601
+ const sorted = [..._prRefVerifyCache.entries()].sort((a, b) => a[1].at - b[1].at);
602
+ for (let i = 0; i < toEvict; i++) _prRefVerifyCache.delete(sorted[i][0]);
595
603
  }
596
604
 
597
605
  // Test seam (issue #246) — inject a fake verifier so handler tests can assert
@@ -223,6 +223,8 @@
223
223
  "id": "ado-throttle-arg-less-shim",
224
224
  "description": "Arg-less form of isAdoThrottled() in engine/ado.js. Introduced by W-mq03l6zh0006f0a1-b as a back-compat shim during the per-org ADO throttle isolation rollout: pre-rollout, isAdoThrottled() collapsed the single process-global tracker to one boolean; post-rollout, the canonical form is isAdoThrottled(orgBase) against the per-org Map. The arg-less call site is preserved transiently so engine code (and any in-process callers) that haven't yet been threaded through with a per-org `orgBase` keep returning the safe global-OR (true if ANY org is currently throttled) — preventing a regression where new poll work bypasses a still-warm throttle backoff on an unrelated noisy org.",
225
225
  "deprecated": "2026-06-04",
226
+ "status": "removed",
227
+ "removedDate": "2026-06-25",
226
228
  "code": [
227
229
  {
228
230
  "file": "engine/ado.js",
package/engine/ado.js CHANGED
@@ -2630,18 +2630,13 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
2630
2630
 
2631
2631
  // ─── ADO Throttle Queries ────────────────────────────────────────────────────
2632
2632
 
2633
- /** Returns true if ADO is throttled. If orgBase is provided, checks that org's
2634
- * tracker only; if omitted, returns true when ANY tracked org is throttled
2635
- * (back-compat OR semantics for existing call sites). Auto-clears stale state. */
2633
+ /** Returns true if the given org is currently throttled. Auto-clears stale state.
2634
+ * orgBase is required callers must pass shared.getAdoOrgBase(project).
2635
+ * (The arg-less back-compat OR shim was removed: docs/deprecated.json id: ado-throttle-arg-less-shim) */
2636
2636
  const isAdoThrottled = (orgBase) => {
2637
- if (orgBase != null) {
2638
- const tracker = _adoThrottlesByOrg.get(canonicalAdoOrgKey(orgBase));
2639
- return tracker ? tracker.isThrottled() : false;
2640
- }
2641
- for (const tracker of _adoThrottlesByOrg.values()) {
2642
- if (tracker.isThrottled()) return true;
2643
- }
2644
- return false;
2637
+ if (orgBase == null) throw new TypeError('isAdoThrottled requires an orgBase argument');
2638
+ const tracker = _adoThrottlesByOrg.get(canonicalAdoOrgKey(orgBase));
2639
+ return tracker ? tracker.isThrottled() : false;
2645
2640
  };
2646
2641
 
2647
2642
  /** Returns a snapshot of the throttle state.
@@ -5353,6 +5353,8 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
5353
5353
  try { stampPrdItemWorkItemId(meta.item.id, meta.item.sourcePlan); } catch (err) { log('warn', `stampPrdItemWorkItemId: ${err.message}`); }
5354
5354
  }
5355
5355
  promoteCompletionArtifacts(meta, agentId, dispatchItem.id, structuredCompletion, { resultSummary });
5356
+ // M003 — auto-dispatch a live-validation WI when a coding WI completes.
5357
+ try { autoDispatchLiveValidationWi(meta, config); } catch (err) { log('warn', `autoDispatchLiveValidationWi: ${err.message}`); }
5356
5358
  }
5357
5359
  // Failure retry is handled by completeDispatch in dispatch.js — not duplicated here.
5358
5360
  // Only clear _decomposing flag on failure so decompose items don't get permanently stuck.
@@ -6124,6 +6126,78 @@ function pruneScopeMismatchDuplicatePrs(config) {
6124
6126
  return { pruned, scanned };
6125
6127
  }
6126
6128
 
6129
+ // M003 — After a coding WI completes successfully, auto-dispatch a live-validation
6130
+ // WI when project.liveValidation.autoDispatch === true and the completed item is
6131
+ // a coding WI (not the validation type itself). Skips if the coding WI has no PR.
6132
+ // Deduplicates: a non-terminal WI with meta.liveValidationFor === codingWiId
6133
+ // blocks a second dispatch.
6134
+ function autoDispatchLiveValidationWi(meta, config) {
6135
+ const item = meta?.item;
6136
+ if (!item?.id || !item?.type) return;
6137
+
6138
+ const projectName = meta?.project?.name;
6139
+ if (!projectName) return;
6140
+
6141
+ // Look up the full project config (which carries liveValidation) from config.
6142
+ const projects = shared.getProjects(config || {});
6143
+ const project = projects.find(p => p && p.name === projectName);
6144
+ if (!project) return;
6145
+
6146
+ const lv = project.liveValidation;
6147
+ if (!lv || lv.autoDispatch !== true || !lv.type) return;
6148
+
6149
+ // Only auto-dispatch for coding WIs — skip if this IS the validation type.
6150
+ if (item.type === lv.type) return;
6151
+
6152
+ // Resolve PR reference: prefer the canonical stamped _pr field (set by
6153
+ // stampWiPrRef which runs earlier in runPostCompletionHooks), then fall
6154
+ // back to extractWorkItemPrRef for structured fields / references[].
6155
+ const prRef = item._pr || item._prUrl || shared.extractWorkItemPrRef(item) || null;
6156
+ if (!prRef) return;
6157
+
6158
+ const codingWiId = item.id;
6159
+ const wiPath = resolveWorkItemPath(meta);
6160
+ if (!wiPath) return;
6161
+
6162
+ try {
6163
+ mutateWorkItems(wiPath, items => {
6164
+ if (!Array.isArray(items)) return items;
6165
+
6166
+ // Dedup: skip if a non-terminal WI already tracks this coding WI.
6167
+ const existing = items.find(i =>
6168
+ i &&
6169
+ i.meta &&
6170
+ i.meta.liveValidationFor === codingWiId &&
6171
+ !PLAN_TERMINAL_STATUSES.has(i.status)
6172
+ );
6173
+ if (existing) {
6174
+ log('info', `liveValidation: dedup — non-terminal validation WI ${existing.id} already exists for ${codingWiId}`);
6175
+ return items;
6176
+ }
6177
+
6178
+ const validationWi = {
6179
+ id: 'W-' + shared.uid(),
6180
+ title: 'Validate: ' + (item.title || codingWiId),
6181
+ type: lv.type,
6182
+ status: WI_STATUS.PENDING,
6183
+ depends_on: [codingWiId],
6184
+ references: [prRef],
6185
+ meta: { liveValidationFor: codingWiId },
6186
+ project: projectName,
6187
+ priority: item.priority || 'medium',
6188
+ created: ts(),
6189
+ createdBy: 'lifecycle:live-validation-auto-dispatch',
6190
+ };
6191
+
6192
+ items.push(validationWi);
6193
+ log('info', `liveValidation: auto-dispatched validation WI ${validationWi.id} (type=${lv.type}) for coding WI ${codingWiId}`);
6194
+ return items;
6195
+ });
6196
+ } catch (err) {
6197
+ log('warn', `autoDispatchLiveValidationWi for ${codingWiId}: ${err.message}`);
6198
+ }
6199
+ }
6200
+
6127
6201
  module.exports = {
6128
6202
  checkPlanCompletion,
6129
6203
  archivePlan,
@@ -6198,4 +6272,6 @@ module.exports = {
6198
6272
  // W-mqtplpk6001oe6d5 — stamp PR ref + workItemId onto WI and PRD item at completion time.
6199
6273
  stampWiPrRef,
6200
6274
  stampPrdItemWorkItemId,
6275
+ // M003 — auto-dispatch live-validation WI after coding WI completion.
6276
+ autoDispatchLiveValidationWi,
6201
6277
  };
@@ -626,14 +626,18 @@ function doctor(minionsHome) {
626
626
  runtimeResults.push({ name: 'Engine', ok: 'warn', message: 'not started — run: minions start (see docs/engine-restart.md)' });
627
627
  }
628
628
 
629
- // Check dashboard (try HTTP)
629
+ // Check dashboard (try HTTP). Read the actual bound port from the
630
+ // dashboard-port.json beacon so the probe works when the dashboard falls
631
+ // back to a non-default port after EADDRINUSE scanning.
630
632
  const http = require('http');
633
+ const beaconData = shared.readDashboardPortFile(minionsHome);
634
+ const dashPort = (beaconData && beaconData.port) || shared.DEFAULT_DASHBOARD_PORT;
631
635
  const dashCheck = new Promise(resolve => {
632
- const req = http.get('http://localhost:7331/api/health', { timeout: 2000 }, res => {
633
- resolve({ name: 'Dashboard', ok: true, message: 'running on http://localhost:7331' });
636
+ const req = http.get(`http://localhost:${dashPort}/api/health`, { timeout: 2000 }, res => {
637
+ resolve({ name: 'Dashboard', ok: true, message: `running on http://localhost:${dashPort}` });
634
638
  });
635
- req.on('error', () => resolve({ name: 'Dashboard', ok: 'warn', message: 'not reachable on :7331 — run: minions dash (see docs/engine-restart.md)' }));
636
- req.on('timeout', () => { req.destroy(); resolve({ name: 'Dashboard', ok: 'warn', message: 'not reachable on :7331 — run: minions dash (see docs/engine-restart.md)' }); });
639
+ req.on('error', () => resolve({ name: 'Dashboard', ok: 'warn', message: `not reachable on :${dashPort} — run: minions dash (see docs/engine-restart.md)` }));
640
+ req.on('timeout', () => { req.destroy(); resolve({ name: 'Dashboard', ok: 'warn', message: `not reachable on :${dashPort} — run: minions dash (see docs/engine-restart.md)` }); });
637
641
  });
638
642
 
639
643
  return dashCheck.then(async dashResult => {
@@ -649,9 +653,9 @@ function doctor(minionsHome) {
649
653
  runtimeResults.push({ name: 'Playbooks', ok: false, message: 'no playbooks found in playbooks/ — run: minions init --force (see docs/distribution.md)' });
650
654
  }
651
655
 
652
- // Check port 7331 availability (only if dashboard isn't running)
656
+ // Check port availability (only if dashboard isn't running)
653
657
  if (dashResult.ok !== true) {
654
- runtimeResults.push({ name: 'Port 7331', ok: 'warn', message: 'dashboard not running — port status unknown (see docs/engine-restart.md)' });
658
+ runtimeResults.push({ name: `Port ${dashPort}`, ok: 'warn', message: `dashboard not running — port status unknown (see docs/engine-restart.md)` });
655
659
  }
656
660
 
657
661
  // Self-check the out-of-process recovery net. A registered-but-broken
@@ -15,7 +15,7 @@ const shared = require('./shared');
15
15
  // the literal is only a fallback if shared somehow failed to load.
16
16
  const DEFAULT_RESTART_HEALTH_TIMEOUT_MS =
17
17
  (shared.ENGINE_DEFAULTS && shared.ENGINE_DEFAULTS.restartHealthTimeoutMs) || 60000;
18
- const DEFAULT_RESTART_HEALTH_INTERVAL_MS = 250;
18
+ const DEFAULT_RESTART_HEALTH_INTERVAL_MS = 100;
19
19
  // Consecutive dead-PID reads tolerated before fail-fast. A small guard avoids
20
20
  // a single flaky tasklist / kill(0) false-negative aborting a healthy boot.
21
21
  const DEFAULT_DEAD_POLLS_BEFORE_FAST_FAIL = 2;
@@ -45,7 +45,7 @@ function isProcessAlive(pid) {
45
45
  const out = execSync(`tasklist /FI "PID eq ${n}" /NH`, {
46
46
  encoding: 'utf8',
47
47
  windowsHide: true,
48
- timeout: 3000,
48
+ timeout: 1000,
49
49
  });
50
50
  return new RegExp(`\\b${n}\\b`).test(out) && out.toLowerCase().includes('node');
51
51
  }
@@ -61,8 +61,8 @@ function isPortListening(port) {
61
61
  if (!Number.isInteger(n) || n <= 0) return false;
62
62
  try {
63
63
  if (process.platform === 'win32') {
64
- const out = execSync(`netstat -ano -p TCP`, {
65
- encoding: 'utf8', windowsHide: true, timeout: 3000, maxBuffer: 4 * 1024 * 1024,
64
+ const out = execSync(`netstat -ano | findstr ":${n} "`, {
65
+ encoding: 'utf8', windowsHide: true, timeout: 1000, maxBuffer: 64 * 1024, shell: true,
66
66
  });
67
67
  const re = new RegExp(`\\s127\\.0\\.0\\.1:${n}\\s+\\S+\\s+LISTENING`, 'i');
68
68
  const re6 = new RegExp(`\\s\\[::1?\\]:${n}\\s+\\S+\\s+LISTENING`, 'i');
@@ -70,12 +70,12 @@ function isPortListening(port) {
70
70
  }
71
71
  try {
72
72
  const out = execSync(`lsof -nP -iTCP:${n} -sTCP:LISTEN`, {
73
- encoding: 'utf8', timeout: 3000,
73
+ encoding: 'utf8', timeout: 1000,
74
74
  });
75
75
  if (/\bLISTEN\b/i.test(out)) return true;
76
76
  } catch {}
77
77
  const out = execSync(`ss -ltn 'sport = :${n}' 2>/dev/null || netstat -ltn 2>/dev/null || netstat -an -p tcp 2>/dev/null`, {
78
- encoding: 'utf8', timeout: 3000, shell: true,
78
+ encoding: 'utf8', timeout: 1000, shell: true,
79
79
  });
80
80
  return new RegExp(`(?:[:.])${n}\\b[^\\n]*\\b(?:LISTEN|LISTENING)\\b`, 'i').test(out);
81
81
  } catch { return false; }
@@ -152,7 +152,6 @@ async function checkRestartHealth(options = {}) {
152
152
  dashboardKind = 'process';
153
153
  const dpid = normalizePid(dashboardPid);
154
154
  const dashAlive = dpid ? isAlive(dpid) : false;
155
- const portOpen = portCheck(dashboardPort);
156
155
  // Ownership gate (opt-in via requireBeaconOwner). "PID alive + port
157
156
  // listening" is NOT sufficient: a stale pre-restart dashboard still holding
158
157
  // the port satisfies the listening probe, so the verifier used to report
@@ -174,6 +173,10 @@ async function checkRestartHealth(options = {}) {
174
173
  beaconPid = beacon && normalizePid(beacon.pid);
175
174
  beaconOwned = !!(beacon && beaconPid === dpid && Number(beacon.port) === Number(dashboardPort));
176
175
  }
176
+ // If the beacon already confirms this PID owns the port, skip the expensive
177
+ // netstat — the dashboard writes dashboard-port.json in its listen() callback,
178
+ // so beacon.pid === dpid IS the port-bind signal.
179
+ const portOpen = (options.requireBeaconOwner && beaconOwned) ? true : portCheck(dashboardPort);
177
180
  dashboardOk = !!(dashAlive && portOpen && beaconOwned);
178
181
  dashboardDetail = `pid=${dpid || 'none'} alive=${dashAlive ? 'yes' : 'no'} port=${dashboardPort} listening=${portOpen ? 'yes' : 'no'}`
179
182
  + (options.requireBeaconOwner ? ` beaconPid=${beaconPid || 'none'} owned=${beaconOwned ? 'yes' : 'no'}` : '');
package/engine/shared.js CHANGED
@@ -975,6 +975,19 @@ function _routeJsonReadToSql(p) {
975
975
  * reads (cooldowns, archived PRDs, ephemeral session state) where reviving a
976
976
  * stale `.backup` is actively harmful. See its JSDoc for selection guidance.
977
977
  */
978
+
979
+ // PL-prd-no-backup (W-mqub65ez0004b3bd) — archived PRDs under prd/archive/ are
980
+ // permanently removed terminal artifacts. They must NOT auto-restore from a
981
+ // stale `.backup` sidecar (W-mouptdh1000h9f39: archived PRD came back and
982
+ // re-dispatched work). Intentionally NARROW to prd/archive/ only:
983
+ // - prd/archive/*.json → no backup write + no safeJson restore
984
+ // - prd/*.json (root-level canonical) → normal backup/restore lifecycle
985
+ // preserved so concurrent-sweep loss is recoverable (prd-rename-race.test.js).
986
+ const _NO_BACKUP_JSON_RE = /(?:^|[\\/])prd[\\/]archive[\\/][^\\/]+\.json$/i;
987
+ function _isNoBackupJsonPath(p) {
988
+ return typeof p === 'string' && _NO_BACKUP_JSON_RE.test(p);
989
+ }
990
+
978
991
  function safeJson(p) {
979
992
  // Internal opt-out (positional second arg from mutateJsonFileLocked):
980
993
  // when truthy, skip the SQL-routing shim and do a raw disk read. Used
@@ -1008,6 +1021,8 @@ function safeJson(p) {
1008
1021
  console.error(`[safeJson] parse failure for ${path.basename(p)}: ${parseErr.message}`);
1009
1022
  }
1010
1023
  }
1024
+ // Archived PRDs (prd/archive/) are permanently gone — skip .backup restore.
1025
+ if (_isNoBackupJsonPath(p)) return null;
1011
1026
  // Primary missing or corrupted — try restoring from .backup sidecar.
1012
1027
  const backupPath = p + '.backup';
1013
1028
  try {
@@ -1837,9 +1852,14 @@ function mutateJsonFileLocked(filePath, mutateFn, {
1837
1852
  const finalData = next === undefined ? data : next;
1838
1853
  const shouldWrite = !skipWriteIfUnchanged || parsedInvalid || JSON.stringify(finalData) !== beforeSerialized;
1839
1854
  if (shouldWrite) {
1840
- // Back up last-known-good state before mutation (best-effort)
1841
- const backupPath = filePath + '.backup';
1842
- try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
1855
+ // Back up last-known-good state before mutation (best-effort). SKIP for
1856
+ // archived PRDs under prd/archive/: a .backup there is resurrection fuel
1857
+ // (PL-prd-no-backup, W-mqub65ez0004b3bd). Root-level prd/*.json retains
1858
+ // the backup lifecycle — concurrent-sweep loss must be recoverable.
1859
+ if (!_isNoBackupJsonPath(filePath)) {
1860
+ const backupPath = filePath + '.backup';
1861
+ try { if (fileExists) fs.copyFileSync(filePath, backupPath); } catch { /* backup is best-effort */ }
1862
+ }
1843
1863
  safeWrite(filePath, finalData);
1844
1864
  // Side-effect hook fired only when an actual write happened. Callers
1845
1865
  // use this to emit cache-invalidation signals (events table row) so
package/engine.js CHANGED
@@ -6121,8 +6121,9 @@ function materializePlansAsWorkItems(config) {
6121
6121
  continue; // Skip — waiting for human approval
6122
6122
  }
6123
6123
  }
6124
- if (planStatus === PLAN_STATUS.PAUSED || planStatus === PLAN_STATUS.REJECTED || planStatus === PLAN_STATUS.REVISION_REQUESTED) {
6125
- continue; // Skip paused or revision requested
6124
+ if (planStatus === PLAN_STATUS.PAUSED || planStatus === PLAN_STATUS.REJECTED ||
6125
+ planStatus === PLAN_STATUS.REVISION_REQUESTED || planStatus === PLAN_STATUS.COMPLETED) {
6126
+ continue; // Skip — paused, rejected, revision-requested, or completed
6126
6127
  }
6127
6128
  // Stale PRDs: source plan was revised — don't materialize NEW items until user regenerates
6128
6129
  if (plan.planStale) {
@@ -6700,7 +6701,7 @@ async function discoverFromPrs(config, project) {
6700
6701
  ? (config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled)
6701
6702
  : (config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled));
6702
6703
  const evalLoopEnabled = config.engine?.evalLoop !== false;
6703
- const fixThrottled = isAdoProject ? isAdoThrottled() : isGhThrottled();
6704
+ const fixThrottled = isAdoProject ? isAdoThrottled(shared.getAdoOrgBase(project)) : isGhThrottled();
6704
6705
  const autoReviewPrs = config.engine?.autoReviewPrs ?? ENGINE_DEFAULTS.autoReviewPrs;
6705
6706
  const autoReReviewPrs = config.engine?.autoReReviewPrs ?? ENGINE_DEFAULTS.autoReReviewPrs;
6706
6707
  // P-b2e5d8c7: engine.autoFixPaused is a hard-stop master switch over every
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2277",
3
+ "version": "0.1.2279",
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"