@yemi33/minions 0.1.2269 → 0.1.2271

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/bin/minions.js CHANGED
@@ -422,7 +422,12 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
422
422
  // differ from `requested.port` after EADDRINUSE retry). If the dashboard
423
423
  // never writes it, fall back to the requested port — restart-health then
424
424
  // surfaces a clear failure rather than the CLI silently mis-probing.
425
- const actualPort = await _waitForDashboardPortFile(MINIONS_HOME, 12000) || requested.port;
425
+ // Only trust a beacon OUR dashboard wrote (pid match) so a stale leftover
426
+ // beacon can't point the health probe at a port a dying pre-restart
427
+ // dashboard happens to still hold. On no-match we fall back to the
428
+ // requested port and let the ownership gate below surface an honest
429
+ // failure rather than a false "healthy".
430
+ const actualPort = await _waitForDashboardPortFile(MINIONS_HOME, 12000, 150, dashProc.pid) || requested.port;
426
431
  if (actualPort !== requested.port) {
427
432
  console.log(` Dashboard bound to alternate port ${actualPort} (requested ${requested.port} was busy)`);
428
433
  }
@@ -431,6 +436,9 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
431
436
  dashboardPid: dashProc.pid,
432
437
  dashboardPort: actualPort,
433
438
  timeoutMs: _resolveRestartHealthTimeoutMs(),
439
+ // Verify the process bound to the port IS the dashboard we just spawned
440
+ // (beacon pid match), not a stale leftover still holding it.
441
+ requireBeaconOwner: true,
434
442
  });
