@yemi33/minions 0.1.1045 → 0.1.1047

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/CHANGELOG.md CHANGED
@@ -1,6 +1,10 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1045 (2026-04-16)
3
+ ## 0.1.1047 (2026-04-16)
4
+
5
+ ### Fixes
6
+ - stamp live-output.log stub before spawn (#1198)
7
+ - harden settings save and migrate pr poll config
4
8
 
5
9
  ### Other
6
10
  - Harden loop watch conversion
@@ -1,14 +1,18 @@
1
1
  // settings.js — Settings panel functions extracted from dashboard.html
2
2
 
3
+ let _settingsData = null;
4
+
3
5
  async function openSettings() {
4
6
  document.getElementById('modal-title').textContent = 'Settings';
5
7
  document.getElementById('modal-body').innerHTML = '<p style="color:var(--muted)">Loading...</p>';
6
8
  document.getElementById('modal').classList.add('open');
7
9
 
10
+ _settingsData = null;
8
11
  let data;
9
12
  try {
10
13
  const res = await fetch('/api/settings');
11
14
  data = await res.json();
15
+ _settingsData = data;
12
16
  } catch (e) { showToast('cmd-toast', 'Failed to load settings: ' + e.message, false); return; }
13
17
 
14
18
  const e = data.engine || {};
@@ -56,8 +60,8 @@ async function openSettings() {
56
60
  settingsToggle('GitHub Polling', 'set-ghPollEnabled', e.ghPollEnabled !== false, 'Poll GitHub PR status and comments each tick (reconciliation always runs)') +
57
61
  '</div>' +
58
62
  '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px">' +
59
- settingsField('PR Status Poll Frequency', 'set-adoPollStatusEvery', e.adoPollStatusEvery || 6, 'ticks', 'Poll PR build/review/merge status every N ticks for both ADO and GitHub (~6 min at default tick rate)') +
60
- settingsField('PR Comments Poll Frequency', 'set-adoPollCommentsEvery', e.adoPollCommentsEvery || 12, 'ticks', 'Poll PR human comments every N ticks for both ADO and GitHub (~12 min at default tick rate)') +
63
+ settingsField('PR Status Poll Frequency', 'set-prPollStatusEvery', e.prPollStatusEvery ?? e.adoPollStatusEvery ?? 12, 'ticks', 'Poll PR build/review/merge status every N ticks for both ADO and GitHub (~12 min at default tick rate)') +
64
+ settingsField('PR Comments Poll Frequency', 'set-prPollCommentsEvery', e.prPollCommentsEvery ?? e.adoPollCommentsEvery ?? 12, 'ticks', 'Poll PR human comments every N ticks for both ADO and GitHub (~12 min at default tick rate)') +
61
65
  '</div>' +
62
66
  '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px">' +
63
67
  settingsField('Eval Max Iterations', 'set-evalMaxIterations', e.evalMaxIterations || 3, '', 'Max review→fix cycles before escalating (1-10)') +
@@ -258,8 +262,8 @@ async function saveSettings() {
258
262
  autoCompletePrs: document.getElementById('set-autoCompletePrs').checked,
259
263
  adoPollEnabled: document.getElementById('set-adoPollEnabled').checked,
260
264
  ghPollEnabled: document.getElementById('set-ghPollEnabled').checked,
261
- adoPollStatusEvery: document.getElementById('set-adoPollStatusEvery').value,
262
- adoPollCommentsEvery: document.getElementById('set-adoPollCommentsEvery').value,
265
+ prPollStatusEvery: document.getElementById('set-prPollStatusEvery').value,
266
+ prPollCommentsEvery: document.getElementById('set-prPollCommentsEvery').value,
263
267
  evalMaxIterations: document.getElementById('set-evalMaxIterations').value,
264
268
  evalMaxCost: document.getElementById('set-evalMaxCost').value || null,
265
269
  maxBuildFixAttempts: document.getElementById('set-maxBuildFixAttempts').value,
@@ -305,7 +309,8 @@ async function saveSettings() {
305
309
  agentsPayload[id][field] = el.value;
306
310
  });
307
311
 
308
- const projectsPayload = (data.projects || []).map(function(p) {
312
+ const currentProjects = (_settingsData && Array.isArray(_settingsData.projects)) ? _settingsData.projects : [];
313
+ const projectsPayload = currentProjects.map(function(p) {
309
314
  return {
310
315
  name: p.name,
311
316
  workSources: {
@@ -14,5 +14,13 @@
14
14
  "reason": "Redundant with evalLoop; evalLoop is the single gate for the review+fix cycle",
15
15
  "locations": ["engine/shared.js ENGINE_DEFAULTS.autoReview", "engine.js discoverFromPrs autoReview variable"],
16
16
  "cleanup": "Removed from ENGINE_DEFAULTS, removed autoReview variable from engine.js, replaced with reviewEnabled = evalLoopEnabled && pollEnabled"
17
+ },
18
+ {
19
+ "id": "ado-poll-frequency-keys",
20
+ "summary": "adoPollStatusEvery and adoPollCommentsEvery renamed to prPollStatusEvery and prPollCommentsEvery",
21
+ "deprecated": "2026-04-16",
22
+ "reason": "These cadence settings gate both ADO and GitHub PR polling, so the ADO-prefixed names are misleading.",
23
+ "locations": ["engine.js read-side fallback from config.engine.adoPollStatusEvery/adoPollCommentsEvery", "dashboard.js handleSettingsRead/handleSettingsUpdate alias fallback for old keys"],
24
+ "cleanup": "Remove the adoPoll* alias fallback reads after existing configs have been migrated to prPollStatusEvery/prPollCommentsEvery."
17
25
  }
18
26
  ]
package/engine/queries.js CHANGED
@@ -9,7 +9,7 @@ const path = require('path');
9
9
  const os = require('os');
10
10
  const shared = require('./shared');
11
11
 
12
- const { safeRead, safeReadDir, safeJson, safeWrite, getProjects,
12
+ const { safeRead, safeReadDir, safeJson, safeWrite, getProjects, mutateJsonFileLocked,
13
13
  projectWorkItemsPath, projectPrPath, parseSkillFrontmatter, KB_CATEGORIES,
14
14
  WI_STATUS, DONE_STATUSES, PRD_ITEM_STATUS, ENGINE_DEFAULTS } = shared;
15
15
 
@@ -99,7 +99,49 @@ function timeSince(ms) {
99
99
 
100
100
  // ── Core State Readers ──────────────────────────────────────────────────────
101
101
 
102
+ let _configPollKeyMigrationChecked = false;
103
+
104
+ function migrateDeprecatedConfigPollKeysOnce() {
105
+ if (_configPollKeyMigrationChecked) return;
106
+ const initial = safeJson(CONFIG_PATH);
107
+ if (!initial || typeof initial !== 'object' || Array.isArray(initial)) {
108
+ _configPollKeyMigrationChecked = true;
109
+ return;
110
+ }
111
+ const engine = initial.engine;
112
+ if (!engine || typeof engine !== 'object' || Array.isArray(engine)) {
113
+ _configPollKeyMigrationChecked = true;
114
+ return;
115
+ }
116
+ const hasOldStatus = engine.adoPollStatusEvery !== undefined;
117
+ const hasOldComments = engine.adoPollCommentsEvery !== undefined;
118
+ if (!hasOldStatus && !hasOldComments) {
119
+ _configPollKeyMigrationChecked = true;
120
+ return;
121
+ }
122
+ try {
123
+ mutateJsonFileLocked(CONFIG_PATH, (config) => {
124
+ if (!config || typeof config !== 'object' || Array.isArray(config)) return config;
125
+ const nextEngine = config.engine;
126
+ if (!nextEngine || typeof nextEngine !== 'object' || Array.isArray(nextEngine)) return config;
127
+ if (nextEngine.prPollStatusEvery === undefined && nextEngine.adoPollStatusEvery !== undefined) {
128
+ nextEngine.prPollStatusEvery = nextEngine.adoPollStatusEvery;
129
+ }
130
+ if (nextEngine.prPollCommentsEvery === undefined && nextEngine.adoPollCommentsEvery !== undefined) {
131
+ nextEngine.prPollCommentsEvery = nextEngine.adoPollCommentsEvery;
132
+ }
133
+ delete nextEngine.adoPollStatusEvery;
134
+ delete nextEngine.adoPollCommentsEvery;
135
+ return config;
136
+ });
137
+ _configPollKeyMigrationChecked = true;
138
+ } catch (e) {
139
+ console.warn('[config] one-time prPoll migration failed:', e.message);
140
+ }
141
+ }
142
+
102
143
  function getConfig() {
144
+ migrateDeprecatedConfigPollKeysOnce();
103
145
  return safeJson(CONFIG_PATH) || {};
104
146
  }
105
147
 
package/engine/shared.js CHANGED
@@ -673,8 +673,8 @@ const ENGINE_DEFAULTS = {
673
673
  buildFixGracePeriod: 600000, // 10min — wait for CI to run after build fix before re-dispatching
674
674
  adoPollEnabled: true, // poll ADO PR status, comments, and reconciliation on each tick cycle
675
675
  ghPollEnabled: true, // poll GitHub PR status, comments, and reconciliation on each tick cycle
676
- adoPollStatusEvery: 6, // poll ADO PR build/review/merge status every N ticks (~6 min at default interval)
677
- adoPollCommentsEvery: 12, // poll ADO PR human comments every N ticks (~12 min at default interval)
676
+ prPollStatusEvery: 12, // poll PR build/review/merge status every N ticks for both ADO and GitHub (~12 min at default interval)
677
+ prPollCommentsEvery: 12, // poll PR human comments every N ticks for both ADO and GitHub (~12 min at default interval)
678
678
  autoCompletePrs: false, // auto-merge PRs when builds green + review approved (opt-in)
679
679
  prMergeMethod: 'squash', // merge method: squash, merge, rebase
680
680
  ignoredCommentAuthors: [], // comments from these authors are auto-closed and never trigger fixes
package/engine/timeout.js CHANGED
@@ -308,16 +308,27 @@ function checkTimeouts(config) {
308
308
  const procInfo = activeProcesses.get(item.id);
309
309
  if (procInfo?._steeringAt && Date.now() - procInfo._steeringAt < 60000) continue;
310
310
 
311
+ // Capture live-output.log file state for orphan/hung diagnostics (#W-mo248lkjwgsu).
312
+ // Three distinguishable failure modes:
313
+ // logExists=false → spawn call itself threw, no log ever written
314
+ // logExists=true, size~stub → process died at startup (stub-only content)
315
+ // logExists=true, size>stub → genuine hang (process alive, wrote output, then stopped)
316
+ let _logState = 'logExists=false logSize=0';
317
+ try {
318
+ const lst = fs.statSync(liveLogPath);
319
+ _logState = `logExists=true logSize=${lst.size}`;
320
+ } catch { /* ENOENT — keep default */ }
321
+
311
322
  if (!hasProcess && silentMs > effectiveTimeout && (Date.now() > engineRestartGraceUntil || engineRestartGraceExempt?.has(item.id))) {
312
323
  // No tracked process AND no recent output past effective timeout AND (grace period expired OR confirmed-dead at restart) → orphaned
313
- log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, silent for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
324
+ log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, silent for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''} [${_logState}]`);
314
325
  dispatch().updateAgentStatus(item.id, AGENT_STATUS.TIMED_OUT, `Orphaned — no process, silent for ${silentSec}s`);
315
326
  // Clear session so retry starts fresh
316
327
  try { shared.safeUnlink(path.join(AGENTS_DIR, item.agent, 'session.json')); } catch {}
317
328
  deadItems.push({ item, reason: `Orphaned — no process, silent for ${silentSec}s` });
318
329
  } else if (hasProcess && silentMs > effectiveTimeout) {
319
330
  // Has process but no output past effective timeout → hung
320
- log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
331
+ log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''} [${_logState}]`);
321
332
  dispatch().updateAgentStatus(item.id, AGENT_STATUS.TIMED_OUT, `Hung — no output for ${silentSec}s`);
322
333
  const procInfo = activeProcesses.get(item.id);
323
334
  if (procInfo) {
package/engine.js CHANGED
@@ -871,25 +871,16 @@ async function spawnAgent(dispatchItem, config) {
871
871
  const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
872
872
  const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
873
873
 
874
- const proc = runFile(process.execPath, spawnArgs, {
875
- cwd,
876
- stdio: ['pipe', 'pipe', 'pipe'],
877
- env: childEnv,
878
- });
879
-
880
- const MAX_OUTPUT = 1024 * 1024; // 1MB
881
- let stdout = '';
882
- let stderr = '';
883
- let lastOutputAt = Date.now();
884
- let heartbeatTimer = null;
885
- let _trustCheckDone = false;
886
- const _spawnTime = Date.now();
887
-
888
- // Live output file — written as data arrives so dashboard can tail it
874
+ // Live output file — stamped BEFORE child process is spawned (#W-mo248lkjwgsu).
875
+ // Writing the stub pre-spawn lets the orphan detector distinguish three failure modes
876
+ // that otherwise look identical:
877
+ // 1. No log file → spawn call itself threw before reaching this line
878
+ // 2. Log has stub only → process started but died before its first write
879
+ // 3. Log has stub + ... → process alive but hung (the only case that warrants orphan kill+retry)
889
880
  const liveOutputPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
890
881
 
891
882
  // Rotate previous live output to preserve session history (fixes #543: orphan recovery overwrites)
892
- // Only rotate if the existing file has meaningful content (beyond just the header)
883
+ // Only rotate if the existing file has meaningful content (beyond just the header stub)
893
884
  const LIVE_OUTPUT_SPARSE_THRESHOLD = 500; // bytes — header + init JSON is typically < 500
894
885
  try {
895
886
  if (fs.existsSync(liveOutputPath)) {
@@ -901,7 +892,37 @@ async function spawnAgent(dispatchItem, config) {
901
892
  }
902
893
  } catch { /* rotation is best-effort — overwrite still happens below */ }
903
894
 
904
- safeWrite(liveOutputPath, `# Live output for ${agentId} ${id}\n# Started: ${startedAt}\n# Task: ${dispatchItem.task}\n\n`);
895
+ // Stamp the log synchronously before spawn. If the synchronous write throws,
896
+ // we still attempt to spawn (log-file stamp failure must not block the agent),
897
+ // but we note it so the diagnostic trail isn't silent either.
898
+ try {
899
+ safeWrite(liveOutputPath, `# Live output for ${agentId} — ${id}\n# Started: ${startedAt}\n# Task: ${dispatchItem.task}\n[${new Date().toISOString()}] spawn: agent=${agentId} item=${id}\n\n`);
900
+ } catch (stubErr) {
901
+ log('warn', `Failed to stamp live-output stub for ${agentId} (${id}): ${stubErr.message}`);
902
+ }
903
+
904
+ let proc;
905
+ try {
906
+ proc = runFile(process.execPath, spawnArgs, {
907
+ cwd,
908
+ stdio: ['pipe', 'pipe', 'pipe'],
909
+ env: childEnv,
910
+ });
911
+ } catch (spawnErr) {
912
+ // Synchronous spawn failure — record it to the (already-stamped) log so the
913
+ // orphan detector's "logSize > stub-only" check can tell this apart from a
914
+ // hung process. Then rethrow so the dispatch loop handles it normally.
915
+ try { fs.appendFileSync(liveOutputPath, `[${new Date().toISOString()}] spawn-failed: ${spawnErr.message}\n[process-exit] spawn-failed\n`); } catch { /* cleanup-only best effort */ }
916
+ throw spawnErr;
917
+ }
918
+
919
+ const MAX_OUTPUT = 1024 * 1024; // 1MB
920
+ let stdout = '';
921
+ let stderr = '';
922
+ let lastOutputAt = Date.now();
923
+ let heartbeatTimer = null;
924
+ let _trustCheckDone = false;
925
+ const _spawnTime = Date.now();
905
926
 
906
927
  // Keep live log active even when the agent produces no stdout/stderr for long stretches.
907
928
  // This makes "silent but running" states visible in the dashboard tail view.
@@ -3226,13 +3247,19 @@ async function tickInner() {
3226
3247
 
3227
3248
  const adoPollEnabled = config.engine?.adoPollEnabled ?? ENGINE_DEFAULTS.adoPollEnabled;
3228
3249
  const ghPollEnabled = config.engine?.ghPollEnabled ?? ENGINE_DEFAULTS.ghPollEnabled;
3229
- const adoPollStatusEvery = Math.max(1, Number(config.engine?.adoPollStatusEvery) || ENGINE_DEFAULTS.adoPollStatusEvery);
3230
- const adoPollCommentsEvery = Math.max(1, Number(config.engine?.adoPollCommentsEvery) || ENGINE_DEFAULTS.adoPollCommentsEvery);
3250
+ const prPollStatusEvery = Math.max(
3251
+ 1,
3252
+ Number(config.engine?.prPollStatusEvery ?? config.engine?.adoPollStatusEvery) || ENGINE_DEFAULTS.prPollStatusEvery
3253
+ );
3254
+ const prPollCommentsEvery = Math.max(
3255
+ 1,
3256
+ Number(config.engine?.prPollCommentsEvery ?? config.engine?.adoPollCommentsEvery) || ENGINE_DEFAULTS.prPollCommentsEvery
3257
+ );
3231
3258
 
3232
- // 2.6. Poll PR status: build, review, merge (every adoPollStatusEvery ticks, default ~6 minutes)
3259
+ // 2.6. Poll PR status: build, review, merge (every prPollStatusEvery ticks, default ~12 minutes)
3233
3260
  // Awaited so PR state is consistent before discoverWork reads it
3234
3261
  // Also re-polls early if previous tick had ADO auth failures (stale build status recovery)
3235
- if (tickCount % adoPollStatusEvery === 0 || needsAdoPollRetry()) {
3262
+ if (tickCount % prPollStatusEvery === 0 || needsAdoPollRetry()) {
3236
3263
  // Build promise array — enabled+unthrottled polls run concurrently via Promise.allSettled
3237
3264
  const statusPolls = [];
3238
3265
  if (adoPollEnabled && !isAdoThrottled()) {
@@ -3265,8 +3292,8 @@ async function tickInner() {
3265
3292
  } catch (err) { log('warn', `Plan completion check error: ${err?.message || err}`); }
3266
3293
  }
3267
3294
 
3268
- // 2.7. Poll PR threads for human comments (every adoPollCommentsEvery ticks, default ~12 minutes)
3269
- if (tickCount % adoPollCommentsEvery === 0) {
3295
+ // 2.7. Poll PR threads for human comments (every prPollCommentsEvery ticks, default ~12 minutes)
3296
+ if (tickCount % prPollCommentsEvery === 0) {
3270
3297
  // Build promise array — enabled+unthrottled comment polls run concurrently via Promise.allSettled
3271
3298
  const commentPolls = [];
3272
3299
  if (adoPollEnabled && !isAdoThrottled()) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1045",
3
+ "version": "0.1.1047",
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"