@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 +29 -14
- package/dashboard/js/render-dispatch.js +5 -1
- package/dashboard.js +49 -1
- package/engine/ado-comment.js +17 -1
- package/engine/cli.js +24 -2
- package/engine/dispatch.js +63 -11
- package/engine/playbook.js +22 -0
- package/engine/preflight.js +19 -0
- package/engine/restart-health.js +34 -3
- package/engine/shared.js +30 -2
- package/engine/supervisor.js +35 -0
- package/engine/watchdog.js +68 -0
- package/engine.js +91 -13
- package/package.json +1 -1
- package/playbooks/review.md +3 -0
- package/playbooks/shared-rules.md +58 -0
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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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}`);
|
package/engine/ado-comment.js
CHANGED
|
@@ -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:
|
|
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)
|
|
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>)');
|
package/engine/dispatch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
961
|
-
|
|
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
|
-
//
|
|
966
|
-
//
|
|
967
|
-
//
|
|
968
|
-
//
|
|
969
|
-
//
|
|
970
|
-
|
|
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) : [];
|
package/engine/playbook.js
CHANGED
|
@@ -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`;
|
package/engine/preflight.js
CHANGED
|
@@ -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.
|
package/engine/restart-health.js
CHANGED
|
@@ -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
|
-
|
|
157
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/engine/supervisor.js
CHANGED
|
@@ -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.
|
package/engine/watchdog.js
CHANGED
|
@@ -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
|
-
|
|
9219
|
-
|
|
9220
|
-
|
|
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.
|
|
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"
|
package/playbooks/review.md
CHANGED
|
@@ -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
|
|