435
443
  if (!result.ok) {
436
444
  console.error(formatRestartHealthError(result));
@@ -462,28 +470,35 @@ function spawnFullStackAndVerify({ rest, forceOpen, dashWasUp, restartStartMs })
462
470
  * if the file pre-dates the current spawn we still pick it up; the
463
471
  * subsequent isPortListening probe in waitForRestartHealth will catch a
464
472
  * truly dead dashboard. */
465
- async function _waitForDashboardPortFile(home, timeoutMs = 12000, pollMs = 150) {
473
+ async function _waitForDashboardPortFile(home, timeoutMs = 12000, pollMs = 150, expectedPid = null) {
466
474
  const start = Date.now();
467
475
  const startedAt = start;
476
+ const wp = Number(expectedPid);
477
+ const wantPid = Number.isInteger(wp) && wp > 0 ? wp : null;
468
478
  while (Date.now() - start < timeoutMs) {
469
479
  const rt = shared.readDashboardPortFile(home);
470
- if (rt && rt.port) {
471
- // Prefer entries written AFTER we started polling. If the file is
472
- // older (stale from a previous instance) it'll get rewritten by the
473
- // new dashboard within the polling window — keep polling until then.
474
- if (rt.boundAt) {
475
- const ts = Date.parse(rt.boundAt);
476
- if (Number.isFinite(ts) && ts >= startedAt - 1000) return rt.port;
477
- } else {
478
- return rt.port;
479
- }
480
+ // When the caller knows the pid it just spawned, ONLY accept a beacon that
481
+ // pid wrote a stale beacon from a prior (now-dying) dashboard must not be
482
+ // mistaken for our fresh one. Keep polling until our dashboard rewrites it
483
+ // on listen(). (readDashboardPortFile returns pid as integer-or-null.)
484
+ if (rt && rt.port && (wantPid == null || rt.pid === wantPid)) {
485
+ // Prefer entries written AFTER we started polling. If the file is older
486
+ // (stale from a previous instance) it'll get rewritten by the new
487
+ // dashboard within the polling window — keep polling until then.
488
+ if (!rt.boundAt) return rt.port;
489
+ const ts = Date.parse(rt.boundAt);
490
+ if (Number.isFinite(ts) && ts >= startedAt - 1000) return rt.port;
480
491
  }
481
492
  await new Promise(r => setTimeout(r, pollMs));
482
493
  }
483
494
  // Final read — even a stale entry is better than nothing if we timed out
484
- // (the chain fallback in the caller covers a fully-missing file).
495
+ // (the chain fallback in the caller covers a fully-missing file). When a
496
+ // pid was required, only return a pid-matched beacon; otherwise return null
497
+ // so the caller falls back to the requested port and the ownership gate
498
+ // reports an honest failure instead of probing a foreign port.
485
499
  const rtFinal = shared.readDashboardPortFile(home);
486
- return rtFinal && rtFinal.port ? rtFinal.port : null;
500
+ if (rtFinal && rtFinal.port && (wantPid == null || rtFinal.pid === wantPid)) return rtFinal.port;
501
+ return null;
487
502
  }
488
503
 
489
504
  /** Clear the stop-intent flag so the supervisor resumes guarding the engine
@@ -103,7 +103,11 @@ function renderEngineStatus(engine) {
103
103
  // against a possibly-cached heartbeat — the bug pattern that produced false-
104
104
  // positive STALE banners after control.json was dropped from the mtime tracker.
105
105
  const staleMs = engine?.heartbeatAgeMs || 0;
106
- if (state === 'running' && engine?.heartbeatStale) state = 'stale';
106
+ // Map running→stale and degraded→stale when both heartbeat signals are stale.
107
+ // degraded is set by the dashboard watchdog (issue #423) when the engine PID
108
+ // is alive but the tick loop is frozen; treat it identically to stale so the
109
+ // Restart Engine banner fires in both cases.
110
+ if ((state === 'running' || state === 'degraded') && engine?.heartbeatStale) state = 'stale';
107
111
 
108
112
  // Clear restart grace as soon as the engine reports a fresh heartbeat — the
109
113
  // new engine has caught up, so STALE/restart banners should vanish.
package/dashboard.js CHANGED
@@ -582,9 +582,18 @@ function inferActionPrRecord(action, prs, project = null) {
582
582
  // Returns true only when a PR is positively confirmed; false / null / unknown
583
583
  // all mean "do not stamp" (prefer a fresh PR over a phantom one).
584
584
  const _PR_REF_VERIFY_TTL_MS = 5 * 60 * 1000;
585
+ const _PR_REF_VERIFY_CACHE_MAX = 1000;
585
586
  const _prRefVerifyCache = new Map(); // cacheKey → { value: boolean, at: ms }
586
587
  let _prRefVerifierOverride = null; // test seam
587
588
 
589
+ // Evict stale entries when the cache exceeds _PR_REF_VERIFY_CACHE_MAX to prevent
590
+ // unbounded growth. Called after every Map.set() in verifyLoosePrRefIsPr.
591
+ function _evictPrRefVerifyCacheIfNeeded() {
592
+ if (_prRefVerifyCache.size <= _PR_REF_VERIFY_CACHE_MAX) return;
593
+ const cutoff = Date.now() - _PR_REF_VERIFY_TTL_MS;
594
+ for (const [k, v] of _prRefVerifyCache) if (v.at < cutoff) _prRefVerifyCache.delete(k);
595
+ }
596
+
588
597
  // Test seam (issue #246) — inject a fake verifier so handler tests can assert
589
598
  // the stamp decision without a live gh/ado call. The override receives
590
599
  // (prRef, project) and returns true | false | null.
@@ -741,7 +750,10 @@ async function verifyLoosePrRefIsPr(prRef, project) {
741
750
  } catch { verdict = null; }
742
751
  const value = verdict === true;
743
752
  // Only cache a definitive answer; an unknown (null) may resolve next time.
744
- if (verdict === true || verdict === false) _prRefVerifyCache.set(cacheKey, { value, at: Date.now() });
753
+ if (verdict === true || verdict === false) {
754
+ _prRefVerifyCache.set(cacheKey, { value, at: Date.now() });
755
+ _evictPrRefVerifyCacheIfNeeded();
756
+ }
745
757
  return value;
746
758
  }
747
759
 
@@ -5951,6 +5963,35 @@ function restartEngine() {
5951
5963
  return newPid;
5952
5964
  }
5953
5965
 
5966
+ // ── Frozen-engine detection (issue #423) ─────────────────────────────────────
5967
+ // Called by the 30s watchdog when the engine PID is still alive. If both
5968
+ // control.heartbeat AND control.lastTickAt have aged past their thresholds,
5969
+ // the tick loop is frozen (event-loop blocked). Flip state to 'degraded' so:
5970
+ // • The dashboard UI surfaces a hard warning with a Restart Engine button.
5971
+ // • When the engine eventually unhangs, tickInner reads state !== 'running'
5972
+ // and calls process.exit(0); the PID-dead watchdog then auto-restarts it.
5973
+ // The restarted engine reclaims state:'running' on its next boot.
5974
+ // Returns true if it wrote the degraded state, false otherwise.
5975
+ function _markEngineAsDegradedIfFrozen() {
5976
+ const control = getEngineState();
5977
+ if (control.state !== 'running' || !control.pid) return false;
5978
+
5979
+ const hbAge = control.heartbeat ? Date.now() - control.heartbeat : 0;
5980
+ const tickInterval = Number(CONFIG?.engine?.tickInterval) || shared.ENGINE_DEFAULTS.tickInterval;
5981
+ const tickStaleThresholdMs = Math.max(ENGINE_HEARTBEAT_STALE_MS, 2 * tickInterval);
5982
+ const tickAge = control.lastTickAt ? Date.now() - control.lastTickAt : Infinity;
5983
+
5984
+ const bothStale = !!(control.heartbeat
5985
+ && hbAge > ENGINE_HEARTBEAT_STALE_MS
5986
+ && tickAge > tickStaleThresholdMs);
5987
+ if (!bothStale) return false;
5988
+
5989
+ shared.mutateControl(c => ({ ...c, state: 'degraded' }));
5990
+ console.log(`[watchdog] Engine tick frozen (heartbeat ${Math.round(hbAge / 1000)}s old, last tick ${Math.round(tickAge / 1000)}s old) — marking state as degraded`);
5991
+ try { invalidateStatusCache(); } catch { /* best effort */ }
5992
+ return true;
5993
+ }
5994
+
5954
5995
  // -- Server --
5955
5996
 
5956
5997
  // Mutating HTTP methods that require Origin and Content-Type gating.
@@ -14301,6 +14342,9 @@ function _installCrashHandlers() {
14301
14342
  module.exports = {
14302
14343
  getMcpServers,
14303
14344
  _setPrRefVerifierForTest, // issue #246 — inject a fake loose-PR-ref verifier in handler tests
14345
+ _prRefVerifyCache, // W-mqtzrix100060c9f — test seam for cache size inspection
14346
+ _PR_REF_VERIFY_TTL_MS, // W-mqtzrix100060c9f — exported for test assertions
14347
+ _evictPrRefVerifyCacheIfNeeded, // W-mqtzrix100060c9f — test seam for eviction logic
14304
14348
  _setLivePrFetchForTest, // W-mqtrnp7y00056bc8 — inject a mock live fetch for unit tests
14305
14349
  _fetchLivePrRecord, // W-mqtrnp7y00056bc8 — exported for direct unit testing
14306
14350
  _parseClaudeMcpListLine,
@@ -14415,6 +14459,8 @@ module.exports = {
14415
14459
  // staleness verdict it stamps on engine.heartbeatStale is the contract under
14416
14460
  // test. No production caller imports this; it is a test seam.
14417
14461
  _buildStatusFastState,
14462
+ // #423 — exported for unit testing the frozen-engine watchdog logic.
14463
+ _markEngineAsDegradedIfFrozen,
14418
14464
  // W-mq5xg5e9000nec0e — exported for direct unit testing of the slim shape
14419
14465
  // produced by GET /api/work-items. Production callers go through the
14420
14466
  // route's `builder` closure (getWorkItems().map(slimWorkItemForList)).
@@ -14643,6 +14689,8 @@ if (require.main === module) {
14643
14689
  if (!alive) {
14644
14690
  console.log(`[watchdog] Engine PID ${control.pid} is dead — auto-restarting...`);
14645
14691
  restartEngine();
14692
+ } else {
14693
+ _markEngineAsDegradedIfFrozen();
14646
14694
  }
14647
14695
  } catch (e) {
14648
14696
  console.error(`[watchdog] Error: ${e.message}`);
@@ -25,6 +25,13 @@
25
25
  * /pullRequests/{prNumber}/threads?api-version=7.1
26
26
  * body: { comments: [{ parentCommentId: 0, content, commentType: 1 }], status: 1 }
27
27
  * (commentType 1 = text; thread status 1 = active.)
28
+ *
29
+ * Non-actionable threads (W-mqu0u9cf): a review agent can mark an informational
30
+ * / FYI / praise comment as already-resolved at creation time by passing
31
+ * `resolved: true`, which posts the thread with status `closed` (4) instead of
32
+ * `active` (1). ADO honors the status field on thread creation, so unlike GitHub
33
+ * this needs no second API round-trip. Actionable comments (bugs, required
34
+ * changes) stay `active` so they block/notify the author.
28
35
  */
29
36
 
30
37
  const { buildMinionsCommentBody } = require('./comment-format');
@@ -32,6 +39,12 @@ const { acquireAdoToken } = require('./ado-token');
32
39
 
33
40
  const ADO_API_VERSION = '7.1';
34
41
 
42
+ // ADO CommentThreadStatus enum values we use. `active` (1) is the default for
43
+ // actionable threads; `closed` (4) is used for non-actionable threads posted
44
+ // pre-resolved so they don't clutter the PR with noise the author must triage.
45
+ const ADO_THREAD_STATUS_ACTIVE = 1;
46
+ const ADO_THREAD_STATUS_CLOSED = 4;
47
+
35
48
  // ── Validation (ADO-specific) ────────────────────────────────────────────────
36
49
  // Field validation for the marker (agentId / kind / workItemId) is done inside
37
50
  // buildMinionsCommentBody. Here we only guard the ADO addressing tuple.
@@ -110,6 +123,7 @@ async function postAdoPrComment({
110
123
  kind,
111
124
  workItemId,
112
125
  harnessUsed,
126
+ resolved = false,
113
127
  timeoutMs = 30000,
114
128
  acquireToken = _defaultAcquireToken,
115
129
  fetchImpl,
@@ -133,7 +147,7 @@ async function postAdoPrComment({
133
147
 
134
148
  const payload = {
135
149
  comments: [{ parentCommentId: 0, content: finalBody, commentType: 1 }],
136
- status: 1, // active
150
+ status: resolved ? ADO_THREAD_STATUS_CLOSED : ADO_THREAD_STATUS_ACTIVE,
137
151
  };
138
152
 
139
153
  const res = await doFetch(url, {
@@ -165,6 +179,8 @@ module.exports = {
165
179
  postAdoPrComment,
166
180
  buildThreadsUrl,
167
181
  ADO_API_VERSION,
182
+ ADO_THREAD_STATUS_ACTIVE,
183
+ ADO_THREAD_STATUS_CLOSED,
168
184
  // Re-export the neutral builder so ADO callers have a single import surface,
169
185
  // mirroring engine/gh-comment.js.
170
186
  buildMinionsCommentBody,
package/engine/cli.js CHANGED
@@ -250,7 +250,7 @@ const CLI_COMMAND_DOCS = Object.freeze({
250
250
  'mcp-sync': { args: '', summary: 'Print harness propagation diagnostic (same source as `minions doctor --harness`; read-only, no writes)' },
251
251
  doctor: { args: '[--harness]', summary: 'Check prerequisites and runtime health (--harness: print harness propagation diagnostic)' },
252
252
  config: { args: 'set-cli <R> [--model M]', summary: 'Persist defaultCli/defaultModel without starting' },
253
- pr: { args: 'comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] [--harness-file <f>|--harness-json <j>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh' },
253
+ pr: { args: 'comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] [--resolved] [--harness-file <f>|--harness-json <j>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh (ADO: --resolved posts pre-closed)' },
254
254
  bridge: { args: 'status|health|enable|disable', summary: 'Constellation bridge: toggle and inspect the read-only cross-repo feed' },
255
255
  });
256
256
 
@@ -2037,6 +2037,7 @@ const commands = {
2037
2037
  console.log(' --harness-file <path> JSON file with the { skills, mcpServers, commands, docs } record (wins over --harness-json)');
2038
2038
  console.log(' --harness-json <json> same record inline as a JSON string');
2039
2039
  console.log(' --dispatch-id <id> grounding manifest override (else basename of $MINIONS_COMPLETION_REPORT)');
2040
+ console.log(' --resolved (ADO only) post the thread pre-resolved/closed — for non-actionable FYI/praise notes');
2040
2041
  console.log('');
2041
2042
  console.log('Posts a PR comment with the hidden minions marker, the collapsible');
2042
2043
  console.log('"Harnesses used" section, and the brand link folded in by the shared');
@@ -2054,11 +2055,18 @@ const commands = {
2054
2055
 
2055
2056
  const positional = [];
2056
2057
  const flags = {};
2058
+ // Valueless boolean flags — present means true, no following value consumed.
2059
+ const BOOLEAN_FLAGS = new Set(['resolved']);
2057
2060
  let i = 0;
2058
2061
  while (i < rest.length) {
2059
2062
  const a = rest[i];
2060
2063
  if (typeof a === 'string' && a.startsWith('--')) {
2061
2064
  const key = a.slice(2);
2065
+ if (BOOLEAN_FLAGS.has(key)) {
2066
+ flags[key] = true;
2067
+ i += 1;
2068
+ continue;
2069
+ }
2062
2070
  const val = rest[i + 1];
2063
2071
  if (val === undefined || (typeof val === 'string' && val.startsWith('--'))) {
2064
2072
  console.error(`error: --${key} requires a value`);
@@ -2122,8 +2130,11 @@ const commands = {
2122
2130
  const adoComment = require('./ado-comment');
2123
2131
  adoComment.postAdoPrComment({
2124
2132
  orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId, harnessUsed,
2133
+ resolved: flags.resolved === true,
2125
2134
  }).then((result) => {
2126
- if (result && result.threadId) console.log(`ADO thread ${result.threadId} created`);
2135
+ if (result && result.threadId) {
2136
+ console.log(`ADO thread ${result.threadId} created${flags.resolved === true ? ' (resolved/closed)' : ''}`);
2137
+ }
2127
2138
  }).catch((e) => {
2128
2139
  console.error(`error: ${e.message}`);
2129
2140
  process.exit(1);
@@ -2133,6 +2144,17 @@ const commands = {
2133
2144
 
2134
2145
  // ── GitHub: <repo> <prNumber> ──
2135
2146
  const ghComment = require('./gh-comment');
2147
+ // GitHub conversation (issue) comments have no "resolved" concept — only
2148
+ // inline review threads can be resolved, and that needs a GraphQL
2149
+ // resolveReviewThread round-trip the agent runs itself (see shared-rules.md
2150
+ // → "Actionable vs Non-Actionable Comments"). Fail loudly rather than
2151
+ // silently dropping the intent.
2152
+ if (flags.resolved === true) {
2153
+ console.error('error: --resolved is ADO-only. GitHub conversation comments cannot be resolved; ' +
2154
+ 'fold non-actionable notes into a collapsed <details> section of the verdict comment, or post an ' +
2155
+ 'inline review thread and resolve it via the GraphQL resolveReviewThread mutation (see shared-rules.md).');
2156
+ process.exit(2);
2157
+ }
2136
2158
  const [repo, prNumberRaw] = positional;
2137
2159
  if (!repo || prNumberRaw === undefined) {
2138
2160
  console.error('Usage: minions pr comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
@@ -880,7 +880,11 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
880
880
  delete wi.failedAt;
881
881
  delete wi.dispatched_at;
882
882
  delete wi.dispatched_to;
883
- delete wi._pendingReason;
883
+ // W-mqtzriwd0005de47: preserve live_checkout_dirty stamp so the
884
+ // two-strike guard in engine.js#spawnAgent can detect a second
885
+ // consecutive dirty failure and fail non-retryably. All other
886
+ // _pendingReason values are cleared as before.
887
+ if (wi._pendingReason !== 'live_checkout_dirty') delete wi._pendingReason;
884
888
  }
885
889
  });
886
890
  }
@@ -942,32 +946,80 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
942
946
  } catch (e) { log('warn', 'manual completion PRD sync: ' + e.message); }
943
947
  }
944
948
 
945
- // Restore pendingFix on failed human-feedback fix so engine re-dispatches on next tick
949
+ // Restore pendingFix on failed human-feedback fix so engine re-dispatches on next tick.
950
+ // #418 — transient preflight failure classes (WORKTREE_DIRTY / WORKTREE_DIVERGENT /
951
+ // WORKTREE_QUARANTINE_ENV_BLOCKED) get special treatment: the coalesce path in
952
+ // discoverFromPrs calls coalesceCurrentHumanFeedback() whenever isAlreadyDispatched
953
+ // returns true, which in turn calls clearPendingHumanFeedbackFlag() and sets
954
+ // pendingFix=false again. If the dispatch key is preserved in completed (wasPending=true
955
+ // pre-spawn path), the 15-min error window keeps isAlreadyDispatched true, so pendingFix
956
+ // stays false after the window expires and the fix is permanently abandoned.
957
+ // Fix: for transient preflight classes, clear the dispatch key from completed so
958
+ // isAlreadyDispatched returns false immediately → coalesce does not interfere.
959
+ // A _pendingFixPreflightRetries counter on humanFeedback enforces a cap
960
+ // (quarantineAutoRecoveryMax) to prevent infinite tight-loop re-dispatch when the
961
+ // environmental issue persists.
946
962
  if (result === DISPATCH_RESULT.ERROR && item.meta?.source === 'pr-human-feedback') {
947
963
  const prId = item.meta.pr?.id;
948
964
  const project = item.meta.project;
965
+ // Transient preflight failure classes: the worktree was quarantined or the env
966
+ // blocked the rename — the next dispatch gets a fresh worktree, so retry is safe.
967
+ const TRANSIENT_PREFLIGHT_CLASSES = new Set([
968
+ FAILURE_CLASS.WORKTREE_DIRTY,
969
+ FAILURE_CLASS.WORKTREE_DIVERGENT,
970
+ FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED,
971
+ ]);
972
+ const isTransientPreflight = TRANSIENT_PREFLIGHT_CLASSES.has(failureClass);
949
973
  if (prId && project) {
950
974
  try {
951
975
  const prsPath = projectPrPath(project);
952
976
  let restored = false;
977
+ let gaveUp = false;
953
978
  mutatePullRequests(prsPath, prs => {
954
979
  const target = shared.findPrRecord(prs, { id: prId }, project);
955
- if (target?.humanFeedback) {
980
+ if (!target?.humanFeedback) return;
981
+ if (isTransientPreflight) {
982
+ // Apply quarantineAutoRecoveryMax cap via a per-PR counter so
983
+ // transient git-status failures cannot loop indefinitely.
984
+ const cap = ENGINE_DEFAULTS.quarantineAutoRecoveryMax || 2;
985
+ const count = (target.humanFeedback._pendingFixPreflightRetries || 0) + 1;
986
+ if (count <= cap) {
987
+ target.humanFeedback.pendingFix = true;
988
+ target.humanFeedback._pendingFixPreflightRetries = count;
989
+ restored = true;
990
+ } else {
991
+ gaveUp = true;
992
+ }
993
+ } else {
956
994
  target.humanFeedback.pendingFix = true;
957
995
  restored = true;
958
996
  }
959
997
  });
960
- if (restored) log('info', `Restored pendingFix=true on ${prId} after failed human-feedback fix`);
961
- else log('info', `Skipped pendingFix restore for ${prId} PR is no longer tracked`);
998
+ if (restored && isTransientPreflight) {
999
+ log('info', `Restored pendingFix=true on ${prId} after transient preflight failure (${failureClass || 'unknown'})`);
1000
+ } else if (restored) {
1001
+ log('info', `Restored pendingFix=true on ${prId} after failed human-feedback fix`);
1002
+ } else if (gaveUp) {
1003
+ log('warn', `pendingFix not restored for ${prId}: transient preflight retry cap hit (${ENGINE_DEFAULTS.quarantineAutoRecoveryMax || 2}) — leaving for human intervention`);
1004
+ } else {
1005
+ log('info', `Skipped pendingFix restore for ${prId} — PR is no longer tracked`);
1006
+ }
962
1007
  } catch (e) { log('warn', `restore pendingFix: ${e.message}`); }
963
1008
  }
964
1009
  // Clear completed dispatch entry so dedup doesn't block re-dispatch.
965
- // SKIP this clear when the failure happened before spawn (e.g. worktree
966
- // creation failure, #2454) otherwise the engine sees no recent dispatch
967
- // history, clears the cooldown as "stale", and re-queues immediately on
968
- // the very next tick, looping forever for structural failures like a
969
- // stale-locked worktree entry that the agent can't fix on its own.
970
- if (item.meta?.dispatchKey && !wasPending) {
1010
+ // Transient preflight failures (#418): clear the dispatch key even when wasPending=true
1011
+ // so isAlreadyDispatched returns false immediately. Without this, the discoverFromPrs
1012
+ // coalesce path (isAlreadyDispatched=true coalesceCurrentHumanFeedback
1013
+ // clearPendingHumanFeedbackFlag) re-clears pendingFix before the 15-min error window
1014
+ // expires, permanently abandoning the fix.
1015
+ // Non-transient pre-spawn failures (e.g. worktree creation error): preserve the
1016
+ // completed entry so the cooldown throttles re-dispatch and prevents tight loops for
1017
+ // structural failures that the engine can't self-heal.
1018
+ const clearKey = item.meta?.dispatchKey && (
1019
+ !wasPending // post-spawn failure: already the existing clear path
1020
+ || isTransientPreflight // pre-spawn transient: clear to unblock coalesce interference
1021
+ );
1022
+ if (clearKey) {
971
1023
  try {
972
1024
  mutateDispatch((dp) => {
973
1025
  dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== item.meta.dispatchKey) : [];
@@ -816,6 +816,28 @@ function renderPlaybook(type, vars) {
816
816
  } catch (e) { log('warn', `qa-validate context render failed: ${e.message}`); }
817
817
  }
818
818
 
819
+ // M005 — live-validation deferred-build notice.
820
+ // Inject only for coding playbooks (implement, fix, docs, decompose) when the
821
+ // matched project has liveValidation.autoDispatch === true. Agents on these
822
+ // projects must push code without running builds or tests; a separate dispatch
823
+ // (of liveValidation.type) runs the full build on the live environment after
824
+ // the PR lands. When autoDispatch is false the agent is responsible for its own
825
+ // inline validation (may fail in isolated worktrees — documented limitation).
826
+ const LIVE_VALIDATION_PLAYBOOKS = new Set(['implement', 'fix', 'docs', 'decompose']);
827
+ if (LIVE_VALIDATION_PLAYBOOKS.has(type)) {
828
+ const lv = matchedProject && matchedProject.liveValidation;
829
+ if (lv && lv.autoDispatch === true) {
830
+ const lvType = lv.type || 'live-validation';
831
+ inertAppendices.push(
832
+ `\n\n---\n\n## Live Validation — Validation Deferred\n\n` +
833
+ `**Live validation is deferred for this project.** ` +
834
+ `Do not attempt builds, test runs, or compilation steps. ` +
835
+ `Write and push the code; a separate \`${lvType}\` dispatch will run ` +
836
+ `the full build on the live environment after your PR is pushed.`,
837
+ );
838
+ }
839
+ }
840
+
819
841
  // Inject KB guardrail
820
842
  content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
821
843
  content += `**Never delete, move, or overwrite files in \`knowledge/\`.** The sweep (consolidation engine) is the only process that writes to \`knowledge/\`. If you think a KB file is wrong, note it in your learnings file — do not touch \`knowledge/\` directly.\n`;
@@ -654,6 +654,25 @@ function doctor(minionsHome) {
654
654
  runtimeResults.push({ name: 'Port 7331', ok: 'warn', message: 'dashboard not running — port status unknown (see docs/engine-restart.md)' });
655
655
  }
656
656
 
657
+ // Self-check the out-of-process recovery net. A registered-but-broken
658
+ // watchdog (scheduled task pointing at a deleted launcher .cmd → exits 1
659
+ // every interval → nothing restores a dead dashboard) is otherwise silent
660
+ // for hours. Only flagged when installed AND broken; not-installed is a
661
+ // neutral note since the watchdog is opt-in.
662
+ try {
663
+ const watchdog = require(path.join(minionsHome, 'engine', 'watchdog'));
664
+ const wh = watchdog.health({ minionsHome });
665
+ if (!wh.installed) {
666
+ runtimeResults.push({ name: 'Watchdog', ok: 'warn', message: wh.message });
667
+ } else if (!wh.ok) {
668
+ runtimeResults.push({ name: 'Watchdog', ok: false, message: wh.problems.join('; ') });
669
+ } else {
670
+ runtimeResults.push({ name: 'Watchdog', ok: true, message: wh.message });
671
+ }
672
+ } catch (err) {
673
+ runtimeResults.push({ name: 'Watchdog', ok: 'warn', message: `self-check unavailable: ${err && err.message || err}` });
674
+ }
675
+
657
676
  // Fleet defaults + per-runtime model discovery (P-9e8a3f1d). Both depend
658
677
  // on the config that we already loaded above; re-using `preflightConfig`
659
678
  // avoids a second JSON.parse round-trip.
@@ -153,9 +153,32 @@ async function checkRestartHealth(options = {}) {
153
153
  const dpid = normalizePid(dashboardPid);
154
154
  const dashAlive = dpid ? isAlive(dpid) : false;
155
155
  const portOpen = portCheck(dashboardPort);
156
- dashboardOk = !!(dashAlive && portOpen);
157
- dashboardDetail = `pid=${dpid || 'none'} alive=${dashAlive ? 'yes' : 'no'} port=${dashboardPort} listening=${portOpen ? 'yes' : 'no'}`;
156
+ // Ownership gate (opt-in via requireBeaconOwner). "PID alive + port
157
+ // listening" is NOT sufficient: a stale pre-restart dashboard still holding
158
+ // the port satisfies the listening probe, so the verifier used to report
159
+ // "healthy" off a leftover process that then died seconds later — leaving
160
+ // the dashboard down with the in-browser "unreachable" banner stuck on.
161
+ // The dashboard writes the port beacon (engine/dashboard-port.json) with
162
+ // its OWN pid on listen(), so the authoritative "the dashboard that bound
163
+ // the port is the one we just spawned" signal is beacon.pid === dpid AND
164
+ // beacon.port === dashboardPort. spawnDashboard() launches node directly
165
+ // (no re-exec), so the spawned pid is the listener. While our pid is alive
166
+ // but hasn't written/refreshed the beacon yet, this stays false and the
167
+ // poller keeps waiting (legitimate cold start) rather than lying.
168
+ let beaconOwned = true;
169
+ let beaconPid = null;
170
+ if (options.requireBeaconOwner) {
171
+ const readBeacon = options.readDashboardPortFile || (shared && shared.readDashboardPortFile);
172
+ let beacon = null;
173
+ try { beacon = readBeacon ? readBeacon(minionsHome) : null; } catch { beacon = null; }
174
+ beaconPid = beacon && normalizePid(beacon.pid);
175
+ beaconOwned = !!(beacon && beaconPid === dpid && Number(beacon.port) === Number(dashboardPort));
176
+ }
177
+ dashboardOk = !!(dashAlive && portOpen && beaconOwned);
178
+ dashboardDetail = `pid=${dpid || 'none'} alive=${dashAlive ? 'yes' : 'no'} port=${dashboardPort} listening=${portOpen ? 'yes' : 'no'}`
179
+ + (options.requireBeaconOwner ? ` beaconPid=${beaconPid || 'none'} owned=${beaconOwned ? 'yes' : 'no'}` : '');
158
180
  dashboardSnapshot = { kind: 'process', pid: dpid, alive: dashAlive, port: dashboardPort, listening: portOpen };
181
+ if (options.requireBeaconOwner) { dashboardSnapshot.beaconPid = beaconPid; dashboardSnapshot.owned = beaconOwned; }
159
182
  } else {
160
183
  dashboardKind = 'http';
161
184
  const url = dashboardUrl || `http://127.0.0.1:${dashboardPort}/api/health`;
@@ -253,7 +276,15 @@ function formatRestartHealthError(result) {
253
276
  // implying breakage.
254
277
  let guidance = '';
255
278
  const dash = result && result.dashboard;
256
- if (dash && dash.kind === 'process' && dash.alive && !dash.listening) {
279
+ if (dash && dash.kind === 'process' && dash.alive && dash.listening && dash.owned === false) {
280
+ const port = dash.port || 7331;
281
+ guidance =
282
+ `\n Port ${port} is held by a DIFFERENT process (beacon pid=${dash.beaconPid || 'none'},\n` +
283
+ ` expected the freshly-spawned dashboard pid=${dash.pid || 'none'}). A stale\n` +
284
+ ` pre-restart dashboard is still bound to the port and ours could not take\n` +
285
+ ` it over. Run \`minions restart\` again, or kill the stale listener\n` +
286
+ ` (\`netstat -ano | findstr :${port}\`) and re-check http://localhost:${port}.\n`;
287
+ } else if (dash && dash.kind === 'process' && dash.alive && !dash.listening) {
257
288
  const port = dash.port || 7331;
258
289
  guidance =
259
290
  `\n The dashboard process is alive but has not yet bound port ${port}.\n` +
package/engine/shared.js CHANGED
@@ -2601,10 +2601,29 @@ const CHECKOUT_MODES = Object.freeze({ WORKTREE: 'worktree', LIVE: 'live' });
2601
2601
  // 3. default → 'worktree'
2602
2602
  // Always returns one of CHECKOUT_MODES — never undefined — so call sites can
2603
2603
  // compare against the enum without a falsy guard.
2604
- function resolveCheckoutMode(project) {
2604
+ //
2605
+ // Optional second argument `workItemType` enables liveValidation routing:
2606
+ // If checkoutMode === 'live' AND project.liveValidation is set:
2607
+ // - workItemType matches liveValidation.type → 'live' (validation serialized)
2608
+ // - otherwise → 'worktree' (coding escapes the live cap)
2609
+ // liveValidation without checkoutMode: 'live' is ignored with a warn log.
2610
+ function resolveCheckoutMode(project, workItemType) {
2605
2611
  if (!project || typeof project !== 'object') return CHECKOUT_MODES.WORKTREE;
2606
2612
  const canonical = project.checkoutMode;
2607
- if (canonical === CHECKOUT_MODES.LIVE) return CHECKOUT_MODES.LIVE;
2613
+ // liveValidation without checkoutMode: 'live' is a misconfiguration — warn and ignore.
2614
+ if (project.liveValidation && canonical !== CHECKOUT_MODES.LIVE) {
2615
+ log('warn', 'resolveCheckoutMode: liveValidation is set but checkoutMode is not "live" — liveValidation ignored',
2616
+ { projectName: project.name });
2617
+ }
2618
+ if (canonical === CHECKOUT_MODES.LIVE) {
2619
+ // Apply liveValidation routing when the block is present and workItemType is provided.
2620
+ if (project.liveValidation && workItemType !== undefined) {
2621
+ return workItemType === project.liveValidation.type
2622
+ ? CHECKOUT_MODES.LIVE
2623
+ : CHECKOUT_MODES.WORKTREE;
2624
+ }
2625
+ return CHECKOUT_MODES.LIVE;
2626
+ }
2608
2627
  if (canonical === CHECKOUT_MODES.WORKTREE) return CHECKOUT_MODES.WORKTREE;
2609
2628
  // Legacy field fallback (only consulted when checkoutMode is absent/unknown).
2610
2629
  const legacy = project.worktreeMode;
@@ -2820,6 +2839,13 @@ const ENGINE_DEFAULTS = {
2820
2839
  pipelineApiRetries: 2, // max attempts for pipeline API calls
2821
2840
  pipelineApiRetryDelay: 2000, // ms delay between pipeline API retries
2822
2841
  pipelineApiTimeoutMs: 30000, // P-bfa1e-pipeline-state-machine-b — per-attempt request timeout for pipeline API calls; on timeout the request is destroyed and the attempt fails through the normal retry path. After all retries exhaust, executeApiStage returns FAILED instead of COMPLETED.
2842
+ // #422 — per-operation timeout for top-level async tick phases (pollPrStatus,
2843
+ // discoverWork, reconcilePrs, etc.). If any phase Promise does not settle within
2844
+ // this window it is treated as a rejection and the loop continues with the next
2845
+ // phase rather than hanging indefinitely. Individual fetch calls already carry
2846
+ // their own AbortSignal timeouts; this is a belt-and-suspenders outer deadline
2847
+ // for the whole phase. Default 60s; min 10s; configurable via config.engine.tickOpTimeoutMs.
2848
+ tickOpTimeoutMs: 60000,
2823
2849
  prAutoLinkRetries: 3, // max attempts for gh pr list lookup when auto-linking PR after merge (3s backoff between attempts)
2824
2850
  rebaseQueueRetries: 3, // max rebase attempts per queued PR before giving up
2825
2851
  versionCheckInterval: 3600000, // 1 hour — how often to check npm for updates (ms)
@@ -2829,6 +2855,8 @@ const ENGINE_DEFAULTS = {
2829
2855
  logCapTrimTo: 2000, // size to trim back to when cap is hit (matches legacy JSON splice 2500→2000)
2830
2856
  lockRetries: 0, // no retries — single 5s timeout window with 25ms polling (200 attempts) is sufficient; stale lock recovery at 60s handles crashes
2831
2857
  lockRetryBackoffMs: 500, // base backoff between lock retries (doubles each attempt: 500ms, 1s, 2s, ...)
2858
+ // #421 — whole-tick hard Promise.race timeout (ms). Set to 0 to disable.
2859
+ tickHardTimeoutMs: 300000, // 5 min — same budget as TICK_TIMEOUT_MS
2832
2860
  buildFixGracePeriod: 600000, // 10min — wait for CI to run after a verified build-fix push before re-dispatching
2833
2861
  // W-mpoeirqx0007712a: cap re-dispatch attempts when build-fix pushes
2834
2862
  // silently fail to advance the remote head (stale-worktree push rejected,
@@ -74,6 +74,8 @@ function _resolveDashPort() {
74
74
  // or write its PID before we re-probe. Without this we'd race the freshly
75
75
  // spawned process and double-spawn.
76
76
  const POST_SPAWN_GRACE_MS = Number(process.env.MINIONS_SUPERVISOR_GRACE_MS) || 15000;
77
+ // #421 — heartbeat age threshold before supervisor considers the engine event loop frozen.
78
+ const SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS = Number(process.env.MINIONS_SUPERVISOR_STALE_HEARTBEAT_MS) || 180000; // 3 min
77
79
  const isWin = process.platform === 'win32';
78
80
 
79
81
  function safeReadJson(p) {
@@ -392,6 +394,36 @@ function checkEngine(now) {
392
394
  console.log(`[supervisor] Engine respawned (new PID: ${newPid})`);
393
395
  }
394
396
 
397
+ // #421 — Heartbeat-stale watchdog: detect when the engine PID is alive but its
398
+ // event loop has frozen (sync busy-loop, blocked system call). Unlike
399
+ // checkEngine (which only fires when the PID is dead), this fires when the PID
400
+ // is alive but control.heartbeat has not advanced for SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS.
401
+ // The heartbeat is written every ~15s by a dedicated timer in engine/cli.js; if
402
+ // it goes stale while the PID persists, the event loop is stuck.
403
+ function checkEngineHung(now) {
404
+ if (now - _lastEngineRespawnAt < POST_SPAWN_GRACE_MS) return;
405
+ const control = safeReadJson(CONTROL_PATH_FN());
406
+ if (!control || control.state !== 'running') return;
407
+ // Only acts when the PID is alive — a dead PID is handled by checkEngine().
408
+ if (!control.pid || !isPidAlive(control.pid)) return;
409
+
410
+ const heartbeat = Number(control.heartbeat);
411
+ if (!heartbeat || !Number.isFinite(heartbeat)) return; // no heartbeat yet (fresh start)
412
+
413
+ const heartbeatAge = now - heartbeat;
414
+ if (heartbeatAge < SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS) return;
415
+
416
+ console.log(
417
+ `[supervisor] Engine PID ${control.pid} is alive but heartbeat is stale ` +
418
+ `(${Math.round(heartbeatAge / 1000)}s old, threshold ${SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS / 1000}s) — ` +
419
+ `event loop likely frozen; restarting engine`,
420
+ );
421
+ reapStrayProcesses(path.join(MINIONS_DIR, 'engine.js'), 'engine');
422
+ const newPid = spawnEngine();
423
+ _lastEngineRespawnAt = now;
424
+ console.log(`[supervisor] Engine restarted due to stale heartbeat (new PID: ${newPid})`);
425
+ }
426
+
395
427
  function checkDashboard(now) {
396
428
  if (now - _lastDashboardRespawnAt < POST_SPAWN_GRACE_MS) return;
397
429
  const dashPort = _resolveDashPort();
@@ -413,6 +445,7 @@ function tick() {
413
445
  if (isStopIntentSet()) return;
414
446
  const now = Date.now();
415
447
  checkEngine(now);
448
+ checkEngineHung(now);
416
449
  checkDashboard(now);
417
450
  } catch (e) {
418
451
  console.error(`[supervisor] tick error: ${e && e.message}`);
@@ -483,7 +516,9 @@ module.exports = {
483
516
  _cmdRunsScript,
484
517
  openAppendFd,
485
518
  checkEngine,
519
+ checkEngineHung,
486
520
  checkDashboard,
521
+ SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS,
487
522
  tick,
488
523
  // Path getters honor MINIONS_TEST_DIR via shared.ENGINE_DIR, so test
489
524
  // isolation correctly redirects writes/reads under createTestMinionsDir.
@@ -368,6 +368,72 @@ function statusWindows(opts) {
368
368
  };
369
369
  }
370
370
 
371
+ // Parse the decimal "Last Result" code from schtasks /V /FO LIST output. The
372
+ // scheduler reports the launcher's exit code here; benign transient codes are
373
+ // whitelisted by the caller. Returns null when the field is absent.
374
+ function _parseWindowsLastResult(details) {
375
+ if (!details) return null;
376
+ const m = /Last Result:\s*(-?\d+)/i.exec(String(details));
377
+ if (!m) return null;
378
+ const n = Number(m[1]);
379
+ return Number.isInteger(n) ? n : null;
380
+ }
381
+
382
+ // Benign Task Scheduler "Last Result" codes that do NOT indicate a broken
383
+ // recovery net: 0 (success), and the "not yet run" / "currently running" /
384
+ // "will run again" family (0x41300–0x41303). Anything else nonzero means the
385
+ // launcher actually exited with an error (the canonical case: a missing
386
+ // launcher .cmd → exit 1 → silent no-op recovery for hours).
387
+ const _BENIGN_LAST_RESULTS = new Set([0, 267008, 267009, 267010, 267011]);
388
+
389
+ // Self-check the out-of-process recovery net so a SILENTLY broken watchdog
390
+ // (e.g. the scheduled task points at a launcher .cmd that was deleted —
391
+ // observed 2026-06-25, exit 1 every 5 min for ~24h with nothing restoring a
392
+ // dead dashboard) surfaces in `minions doctor` instead of rotting unnoticed.
393
+ // Returns { installed, ok, problems[], message, lastResult }. When the
394
+ // watchdog isn't installed at all, ok=true (it's an opt-in feature — don't
395
+ // nag users who never enabled it).
396
+ function health(opts) {
397
+ opts = opts || {};
398
+ const plat = opts.platform || process.platform;
399
+ const minionsHome = opts.minionsHome;
400
+ let st;
401
+ try { st = status({ platform: plat, spawner: opts.spawner }); }
402
+ catch (err) { return { installed: false, ok: true, problems: [], message: `status unavailable: ${err && err.message || err}` }; }
403
+
404
+ if (!st.installed) {
405
+ return {
406
+ installed: false, ok: true, problems: [],
407
+ message: 'not installed (optional out-of-process recovery — enable with: minions watchdog install)',
408
+ };
409
+ }
410
+
411
+ const problems = [];
412
+ // The exact failure mode that bricked recovery: the task is registered but
413
+ // its launcher file is gone, so every fire runs a missing path → exits 1 →
414
+ // does nothing. Deterministic, win32-only (mac/launchd + linux/systemd embed
415
+ // the command inline, so there's no separate launcher to lose).
416
+ if (plat === 'win32' && minionsHome) {
417
+ const lp = windowsLauncherPath(minionsHome);
418
+ if (!fs.existsSync(lp)) {
419
+ problems.push(`launcher missing at ${lp} — the scheduled task runs a non-existent file (exits 1, no recovery). Fix: minions watchdog install`);
420
+ }
421
+ }
422
+
423
+ const lastResult = plat === 'win32' ? _parseWindowsLastResult(st.details) : null;
424
+ if (lastResult != null && !_BENIGN_LAST_RESULTS.has(lastResult)) {
425
+ problems.push(`scheduled task last exited ${lastResult} (nonzero) — recovery net is failing silently. Fix: minions watchdog install`);
426
+ }
427
+
428
+ return {
429
+ installed: true,
430
+ ok: problems.length === 0,
431
+ problems,
432
+ lastResult,
433
+ message: problems[0] || 'installed and healthy',
434
+ };
435
+ }
436
+
371
437
  // ─── macOS: launchd LaunchAgent ──────────────────────────────────────────────
372
438
 
373
439
  function macPlistPath() {
@@ -540,6 +606,7 @@ module.exports = {
540
606
  install,
541
607
  uninstall,
542
608
  status,
609
+ health,
543
610
  isPidAlive,
544
611
  // Constants + builders exported for unit tests and callers wiring CLI help.
545
612
  DEFAULT_INTERVAL_MIN,
@@ -559,4 +626,5 @@ module.exports = {
559
626
  macPlistPath,
560
627
  windowsLauncherPath,
561
628
  linuxUnitDir,
629
+ _parseWindowsLastResult,
562
630
  };
package/engine.js CHANGED
@@ -2321,6 +2321,28 @@ async function spawnAgent(dispatchItem, config) {
2321
2321
  // Read-only types short-circuited above (branchName was set to null) and
2322
2322
  // skip this block — the agent already runs read-only in cwd=localPath in
2323
2323
  // both isolated and live mode, so no in-place branch checkout is needed.
2324
+ //
2325
+ // M004: When dispatching in live mode with no branchName set (e.g. a
2326
+ // validation WI auto-created by the post-completion hook that carries a PR
2327
+ // reference but no explicit branch), resolve the target branch from the work
2328
+ // item's PR reference so prepareLiveCheckout can check out the correct PR
2329
+ // source branch. resolveWorkItemPrRecord loads pull-requests.json + finds the
2330
+ // PR record; resolvePrBranch extracts the source branch from it. Both helpers
2331
+ // are hoisted function declarations so the order-of-definition is safe.
2332
+ if (liveMode && !branchName && !READ_ONLY_ROOT_TASK_TYPES.has(type)) {
2333
+ const _validationItem = meta?.item;
2334
+ if (_validationItem) {
2335
+ const _prRef = shared.extractWorkItemPrRef(_validationItem);
2336
+ if (_prRef) {
2337
+ const _linkedPr = resolveWorkItemPrRecord(_validationItem, project);
2338
+ const _prSourceBranch = _linkedPr ? resolvePrBranch(_linkedPr) : '';
2339
+ if (_prSourceBranch) {
2340
+ branchName = sanitizeBranch(_prSourceBranch);
2341
+ log('info', `spawnAgent: live-checkout validation WI ${_validationItem.id || id} — resolved branch ${branchName} from PR ref ${_prRef}`);
2342
+ }
2343
+ }
2344
+ }
2345
+ }
2324
2346
  if (liveMode && branchName && !READ_ONLY_ROOT_TASK_TYPES.has(type)) {
2325
2347
  const _liveMainRef = sanitizeBranch(shared.resolveMainBranch(cwd, project.mainBranch));
2326
2348
  const _wiIdForAlert = meta?.item?.id || id;
@@ -9166,6 +9188,21 @@ let tickRunning = false;
9166
9188
  let _tickStartedAt = 0;
9167
9189
  const TICK_TIMEOUT_MS = 300000; // 5 min — force-release tick lock if stuck
9168
9190
 
9191
+ // #422 — per-operation timeout for top-level async tick phases.
9192
+ // Wraps `fn()` in a Promise.race against a configurable deadline so a hung
9193
+ // network call (ADO token fetch, pollPrStatus, discoverWork, …) cannot block
9194
+ // the entire tick loop indefinitely. The outer TICK_TIMEOUT_MS guard only
9195
+ // rescues the tick lock; withTickTimeout surfaces the hung operation as an
9196
+ // error on that tick so subsequent phases can still run.
9197
+ function withTickTimeout(fn, label, ms) {
9198
+ return Promise.race([
9199
+ fn(),
9200
+ new Promise((_, reject) =>
9201
+ setTimeout(() => reject(new Error(`tick-timeout: ${label} exceeded ${ms}ms`)), ms)
9202
+ ),
9203
+ ]);
9204
+ }
9205
+
9169
9206
  // P-c2e5a1d9-a — Generation counter that is incremented on every tick start
9170
9207
  // AND every time the force-release branch reclaims a hung tick. The in-flight
9171
9208
  // (hung) tickInner captures its own `myGeneration` at entry; if a force-release
@@ -9214,16 +9251,54 @@ async function tick() {
9214
9251
  }
9215
9252
  tickRunning = true;
9216
9253
  _tickStartedAt = Date.now();
9254
+ // #421 — hard wall-clock timeout wrapping the entire tickInner so that a
9255
+ // hung async operation (e.g. a git command waiting on stdin, a file lock that
9256
+ // never releases) cannot hold the tick lock beyond the configured budget.
9257
+ // When the deadline fires we bump tickGeneration so the stale in-flight
9258
+ // tickInner detects a mismatch via _isTickStale() and aborts instead of
9259
+ // mutating shared state alongside the fresh tick that takes over.
9260
+ // Set config.engine.tickHardTimeoutMs = 0 to disable (fallback: TICK_TIMEOUT_MS).
9261
+ const _hardMs = _resolveTickHardTimeoutMs();
9217
9262
  try {
9218
- await tickInner();
9219
- } catch (e) {
9220
- log('error', `Tick error: ${e.message}`);
9263
+ if (_hardMs > 0) {
9264
+ let _hardTimer;
9265
+ const _hardTimeoutP = new Promise((_, reject) => {
9266
+ _hardTimer = setTimeout(
9267
+ () => reject(new Error(`tick hard timeout: tickInner exceeded ${_hardMs}ms — abandoning hung tick`)),
9268
+ _hardMs,
9269
+ );
9270
+ });
9271
+ try {
9272
+ await Promise.race([tickInner(), _hardTimeoutP]);
9273
+ } catch (e) {
9274
+ if (e.message && e.message.startsWith('tick hard timeout')) {
9275
+ log('error', e.message);
9276
+ tickGeneration++; // invalidate the stale hung tickInner
9277
+ } else {
9278
+ log('error', `Tick error: ${e.message}`);
9279
+ }
9280
+ } finally {
9281
+ clearTimeout(_hardTimer);
9282
+ }
9283
+ } else {
9284
+ // Hard timeout disabled — run tickInner without a Promise.race wrapper.
9285
+ try { await tickInner(); } catch (e) { log('error', `Tick error: ${e.message}`); }
9286
+ }
9221
9287
  } finally {
9222
9288
  tickRunning = false;
9223
9289
  _tickStartedAt = 0;
9224
9290
  }
9225
9291
  }
9226
9292
 
9293
+ // #421 — resolves the hard-timeout budget for the current tick. Reads
9294
+ // config.engine.tickHardTimeoutMs first; falls back to ENGINE_DEFAULTS.
9295
+ function _resolveTickHardTimeoutMs() {
9296
+ try {
9297
+ const v = Number(getConfig()?.engine?.tickHardTimeoutMs);
9298
+ if (Number.isFinite(v) && v >= 0) return v;
9299
+ } catch { /* config read failure — use default */ }
9300
+ return ENGINE_DEFAULTS.tickHardTimeoutMs;
9301
+ }
9227
9302
  async function tickInner() {
9228
9303
  // P-c2e5a1d9-a — Capture this tick's generation as the very first statement
9229
9304
  // so any guard later in this function can detect a force-release that
@@ -9262,6 +9337,7 @@ async function tickInner() {
9262
9337
  tickCount++;
9263
9338
  const now = Date.now();
9264
9339
  const tickIntervalMs = Math.max(1, Number(config.engine?.tickInterval) || ENGINE_DEFAULTS.tickInterval);
9340
+ const tickOpTimeoutMs = Math.max(10000, Number(config.engine?.tickOpTimeoutMs) || ENGINE_DEFAULTS.tickOpTimeoutMs);
9265
9341
  _failedRefCache.clear(); // Reset per-tick failed-ref cache
9266
9342
 
9267
9343
  // Helper: run a phase, log + continue on error
@@ -9310,7 +9386,7 @@ async function tickInner() {
9310
9386
 
9311
9387
  // 2.5. Periodic cleanup + MCP sync (~10 min — cadence in ENGINE_DEFAULTS.cleanupEvery)
9312
9388
  if (tickCount % (ENGINE_DEFAULTS.cleanupEvery || 60) === 0) {
9313
- try { await runCleanup(config); } catch (e) { log('warn', `runCleanup: ${e.message}`); }
9389
+ try { await withTickTimeout(() => runCleanup(config), 'runCleanup', tickOpTimeoutMs); } catch (e) { log('warn', `runCleanup: ${e.message}`); }
9314
9390
  if (_isTickStale(myGeneration)) return;
9315
9391
  }
9316
9392
 
@@ -9499,18 +9575,18 @@ async function tickInner() {
9499
9575
  if (allAdoThrottled) {
9500
9576
  log('info', `[ado] PR status poll skipped — all ${adoOrgCount} known orgs throttled`);
9501
9577
  } else {
9502
- statusPolls.push(pollPrStatus(config).catch(err => { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9578
+ statusPolls.push(withTickTimeout(() => pollPrStatus(config), 'pollPrStatus', tickOpTimeoutMs).catch(err => { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9503
9579
  }
9504
9580
  }
9505
9581
  if (ghStatusPollEnabled && !isGhThrottled()) {
9506
- statusPolls.push(ghPollPrStatus(config).catch(err => { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9582
+ statusPolls.push(withTickTimeout(() => ghPollPrStatus(config), 'ghPollPrStatus', tickOpTimeoutMs).catch(err => { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9507
9583
  } else if (ghStatusPollEnabled && isGhThrottled()) {
9508
9584
  log('info', '[gh] PR status poll skipped — throttled');
9509
9585
  }
9510
9586
  if (statusPolls.length) await Promise.allSettled(statusPolls);
9511
9587
  if (_isTickStale(myGeneration)) return;
9512
9588
  if (rebaseProcessorEnabled) {
9513
- try { await processPendingRebases(config); } catch (err) { log('warn', `Pending rebase processing error: ${err?.message || err}`); }
9589
+ try { await withTickTimeout(() => processPendingRebases(config), 'processPendingRebases', tickOpTimeoutMs); } catch (err) { log('warn', `Pending rebase processing error: ${err?.message || err}`); }
9514
9590
  }
9515
9591
  if (_isTickStale(myGeneration)) return;
9516
9592
  // Sync PR status back to PRD items (missing → done when active PR exists)
@@ -9558,11 +9634,11 @@ async function tickInner() {
9558
9634
  if (allAdoThrottled) {
9559
9635
  log('info', `[ado] PR comment poll skipped — all ${adoOrgCount} known orgs throttled`);
9560
9636
  } else {
9561
- commentPolls.push(pollPrHumanComments(config).catch(err => { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9637
+ commentPolls.push(withTickTimeout(() => pollPrHumanComments(config), 'pollPrHumanComments', tickOpTimeoutMs).catch(err => { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9562
9638
  }
9563
9639
  }
9564
9640
  if (ghCommentsPollEnabled && !isGhThrottled()) {
9565
- commentPolls.push(ghPollPrHumanComments(config).catch(err => { log('warn', `GitHub PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9641
+ commentPolls.push(withTickTimeout(() => ghPollPrHumanComments(config), 'ghPollPrHumanComments', tickOpTimeoutMs).catch(err => { log('warn', `GitHub PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9566
9642
  } else if (ghCommentsPollEnabled && isGhThrottled()) {
9567
9643
  log('info', '[gh] PR comment poll skipped — throttled');
9568
9644
  }
@@ -9575,10 +9651,10 @@ async function tickInner() {
9575
9651
  // ADO and GitHub reconciliation are independent and run in parallel.
9576
9652
  const reconcilePolls = [];
9577
9653
  if (adoReconcileEnabled) {
9578
- reconcilePolls.push(reconcilePrs(config).catch(err => { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9654
+ reconcilePolls.push(withTickTimeout(() => reconcilePrs(config), 'reconcilePrs', tickOpTimeoutMs).catch(err => { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9579
9655
  }
9580
9656
  if (ghReconcileEnabled) {
9581
- reconcilePolls.push(ghReconcilePrs(config).catch(err => { log('warn', `GitHub PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9657
+ reconcilePolls.push(withTickTimeout(() => ghReconcilePrs(config), 'ghReconcilePrs', tickOpTimeoutMs).catch(err => { log('warn', `GitHub PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
9582
9658
  }
9583
9659
  if (reconcilePolls.length) await Promise.allSettled(reconcilePolls);
9584
9660
  if (_isTickStale(myGeneration)) return;
@@ -9605,7 +9681,7 @@ async function tickInner() {
9605
9681
  // so it runs unconditionally in the reconcile phase like the other recovery
9606
9682
  // sweeps — independent of the ADO/GitHub poll + throttle gates.
9607
9683
  try {
9608
- await reconcileSharedBranchPrs(config);
9684
+ await withTickTimeout(() => reconcileSharedBranchPrs(config), 'reconcileSharedBranchPrs', tickOpTimeoutMs);
9609
9685
  } catch (err) {
9610
9686
  log('warn', `[shared-branch-reconcile] sweep error: ${err?.message || err}`);
9611
9687
  }
@@ -10140,7 +10216,7 @@ async function tickInner() {
10140
10216
  setTempBudget(Math.max(0, maxC - activeCountPre));
10141
10217
  }
10142
10218
  let discoveryOk = true;
10143
- try { await discoverWork(config); } catch (e) { log('warn', 'discoverWork: ' + e.message); discoveryOk = false; }
10219
+ try { await withTickTimeout(() => discoverWork(config), 'discoverWork', tickOpTimeoutMs); } catch (e) { log('warn', 'discoverWork: ' + e.message); discoveryOk = false; }
10144
10220
  if (_isTickStale(myGeneration)) return;
10145
10221
 
10146
10222
  // 5. Update snapshot
@@ -10287,6 +10363,8 @@ module.exports = {
10287
10363
  resolvePreDispatchEvalConcurrency,
10288
10364
  // P-c2e5a1d9-a — exported for testing the tick-generation force-release path
10289
10365
  _isTickStale,
10366
+ // #421 — exported for testing the per-tick hard timeout path
10367
+ _resolveTickHardTimeoutMs,
10290
10368
  // P-b2c3d4e5 — exported for testing the memory baseline emitter + sidecar path
10291
10369
  emitMemoryBaseline, DIAGNOSTICS_MEMORY_PATH,
10292
10370
  get tickGeneration() { return tickGeneration; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2269",
3
+ "version": "0.1.2271",
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"
@@ -50,6 +50,8 @@ Use subagents only for genuinely parallel, independent tasks (e.g., reviewing un
50
50
  - **Blocking:** failing checks, security/data-loss risk, broken existing behavior, missing requested behavior, invalid API/schema/data migration, or tests that do not cover changed critical logic.
51
51
  - **Non-blocking:** style preferences, minor refactors, optional documentation, low-risk performance ideas, or additional tests that are useful but not required for safety.
52
52
 
53
+ Blocking findings are **actionable** (the author MUST act) → post as **active/unresolved** threads. Non-blocking findings are **non-actionable** (FYI, optional, or purely positive) → post as **resolved/closed** so they don't clutter the PR with noise the author must triage. Apply the classification and host-specific resolve mechanism exactly as defined in shared-rules → "Actionable vs Non-Actionable Comments — Resolve Noise at Post Time" (ADO: `minions pr comment … --host ado … --resolved`; GitHub: fold non-blocking notes into a collapsed `<details>` block of the verdict comment, or resolve inline review threads via the GraphQL `resolveReviewThread` mutation).
54
+
53
55
  6. Keep review comments high-signal and evidence-backed:
54
56
  - Every blocking issue must cite the file/line or exact changed behavior, explain the failure mode, and state the required fix.
55
57
  - Do not turn assumptions, preferences, or speculative alternatives into requested changes. Mark them non-blocking or omit them.
@@ -98,6 +100,7 @@ Minimum diff to ship: <one-line description of the smallest change that would fl
98
100
 
99
101
  Non-blocking observations: None
100
102
  - (default to `None`; if you list any, each must be tied to an existing diff line in the form `path:line — observation`)
103
+ - These are non-actionable. On GitHub, keep them inside a collapsed `<details><summary>Non-blocking observations</summary>…</details>` block so they don't read as required changes or spawn separate unresolved threads. On ADO, if you post any non-blocking note as its own thread, post it pre-resolved with `minions pr comment … --host ado … --resolved` (see shared-rules → "Actionable vs Non-Actionable Comments").
101
104
 
102
105
  Review by Minions ({{agent_name}} — {{agent_role}} · {{agent_model}})
103
106
  ```
@@ -246,6 +246,64 @@ Hard rule: resolving a thread with no reply comment is a process violation.
246
246
  The chunk-5 / Caleb-Tseng / MaiLibraryViewModel thread (ADO 5215549 thread
247
247
  65692221) is the canonical bad example — closed silently with no audit trail.
248
248
 
249
+ ## Actionable vs Non-Actionable Comments — Resolve Noise at Post Time
250
+
251
+ When you post PR review comments, classify every comment and post non-actionable
252
+ ones as **already-resolved** so they don't clutter the PR with noise the author
253
+ must triage. This keeps the author's "unresolved threads" list focused on things
254
+ they actually have to act on.
255
+
256
+ Classify by one test: **does the author MUST do something?**
257
+
258
+ - **Post as ACTIVE / unresolved** (the author must act): bugs, security issues,
259
+ logic errors, API/schema/data-migration contract violations, broken existing
260
+ behavior, missing required tests, required refactors, and anything you would
261
+ block the merge on.
262
+ - **Post as RESOLVED / closed** (informational, optional, or purely positive):
263
+ FYI observations, optional improvements, style/naming preferences, praise,
264
+ context-setting notes, "already noted elsewhere" pointers, and speculative
265
+ ideas. None of these require the author to do anything before merge.
266
+
267
+ This classification is distinct from the no-silent-closures rule above: that rule
268
+ governs threads the *reviewer left open and you later close*; this one governs the
269
+ status you choose **at creation time** for your own non-actionable comments. A
270
+ non-actionable comment is self-explanatory at post time, so it needs no separate
271
+ disposition reply — the comment body IS the explanation.
272
+
273
+ {{#ado_shared_rules}}
274
+ **Azure DevOps mechanism.** ADO honors the thread status on creation, so a
275
+ non-actionable thread is posted pre-resolved in a single call. Prefer
276
+ `minions pr comment … --host ado … --resolved`, which sets the thread status to
277
+ `closed` (4) instead of `active` (1). On the raw `az` / REST fallback set the
278
+ thread `status` field yourself: `closed` (4) for non-actionable notes (or `fixed`
279
+ (2) when the comment records something already handled), `active` (1) for
280
+ actionable findings. The verdict/summary comment itself stays `active`.
281
+ {{/ado_shared_rules}}
282
+ {{#github_shared_rules}}
283
+ **GitHub mechanism.** GitHub has no "resolved" state for PR *conversation*
284
+ (issue) comments — only inline **review threads** can be resolved, and there is no
285
+ single API call that creates a thread pre-resolved. So on GitHub:
286
+
287
+ - For the **single verdict/summary comment** (`minions pr comment`), do NOT split
288
+ every non-actionable note into its own thread. Fold them into a collapsed
289
+ `<details><summary>Non-blocking observations</summary>…</details>` block inside
290
+ the verdict comment so they are visible but de-emphasized and create no separate
291
+ unresolved threads. `--resolved` is rejected on the GitHub path for this reason.
292
+ - If you DO post a non-actionable note as its own **inline review thread**, resolve
293
+ it immediately after posting via the GraphQL `resolveReviewThread` mutation
294
+ (issue comments cannot be resolved this way; only review threads can):
295
+ ```bash
296
+ # 1. find the thread node id for the comment you just posted
297
+ gh api graphql -f query='query($owner:String!,$repo:String!,$pr:Int!){
298
+ repository(owner:$owner,name:$repo){ pullRequest(number:$pr){
299
+ reviewThreads(first:100){ nodes{ id isResolved comments(first:1){ nodes{ databaseId body } } } } } } }' \
300
+ -f owner=OWNER -f repo=REPO -F pr=NUMBER
301
+ # 2. resolve the thread whose comment databaseId matches your post
302
+ gh api graphql -f query='mutation($id:ID!){ resolveReviewThread(input:{threadId:$id}){ thread{ isResolved } } }' \
303
+ -f id=<threadNodeId>
304
+ ```
305
+ {{/github_shared_rules}}
306
+
249
307
  {{#github_shared_rules}}
250
308
  ## Checking PR and Build Status
251
309