@yemi33/minions 0.1.2381 → 0.1.2383
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 +42 -2
- package/dashboard/js/refresh.js +8 -2
- package/dashboard/js/render-other.js +38 -0
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +7 -8
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +110 -27
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/completion-reports.md +20 -1
- package/docs/engine-restart.md +10 -0
- package/docs/runtime-adapters.md +54 -22
- package/docs/skills.md +2 -0
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +23 -0
- package/engine/acp-transport.js +63 -35
- package/engine/ado-comment.js +3 -1
- package/engine/agent-worker-pool.js +11 -4
- package/engine/cc-worker-pool.js +4 -2
- package/engine/claude-md-context.js +195 -0
- package/engine/cli.js +9 -1
- package/engine/comment-format.js +51 -14
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +80 -23
- package/engine/execution-model.js +68 -0
- package/engine/gh-comment.js +6 -3
- package/engine/github.js +6 -7
- package/engine/lifecycle.js +195 -56
- package/engine/llm.js +59 -83
- package/engine/memory-store.js +3 -1
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +39 -6
- package/engine/pooled-agent-process.js +28 -26
- package/engine/prd-store.js +14 -6
- package/engine/quarantine-refs.js +33 -0
- package/engine/queries.js +8 -6
- package/engine/restart-health.js +69 -4
- package/engine/runtimes/claude.js +100 -25
- package/engine/runtimes/codex.js +19 -0
- package/engine/runtimes/contract.js +239 -0
- package/engine/runtimes/copilot.js +85 -8
- package/engine/runtimes/index.js +37 -9
- package/engine/shared.js +157 -64
- package/engine/spawn-agent.js +79 -113
- package/engine/spawn-phase-watchdog.js +8 -37
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +434 -125
- package/package.json +1 -1
- package/playbooks/fix.md +20 -0
- package/playbooks/review.md +2 -0
- package/skills/check-self-authored-review-comment/SKILL.md +34 -0
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* worker exists and the pool is already at its configured size.
|
|
19
19
|
* WorkerHandle: {
|
|
20
20
|
* dispatchId,
|
|
21
|
+
* currentModel, // ACP-selected session model
|
|
21
22
|
* stream(promptText, opts) → Promise, // ACP session/prompt turn
|
|
22
23
|
* cancel(), // ACP session/cancel
|
|
23
24
|
* release(), // return the worker to the
|
|
@@ -133,7 +134,8 @@ function _pump() {
|
|
|
133
134
|
const req = _queue[0];
|
|
134
135
|
|
|
135
136
|
const matchIdx = _free.findIndex((w) =>
|
|
136
|
-
w.
|
|
137
|
+
w.runtime === req.runtime
|
|
138
|
+
&& w.mcpServersHash === req.mcpServersHash
|
|
137
139
|
&& w.processContextHash === req.processContextHash
|
|
138
140
|
);
|
|
139
141
|
if (matchIdx >= 0) {
|
|
@@ -230,6 +232,7 @@ async function _spawnFreshWorker(req) {
|
|
|
230
232
|
// queued request can spawn its replacement.
|
|
231
233
|
async function _bootWorker(req) {
|
|
232
234
|
const worker = new Worker({
|
|
235
|
+
runtime: req.runtime,
|
|
233
236
|
id: `agent-pool-${_nextWorkerId++}`,
|
|
234
237
|
model: req.model,
|
|
235
238
|
effort: req.effort,
|
|
@@ -312,7 +315,7 @@ function _clearLeaseContext(dispatchId) {
|
|
|
312
315
|
}
|
|
313
316
|
|
|
314
317
|
async function acquireWorker({
|
|
315
|
-
dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
|
|
318
|
+
runtime, dispatchId, cwd, model, effort, mcpServers, hermeticDirs, processEnv, sessionContext,
|
|
316
319
|
} = {}) {
|
|
317
320
|
if (!dispatchId) throw new Error('agent-worker-pool.acquireWorker: dispatchId is required');
|
|
318
321
|
if (_draining) throw new Error('agent-worker-pool: draining');
|
|
@@ -324,6 +327,7 @@ async function acquireWorker({
|
|
|
324
327
|
}
|
|
325
328
|
|
|
326
329
|
const mcpServersHash = hashMcpServers(mcpServers);
|
|
330
|
+
const runtimeAdapter = transport.resolveWorkerRuntime(runtime);
|
|
327
331
|
const workerProcessEnv = processEnv && typeof processEnv === 'object'
|
|
328
332
|
? Object.freeze({ ...processEnv })
|
|
329
333
|
: undefined;
|
|
@@ -332,6 +336,7 @@ async function acquireWorker({
|
|
|
332
336
|
? { ...sessionContext }
|
|
333
337
|
: {};
|
|
334
338
|
const req = {
|
|
339
|
+
runtime: runtimeAdapter,
|
|
335
340
|
dispatchId, cwd, model, effort, mcpServers, mcpServersHash,
|
|
336
341
|
processEnv: workerProcessEnv, processContextHash,
|
|
337
342
|
};
|
|
@@ -365,12 +370,14 @@ async function acquireWorker({
|
|
|
365
370
|
dispatchId,
|
|
366
371
|
// P-1d8f0b93 — exposed so engine.js's PooledAgentProcess facade (the
|
|
367
372
|
// spawnAgent integration) can write the same PID-file/log identity a
|
|
368
|
-
// cold-spawned child_process would,
|
|
369
|
-
// synthesized `result` event
|
|
373
|
+
// cold-spawned child_process would, stamp the ACP sessionId into the
|
|
374
|
+
// synthesized `result` event, and expose the model selected by session/new.
|
|
375
|
+
// All are read-time
|
|
370
376
|
// snapshots of the underlying worker's current values (a getter, not a
|
|
371
377
|
// captured copy), since sessionId can rotate under a reused worker.
|
|
372
378
|
get pid() { return (worker.proc && worker.proc.pid) || null; },
|
|
373
379
|
get sessionId() { return worker.sessionId || null; },
|
|
380
|
+
get currentModel() { return worker.currentModel || null; },
|
|
374
381
|
stream: async (promptText, opts = {}) => {
|
|
375
382
|
try {
|
|
376
383
|
return await worker.stream(promptText, {
|
package/engine/cc-worker-pool.js
CHANGED
|
@@ -151,8 +151,9 @@ function _trace(...parts) {
|
|
|
151
151
|
|
|
152
152
|
// ── Public API ────────────────────────────────────────────────────────────
|
|
153
153
|
|
|
154
|
-
async function getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
|
|
154
|
+
async function getSession({ runtime, tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
|
|
155
155
|
if (!tabId) throw new Error('cc-worker-pool.getSession: tabId is required');
|
|
156
|
+
const runtimeAdapter = transport.resolveWorkerRuntime(runtime);
|
|
156
157
|
const mcpServersHash = _hashMcpServers(mcpServers);
|
|
157
158
|
let worker = _tabs.get(tabId);
|
|
158
159
|
// Track which lifecycle path we took so the dashboard's [cc-timing] log can
|
|
@@ -195,7 +196,7 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
|
|
|
195
196
|
if (worker.killed) {
|
|
196
197
|
_tabs.delete(tabId);
|
|
197
198
|
worker = null;
|
|
198
|
-
} else if (worker.mcpServersHash !== mcpServersHash) {
|
|
199
|
+
} else if (worker.runtime !== runtimeAdapter || worker.mcpServersHash !== mcpServersHash) {
|
|
199
200
|
// mcpServers shape changed → must respawn the proc; the daemon
|
|
200
201
|
// resolves MCP server config at process boot, not per session.
|
|
201
202
|
_tabs.delete(tabId);
|
|
@@ -222,6 +223,7 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
|
|
|
222
223
|
|
|
223
224
|
if (!worker) {
|
|
224
225
|
worker = new Worker({
|
|
226
|
+
runtime: runtimeAdapter,
|
|
225
227
|
id: tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd,
|
|
226
228
|
internals: _internals, trace: _trace,
|
|
227
229
|
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/claude-md-context.js — CLAUDE.md propagation for non-Claude runtimes.
|
|
3
|
+
*
|
|
4
|
+
* W-mrdon0pe000l045a. CLAUDE.md auto-discovery is a Claude-Code-CLI-only
|
|
5
|
+
* convention (the claude binary reads CLAUDE.md itself at startup). Copilot and
|
|
6
|
+
* Codex have NO equivalent auto-load, so repo-authored CLAUDE.md files — which
|
|
7
|
+
* frequently carry load-bearing "must be kept in sync" rules and name blessed
|
|
8
|
+
* tooling — are structurally invisible to a Copilot/Codex-based fleet.
|
|
9
|
+
*
|
|
10
|
+
* This module discovers the *nearest applicable* CLAUDE.md files for a dispatch
|
|
11
|
+
* (bounded walk-UP from path hints toward the project root — NOT a full monorepo
|
|
12
|
+
* walk) and renders them into a prompt block. engine/playbook.js injects that
|
|
13
|
+
* block as a "Project instructions (CLAUDE.md)" context layer, gated on the
|
|
14
|
+
* runtime capability `claudeMdNativeDiscovery` (skip when true → Claude) and the
|
|
15
|
+
* `propagateClaudeMdForNonClaudeRuntimes` config flag.
|
|
16
|
+
*
|
|
17
|
+
* The whole point is to be CHEAP: it only stats CLAUDE.md at each ancestor
|
|
18
|
+
* directory of a small set of hint dirs (capped depth + capped file count), so
|
|
19
|
+
* it never walks a huge monorepo like office/src.
|
|
20
|
+
*
|
|
21
|
+
* This module is pure file discovery + string building — no engine state, no
|
|
22
|
+
* locking, no writes.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const { truncateTextBytes, ENGINE_DEFAULTS } = require('./shared');
|
|
28
|
+
|
|
29
|
+
const CLAUDE_MD_FILENAME = 'CLAUDE.md';
|
|
30
|
+
// Cap the number of CLAUDE.md files collected across all hints — defense against
|
|
31
|
+
// a dispatch with many path hints spread across a deep tree.
|
|
32
|
+
const MAX_FILES = 8;
|
|
33
|
+
// Cap the walk-up depth from any single hint dir — defense for pathological deep
|
|
34
|
+
// Windows paths so a single hint can't spin the loop unbounded.
|
|
35
|
+
const MAX_WALK_DEPTH = 24;
|
|
36
|
+
// Byte cap for the whole injected block. Reuses the same budget the sibling
|
|
37
|
+
// notes / agent-memory context layers use (maxNotesPromptBytes), so CLAUDE.md
|
|
38
|
+
// propagation can never balloon a prompt on a large monorepo.
|
|
39
|
+
const DEFAULT_MAX_BYTES = ENGINE_DEFAULTS.maxNotesPromptBytes;
|
|
40
|
+
|
|
41
|
+
function _pathEq(a, b) {
|
|
42
|
+
if (process.platform === 'win32') return a.toLowerCase() === b.toLowerCase();
|
|
43
|
+
return a === b;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Is `target` inside (or equal to) `root`?
|
|
47
|
+
function _isWithin(root, target) {
|
|
48
|
+
const rel = path.relative(root, target);
|
|
49
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Normalize a hint (a file OR directory, relative-to-root OR absolute) into an
|
|
54
|
+
* absolute directory contained within `root`. Returns null when the hint
|
|
55
|
+
* escapes the project root (path traversal guard). Non-existent hints are still
|
|
56
|
+
* resolved structurally (extension ⇒ file ⇒ parent dir; otherwise dir).
|
|
57
|
+
*/
|
|
58
|
+
function _hintToDir(root, hint) {
|
|
59
|
+
if (!hint || typeof hint !== 'string' || !hint.trim()) return null;
|
|
60
|
+
const cleaned = hint.trim();
|
|
61
|
+
let abs = path.isAbsolute(cleaned) ? cleaned : path.join(root, cleaned);
|
|
62
|
+
abs = path.resolve(abs);
|
|
63
|
+
if (!_isWithin(root, abs)) return null;
|
|
64
|
+
try {
|
|
65
|
+
const st = fs.statSync(abs);
|
|
66
|
+
if (st.isDirectory()) return abs;
|
|
67
|
+
return path.dirname(abs);
|
|
68
|
+
} catch {
|
|
69
|
+
// Doesn't exist on disk — infer file vs dir from a trailing extension.
|
|
70
|
+
return path.extname(abs) ? path.dirname(abs) : abs;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Discover CLAUDE.md files by walking UP from each hint directory to the project
|
|
76
|
+
* root, plus the project root itself as a guaranteed fallback. Returns entries
|
|
77
|
+
* ordered nearest-first (deepest directory first, project root last) so the
|
|
78
|
+
* caller renders "nearest wins" — the most specific instructions lead.
|
|
79
|
+
*
|
|
80
|
+
* @param {{ projectRoot: string, pathHints?: string[] }} opts
|
|
81
|
+
* @returns {{ absPath: string, relPath: string, depth: number }[]}
|
|
82
|
+
*/
|
|
83
|
+
function discoverClaudeMdFiles({ projectRoot, pathHints = [] } = {}) {
|
|
84
|
+
if (!projectRoot || typeof projectRoot !== 'string') return [];
|
|
85
|
+
const root = path.resolve(projectRoot);
|
|
86
|
+
try { if (!fs.statSync(root).isDirectory()) return []; } catch { return []; }
|
|
87
|
+
|
|
88
|
+
// Starting dirs: each valid hint dir, then the root itself (fallback so the
|
|
89
|
+
// project-root CLAUDE.md is always considered even with zero hints).
|
|
90
|
+
const startDirs = [];
|
|
91
|
+
for (const h of Array.isArray(pathHints) ? pathHints : []) {
|
|
92
|
+
const d = _hintToDir(root, h);
|
|
93
|
+
if (d) startDirs.push(d);
|
|
94
|
+
}
|
|
95
|
+
startDirs.push(root);
|
|
96
|
+
|
|
97
|
+
const found = new Map(); // absPath -> { depth, rel }
|
|
98
|
+
for (const start of startDirs) {
|
|
99
|
+
let dir = start;
|
|
100
|
+
let steps = 0;
|
|
101
|
+
while (steps <= MAX_WALK_DEPTH) {
|
|
102
|
+
const candidate = path.join(dir, CLAUDE_MD_FILENAME);
|
|
103
|
+
if (!found.has(candidate)) {
|
|
104
|
+
try {
|
|
105
|
+
if (fs.statSync(candidate).isFile()) {
|
|
106
|
+
const rel = path.relative(root, dir);
|
|
107
|
+
const depth = (rel === '' || rel === '.') ? 0 : rel.split(/[\\/]/).length;
|
|
108
|
+
found.set(candidate, { depth });
|
|
109
|
+
}
|
|
110
|
+
} catch { /* no CLAUDE.md at this level */ }
|
|
111
|
+
}
|
|
112
|
+
if (_pathEq(dir, root)) break; // stop at project root (never above)
|
|
113
|
+
const parent = path.dirname(dir);
|
|
114
|
+
if (_pathEq(parent, dir)) break; // filesystem-root guard
|
|
115
|
+
if (!_isWithin(root, parent)) break;
|
|
116
|
+
dir = parent;
|
|
117
|
+
steps++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const entries = Array.from(found.entries()).map(([absPath, meta]) => ({
|
|
122
|
+
absPath,
|
|
123
|
+
relPath: (path.relative(root, absPath) || CLAUDE_MD_FILENAME).replace(/\\/g, '/'),
|
|
124
|
+
depth: meta.depth,
|
|
125
|
+
}));
|
|
126
|
+
// nearest-first: deepest dir first; stable tiebreak on relPath for determinism.
|
|
127
|
+
entries.sort((a, b) => (b.depth - a.depth) || a.relPath.localeCompare(b.relPath));
|
|
128
|
+
return entries.slice(0, MAX_FILES);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the CLAUDE.md propagation block (inner content, no outer header/fence —
|
|
133
|
+
* engine/playbook.js owns those). Reads each discovered file, labels it by its
|
|
134
|
+
* repo-relative path, and truncates to stay within `maxBytes` total. Returns
|
|
135
|
+
* `{ block, files }` where `block` is '' when nothing was found or all files
|
|
136
|
+
* were empty.
|
|
137
|
+
*
|
|
138
|
+
* @param {{ projectRoot: string, pathHints?: string[], maxBytes?: number }} opts
|
|
139
|
+
*/
|
|
140
|
+
function buildClaudeMdPropagationBlock({ projectRoot, pathHints = [], maxBytes = DEFAULT_MAX_BYTES } = {}) {
|
|
141
|
+
const files = discoverClaudeMdFiles({ projectRoot, pathHints });
|
|
142
|
+
if (files.length === 0) return { block: '', files: [] };
|
|
143
|
+
|
|
144
|
+
const cap = Math.max(256, Number(maxBytes) || DEFAULT_MAX_BYTES);
|
|
145
|
+
const parts = [];
|
|
146
|
+
const included = [];
|
|
147
|
+
let used = 0;
|
|
148
|
+
|
|
149
|
+
for (const f of files) {
|
|
150
|
+
let content;
|
|
151
|
+
try { content = fs.readFileSync(f.absPath, 'utf8'); } catch { continue; }
|
|
152
|
+
if (!content || !content.trim()) continue;
|
|
153
|
+
|
|
154
|
+
const heading = `\n\n### ${f.relPath}\n\n`;
|
|
155
|
+
const headingBytes = Buffer.byteLength(heading, 'utf8');
|
|
156
|
+
const remaining = cap - used - headingBytes;
|
|
157
|
+
if (remaining <= 0) break; // no room even for the next heading
|
|
158
|
+
|
|
159
|
+
const body = truncateTextBytes(content, remaining, '\n\n_...CLAUDE.md truncated (read the full file if needed)_');
|
|
160
|
+
parts.push(heading + body);
|
|
161
|
+
used += headingBytes + Buffer.byteLength(body, 'utf8');
|
|
162
|
+
included.push(f.relPath);
|
|
163
|
+
if (used >= cap) break;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (parts.length === 0) return { block: '', files: [] };
|
|
167
|
+
return { block: parts.join('').replace(/^\n+/, ''), files: included };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Does the named runtime auto-discover CLAUDE.md natively? Reads the adapter's
|
|
172
|
+
* `capabilities.claudeMdNativeDiscovery`. Unknown runtimes return false ("does
|
|
173
|
+
* NOT auto-discover") so propagation is the safe additive default — an
|
|
174
|
+
* unrecognized runtime is far more likely to lack native CLAUDE.md loading than
|
|
175
|
+
* to have it.
|
|
176
|
+
*/
|
|
177
|
+
function runtimeAutoDiscoversClaudeMd(cliName) {
|
|
178
|
+
try {
|
|
179
|
+
const { resolveRuntime } = require('./runtimes');
|
|
180
|
+
const rt = resolveRuntime(cliName);
|
|
181
|
+
return !!(rt && rt.capabilities && rt.capabilities.claudeMdNativeDiscovery);
|
|
182
|
+
} catch {
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
CLAUDE_MD_FILENAME,
|
|
189
|
+
MAX_FILES,
|
|
190
|
+
MAX_WALK_DEPTH,
|
|
191
|
+
DEFAULT_MAX_BYTES,
|
|
192
|
+
discoverClaudeMdFiles,
|
|
193
|
+
buildClaudeMdPropagationBlock,
|
|
194
|
+
runtimeAutoDiscoversClaudeMd,
|
|
195
|
+
};
|
package/engine/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ const shared = require('./shared');
|
|
|
9
9
|
const { safeRead, safeJson, safeJsonArr, safeWrite, readWorkItems, readPullRequests, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, FAILURE_CLASS } = shared;
|
|
10
10
|
const queries = require('./queries');
|
|
11
11
|
const agentWorkerPool = require('./agent-worker-pool');
|
|
12
|
+
const { resolveCommentExecutionModel } = require('./execution-model');
|
|
12
13
|
const { getConfig, getControl, getDispatch, getAgentStatus,
|
|
13
14
|
MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH } = queries;
|
|
14
15
|
|
|
@@ -2076,6 +2077,11 @@ const commands = {
|
|
|
2076
2077
|
console.error('error: must supply either --body-file <path> or --body <text>');
|
|
2077
2078
|
process.exit(2);
|
|
2078
2079
|
}
|
|
2080
|
+
const executionModel = resolveCommentExecutionModel({
|
|
2081
|
+
dispatch: getDispatch(),
|
|
2082
|
+
agentId,
|
|
2083
|
+
workItemId,
|
|
2084
|
+
});
|
|
2079
2085
|
|
|
2080
2086
|
// ── Azure DevOps: <prNumber> --host ado --ado-org --ado-project --repo-id ──
|
|
2081
2087
|
if (isAdo) {
|
|
@@ -2097,6 +2103,7 @@ const commands = {
|
|
|
2097
2103
|
const adoComment = require('./ado-comment');
|
|
2098
2104
|
adoComment.postAdoPrComment({
|
|
2099
2105
|
orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId,
|
|
2106
|
+
model: executionModel.model,
|
|
2100
2107
|
resolved: flags.resolved === true,
|
|
2101
2108
|
}).then((result) => {
|
|
2102
2109
|
if (result && result.threadId) {
|
|
@@ -2135,7 +2142,7 @@ const commands = {
|
|
|
2135
2142
|
|
|
2136
2143
|
try {
|
|
2137
2144
|
const result = ghComment.postPrComment({
|
|
2138
|
-
repo, prNumber, body, agentId, kind, workItemId,
|
|
2145
|
+
repo, prNumber, body, agentId, kind, workItemId, model: executionModel.model,
|
|
2139
2146
|
});
|
|
2140
2147
|
if (result.output) console.log(result.output);
|
|
2141
2148
|
} catch (e) {
|
|
@@ -2274,6 +2281,7 @@ module.exports = {
|
|
|
2274
2281
|
_readDispatchPid: readDispatchPid,
|
|
2275
2282
|
_normalizeSessionBranch: normalizeSessionBranch,
|
|
2276
2283
|
_dispatchSessionBranch: dispatchSessionBranch,
|
|
2284
|
+
_resolveCommentExecutionModel: resolveCommentExecutionModel, // exported for testing
|
|
2277
2285
|
// W-mpcyvff6000pf828 (#2653) — heartbeat writer + factory exported for tests
|
|
2278
2286
|
_writeHeartbeatNow: writeHeartbeatNow,
|
|
2279
2287
|
_createHeartbeatInterval: createHeartbeatInterval,
|
package/engine/comment-format.js
CHANGED
|
@@ -48,14 +48,17 @@ function linkifyBrandTrailer(text) {
|
|
|
48
48
|
// org/project/repoId).
|
|
49
49
|
//
|
|
50
50
|
// Marker format (single line, ASCII):
|
|
51
|
-
// <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> -->
|
|
51
|
+
// <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> model=<modelId> -->
|
|
52
52
|
//
|
|
53
53
|
// Validation (enforced before the marker is built):
|
|
54
54
|
// - agentId /^[a-z][a-z0-9-]{0,30}$/
|
|
55
55
|
// - kind /^[a-z][a-z0-9-]{0,30}$/
|
|
56
56
|
// - workItemId /^[A-Z]-[a-z0-9]+$/ (optional)
|
|
57
|
+
// - model concrete runtime execution model (optional)
|
|
57
58
|
// - no field may contain `--` (would close the HTML comment early)
|
|
58
59
|
|
|
60
|
+
const { normalizeExecutionModel } = require('./execution-model');
|
|
61
|
+
|
|
59
62
|
const AGENT_ID_RE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
60
63
|
const KIND_RE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
61
64
|
const WORK_ITEM_ID_RE = /^[A-Z]-[a-z0-9]+$/;
|
|
@@ -63,7 +66,7 @@ const WORK_ITEM_ID_RE = /^[A-Z]-[a-z0-9]+$/;
|
|
|
63
66
|
// Marker line (single-line HTML comment). Multiline flag so it matches at the
|
|
64
67
|
// start of any line — required for round-trip detection of the builder output.
|
|
65
68
|
const MINIONS_COMMENT_MARKER_RE =
|
|
66
|
-
/^<!--\s*minions:agent=([^\s]+)\s+kind=([^\s]+)(?:\s+wi=([^\s]+))?\s*-->/m;
|
|
69
|
+
/^<!--\s*minions:agent=([^\s]+)\s+kind=([^\s]+)(?:\s+wi=([^\s]+))?(?:\s+model=([^\s]+))?\s*-->/m;
|
|
67
70
|
|
|
68
71
|
// Canonical-format sample marker for tests/fixtures that need a marked body but
|
|
69
72
|
// don't care about the specific fields.
|
|
@@ -85,18 +88,44 @@ function _validateField(name, value, re) {
|
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
function _validateMarkerInputs({ agentId, kind, workItemId }) {
|
|
91
|
+
function _validateMarkerInputs({ agentId, kind, workItemId, model }) {
|
|
89
92
|
_validateField('agentId', agentId, AGENT_ID_RE);
|
|
90
93
|
_validateField('kind', kind, KIND_RE);
|
|
91
94
|
if (workItemId !== undefined && workItemId !== null) {
|
|
92
95
|
_validateField('workItemId', workItemId, WORK_ITEM_ID_RE);
|
|
93
96
|
}
|
|
97
|
+
if (model !== undefined && model !== null && !normalizeExecutionModel(model)) {
|
|
98
|
+
throw new Error(`invalid model: ${JSON.stringify(model)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _encodeMarkerModel(model) {
|
|
103
|
+
const encoded = encodeURIComponent(model);
|
|
104
|
+
return encoded.includes('--') ? encoded.replace(/-/g, '%2D') : encoded;
|
|
94
105
|
}
|
|
95
106
|
|
|
96
|
-
function _buildMarker({ agentId, kind, workItemId }) {
|
|
97
|
-
_validateMarkerInputs({ agentId, kind, workItemId });
|
|
107
|
+
function _buildMarker({ agentId, kind, workItemId, model }) {
|
|
108
|
+
_validateMarkerInputs({ agentId, kind, workItemId, model });
|
|
98
109
|
const wi = (workItemId !== undefined && workItemId !== null) ? ` wi=${workItemId}` : '';
|
|
99
|
-
|
|
110
|
+
const modelPart = model ? ` model=${_encodeMarkerModel(model)}` : '';
|
|
111
|
+
return `<!-- minions:agent=${agentId} kind=${kind}${wi}${modelPart} -->`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const MODEL_SIGNOFF_RE =
|
|
115
|
+
/(\b(?:Review|Reviewed|Fixed|Verified|Rebased) by (?:Minions|\[Minions\]\([^)]+\)) \([^\r\n)]*? · )[^)\r\n]+(\))/g;
|
|
116
|
+
|
|
117
|
+
function stampExecutionModel(text, model) {
|
|
118
|
+
const normalized = normalizeExecutionModel(model);
|
|
119
|
+
if (!normalized || typeof text !== 'string') return text;
|
|
120
|
+
return text.replace(MODEL_SIGNOFF_RE, (_match, prefix, suffix) => `${prefix}${normalized}${suffix}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _stampLeadingMarkerModel(text, model) {
|
|
124
|
+
if (!model || typeof text !== 'string') return text;
|
|
125
|
+
return text.replace(MINIONS_COMMENT_MARKER_RE, (marker, agentId, kind, workItemId) => {
|
|
126
|
+
if (marker !== text.slice(0, marker.length)) return marker;
|
|
127
|
+
return _buildMarker({ agentId, kind, workItemId, model });
|
|
128
|
+
});
|
|
100
129
|
}
|
|
101
130
|
|
|
102
131
|
/**
|
|
@@ -106,41 +135,49 @@ function _buildMarker({ agentId, kind, workItemId }) {
|
|
|
106
135
|
* Idempotency: if `body` already starts with a minions marker it is returned
|
|
107
136
|
* with the brand transform applied but no second marker prepended.
|
|
108
137
|
*
|
|
109
|
-
* @param {{agentId:string, kind:string, workItemId?:string, body?:string}} args
|
|
138
|
+
* @param {{agentId:string, kind:string, workItemId?:string, model?:string, body?:string}} args
|
|
110
139
|
* @returns {string} the final comment body
|
|
111
140
|
*/
|
|
112
|
-
function buildMinionsCommentBody({ agentId, kind, workItemId, body }) {
|
|
141
|
+
function buildMinionsCommentBody({ agentId, kind, workItemId, model, body }) {
|
|
113
142
|
// Validate the inputs even when the body is pre-marked, so callers can't
|
|
114
143
|
// silently bypass validation by pre-marking their body.
|
|
115
|
-
_validateMarkerInputs({ agentId, kind, workItemId });
|
|
144
|
+
_validateMarkerInputs({ agentId, kind, workItemId, model });
|
|
116
145
|
let safeBody = body == null ? '' : String(body);
|
|
117
146
|
|
|
147
|
+
safeBody = stampExecutionModel(safeBody, model);
|
|
148
|
+
|
|
118
149
|
// Brand-link safety net: deterministically hyperlink the first bare
|
|
119
150
|
// "… by Minions" signature trailer (no-op when the agent already linked it).
|
|
120
151
|
safeBody = linkifyBrandTrailer(safeBody);
|
|
121
152
|
|
|
122
|
-
if (_LEADING_MARKER_RE.test(safeBody)) return safeBody;
|
|
123
|
-
const marker = _buildMarker({ agentId, kind, workItemId });
|
|
153
|
+
if (_LEADING_MARKER_RE.test(safeBody)) return _stampLeadingMarkerModel(safeBody, model);
|
|
154
|
+
const marker = _buildMarker({ agentId, kind, workItemId, model });
|
|
124
155
|
return `${marker}\n\n${safeBody}`;
|
|
125
156
|
}
|
|
126
157
|
|
|
127
158
|
/**
|
|
128
|
-
* Inverse of the marker builder: parse `{agentId, kind, workItemId}`
|
|
129
|
-
* marked body, or null when no marker is present.
|
|
159
|
+
* Inverse of the marker builder: parse `{agentId, kind, workItemId, model?}`
|
|
160
|
+
* out of a marked body, or null when no marker is present.
|
|
130
161
|
*/
|
|
131
162
|
function parseMinionsMarker(body) {
|
|
132
163
|
if (typeof body !== 'string' || body.length === 0) return null;
|
|
133
164
|
const m = body.match(MINIONS_COMMENT_MARKER_RE);
|
|
134
165
|
if (!m) return null;
|
|
135
|
-
|
|
166
|
+
const parsed = {
|
|
136
167
|
agentId: m[1],
|
|
137
168
|
kind: m[2],
|
|
138
169
|
workItemId: m[3] === undefined ? undefined : m[3],
|
|
139
170
|
};
|
|
171
|
+
if (m[4] !== undefined) {
|
|
172
|
+
try { parsed.model = decodeURIComponent(m[4]); }
|
|
173
|
+
catch { return null; }
|
|
174
|
+
}
|
|
175
|
+
return parsed;
|
|
140
176
|
}
|
|
141
177
|
|
|
142
178
|
module.exports = {
|
|
143
179
|
linkifyBrandTrailer,
|
|
180
|
+
stampExecutionModel,
|
|
144
181
|
MINIONS_BRAND_URL,
|
|
145
182
|
// Neutral marker + body builder (consumed by gh-comment.js and ado-comment.js).
|
|
146
183
|
buildMinionsCommentBody,
|
package/engine/consolidation.js
CHANGED
|
@@ -85,6 +85,34 @@ function _readNotesForAppendOrNull() {
|
|
|
85
85
|
return fs.existsSync(NOTES_PATH) ? null : '';
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// Resiliently write notes.md from inside a consolidation lock callback.
|
|
89
|
+
// CRASH GUARD (#858): safeWrite -> renameSync throws EPERM on Windows when
|
|
90
|
+
// notes.md is held open by another process (e.g. a leaked dashboard
|
|
91
|
+
// /state/notes.md read stream). That throw used to propagate out of the
|
|
92
|
+
// async consolidateWithLLM(...) chain / the regex-fallback timer callback with
|
|
93
|
+
// NO surrounding try/catch, becoming an UNHANDLED PROMISE REJECTION (or an
|
|
94
|
+
// uncaught timer exception) that killed the whole engine daemon. Supervisor
|
|
95
|
+
// respawned it, but pooled Copilot ACP agents cannot reattach across restart,
|
|
96
|
+
// so every in-flight agent was orphaned.
|
|
97
|
+
//
|
|
98
|
+
// This wraps safeWrite in shared._retryFsOp (extra exponential-backoff retries
|
|
99
|
+
// on EPERM/EBUSY/EACCES on top of safeWrite's own short retry loop) AND a
|
|
100
|
+
// try/catch, so a transient lock retries then DEFERS: returns false so the
|
|
101
|
+
// caller leaves the inbox notes in place for the next tick instead of crashing.
|
|
102
|
+
// Returns true on success, false if the write could not be completed.
|
|
103
|
+
function _writeNotesOrDefer(content, label) {
|
|
104
|
+
try {
|
|
105
|
+
// Keep the in-lock retry budget small (safeWrite already retries EPERM ~5x
|
|
106
|
+
// internally): a few extra exponential-backoff attempts cover a slightly
|
|
107
|
+
// longer transient hold without stalling the consolidation lock.
|
|
108
|
+
shared._retryFsOp(() => safeWrite(NOTES_PATH, content), `write notes.md (${label})`, { attempts: 3, baseMs: 100 });
|
|
109
|
+
return true;
|
|
110
|
+
} catch (err) {
|
|
111
|
+
log('warn', `${label}: notes.md write failed after retries (${err?.code || ''} ${err?.message || err}) \u2014 deferring, inbox notes left for next cycle`);
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
88
116
|
// Per-agent memory files live under knowledge/agents/<agent>.md and are
|
|
89
117
|
// injected into individual agent prompts (in addition to the broadcast
|
|
90
118
|
// notes.md). See knowledge/agents/README.md for the convention.
|
|
@@ -971,7 +999,17 @@ function consolidateWithLLM(items, existingNotes, files, config) {
|
|
|
971
999
|
fallbackDone = true;
|
|
972
1000
|
if (message) log('warn', message);
|
|
973
1001
|
if (err?.message) log('debug', `LLM error: ${err.message}`);
|
|
974
|
-
|
|
1002
|
+
// CRASH GUARD (#858): _fallback runs from a promise .catch AND from a
|
|
1003
|
+
// setTimeout callback. An uncaught throw from consolidateWithRegex in the
|
|
1004
|
+
// timer path is an uncaught exception that kills the daemon; in the .catch
|
|
1005
|
+
// path it is an unhandled rejection. The notes.md write already defers on
|
|
1006
|
+
// lock contention, but keep a top-level guard so no other regex-fallback
|
|
1007
|
+
// failure can ever crash the engine — leave inbox notes for the next tick.
|
|
1008
|
+
try {
|
|
1009
|
+
consolidateWithRegex(items, files, config);
|
|
1010
|
+
} catch (e) {
|
|
1011
|
+
log('warn', `Regex consolidation fallback failed (${e?.code || ''} ${e?.message || e}) \u2014 inbox notes left for next cycle`);
|
|
1012
|
+
}
|
|
975
1013
|
}
|
|
976
1014
|
|
|
977
1015
|
const llmCall = callLLM(prompt, sysPrompt, {
|
|
@@ -1033,7 +1071,9 @@ function consolidateWithLLM(items, existingNotes, files, config) {
|
|
|
1033
1071
|
// DATA-LOSS GUARD: cap size but archive the overflow instead of
|
|
1034
1072
|
// silently discarding it (see _capNotesPreservingOverflow).
|
|
1035
1073
|
const newContent = _capNotesPreservingOverflow(current + entry);
|
|
1036
|
-
|
|
1074
|
+
// CRASH GUARD (#858): defer (return false) on a transient write lock
|
|
1075
|
+
// instead of throwing out of this async chain and crashing the daemon.
|
|
1076
|
+
if (!_writeNotesOrDefer(newContent, 'LLM consolidation')) return false;
|
|
1037
1077
|
return true;
|
|
1038
1078
|
});
|
|
1039
1079
|
if (!wrote) {
|
|
@@ -1169,7 +1209,10 @@ function consolidateWithRegex(items, files, config) {
|
|
|
1169
1209
|
}
|
|
1170
1210
|
// DATA-LOSS GUARD: cap size but archive the overflow (see LLM path).
|
|
1171
1211
|
const newContent = _capNotesPreservingOverflow(current + entry);
|
|
1172
|
-
|
|
1212
|
+
// CRASH GUARD (#858): defer (return false) on a transient write lock
|
|
1213
|
+
// instead of throwing out of this synchronous fallback (invoked from a
|
|
1214
|
+
// timer callback and a promise .catch) and crashing the daemon.
|
|
1215
|
+
if (!_writeNotesOrDefer(newContent, 'Regex consolidation')) return false;
|
|
1173
1216
|
return true;
|
|
1174
1217
|
});
|
|
1175
1218
|
if (!wrote) {
|
|
@@ -1530,4 +1573,5 @@ module.exports = {
|
|
|
1530
1573
|
buildCondensedKbBody,
|
|
1531
1574
|
_alertContentHash,
|
|
1532
1575
|
_alertHashExists,
|
|
1576
|
+
_writeNotesOrDefer,
|
|
1533
1577
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
function _parseObject(raw) {
|
|
2
|
+
try {
|
|
3
|
+
const value = JSON.parse(raw);
|
|
4
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
5
|
+
} catch {
|
|
6
|
+
return null;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
version: 26,
|
|
12
|
+
description: 'normalize exhausted pre-dispatch rejections out of execution failures',
|
|
13
|
+
up(db) {
|
|
14
|
+
const rows = db.prepare(`
|
|
15
|
+
SELECT rowid, data
|
|
16
|
+
FROM work_items
|
|
17
|
+
WHERE status = 'failed' AND archived = 0
|
|
18
|
+
`).all();
|
|
19
|
+
const update = db.prepare(`
|
|
20
|
+
UPDATE work_items
|
|
21
|
+
SET status = 'pending', data = ?, updated_at = ?
|
|
22
|
+
WHERE rowid = ? AND archived = 0
|
|
23
|
+
`);
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
for (const row of rows) {
|
|
26
|
+
const item = _parseObject(row.data);
|
|
27
|
+
if (!item || item._failureClass !== 'pre-dispatch-eval-stuck') continue;
|
|
28
|
+
const evaluation = item._preDispatchEval && typeof item._preDispatchEval === 'object'
|
|
29
|
+
? item._preDispatchEval
|
|
30
|
+
: {};
|
|
31
|
+
const evaluatedAt = evaluation.evaluatedAt || item.failedAt || new Date(now).toISOString();
|
|
32
|
+
const history = Array.isArray(evaluation.history) ? evaluation.history : [];
|
|
33
|
+
if (history.length === 0 && evaluation.reason) {
|
|
34
|
+
history.push({
|
|
35
|
+
reason: evaluation.reason,
|
|
36
|
+
evaluatedAt,
|
|
37
|
+
description: evaluation.description || '',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
item.status = 'pending';
|
|
41
|
+
item._pendingReason = 'pre_dispatch_eval_rejected';
|
|
42
|
+
item._preDispatchEval = {
|
|
43
|
+
...evaluation,
|
|
44
|
+
valid: false,
|
|
45
|
+
exhausted: true,
|
|
46
|
+
exhaustedAt: evaluation.exhaustedAt || evaluatedAt,
|
|
47
|
+
history,
|
|
48
|
+
};
|
|
49
|
+
delete item._failureClass;
|
|
50
|
+
delete item.failReason;
|
|
51
|
+
delete item.failedAt;
|
|
52
|
+
update.run(JSON.stringify(item), now, row.rowid);
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
};
|