@yemi33/minions 0.1.2227 → 0.1.2228
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/js/render-work-items.js +1 -1
- package/dashboard.js +21 -1
- package/docs/worktree-lifecycle.md +28 -0
- package/engine/cli.js +4 -4
- package/engine/comment-format.js +1 -1
- package/engine/gh-token.js +2 -2
- 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 +45 -0
- package/engine/supervisor.js +27 -2
- package/engine.js +49 -11
- package/package.json +1 -1
|
@@ -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;
|
|
@@ -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;
|
|
@@ -782,7 +782,7 @@ const commands = {
|
|
|
782
782
|
const projName = item.meta.project?.name;
|
|
783
783
|
if (projName) {
|
|
784
784
|
const prPath = path.join(MINIONS_DIR, 'projects', projName, 'pull-requests.json');
|
|
785
|
-
const prs =
|
|
785
|
+
const prs = safeJsonArr(prPath);
|
|
786
786
|
const matchingPr = prs.find(pr =>
|
|
787
787
|
(pr.prdItems || []).includes(item.meta.item.id) &&
|
|
788
788
|
pr.status !== 'abandoned' && pr.status !== 'closed'
|
|
@@ -1760,13 +1760,13 @@ const commands = {
|
|
|
1760
1760
|
}
|
|
1761
1761
|
}
|
|
1762
1762
|
if (exists && name === 'pullRequests') {
|
|
1763
|
-
const prs =
|
|
1763
|
+
const prs = safeJsonArr(filePath);
|
|
1764
1764
|
const pending = prs.filter(p => p.status === PR_STATUS.ACTIVE && (p.reviewStatus === REVIEW_STATUS.PENDING || p.reviewStatus === REVIEW_STATUS.WAITING));
|
|
1765
1765
|
const needsFix = prs.filter(p => p.status === PR_STATUS.ACTIVE && p.reviewStatus === REVIEW_STATUS.CHANGES_REQUESTED);
|
|
1766
1766
|
console.log(` PRs: ${pending.length} pending review, ${needsFix.length} need fixes`);
|
|
1767
1767
|
}
|
|
1768
1768
|
if (exists && name === 'workItems') {
|
|
1769
|
-
const items =
|
|
1769
|
+
const items = safeJsonArr(filePath);
|
|
1770
1770
|
const queued = items.filter(i => i.status === WI_STATUS.QUEUED);
|
|
1771
1771
|
console.log(` Items: ${queued.length} queued`);
|
|
1772
1772
|
}
|
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/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
|
@@ -7197,6 +7197,23 @@ function killImmediate(proc) {
|
|
|
7197
7197
|
}
|
|
7198
7198
|
}
|
|
7199
7199
|
|
|
7200
|
+
// Decide whether a Windows `tasklist /FI "PID eq <pid>" /NH` output proves the
|
|
7201
|
+
// pid is alive. A bare `out.includes(String(pid))` can read a DEAD pid as alive
|
|
7202
|
+
// on a digit-substring collision — the pid appears inside another column (a
|
|
7203
|
+
// larger PID, a memory-KB figure, a session id). Require a word-boundary match
|
|
7204
|
+
// (`\b<pid>\b`) and, when an `imageName` is supplied, that the expected process
|
|
7205
|
+
// image is present too. Mirrors the hardened check in engine/restart-health.js.
|
|
7206
|
+
// Pure (no shell-out) so callers can pass captured output and unit tests can
|
|
7207
|
+
// feed crafted samples.
|
|
7208
|
+
function tasklistOutputShowsPid(out, pid, { imageName } = {}) {
|
|
7209
|
+
if (!out) return false;
|
|
7210
|
+
const n = Number(pid);
|
|
7211
|
+
if (!Number.isInteger(n) || n <= 0) return false;
|
|
7212
|
+
if (!new RegExp(`\\b${n}\\b`).test(out)) return false;
|
|
7213
|
+
if (imageName && !out.toLowerCase().includes(String(imageName).toLowerCase())) return false;
|
|
7214
|
+
return true;
|
|
7215
|
+
}
|
|
7216
|
+
|
|
7200
7217
|
// W-mq0e2dae000a003d — cross-platform CPU-seconds sampler used by the
|
|
7201
7218
|
// spawn-phase watchdog to decide whether a process is genuinely wedged
|
|
7202
7219
|
// vs busy. Returns the cumulative user+system CPU time in seconds, or
|
|
@@ -8409,6 +8426,32 @@ function hasWorktreeOwnerMarker(worktreePath) {
|
|
|
8409
8426
|
}
|
|
8410
8427
|
}
|
|
8411
8428
|
|
|
8429
|
+
// True when a `git status --porcelain` line refers ONLY to the engine's own
|
|
8430
|
+
// worktree-ownership marker (.minions-worktree) at the worktree root (#284).
|
|
8431
|
+
// The marker is gitignored in the repo's current tree, but a reused worktree
|
|
8432
|
+
// that checks out a branch whose tree predates that .gitignore line (or a
|
|
8433
|
+
// foreign repo) surfaces the marker as an untracked `?? .minions-worktree`
|
|
8434
|
+
// entry. Counting that engine-stamped artifact as "dirty" would fail (and
|
|
8435
|
+
// quarantine) an otherwise-safe-to-reuse worktree. Callers filter the marker
|
|
8436
|
+
// out of the dirty-file list before deciding a worktree is dirty.
|
|
8437
|
+
function isWorktreeOwnerMarkerStatusLine(porcelainLine) {
|
|
8438
|
+
if (!porcelainLine) return false;
|
|
8439
|
+
const line = String(porcelainLine).trim();
|
|
8440
|
+
if (!line) return false;
|
|
8441
|
+
// Porcelain v1: "XY <path>" — for an untracked file XY is "??". Strip the
|
|
8442
|
+
// status code (1-2 chars from the porcelain status alphabet) and the
|
|
8443
|
+
// following whitespace; fall back to the raw line if it doesn't match.
|
|
8444
|
+
const m = line.match(/^[ MTADRCU?!]{1,2}\s+(.*)$/);
|
|
8445
|
+
let p = m ? m[1] : line;
|
|
8446
|
+
// git quotes paths containing special chars; the marker name never needs it
|
|
8447
|
+
// but strip defensively. Then normalize separators and a leading "./".
|
|
8448
|
+
if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
|
|
8449
|
+
p = p.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
8450
|
+
// The marker is only ever stamped at the worktree root — a same-named file
|
|
8451
|
+
// nested in a subdir is NOT the ownership marker and stays "dirty".
|
|
8452
|
+
return p === WORKTREE_OWNER_MARKER;
|
|
8453
|
+
}
|
|
8454
|
+
|
|
8412
8455
|
function slugify(text, maxLen = 50) {
|
|
8413
8456
|
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, maxLen);
|
|
8414
8457
|
}
|
|
@@ -8860,6 +8903,7 @@ module.exports = {
|
|
|
8860
8903
|
sleepMs,
|
|
8861
8904
|
killGracefully,
|
|
8862
8905
|
killImmediate,
|
|
8906
|
+
tasklistOutputShowsPid,
|
|
8863
8907
|
getProcessCpuSeconds,
|
|
8864
8908
|
killByPidImmediate,
|
|
8865
8909
|
killByPidsImmediate,
|
|
@@ -8875,6 +8919,7 @@ module.exports = {
|
|
|
8875
8919
|
WORKTREE_OWNER_MARKER,
|
|
8876
8920
|
writeWorktreeOwnerMarker,
|
|
8877
8921
|
hasWorktreeOwnerMarker,
|
|
8922
|
+
isWorktreeOwnerMarkerStatusLine,
|
|
8878
8923
|
_normalizeWorktreePath, // exported for testing
|
|
8879
8924
|
_writeWorktreeSkipLiveInboxNote, // exported for testing
|
|
8880
8925
|
_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.2228",
|
|
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"
|