@yemi33/minions 0.1.2227 → 0.1.2229
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 +1 -0
- package/dashboard/js/render-work-items.js +1 -1
- package/dashboard.js +21 -1
- package/docs/harness-transparency.md +9 -5
- package/docs/worktree-lifecycle.md +28 -0
- package/engine/cli.js +82 -7
- package/engine/comment-format.js +1 -1
- package/engine/gh-token.js +2 -2
- package/engine/github.js +3 -6
- package/engine/lifecycle.js +41 -15
- package/engine/pipeline.js +6 -6
- package/engine/projects.js +1 -1
- package/engine/routing.js +3 -3
- package/engine/shared.js +72 -0
- package/engine/supervisor.js +27 -2
- package/engine.js +49 -11
- package/package.json +1 -1
- package/playbooks/shared-rules.md +6 -2
package/bin/minions.js
CHANGED
|
@@ -1168,6 +1168,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
1168
1168
|
minions cleanup Clean temp files, worktrees, zombies
|
|
1169
1169
|
minions pr comment <repo> <n> Post a marker-prepended PR comment via gh
|
|
1170
1170
|
--agent <id> --kind <k> [--wi <id>] (--body-file <f> | --body <text>)
|
|
1171
|
+
[--harness-file <f> | --harness-json <j>] folds in a "Harnesses used" section
|
|
1171
1172
|
minions nuke --confirm Factory reset (delete state, reset config to defaults)
|
|
1172
1173
|
minions uninstall --confirm Remove everything + uninstall npm package
|
|
1173
1174
|
|
|
@@ -937,7 +937,7 @@ function _wiRenderDetail(item) {
|
|
|
937
937
|
});
|
|
938
938
|
if (!pills) return;
|
|
939
939
|
var legend = anyUngrounded
|
|
940
|
-
? '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:4px">⚠ dashed =
|
|
940
|
+
? '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:4px">⚠ dashed = agent-reported, not verified by the engine</div>'
|
|
941
941
|
: '';
|
|
942
942
|
html += field('Repo harnesses used', '<div style="display:flex;flex-wrap:wrap;gap:4px">' + pills + '</div>' + legend);
|
|
943
943
|
})();
|
package/dashboard.js
CHANGED
|
@@ -14214,6 +14214,23 @@ if (require.main === module) {
|
|
|
14214
14214
|
const { execSync } = require('child_process');
|
|
14215
14215
|
setInterval(() => {
|
|
14216
14216
|
try {
|
|
14217
|
+
// Respect `minions stop` / `minions uninstall` / mid-restart windows.
|
|
14218
|
+
// This is the third engine respawner (alongside engine/supervisor.js and
|
|
14219
|
+
// engine/watchdog.js) and must honor stop-intent too, or it would
|
|
14220
|
+
// resurrect the engine every 30s when stop-intent is set but
|
|
14221
|
+
// control.state is still 'running' (crash mid-stop, or stop-intent set
|
|
14222
|
+
// without a control-state flip). Fail-open: if isStopIntentSet is absent
|
|
14223
|
+
// (forks without a stop-intent producer), proceed with recovery —
|
|
14224
|
+
// matching engine/watchdog.js.
|
|
14225
|
+
let stopWanted = false;
|
|
14226
|
+
if (typeof shared.isStopIntentSet === 'function') {
|
|
14227
|
+
try { stopWanted = !!shared.isStopIntentSet(); } catch { stopWanted = false; }
|
|
14228
|
+
}
|
|
14229
|
+
if (stopWanted) {
|
|
14230
|
+
console.log(`[watchdog] stop-intent set — standing down (no restart)`);
|
|
14231
|
+
return;
|
|
14232
|
+
}
|
|
14233
|
+
|
|
14217
14234
|
const control = getEngineState();
|
|
14218
14235
|
if (control.state !== 'running' || !control.pid) return;
|
|
14219
14236
|
|
|
@@ -14222,7 +14239,10 @@ if (require.main === module) {
|
|
|
14222
14239
|
try {
|
|
14223
14240
|
if (process.platform === 'win32') {
|
|
14224
14241
|
const out = execSync(`tasklist /FI "PID eq ${control.pid}" /NH`, { encoding: 'utf8', timeout: 3000, windowsHide: true });
|
|
14225
|
-
|
|
14242
|
+
// Word-boundary + image-name match (mirrors engine/restart-health.js)
|
|
14243
|
+
// so a digit-substring collision in another column can't read a dead
|
|
14244
|
+
// engine pid as alive and suppress a needed respawn.
|
|
14245
|
+
alive = shared.tasklistOutputShowsPid(out, control.pid, { imageName: 'node' });
|
|
14226
14246
|
} else {
|
|
14227
14247
|
process.kill(control.pid, 0); // signal 0 = check existence
|
|
14228
14248
|
alive = true;
|
|
@@ -110,11 +110,15 @@ evaluation pass) can see what tooling drove a dispatch:
|
|
|
110
110
|
and ⚠️-marked), and returns `''` for an empty/absent record. The GitHub
|
|
111
111
|
comment path folds it in via `engine/gh-comment.js#buildMinionsCommentBody`
|
|
112
112
|
(optional `harnessUsed` arg, threaded through `postPrComment` /
|
|
113
|
-
`postPrReviewComment` / `postPrReview`); the Azure DevOps path
|
|
114
|
-
engine-
|
|
115
|
-
|
|
116
|
-
`
|
|
117
|
-
|
|
113
|
+
`postPrReviewComment` / `postPrReview`); the Azure DevOps path mirrors it via
|
|
114
|
+
`engine/ado-comment.js#postAdoPrComment` (same builder, same `harnessUsed`
|
|
115
|
+
arg). Both posters are reached from the `minions pr comment` CLI, which turns
|
|
116
|
+
`--harness-file` / `--harness-json` into the grounded record and folds the
|
|
117
|
+
**byte-identical** section in for the agent. Only the raw `gh pr comment` /
|
|
118
|
+
`az repos pr comment` / REST fallbacks bypass that chokepoint, so on those
|
|
119
|
+
paths review/fix agents append the section themselves per
|
|
120
|
+
`playbooks/shared-rules.md` → "Harness transparency / self-report". Every
|
|
121
|
+
surface consumes the one renderer, so there is no second formatter to drift.
|
|
118
122
|
2. **notes/inbox digest** — harness usage is summarized into the learnings /
|
|
119
123
|
inbox stream that feeds consolidation, so cross-task patterns ("everyone
|
|
120
124
|
reaches for skill X on Android fixes") become visible to the team-memory
|
|
@@ -47,6 +47,34 @@ recycle worktree dirs across branches.
|
|
|
47
47
|
→ `git checkout --detach origin/<main>` → mark IDLE.
|
|
48
48
|
- **State** at `engine/worktree-pool.json`; git ops outside any lock.
|
|
49
49
|
|
|
50
|
+
## Ownership marker is never "dirty" (#284)
|
|
51
|
+
|
|
52
|
+
The reused-worktree preflight (`assertCleanSharedWorktree`) builds its
|
|
53
|
+
dirty-file list from `git status --porcelain`. The engine's own
|
|
54
|
+
ownership marker `.minions-worktree` (`shared.WORKTREE_OWNER_MARKER`) is
|
|
55
|
+
gitignored in the repo's *current* tree, but a reused worktree that
|
|
56
|
+
checks out a branch whose tree predates that `.gitignore` line (or a
|
|
57
|
+
foreign repo) surfaces the marker as an untracked `?? .minions-worktree`
|
|
58
|
+
entry. Counting that engine-stamped artifact as filesystem-dirt used to
|
|
59
|
+
flip an otherwise-safe-to-reuse worktree (no upstream, HEAD at main tip)
|
|
60
|
+
into the conservative quarantine path → a non-retryable `WORKTREE_DIRTY`
|
|
61
|
+
failure for the whole work item.
|
|
62
|
+
|
|
63
|
+
`shared.isWorktreeOwnerMarkerStatusLine(porcelainLine)` recognizes a
|
|
64
|
+
porcelain line that refers ONLY to the root-level marker; the preflight
|
|
65
|
+
filters it out of `dirtyFiles` at every status read (initial probe,
|
|
66
|
+
post-reset re-verify) and the dirty-files prompt-injection site
|
|
67
|
+
(`engine.js`). **An untracked marker alone is a CLEAN tree for reuse.**
|
|
68
|
+
A same-named file nested in a subdir is NOT the marker and stays dirty;
|
|
69
|
+
the marker filter never masks a genuine user/agent edit beside it.
|
|
70
|
+
|
|
71
|
+
When the dirt is real and the worktree can't be auto-healed, the engine
|
|
72
|
+
still quarantines and fails non-retryably, and the quarantine
|
|
73
|
+
auto-recovery loop re-queues the item once in a fresh worktree (see
|
|
74
|
+
**Auto-recovery cap** below). The spawn-error completion report
|
|
75
|
+
distinguishes the **original worktree-preflight issue** from a
|
|
76
|
+
**retry-in-fresh-worktree** failure via `_quarantineRecoveryCount`.
|
|
77
|
+
|
|
50
78
|
## Quarantine path (dirty / divergent)
|
|
51
79
|
|
|
52
80
|
When `discoverFromWorkItems` finds a worktree in a `WORKTREE_DIRTY`,
|
package/engine/cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const shared = require('./shared');
|
|
9
|
-
const { safeRead, safeJson, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT } = shared;
|
|
9
|
+
const { safeRead, safeJson, safeJsonArr, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT } = shared;
|
|
10
10
|
const queries = require('./queries');
|
|
11
11
|
const { getConfig, getControl, getDispatch, getAgentStatus,
|
|
12
12
|
MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH, DISPATCH_PATH } = queries;
|
|
@@ -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>] [--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>] [--harness-file <f>|--harness-json <j>] [--body-file <f>|--body <text>]', summary: 'Post a marker-prepended PR comment via gh' },
|
|
254
254
|
bridge: { args: 'status|health|enable|disable', summary: 'Constellation bridge: toggle and inspect the read-only cross-repo feed' },
|
|
255
255
|
});
|
|
256
256
|
|
|
@@ -413,6 +413,68 @@ function _applyRuntimeFlags({ cli, model, modelExplicit }) {
|
|
|
413
413
|
return { warnings, applied: true };
|
|
414
414
|
}
|
|
415
415
|
|
|
416
|
+
// resolveHarnessUsedForComment(flags) — P-7a3c9e21. Turn the `minions pr comment`
|
|
417
|
+
// harness flags into a grounded `harnessUsed` record suitable for the posters'
|
|
418
|
+
// `harnessUsed` param (engine/gh-comment.js / engine/ado-comment.js), which fold
|
|
419
|
+
// it via buildMinionsCommentBody -> buildHarnessUsedSection.
|
|
420
|
+
//
|
|
421
|
+
// Source precedence: `--harness-file <path>` (JSON on disk) wins over the inline
|
|
422
|
+
// `--harness-json <json>` convenience; neither supplied -> returns undefined so
|
|
423
|
+
// the comment body is byte-identical to today (no section). Read/parse failures
|
|
424
|
+
// are HARD errors (process.exit(2) with the offending message) — never a silent
|
|
425
|
+
// drop. Grounding uses the spawn-time `_harnessPropagated` manifest located by
|
|
426
|
+
// dispatch id (explicit `--dispatch-id` -> else basename(MINIONS_COMPLETION_REPORT,
|
|
427
|
+
// '.json')); an unresolvable manifest passes null, marking every entry
|
|
428
|
+
// grounded:false — the honest "no record" state, not a fatal condition.
|
|
429
|
+
function resolveHarnessUsedForComment(flags) {
|
|
430
|
+
const fileArg = flags['harness-file'];
|
|
431
|
+
const jsonArg = flags['harness-json'];
|
|
432
|
+
|
|
433
|
+
let raw;
|
|
434
|
+
if (fileArg !== undefined) {
|
|
435
|
+
try {
|
|
436
|
+
raw = fs.readFileSync(fileArg, 'utf8');
|
|
437
|
+
} catch (e) {
|
|
438
|
+
console.error(`error: could not read --harness-file ${fileArg}: ${e.message}`);
|
|
439
|
+
process.exit(2);
|
|
440
|
+
}
|
|
441
|
+
} else if (jsonArg !== undefined) {
|
|
442
|
+
raw = String(jsonArg);
|
|
443
|
+
} else {
|
|
444
|
+
return undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
let parsed;
|
|
448
|
+
try {
|
|
449
|
+
parsed = JSON.parse(raw);
|
|
450
|
+
} catch (e) {
|
|
451
|
+
const src = fileArg !== undefined ? `--harness-file ${fileArg}` : '--harness-json';
|
|
452
|
+
console.error(`error: invalid JSON in ${src}: ${e.message}`);
|
|
453
|
+
process.exit(2);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Resolve the grounding manifest best-effort: explicit id -> completion-report
|
|
457
|
+
// basename. A missing/unresolvable manifest is non-fatal (groundHarnessUsed
|
|
458
|
+
// with null propagated marks everything grounded:false).
|
|
459
|
+
const dispatchId = flags['dispatch-id']
|
|
460
|
+
|| path.basename(process.env.MINIONS_COMPLETION_REPORT || '', '.json')
|
|
461
|
+
|| undefined;
|
|
462
|
+
let propagated = null;
|
|
463
|
+
if (dispatchId) {
|
|
464
|
+
try {
|
|
465
|
+
const dispatch = getDispatch();
|
|
466
|
+
for (const queue of ['active', 'completed', 'pending']) {
|
|
467
|
+
const list = Array.isArray(dispatch?.[queue]) ? dispatch[queue] : null;
|
|
468
|
+
if (!list) continue;
|
|
469
|
+
const found = list.find(d => d && d.id === dispatchId);
|
|
470
|
+
if (found && found._harnessPropagated) { propagated = found._harnessPropagated; break; }
|
|
471
|
+
}
|
|
472
|
+
} catch { propagated = null; }
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return shared.groundHarnessUsed(parsed, propagated);
|
|
476
|
+
}
|
|
477
|
+
|
|
416
478
|
const commands = {
|
|
417
479
|
start(...startArgs) {
|
|
418
480
|
// Apply --cli / --model fleet flags before any engine wiring touches
|
|
@@ -782,7 +844,7 @@ const commands = {
|
|
|
782
844
|
const projName = item.meta.project?.name;
|
|
783
845
|
if (projName) {
|
|
784
846
|
const prPath = path.join(MINIONS_DIR, 'projects', projName, 'pull-requests.json');
|
|
785
|
-
const prs =
|
|
847
|
+
const prs = safeJsonArr(prPath);
|
|
786
848
|
const matchingPr = prs.find(pr =>
|
|
787
849
|
(pr.prdItems || []).includes(item.meta.item.id) &&
|
|
788
850
|
pr.status !== 'abandoned' && pr.status !== 'closed'
|
|
@@ -1760,13 +1822,13 @@ const commands = {
|
|
|
1760
1822
|
}
|
|
1761
1823
|
}
|
|
1762
1824
|
if (exists && name === 'pullRequests') {
|
|
1763
|
-
const prs =
|
|
1825
|
+
const prs = safeJsonArr(filePath);
|
|
1764
1826
|
const pending = prs.filter(p => p.status === PR_STATUS.ACTIVE && (p.reviewStatus === REVIEW_STATUS.PENDING || p.reviewStatus === REVIEW_STATUS.WAITING));
|
|
1765
1827
|
const needsFix = prs.filter(p => p.status === PR_STATUS.ACTIVE && p.reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED);
|
|
1766
1828
|
console.log(` PRs: ${pending.length} pending review, ${needsFix.length} need fixes`);
|
|
1767
1829
|
}
|
|
1768
1830
|
if (exists && name === 'workItems') {
|
|
1769
|
-
const items =
|
|
1831
|
+
const items = safeJsonArr(filePath);
|
|
1770
1832
|
const queued = items.filter(i => i.status === WI_STATUS.QUEUED);
|
|
1771
1833
|
console.log(` Items: ${queued.length} queued`);
|
|
1772
1834
|
}
|
|
@@ -1982,6 +2044,11 @@ const commands = {
|
|
|
1982
2044
|
console.log(' GitHub: minions pr comment <repo> <prNumber> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
|
|
1983
2045
|
console.log(' ADO: minions pr comment <prNumber> --host ado --ado-org <org> --ado-project <proj> --repo-id <id> --agent <id> --kind <k> [--wi <id>] (--body-file <path> | --body <text>)');
|
|
1984
2046
|
console.log('');
|
|
2047
|
+
console.log('Optional harness self-report (folded into the collapsible "Harnesses used" section):');
|
|
2048
|
+
console.log(' --harness-file <path> JSON file with the { skills, mcpServers, commands, docs } record (wins over --harness-json)');
|
|
2049
|
+
console.log(' --harness-json <json> same record inline as a JSON string');
|
|
2050
|
+
console.log(' --dispatch-id <id> grounding manifest override (else basename of $MINIONS_COMPLETION_REPORT)');
|
|
2051
|
+
console.log('');
|
|
1985
2052
|
console.log('Posts a PR comment with the hidden minions marker, the collapsible');
|
|
1986
2053
|
console.log('"Harnesses used" section, and the brand link folded in by the shared');
|
|
1987
2054
|
console.log('builder — so engine classifiers identify agent-authored comments by');
|
|
@@ -2040,6 +2107,12 @@ const commands = {
|
|
|
2040
2107
|
process.exit(2);
|
|
2041
2108
|
}
|
|
2042
2109
|
|
|
2110
|
+
// P-7a3c9e21 — thread the consulted-sources footprint into both posters so
|
|
2111
|
+
// the auto-folded "Harnesses used" <details> section the help already
|
|
2112
|
+
// promises is actually rendered. Resolved once (host-neutral); absent flags
|
|
2113
|
+
// -> undefined -> body byte-identical to today.
|
|
2114
|
+
const harnessUsed = resolveHarnessUsedForComment(flags);
|
|
2115
|
+
|
|
2043
2116
|
// ── Azure DevOps: <prNumber> --host ado --ado-org --ado-project --repo-id ──
|
|
2044
2117
|
if (isAdo) {
|
|
2045
2118
|
const [prNumberRaw] = positional;
|
|
@@ -2059,7 +2132,7 @@ const commands = {
|
|
|
2059
2132
|
const orgBase = flags['org-base'] || `https://dev.azure.com/${adoOrg}`;
|
|
2060
2133
|
const adoComment = require('./ado-comment');
|
|
2061
2134
|
adoComment.postAdoPrComment({
|
|
2062
|
-
orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId,
|
|
2135
|
+
orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId, harnessUsed,
|
|
2063
2136
|
}).then((result) => {
|
|
2064
2137
|
if (result && result.threadId) console.log(`ADO thread ${result.threadId} created`);
|
|
2065
2138
|
}).catch((e) => {
|
|
@@ -2084,7 +2157,7 @@ const commands = {
|
|
|
2084
2157
|
|
|
2085
2158
|
try {
|
|
2086
2159
|
const result = ghComment.postPrComment({
|
|
2087
|
-
repo, prNumber, body, agentId, kind, workItemId,
|
|
2160
|
+
repo, prNumber, body, agentId, kind, workItemId, harnessUsed,
|
|
2088
2161
|
});
|
|
2089
2162
|
if (result.output) console.log(result.output);
|
|
2090
2163
|
} catch (e) {
|
|
@@ -2223,6 +2296,8 @@ module.exports = {
|
|
|
2223
2296
|
_readDispatchPid: readDispatchPid,
|
|
2224
2297
|
_normalizeSessionBranch: normalizeSessionBranch,
|
|
2225
2298
|
_dispatchSessionBranch: dispatchSessionBranch,
|
|
2299
|
+
// P-7a3c9e21 — harness-flag resolver exported for CLI-threading tests.
|
|
2300
|
+
_resolveHarnessUsedForComment: resolveHarnessUsedForComment,
|
|
2226
2301
|
// W-mpcyvff6000pf828 (#2653) — heartbeat writer + factory exported for tests
|
|
2227
2302
|
_writeHeartbeatNow: writeHeartbeatNow,
|
|
2228
2303
|
_createHeartbeatInterval: createHeartbeatInterval,
|
package/engine/comment-format.js
CHANGED
|
@@ -112,7 +112,7 @@ function buildHarnessUsedSection(harnessUsed) {
|
|
|
112
112
|
if (lines.length === 0) return '';
|
|
113
113
|
|
|
114
114
|
const legend = anyUngrounded
|
|
115
|
-
? `\n\n> ${HARNESS_WARN_ICON}
|
|
115
|
+
? `\n\n> ${HARNESS_WARN_ICON} agent-reported, not verified by the engine`
|
|
116
116
|
: '';
|
|
117
117
|
|
|
118
118
|
return `<details>\n<summary>${HARNESS_SUMMARY_ICON} Harnesses used (${lines.length})</summary>\n\n`
|
package/engine/gh-token.js
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
const { execFileSync } = require('child_process');
|
|
22
22
|
const path = require('path');
|
|
23
23
|
const shared = require('./shared');
|
|
24
|
-
const {
|
|
24
|
+
const { safeJsonObj, MINIONS_DIR, log } = shared;
|
|
25
25
|
|
|
26
26
|
const TOKEN_TTL_MS = 30 * 60 * 1000; // 30 minutes
|
|
27
27
|
const FETCH_TIMEOUT_MS = 10000; // 10s — same ceiling as `gh api user`
|
|
@@ -40,7 +40,7 @@ function _readConfig(opts = {}) {
|
|
|
40
40
|
return _cachedConfig;
|
|
41
41
|
}
|
|
42
42
|
const configPath = path.join(MINIONS_DIR, 'config.json');
|
|
43
|
-
_cachedConfig =
|
|
43
|
+
_cachedConfig = safeJsonObj(configPath);
|
|
44
44
|
_cachedConfigAt = Date.now();
|
|
45
45
|
return _cachedConfig;
|
|
46
46
|
}
|
package/engine/github.js
CHANGED
|
@@ -633,11 +633,11 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
633
633
|
if (updated) {
|
|
634
634
|
// Also update title/author/branch if still placeholder
|
|
635
635
|
const currentTitle = pr.title || '';
|
|
636
|
-
if (
|
|
636
|
+
if (shared.isPlaceholderPrTitle(currentTitle) || pr.agent === 'human' || pr.description === undefined) {
|
|
637
637
|
const prData = await ghApi(`/pulls/${prNum}`, slug);
|
|
638
638
|
if (prData) {
|
|
639
639
|
const latestTitle = pr.title || '';
|
|
640
|
-
if (
|
|
640
|
+
if (shared.isPlaceholderPrTitle(latestTitle)) {
|
|
641
641
|
pr.title = (prData.title || latestTitle).slice(0, 120);
|
|
642
642
|
}
|
|
643
643
|
if (pr.description === undefined) pr.description = (prData.body || '').slice(0, 500);
|
|
@@ -752,10 +752,7 @@ async function pollPrStatus(config) {
|
|
|
752
752
|
// stay stuck on "PR #N (polling...)" forever. `prData` is already in
|
|
753
753
|
// hand from line 622 — no extra API call needed.
|
|
754
754
|
const currentTitleForBackfill = pr.title || '';
|
|
755
|
-
if (
|
|
756
|
-
|| currentTitleForBackfill.includes('polling...')
|
|
757
|
-
|| /[{}"\[\]]/.test(currentTitleForBackfill)
|
|
758
|
-
|| /^[0-9a-f-]{8,}$/i.test(currentTitleForBackfill)) {
|
|
755
|
+
if (shared.isPlaceholderPrTitle(currentTitleForBackfill)) {
|
|
759
756
|
if (prData.title) {
|
|
760
757
|
const nextTitle = String(prData.title).slice(0, 120);
|
|
761
758
|
if (pr.title !== nextTitle) {
|
package/engine/lifecycle.js
CHANGED
|
@@ -32,9 +32,6 @@ function checkPlanCompletion(meta, config) {
|
|
|
32
32
|
// terminal artifacts; missing primary means "gone", not "needs recovery".
|
|
33
33
|
const plan = safeJsonNoRestore(planPath);
|
|
34
34
|
if (!plan?.missing_features) return;
|
|
35
|
-
if (plan.status === PLAN_STATUS.COMPLETED) {
|
|
36
|
-
if (plan._completionNotified) return;
|
|
37
|
-
}
|
|
38
35
|
|
|
39
36
|
const projects = shared.getProjects(config);
|
|
40
37
|
|
|
@@ -43,6 +40,21 @@ function checkPlanCompletion(meta, config) {
|
|
|
43
40
|
const planItems = allWorkItems.filter(w => w.sourcePlan === planFile && w.itemType !== 'pr' && w.itemType !== 'verify');
|
|
44
41
|
if (planItems.length === 0) return;
|
|
45
42
|
|
|
43
|
+
// W-mqk5ld3p: verify-creation must be a pure function of work-item state,
|
|
44
|
+
// independent of who/what set `status: completed`. We do NOT short-circuit on
|
|
45
|
+
// the raw `_completionNotified` boolean — a PRD can land on disk pre-completed
|
|
46
|
+
// with the flag already set (out-of-band write by the plan-to-prd / pipeline
|
|
47
|
+
// path), and the legacy top-of-function `if (_completionNotified) return` then
|
|
48
|
+
// permanently skipped the aggregate build/test verify gate. Instead the flag
|
|
49
|
+
// gates ONLY the one-shot completion summary (below); control always falls
|
|
50
|
+
// through to the verify-creation block, which re-checks for an existing verify
|
|
51
|
+
// WI under the file lock and is therefore safe to reach every scan. The one
|
|
52
|
+
// exception is the REOPEN sub-path (terminal verify → re-open): that is a
|
|
53
|
+
// plan-modification concern (the dashboard clears `_completionNotified` when it
|
|
54
|
+
// re-opens a plan), so it is gated on `!alreadyNotified` to avoid bouncing an
|
|
55
|
+
// already-done verify on every steady-state scan.
|
|
56
|
+
const alreadyNotified = plan.status === PLAN_STATUS.COMPLETED && !!plan._completionNotified;
|
|
57
|
+
|
|
46
58
|
// Hard completion gate: every PRD feature ID must have a corresponding work item in a terminal state.
|
|
47
59
|
const planFeatureIds = new Set((plan.missing_features || []).map(f => f.id).filter(Boolean));
|
|
48
60
|
const workItemById = {};
|
|
@@ -146,18 +158,24 @@ function checkPlanCompletion(meta, config) {
|
|
|
146
158
|
...uniquePrs.map(pr => `- ${pr.id}: ${pr.title || ''} ${pr.url || ''}`),
|
|
147
159
|
].filter(Boolean).join('\n');
|
|
148
160
|
|
|
149
|
-
// Write summary to notes/inbox
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
+
// Write summary to notes/inbox + flip _completionNotified — one-shot side
|
|
162
|
+
// effects, skipped for a pre-completed PRD that already carried the flag
|
|
163
|
+
// (alreadyNotified). The verify-creation block below still runs and is
|
|
164
|
+
// idempotent, so the plan still gets its verify gate without a duplicate
|
|
165
|
+
// summary or a redundant flag write.
|
|
166
|
+
if (!alreadyNotified) {
|
|
167
|
+
const summarySlug = `prd-completion-${planFile.replace('.json', '')}`;
|
|
168
|
+
shared.writeToInbox('engine', summarySlug, summary);
|
|
169
|
+
log('info', `PRD completion summary written to notes/inbox/${summarySlug}`);
|
|
170
|
+
|
|
171
|
+
// Persist completed status + _completionNotified via file lock
|
|
172
|
+
mutateJsonFileLocked(planPath, (data) => {
|
|
173
|
+
data.status = PLAN_STATUS.COMPLETED;
|
|
174
|
+
data.completedAt = plan.completedAt;
|
|
175
|
+
data._completionNotified = true;
|
|
176
|
+
return data;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
161
179
|
|
|
162
180
|
// Resolve the primary project for writing new work items (PR, verify).
|
|
163
181
|
// Multi-project plans (no plan.project) derive primary from the done items —
|
|
@@ -255,6 +273,14 @@ function checkPlanCompletion(meta, config) {
|
|
|
255
273
|
}
|
|
256
274
|
|
|
257
275
|
if (isReopenableVerify(existingVerify)) {
|
|
276
|
+
// Re-opening a terminal verify is a plan-MODIFICATION action, not part of
|
|
277
|
+
// steady-state completion. The dashboard clears `_completionNotified` when
|
|
278
|
+
// it re-opens a plan, so only re-open here on a fresh completion; otherwise
|
|
279
|
+
// an already-done verify would bounce on every periodic scan (W-mqk5ld3p).
|
|
280
|
+
if (alreadyNotified) {
|
|
281
|
+
log('info', `Plan ${planFile}: verify WI ${existingVerify.id} for ${projName} already ${existingVerify.status} and plan already notified — leaving as-is`);
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
258
284
|
const verifyProject = existingVerify.project || projName;
|
|
259
285
|
const vProject = shared.resolveProjectSource(verifyProject, projects, { allowCentral: false }).project || p;
|
|
260
286
|
const vWiPath = shared.projectWorkItemsPath(vProject);
|
package/engine/pipeline.js
CHANGED
|
@@ -8,7 +8,7 @@ const fs = require('fs');
|
|
|
8
8
|
const path = require('path');
|
|
9
9
|
const shared = require('./shared');
|
|
10
10
|
const queries = require('./queries');
|
|
11
|
-
const { safeJson, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
11
|
+
const { safeJson, safeJsonObj, safeJsonArr, safeJsonNoRestore, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, mutatePipelineRuns, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, READ_ONLY_ROOT_TASK_TYPES, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
12
12
|
const routing = require('./routing');
|
|
13
13
|
const http = require('http');
|
|
14
14
|
const { shouldRunNow } = require('./scheduler');
|
|
@@ -70,7 +70,7 @@ function deletePipeline(id) {
|
|
|
70
70
|
// ── Run State ────────────────────────────────────────────────────────────────
|
|
71
71
|
|
|
72
72
|
function getPipelineRuns() {
|
|
73
|
-
return
|
|
73
|
+
return safeJsonObj(PIPELINE_RUNS_PATH);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
function getActiveRun(pipelineId) {
|
|
@@ -335,7 +335,7 @@ function evaluateCondition(condition, ctx) {
|
|
|
335
335
|
// True when all work items created by the pipeline are done (not failed)
|
|
336
336
|
if (!run) return false;
|
|
337
337
|
const wiPath = CENTRAL_WI_PATH;
|
|
338
|
-
const workItems =
|
|
338
|
+
const workItems = safeJsonArr(wiPath);
|
|
339
339
|
const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
|
|
340
340
|
return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
|
|
341
341
|
}, []);
|
|
@@ -876,7 +876,7 @@ function isStageComplete(stage, stageState, run, config) {
|
|
|
876
876
|
case STAGE_TYPE.TASK: {
|
|
877
877
|
// Check root + all project work-items.json (WIs may be moved to project paths)
|
|
878
878
|
const wiPath = CENTRAL_WI_PATH;
|
|
879
|
-
const workItems =
|
|
879
|
+
const workItems = safeJsonArr(wiPath);
|
|
880
880
|
const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
|
|
881
881
|
return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
|
|
882
882
|
}, []);
|
|
@@ -900,7 +900,7 @@ function isStageComplete(stage, stageState, run, config) {
|
|
|
900
900
|
case STAGE_TYPE.PLAN: {
|
|
901
901
|
// Plan stage completion: PRD conversion done + all materialized work items done
|
|
902
902
|
const wiPath = CENTRAL_WI_PATH;
|
|
903
|
-
const workItems =
|
|
903
|
+
const workItems = safeJsonArr(wiPath);
|
|
904
904
|
const allProjectWi = shared.getProjects(config).reduce((acc, p) => {
|
|
905
905
|
return acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []);
|
|
906
906
|
}, []);
|
|
@@ -1064,7 +1064,7 @@ async function discoverPipelineWork(config) {
|
|
|
1064
1064
|
let output = '';
|
|
1065
1065
|
if (stage.type === STAGE_TYPE.TASK) {
|
|
1066
1066
|
const wiPath = CENTRAL_WI_PATH;
|
|
1067
|
-
const workItems =
|
|
1067
|
+
const workItems = safeJsonArr(wiPath);
|
|
1068
1068
|
const projWi = shared.getProjects(config).reduce((acc, p) => acc.concat(safeJson(shared.projectWorkItemsPath(p)) || []), []);
|
|
1069
1069
|
const allWi = [...workItems, ...projWi];
|
|
1070
1070
|
output = (stageState.artifacts?.workItems || []).map(id => {
|
package/engine/projects.js
CHANGED
|
@@ -88,7 +88,7 @@ function _centralDispatchDefaultedToProject(d, removedProject, projects) {
|
|
|
88
88
|
function _collectProjectlessCentralDispatchItemIds(removedProject, projects) {
|
|
89
89
|
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
90
90
|
const ids = new Set();
|
|
91
|
-
const state = shared.
|
|
91
|
+
const state = shared.safeJsonObj(dispatchPath);
|
|
92
92
|
for (const queue of ['pending', 'active']) {
|
|
93
93
|
for (const d of Array.isArray(state?.[queue]) ? state[queue] : []) {
|
|
94
94
|
if (_centralDispatchDefaultedToProject(d, removedProject, projects)) ids.add(d.meta.item.id);
|
package/engine/routing.js
CHANGED
|
@@ -8,7 +8,7 @@ const path = require('path');
|
|
|
8
8
|
const shared = require('./shared');
|
|
9
9
|
const queries = require('./queries');
|
|
10
10
|
|
|
11
|
-
const { safeJson, safeRead, log, ts, WORK_TYPE } = shared;
|
|
11
|
+
const { safeJson, safeJsonObj, safeRead, log, ts, WORK_TYPE } = shared;
|
|
12
12
|
const { ENGINE_DIR, DISPATCH_PATH } = queries;
|
|
13
13
|
|
|
14
14
|
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
@@ -84,7 +84,7 @@ function getMonthlySpend(agentId) {
|
|
|
84
84
|
|
|
85
85
|
function getAgentErrorRate(agentId) {
|
|
86
86
|
const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
|
|
87
|
-
const metrics =
|
|
87
|
+
const metrics = safeJsonObj(metricsPath);
|
|
88
88
|
const m = metrics[agentId];
|
|
89
89
|
if (!m) return 0;
|
|
90
90
|
const total = m.tasksCompleted + m.tasksErrored;
|
|
@@ -93,7 +93,7 @@ function getAgentErrorRate(agentId) {
|
|
|
93
93
|
|
|
94
94
|
function isAgentIdle(agentId) {
|
|
95
95
|
// Dispatch queue is the single source of truth for agent availability
|
|
96
|
-
const dispatch =
|
|
96
|
+
const dispatch = safeJsonObj(DISPATCH_PATH);
|
|
97
97
|
return !(dispatch.active || []).some(d => d.agent === agentId);
|
|
98
98
|
}
|
|
99
99
|
|
package/engine/shared.js
CHANGED
|
@@ -6553,6 +6553,32 @@ function findPrRecord(prs, prRef, project = null) {
|
|
|
6553
6553
|
return numberMatches.length === 1 ? numberMatches[0] : null;
|
|
6554
6554
|
}
|
|
6555
6555
|
|
|
6556
|
+
// Issue #289: single source of truth for "is this stored PR title a
|
|
6557
|
+
// placeholder/fallback that should be backfilled from the live platform
|
|
6558
|
+
// title on the next poll?". Recognizes:
|
|
6559
|
+
// - empty / missing titles
|
|
6560
|
+
// - the link-time "...(polling...)" placeholder
|
|
6561
|
+
// - serialized-JSON / agent-output leakage (contains {}"[] chars)
|
|
6562
|
+
// - bare hex/uuid-ish ids (>= 8 hex/dash chars)
|
|
6563
|
+
// - the fallback WRITER shapes "PR created by <agent>" (engine/lifecycle.js)
|
|
6564
|
+
// and bare "PR #<n>" (engine/ado.js central poller)
|
|
6565
|
+
// The WRITER (engine/lifecycle.js) and the DETECTORS (engine/github.js,
|
|
6566
|
+
// engine/ado.js) MUST agree — route every site through this helper so they
|
|
6567
|
+
// cannot drift apart again (the github poller previously never backfilled a
|
|
6568
|
+
// frozen "PR created by Ripley" title because its detector didn't know the shape).
|
|
6569
|
+
function isPlaceholderPrTitle(title) {
|
|
6570
|
+
const t = typeof title === 'string'
|
|
6571
|
+
? title.trim()
|
|
6572
|
+
: (title == null ? '' : String(title).trim());
|
|
6573
|
+
if (!t) return true;
|
|
6574
|
+
if (t.includes('polling...')) return true;
|
|
6575
|
+
if (/[{}"\[\]]/.test(t)) return true;
|
|
6576
|
+
if (/^[0-9a-f-]{8,}$/i.test(t)) return true;
|
|
6577
|
+
if (/^PR created by /i.test(t)) return true;
|
|
6578
|
+
if (/^PR #\d+$/i.test(t)) return true;
|
|
6579
|
+
return false;
|
|
6580
|
+
}
|
|
6581
|
+
|
|
6556
6582
|
function snapshotPrRecord(pr) {
|
|
6557
6583
|
if (pr === undefined) return undefined;
|
|
6558
6584
|
return JSON.parse(JSON.stringify(pr));
|
|
@@ -7197,6 +7223,23 @@ function killImmediate(proc) {
|
|
|
7197
7223
|
}
|
|
7198
7224
|
}
|
|
7199
7225
|
|
|
7226
|
+
// Decide whether a Windows `tasklist /FI "PID eq <pid>" /NH` output proves the
|
|
7227
|
+
// pid is alive. A bare `out.includes(String(pid))` can read a DEAD pid as alive
|
|
7228
|
+
// on a digit-substring collision — the pid appears inside another column (a
|
|
7229
|
+
// larger PID, a memory-KB figure, a session id). Require a word-boundary match
|
|
7230
|
+
// (`\b<pid>\b`) and, when an `imageName` is supplied, that the expected process
|
|
7231
|
+
// image is present too. Mirrors the hardened check in engine/restart-health.js.
|
|
7232
|
+
// Pure (no shell-out) so callers can pass captured output and unit tests can
|
|
7233
|
+
// feed crafted samples.
|
|
7234
|
+
function tasklistOutputShowsPid(out, pid, { imageName } = {}) {
|
|
7235
|
+
if (!out) return false;
|
|
7236
|
+
const n = Number(pid);
|
|
7237
|
+
if (!Number.isInteger(n) || n <= 0) return false;
|
|
7238
|
+
if (!new RegExp(`\\b${n}\\b`).test(out)) return false;
|
|
7239
|
+
if (imageName && !out.toLowerCase().includes(String(imageName).toLowerCase())) return false;
|
|
7240
|
+
return true;
|
|
7241
|
+
}
|
|
7242
|
+
|
|
7200
7243
|
// W-mq0e2dae000a003d — cross-platform CPU-seconds sampler used by the
|
|
7201
7244
|
// spawn-phase watchdog to decide whether a process is genuinely wedged
|
|
7202
7245
|
// vs busy. Returns the cumulative user+system CPU time in seconds, or
|
|
@@ -8409,6 +8452,32 @@ function hasWorktreeOwnerMarker(worktreePath) {
|
|
|
8409
8452
|
}
|
|
8410
8453
|
}
|
|
8411
8454
|
|
|
8455
|
+
// True when a `git status --porcelain` line refers ONLY to the engine's own
|
|
8456
|
+
// worktree-ownership marker (.minions-worktree) at the worktree root (#284).
|
|
8457
|
+
// The marker is gitignored in the repo's current tree, but a reused worktree
|
|
8458
|
+
// that checks out a branch whose tree predates that .gitignore line (or a
|
|
8459
|
+
// foreign repo) surfaces the marker as an untracked `?? .minions-worktree`
|
|
8460
|
+
// entry. Counting that engine-stamped artifact as "dirty" would fail (and
|
|
8461
|
+
// quarantine) an otherwise-safe-to-reuse worktree. Callers filter the marker
|
|
8462
|
+
// out of the dirty-file list before deciding a worktree is dirty.
|
|
8463
|
+
function isWorktreeOwnerMarkerStatusLine(porcelainLine) {
|
|
8464
|
+
if (!porcelainLine) return false;
|
|
8465
|
+
const line = String(porcelainLine).trim();
|
|
8466
|
+
if (!line) return false;
|
|
8467
|
+
// Porcelain v1: "XY <path>" — for an untracked file XY is "??". Strip the
|
|
8468
|
+
// status code (1-2 chars from the porcelain status alphabet) and the
|
|
8469
|
+
// following whitespace; fall back to the raw line if it doesn't match.
|
|
8470
|
+
const m = line.match(/^[ MTADRCU?!]{1,2}\s+(.*)$/);
|
|
8471
|
+
let p = m ? m[1] : line;
|
|
8472
|
+
// git quotes paths containing special chars; the marker name never needs it
|
|
8473
|
+
// but strip defensively. Then normalize separators and a leading "./".
|
|
8474
|
+
if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
|
|
8475
|
+
p = p.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
8476
|
+
// The marker is only ever stamped at the worktree root — a same-named file
|
|
8477
|
+
// nested in a subdir is NOT the ownership marker and stays "dirty".
|
|
8478
|
+
return p === WORKTREE_OWNER_MARKER;
|
|
8479
|
+
}
|
|
8480
|
+
|
|
8412
8481
|
function slugify(text, maxLen = 50) {
|
|
8413
8482
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, maxLen);
|
|
8414
8483
|
}
|
|
@@ -8791,6 +8860,7 @@ module.exports = {
|
|
|
8791
8860
|
isAdoPrScopeCompatible,
|
|
8792
8861
|
getCanonicalPrId,
|
|
8793
8862
|
findPrRecord,
|
|
8863
|
+
isPlaceholderPrTitle,
|
|
8794
8864
|
snapshotPrRecord,
|
|
8795
8865
|
applyPrFieldDelta,
|
|
8796
8866
|
normalizePrRecord,
|
|
@@ -8860,6 +8930,7 @@ module.exports = {
|
|
|
8860
8930
|
sleepMs,
|
|
8861
8931
|
killGracefully,
|
|
8862
8932
|
killImmediate,
|
|
8933
|
+
tasklistOutputShowsPid,
|
|
8863
8934
|
getProcessCpuSeconds,
|
|
8864
8935
|
killByPidImmediate,
|
|
8865
8936
|
killByPidsImmediate,
|
|
@@ -8875,6 +8946,7 @@ module.exports = {
|
|
|
8875
8946
|
WORKTREE_OWNER_MARKER,
|
|
8876
8947
|
writeWorktreeOwnerMarker,
|
|
8877
8948
|
hasWorktreeOwnerMarker,
|
|
8949
|
+
isWorktreeOwnerMarkerStatusLine,
|
|
8878
8950
|
_normalizeWorktreePath, // exported for testing
|
|
8879
8951
|
_writeWorktreeSkipLiveInboxNote, // exported for testing
|
|
8880
8952
|
_retryFsOp, // exported for testing (W-mq5o6bvy000x7191)
|
package/engine/supervisor.js
CHANGED
|
@@ -103,7 +103,14 @@ function isPidAlive(pid) {
|
|
|
103
103
|
const out = execSync(`tasklist /FI "PID eq ${pid}" /NH`, {
|
|
104
104
|
encoding: 'utf8', timeout: 3000, windowsHide: true,
|
|
105
105
|
});
|
|
106
|
-
|
|
106
|
+
// Word-boundary + image-name match (mirrors engine/restart-health.js) so a
|
|
107
|
+
// digit-substring collision in another column can't read a dead pid as
|
|
108
|
+
// alive. Fall back to an inline regex if shared.js isn't loadable.
|
|
109
|
+
const shared = _sharedOrNull();
|
|
110
|
+
if (shared && typeof shared.tasklistOutputShowsPid === 'function') {
|
|
111
|
+
return shared.tasklistOutputShowsPid(out, pid, { imageName: 'node' });
|
|
112
|
+
}
|
|
113
|
+
return new RegExp(`\\b${pid}\\b`).test(out) && out.toLowerCase().includes('node');
|
|
107
114
|
}
|
|
108
115
|
process.kill(pid, 0);
|
|
109
116
|
return true;
|
|
@@ -180,12 +187,30 @@ function _tokenizeCmdline(cmdline) {
|
|
|
180
187
|
// byte-identical `path.join(MINIONS_DIR, '<script>.js')`, so exact script-token
|
|
181
188
|
// match catches all of them while a foreign process that only *names* the path
|
|
182
189
|
// is left alone (fail-safe: never kill on a loose match).
|
|
190
|
+
//
|
|
191
|
+
// ENGINE subcommand gate: for an `engine.js` target we additionally require the
|
|
192
|
+
// token AFTER the script to be `start`, so a short-lived `node engine.js stop`
|
|
193
|
+
// one-shot (spawned by `minions restart`/`down`/`update` and the `stop` verb
|
|
194
|
+
// during the restart window) is NEVER reaped. The long-lived daemon is ALWAYS
|
|
195
|
+
// spawned with `'start'` (supervisor/dashboard spawnEngine + bin/minions.js), so
|
|
196
|
+
// the requirement loses zero real orphans. The `dashboard.js` match stays BARE
|
|
197
|
+
// (no subcommand) — only the engine candidate gets the `start` requirement.
|
|
183
198
|
function _cmdRunsScript(cmdline, target) {
|
|
184
199
|
const toks = _tokenizeCmdline(cmdline);
|
|
200
|
+
const isEngineTarget = target === 'engine.js' || target.endsWith('/engine.js');
|
|
185
201
|
// toks[0] is the node executable; the script is the first later .js token.
|
|
186
202
|
for (let i = 1; i < toks.length; i++) {
|
|
187
203
|
const norm = _normPath(toks[i]);
|
|
188
|
-
if (norm.endsWith('.js'))
|
|
204
|
+
if (norm.endsWith('.js')) {
|
|
205
|
+
if (norm !== target) return false;
|
|
206
|
+
if (isEngineTarget) {
|
|
207
|
+
// Only the daemon (`engine.js start`) is reapable; one-shots like
|
|
208
|
+
// `engine.js stop` are left alone.
|
|
209
|
+
const next = toks[i + 1] ? _normPath(toks[i + 1]) : '';
|
|
210
|
+
return next === 'start';
|
|
211
|
+
}
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
189
214
|
}
|
|
190
215
|
return false;
|
|
191
216
|
}
|
package/engine.js
CHANGED
|
@@ -1454,7 +1454,13 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
|
|
|
1454
1454
|
return result;
|
|
1455
1455
|
}
|
|
1456
1456
|
if (statusOut) {
|
|
1457
|
-
result.dirtyFiles = statusOut.split('\n').map(l => l.trim()).filter(Boolean)
|
|
1457
|
+
result.dirtyFiles = statusOut.split('\n').map(l => l.trim()).filter(Boolean)
|
|
1458
|
+
// #284: the engine's own .minions-worktree ownership marker can surface
|
|
1459
|
+
// as an untracked entry when the checked-out tree predates the .gitignore
|
|
1460
|
+
// line that excludes it (or it's a foreign repo). An untracked marker
|
|
1461
|
+
// alone is a CLEAN tree for reuse — never let the engine fail/quarantine
|
|
1462
|
+
// a work item over an artifact it stamped itself.
|
|
1463
|
+
.filter(l => !shared.isWorktreeOwnerMarkerStatusLine(l));
|
|
1458
1464
|
}
|
|
1459
1465
|
const filesystemDirty = result.dirtyFiles.length > 0;
|
|
1460
1466
|
|
|
@@ -1597,9 +1603,15 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
|
|
|
1597
1603
|
try {
|
|
1598
1604
|
const r2 = await execAsync(_statusPorcelainCmd(), { ...gitOpts, cwd: worktreePath, timeout: statusTimeoutMs });
|
|
1599
1605
|
const after = (r2 || '').toString().trim();
|
|
1600
|
-
|
|
1606
|
+
// #284: ignore the ownership marker here too — `git clean -fd` removes it,
|
|
1607
|
+
// but the engine may re-stamp it, and it must never count as residual dirt.
|
|
1608
|
+
const afterFiles = after
|
|
1609
|
+
? after.split('\n').map(l => l.trim()).filter(Boolean)
|
|
1610
|
+
.filter(l => !shared.isWorktreeOwnerMarkerStatusLine(l))
|
|
1611
|
+
: [];
|
|
1612
|
+
if (afterFiles.length) {
|
|
1601
1613
|
result.reason = 'dirty-after-clean';
|
|
1602
|
-
result.dirtyFiles =
|
|
1614
|
+
result.dirtyFiles = afterFiles;
|
|
1603
1615
|
return result;
|
|
1604
1616
|
}
|
|
1605
1617
|
} catch (e) {
|
|
@@ -2831,6 +2843,18 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2831
2843
|
const failureClassName = isQuarantineEnvBlocked
|
|
2832
2844
|
? 'WORKTREE_QUARANTINE_ENV_BLOCKED'
|
|
2833
2845
|
: (isDivergent ? 'WORKTREE_DIVERGENT' : 'WORKTREE_DIRTY');
|
|
2846
|
+
// #284: distinguish the ORIGINAL worktree-preflight failure from a
|
|
2847
|
+
// failure on the automatic fresh-worktree retry. The quarantine
|
|
2848
|
+
// auto-recovery loop (discoverFromWorkItems) flips a failed
|
|
2849
|
+
// WORKTREE_DIRTY/DIVERGENT item back to pending and stamps
|
|
2850
|
+
// _quarantineRecoveryCount on the WI; if this dispatch (a re-dispatch
|
|
2851
|
+
// of that item) hits the preflight gate AGAIN, the fresh worktree did
|
|
2852
|
+
// not resolve the dirt — surface that in the completion report so a
|
|
2853
|
+
// human/the engine can tell the two apart.
|
|
2854
|
+
const priorQuarantineRetry = Number(meta?.item?._quarantineRecoveryCount) || 0;
|
|
2855
|
+
const retryLabel = priorQuarantineRetry > 0
|
|
2856
|
+
? ` [retry-in-fresh-worktree #${priorQuarantineRetry} also failed]`
|
|
2857
|
+
: ' [original worktree-preflight issue]';
|
|
2834
2858
|
const reasonMsg = cleanResult.quarantined
|
|
2835
2859
|
? `${failureClassName}: reused worktree at ${worktreePath} was dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} dirty file(s)${previewFiles ? ': ' + previewFiles : ''}) — quarantined to ${cleanResult.quarantinedPath}. Next dispatch will start fresh.`
|
|
2836
2860
|
: `${failureClassName}: reused worktree at ${worktreePath} is dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} file(s)${previewFiles ? ': ' + previewFiles : ''}). Quarantine ${cleanResult.quarantineError ? 'errored: ' + cleanResult.quarantineError : (cleanResult.quarantineSkipped ? 'was skipped — another live dispatch claims the worktree (see notes/inbox/ engine-worktree-skip-live note).' : 'was not attempted (' + cleanResult.reason + ').')}`;
|
|
@@ -2840,7 +2864,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2840
2864
|
id,
|
|
2841
2865
|
DISPATCH_RESULT.ERROR,
|
|
2842
2866
|
reasonMsg.slice(0, 500),
|
|
2843
|
-
`Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996)
|
|
2867
|
+
`Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996).${retryLabel} Reason: ${cleanResult.reason}.${cleanResult.quarantined ? ` Worktree quarantined to ${cleanResult.quarantinedPath}; backup ref ${cleanResult.backupRef || '(skipped)'}. See notes/inbox/ for recovery instructions.` : (cleanResult.quarantineSkipped ? ' Quarantine was skipped because another live dispatch claims this worktree path; this dispatch will not auto-retry until the live claimant clears.' : '')}${isQuarantineEnvBlocked ? ' Environmental quarantine failure (Windows EBUSY); WI auto-recovery loop will re-queue without bumping per-agent retry counter.' : ''}`,
|
|
2844
2868
|
{ agentRetryable: isStatusProbeFailed && cleanResult.quarantined, failureClass: failureClassValue },
|
|
2845
2869
|
);
|
|
2846
2870
|
cleanupTempAgent(agentId);
|
|
@@ -3347,8 +3371,12 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3347
3371
|
try {
|
|
3348
3372
|
const dirtyResult = await execAsync(_statusPorcelainCmd(), { ..._gitOpts, cwd: worktreePath, timeout: 10000 });
|
|
3349
3373
|
const dirtyOutput = (dirtyResult.stdout || '').trim();
|
|
3350
|
-
|
|
3351
|
-
|
|
3374
|
+
const dirtyFiles = dirtyOutput
|
|
3375
|
+
? dirtyOutput.split('\n').map(l => l.trim()).filter(Boolean)
|
|
3376
|
+
// #284: don't surface the engine's own ownership marker as "prior work".
|
|
3377
|
+
.filter(l => !shared.isWorktreeOwnerMarkerStatusLine(l))
|
|
3378
|
+
: [];
|
|
3379
|
+
if (dirtyFiles.length) {
|
|
3352
3380
|
const dirtySection = [
|
|
3353
3381
|
'\n## Uncommitted Work in Worktree\n',
|
|
3354
3382
|
'The worktree has uncommitted changes from a previous agent run. Review these files and continue from where the previous agent left off.\n',
|
|
@@ -8704,8 +8732,16 @@ async function discoverWork(config) {
|
|
|
8704
8732
|
// readdir and read (e.g. concurrent archive), do not resurrect it
|
|
8705
8733
|
// from a stale .backup sidecar (W-mouptdh1000h9f39).
|
|
8706
8734
|
const plan = safeJsonNoRestore(path.join(prdDir, f));
|
|
8707
|
-
if (!plan?.missing_features
|
|
8708
|
-
|
|
8735
|
+
if (!plan?.missing_features) continue;
|
|
8736
|
+
// A completed PRD may still be missing its aggregate verify WI — e.g. it
|
|
8737
|
+
// landed on disk pre-completed via the plan-to-prd / pipeline path, so no
|
|
8738
|
+
// agent-completion event ever drove checkPlanCompletion (W-mqk5ld3p). The
|
|
8739
|
+
// function is idempotent (re-checks for an existing verify WI under lock),
|
|
8740
|
+
// so run it for completed plans too and cache only once it reports nothing
|
|
8741
|
+
// left to do (truthy return).
|
|
8742
|
+
if (plan.status === PLAN_STATUS.COMPLETED) {
|
|
8743
|
+
const done = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
|
|
8744
|
+
if (done) completedPlanCache.add(f);
|
|
8709
8745
|
continue;
|
|
8710
8746
|
}
|
|
8711
8747
|
if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
|
|
@@ -9258,11 +9294,13 @@ async function tickInner() {
|
|
|
9258
9294
|
continue;
|
|
9259
9295
|
}
|
|
9260
9296
|
const plan = safeJson(path.join(PRD_DIR, file));
|
|
9261
|
-
if (plan && plan.missing_features
|
|
9297
|
+
if (plan && plan.missing_features) {
|
|
9298
|
+
// Run for completed PRDs too — a pre-completed plan (flag already set,
|
|
9299
|
+
// items pre-marked done) still needs its aggregate verify gate created.
|
|
9300
|
+
// checkPlanCompletion is idempotent; cache only once it reports nothing
|
|
9301
|
+
// left to do (truthy return) (W-mqk5ld3p).
|
|
9262
9302
|
const completed = checkPlanCompletion({ item: { sourcePlan: file } }, config);
|
|
9263
9303
|
if (completed) completedPlanCache.add(file);
|
|
9264
|
-
} else if (plan?.status === PLAN_STATUS.COMPLETED) {
|
|
9265
|
-
completedPlanCache.add(file);
|
|
9266
9304
|
}
|
|
9267
9305
|
}
|
|
9268
9306
|
} catch (err) { log('warn', `Plan completion check error: ${err?.message || err}`); }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2229",
|
|
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"
|
|
@@ -153,7 +153,11 @@ Copyable worked example:
|
|
|
153
153
|
|
|
154
154
|
Entries you report are **cross-checked** against what the engine propagated into your worktree and annotated `grounded: true | false` — never dropped — so a `grounded: false` entry surfaces a discrepancy for a human rather than being hidden. Full contract: `docs/harness-transparency.md`.
|
|
155
155
|
|
|
156
|
-
When you author a **PR review / fix summary comment
|
|
156
|
+
When you author a **PR review / fix summary comment**, the same harness footprint belongs in the comment body as a collapsible "Harnesses used" section so reviewers see which skills / MCPs / docs informed the change.
|
|
157
|
+
|
|
158
|
+
**Preferred — `minions pr comment` (GitHub and Azure DevOps `--host ado`): do NOT hand-render the section.** Write your `harnessUsed` record — the same `{ skills, mcpServers, commands, docs }` shape as the completion field above — to a JSON file and pass `--harness-file <path>` (or inline via `--harness-json <json>`). The shared builder (`engine/comment-format.js#buildHarnessUsedSection`, consumed by both `engine/gh-comment.js` and `engine/ado-comment.js`) folds the byte-identical `<details>` section into the body for you, so agent-authored and engine-authored comments match. Each entry is cross-checked against the harness manifest the engine propagated into your worktree and annotated `grounded: true | false` (never dropped); a `grounded: false`, ⚠️-marked bullet means **the engine couldn't confirm that affordance against the propagated manifest — not that you did anything wrong**.
|
|
159
|
+
|
|
160
|
+
**Raw fallbacks only — `gh pr comment` / `az repos pr comment` / ADO REST:** these bypass the CLI chokepoint, so you MUST hand-render the section yourself. Render it **identically on both hosts** — append this exact `<details>` block at the end of your comment body (omit it entirely when you used no affordances):
|
|
157
161
|
|
|
158
162
|
```markdown
|
|
159
163
|
<details>
|
|
@@ -167,7 +171,7 @@ When you author a **PR review / fix summary comment** (GitHub `minions pr commen
|
|
|
167
171
|
</details>
|
|
168
172
|
```
|
|
169
173
|
|
|
170
|
-
One bullet per affordance, in the order skills → MCP servers → commands → docs; `N` is the bullet count.
|
|
174
|
+
One bullet per affordance, in the order skills → MCP servers → commands → docs; `N` is the bullet count. Whether the CLI folds it in or you hand-render it on a raw fallback, the section is the human-readable mirror of your `harnessUsed` completion field — keep the two consistent.
|
|
171
175
|
|
|
172
176
|
## Minions API access
|
|
173
177
|
|