@yemi33/minions 0.1.1984 → 0.1.1986
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 +3 -1
- package/dashboard/js/qa.js +53 -0
- package/dashboard/js/refresh.js +4 -2
- package/dashboard/js/render-managed.js +43 -9
- package/dashboard/js/render-other.js +41 -11
- package/dashboard/layout.html +1 -0
- package/dashboard/pages/qa.html +23 -0
- package/dashboard-build.js +2 -2
- package/dashboard.js +135 -24
- package/docs/.nojekyll +0 -0
- package/docs/README.md +2 -0
- package/docs/constellation-bridge.md +94 -0
- package/docs/security.md +177 -0
- package/engine/ado-git-auth.js +206 -0
- package/engine/bridge.js +124 -0
- package/engine/cc-worker-pool.js +48 -1
- package/engine/cleanup.js +72 -23
- package/engine/cli.js +169 -12
- package/engine/dispatch.js +26 -11
- package/engine/github.js +79 -26
- package/engine/issues.js +14 -3
- package/engine/lifecycle.js +55 -14
- package/engine/llm.js +16 -9
- package/engine/meeting.js +16 -5
- package/engine/queries.js +123 -52
- package/engine/recovery.js +6 -0
- package/engine/shared.js +281 -9
- package/engine/spawn-agent.js +13 -5
- package/engine/timeout.js +4 -2
- package/engine.js +242 -52
- package/package.json +1 -1
package/engine/github.js
CHANGED
|
@@ -35,6 +35,29 @@ function _setExecAsyncForTest(fn) {
|
|
|
35
35
|
_execAsyncOverride = (typeof fn === 'function') ? fn : null;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// P-f2-gh-shell (F2): parallel test seam for argv-form (shellSafeGh) shell-outs.
|
|
39
|
+
// The legacy `_runExec` seam intercepts shell-string `gh api ...` invocations,
|
|
40
|
+
// but the F2 conversion routes ghApi / build-log fetch / dismiss-review through
|
|
41
|
+
// shared.shellSafeGh (execFile, shell:false). New tests can mock at the argv
|
|
42
|
+
// level via `_setShellSafeGhForTest`; pre-existing tests that mock the shell-string
|
|
43
|
+
// layer keep working because `_runShellSafeGh` falls back to the legacy override
|
|
44
|
+
// by synthesizing a cmd string from argv (quoting any non-trivial argument so
|
|
45
|
+
// existing `/"repos\/owner\/repo"$/`-style route patterns still match).
|
|
46
|
+
let _shellSafeGhOverride = null;
|
|
47
|
+
function _runShellSafeGh(args, opts) {
|
|
48
|
+
if (_shellSafeGhOverride) return _shellSafeGhOverride(args, opts);
|
|
49
|
+
if (_execAsyncOverride) {
|
|
50
|
+
const cmd = ['gh', ...args]
|
|
51
|
+
.map(a => /^[A-Za-z0-9_=.-]+$/.test(String(a)) ? String(a) : `"${String(a)}"`)
|
|
52
|
+
.join(' ');
|
|
53
|
+
return _execAsyncOverride(cmd, opts);
|
|
54
|
+
}
|
|
55
|
+
return shared.shellSafeGh(args, opts);
|
|
56
|
+
}
|
|
57
|
+
function _setShellSafeGhForTest(fn) {
|
|
58
|
+
_shellSafeGhOverride = (typeof fn === 'function') ? fn : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
38
61
|
// ─── Constants ──────────────────────────────────────────────────────────────
|
|
39
62
|
|
|
40
63
|
// 10 MB — prevents maxBuffer exceeded errors on repos with many open PRs.
|
|
@@ -270,11 +293,15 @@ const GH_NOT_FOUND = Object.freeze({ _notFound: true });
|
|
|
270
293
|
*/
|
|
271
294
|
async function ghApi(endpoint, slug, opts = {}) {
|
|
272
295
|
try {
|
|
273
|
-
|
|
274
|
-
|
|
296
|
+
// P-f2-gh-shell (F2): argv form via shellSafeGh — eliminates shell
|
|
297
|
+
// interpolation of slug/endpoint. validateGhSlug + validateGhEndpoint
|
|
298
|
+
// reject shell metacharacters before they reach child_process.
|
|
299
|
+
const args = ['api'];
|
|
300
|
+
if (opts.paginate) args.push('--paginate');
|
|
301
|
+
args.push(`repos/${shared.validateGhSlug(slug)}${shared.validateGhEndpoint(endpoint)}`);
|
|
275
302
|
const token = ghToken.resolveTokenForSlug(slug);
|
|
276
303
|
const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
|
|
277
|
-
const result = await
|
|
304
|
+
const result = await _runShellSafeGh(args, { timeout: opts.timeout || 30000, maxBuffer: GH_MAX_BUFFER, env });
|
|
278
305
|
const parsed = JSON.parse(result);
|
|
279
306
|
_ghThrottle.recordSuccess();
|
|
280
307
|
return parsed;
|
|
@@ -340,12 +367,18 @@ async function fetchGhBuildErrorLog(slug, failedRuns) {
|
|
|
340
367
|
}
|
|
341
368
|
} catch { /* fall through to job log */ }
|
|
342
369
|
|
|
343
|
-
// Always fetch job log — annotations alone often lack test failure details
|
|
370
|
+
// Always fetch job log — annotations alone often lack test failure details.
|
|
371
|
+
// P-f2-gh-shell (F2): argv form via shellSafeGh. validateJobId rejects
|
|
372
|
+
// non-integer run.id values before they reach child_process. Stderr is
|
|
373
|
+
// captured via shellSafeGh's execFile path (stderr surfaces on the thrown
|
|
374
|
+
// Error in the catch block) instead of a `2>&1` shell redirect, which is
|
|
375
|
+
// moot once shell:false is in effect.
|
|
344
376
|
try {
|
|
345
|
-
const
|
|
377
|
+
const jobId = shared.validateJobId(run.id);
|
|
378
|
+
const args = ['api', `repos/${shared.validateGhSlug(slug)}/actions/jobs/${jobId}/logs`];
|
|
346
379
|
const token = ghToken.resolveTokenForSlug(slug);
|
|
347
380
|
const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
|
|
348
|
-
const result = await
|
|
381
|
+
const result = await _runShellSafeGh(args, { timeout: 15000, maxBuffer: GH_MAX_BUFFER, env });
|
|
349
382
|
if (result && !result.includes('Not Found')) {
|
|
350
383
|
logParts.push(`--- ${run.name || 'Check'} (log) ---\n${result}`);
|
|
351
384
|
}
|
|
@@ -1235,26 +1268,45 @@ async function dismissPriorViewerChangesRequestedReviews(pr, project) {
|
|
|
1235
1268
|
const os = require('os');
|
|
1236
1269
|
let dismissed = 0;
|
|
1237
1270
|
let errors = 0;
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1271
|
+
// P-f6-tmp-toctou: per-call mkdtempSync dir (random 6-char suffix) closes
|
|
1272
|
+
// the predictable-prefix window on <os.tmpdir>/gh-review-dismiss-* that
|
|
1273
|
+
// an attacker could otherwise plant as a symlink.
|
|
1274
|
+
// P-f2-gh-shell: argv form via shellSafeGh — validates slug, prNum, and
|
|
1275
|
+
// reviewId so a poisoned PR record can't smuggle shell metacharacters.
|
|
1276
|
+
let scopedDir = null;
|
|
1277
|
+
try {
|
|
1278
|
+
scopedDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gh-review-dismiss-'));
|
|
1279
|
+
if (process.platform !== 'win32') {
|
|
1280
|
+
try { fs.chmodSync(scopedDir, 0o700); } catch { /* best-effort */ }
|
|
1281
|
+
}
|
|
1282
|
+
for (const reviewId of targets) {
|
|
1283
|
+
tmpFile = path.join(scopedDir, `dismiss-${reviewId}.json`);
|
|
1284
|
+
const body = JSON.stringify({
|
|
1285
|
+
message: 'Superseded by Minions re-review (verdict flipped to APPROVE).',
|
|
1286
|
+
event: 'DISMISS',
|
|
1287
|
+
});
|
|
1288
|
+
try {
|
|
1289
|
+
fs.writeFileSync(tmpFile, body);
|
|
1290
|
+
const args = [
|
|
1291
|
+
'api', '-X', 'PUT', '--input', tmpFile,
|
|
1292
|
+
`repos/${shared.validateGhSlug(slug)}/pulls/${shared.validatePrNum(prNum)}/reviews/${shared.validateReviewId(reviewId)}/dismissals`,
|
|
1293
|
+
];
|
|
1294
|
+
const token = ghToken.resolveTokenForSlug(slug);
|
|
1295
|
+
const env = token ? { ...process.env, GH_TOKEN: token } : undefined;
|
|
1296
|
+
await _runShellSafeGh(args, { timeout: 10000, maxBuffer: GH_MAX_BUFFER, env });
|
|
1297
|
+
dismissed += 1;
|
|
1298
|
+
log('info', `PR ${pr.id}: dismissed prior CHANGES_REQUESTED review ${reviewId} by ${viewerLogin}`);
|
|
1299
|
+
} catch (e) {
|
|
1300
|
+
errors += 1;
|
|
1301
|
+
log('warn', `PR ${pr.id}: dismiss review ${reviewId} failed: ${e?.message || e}`);
|
|
1302
|
+
} finally {
|
|
1303
|
+
try { fs.unlinkSync(tmpFile); } catch {}
|
|
1304
|
+
tmpFile = null;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
} finally {
|
|
1308
|
+
if (scopedDir) {
|
|
1309
|
+
try { fs.rmSync(scopedDir, { recursive: true, force: true }); } catch {}
|
|
1258
1310
|
}
|
|
1259
1311
|
}
|
|
1260
1312
|
return { attempted: true, dismissed, errors };
|
|
@@ -1515,5 +1567,6 @@ module.exports = {
|
|
|
1515
1567
|
_setCachedViewerLogin, // exported for testing (W-mp3bp0ha000997ab-b backfill)
|
|
1516
1568
|
_backfillViewerDidAuthor, // exported for testing (W-mp3bp0ha000997ab-b backfill)
|
|
1517
1569
|
_setExecAsyncForTest, // W-mp5trwh60008386d: test seam to mock `gh api` shell-outs
|
|
1570
|
+
_setShellSafeGhForTest, // P-f2-gh-shell (F2): test seam to mock argv-form gh api calls
|
|
1518
1571
|
GH_NOT_FOUND, // W-mp5trwh60008386d: exported so tests can assert sentinel propagation
|
|
1519
1572
|
};
|
package/engine/issues.js
CHANGED
|
@@ -327,9 +327,19 @@ function createGitHubIssue({
|
|
|
327
327
|
const safeTitle = _redactIssueContent(title, { repo, projects: redactionProjects });
|
|
328
328
|
const safeDescription = _redactIssueContent(description || '', { repo, projects: redactionProjects });
|
|
329
329
|
const issueBody = `${safeDescription}\n\n---\n_Filed via Minions dashboard_`;
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
330
|
+
// P-f6-tmp-toctou: per-call mkdtempSync dir (under engine/tmp/ for
|
|
331
|
+
// collocation with other engine tmp state). Random suffix closes the
|
|
332
|
+
// predictable-prefix window on engine/tmp/bug-body-* that an attacker could
|
|
333
|
+
// otherwise plant as a symlink.
|
|
334
|
+
let scopedDir = null;
|
|
335
|
+
let bodyFile;
|
|
336
|
+
if (tmpDir) {
|
|
337
|
+
fs.mkdirSync(tmpDir, { recursive: true });
|
|
338
|
+
bodyFile = path.join(tmpDir, `bug-body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
|
|
339
|
+
} else {
|
|
340
|
+
scopedDir = shared.createDispatchTmpDir(`issues-${process.pid}-${Date.now()}`);
|
|
341
|
+
bodyFile = path.join(scopedDir, 'bug-body.md');
|
|
342
|
+
}
|
|
333
343
|
fs.writeFileSync(bodyFile, issueBody);
|
|
334
344
|
|
|
335
345
|
let resolved;
|
|
@@ -377,6 +387,7 @@ function createGitHubIssue({
|
|
|
377
387
|
throw new GitHubIssueError(`GitHub issue creation failed: ${conciseGhMessage(e)}`);
|
|
378
388
|
} finally {
|
|
379
389
|
try { fs.unlinkSync(bodyFile); } catch {}
|
|
390
|
+
if (scopedDir) shared.removeDispatchTmpDir(scopedDir);
|
|
380
391
|
}
|
|
381
392
|
}
|
|
382
393
|
|
package/engine/lifecycle.js
CHANGED
|
@@ -12,6 +12,7 @@ const { safeRead, safeJson, safeJsonNoRestore, safeWrite, mutateJsonFileLocked,
|
|
|
12
12
|
ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
|
|
13
13
|
const { trackEngineUsage } = require('./llm');
|
|
14
14
|
const { resolveRuntime } = require('./runtimes');
|
|
15
|
+
const adoGitAuth = require('./ado-git-auth');
|
|
15
16
|
const queries = require('./queries');
|
|
16
17
|
const { isBranchActive } = require('./cooldown');
|
|
17
18
|
const { worktreeMatchesBranch, getWorktreeBranch, cleanupMergedPrLocalBranch } = require('./cleanup');
|
|
@@ -271,30 +272,66 @@ function checkPlanCompletion(meta, config) {
|
|
|
271
272
|
continue;
|
|
272
273
|
}
|
|
273
274
|
|
|
274
|
-
// Build per-project setup block
|
|
275
|
+
// Build per-project setup block.
|
|
276
|
+
// P-f3-verify-prompt — validate every branch ref before splicing into
|
|
277
|
+
// the bash setup commands the verify agent will execute. Without this,
|
|
278
|
+
// a crafted `plan.feature_branch` (e.g. `main; rm -rf ~`) or PR
|
|
279
|
+
// `branch` field reaches the Bash tool via template literal
|
|
280
|
+
// interpolation. github.js validates pr.branch on API persistence
|
|
281
|
+
// (engine/github.js:612-622), but feature_branch has no upstream
|
|
282
|
+
// validator and mainBranch can fall through unvalidated from project
|
|
283
|
+
// config when `git rev-parse` can't verify it (shared.js:1328). Defense
|
|
284
|
+
// in depth lives here, at the actual interpolation site.
|
|
275
285
|
let checkoutBlock;
|
|
276
286
|
let wtPath;
|
|
277
287
|
if (isSharedBranch) {
|
|
288
|
+
let safeFeatureBranch;
|
|
289
|
+
try {
|
|
290
|
+
safeFeatureBranch = shared.validateGitRef(plan.feature_branch);
|
|
291
|
+
} catch (refErr) {
|
|
292
|
+
log('error', `Plan ${planFile}: verify dispatch rejected for ${projName} — invalid feature_branch ref: ${refErr.message}. Skipping verify WI creation.`);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
278
295
|
wtPath = `${p.localPath}/../worktrees/verify-${projName}-${planSlug}`;
|
|
279
|
-
const featureBranch = plan.feature_branch;
|
|
280
296
|
checkoutBlock = [
|
|
281
297
|
`# ${projName} — shared-branch: use existing feature branch directly`,
|
|
282
298
|
`cd "${p.localPath.replace(/\\/g, '/')}"`,
|
|
283
|
-
`git fetch origin "${
|
|
284
|
-
`git worktree add "${wtPath}" "origin/${
|
|
299
|
+
`git fetch origin "${safeFeatureBranch}"`,
|
|
300
|
+
`git worktree add "${wtPath}" "origin/${safeFeatureBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${safeFeatureBranch}" && git pull origin "${safeFeatureBranch}")`,
|
|
285
301
|
].join('\n');
|
|
286
302
|
} else {
|
|
303
|
+
let safeMainBranch;
|
|
304
|
+
try {
|
|
305
|
+
safeMainBranch = shared.validateGitRef(mainBranch);
|
|
306
|
+
} catch (refErr) {
|
|
307
|
+
log('error', `Plan ${planFile}: verify dispatch rejected for ${projName} — invalid mainBranch ref: ${refErr.message}. Skipping verify WI creation.`);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const safeBranches = [];
|
|
311
|
+
let badPrBranch = null;
|
|
312
|
+
for (const pr of prs) {
|
|
313
|
+
if (!pr.branch) continue;
|
|
314
|
+
try {
|
|
315
|
+
safeBranches.push({ id: pr.id || pr.branch, branch: shared.validateGitRef(pr.branch) });
|
|
316
|
+
} catch (refErr) {
|
|
317
|
+
badPrBranch = { id: pr.id || '(unknown)', raw: pr.branch, err: refErr };
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (badPrBranch) {
|
|
322
|
+
log('error', `Plan ${planFile}: verify dispatch rejected for ${projName} — invalid pr.branch[${badPrBranch.id}] ref: ${badPrBranch.err.message}. Skipping verify WI creation.`);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
287
325
|
wtPath = `${p.localPath}/../worktrees/verify-${projName}-${planSlug}-${shared.uid()}`;
|
|
288
|
-
const branches = prs.map(pr => pr.branch).filter(Boolean);
|
|
289
326
|
checkoutBlock = [
|
|
290
|
-
`# ${projName} — merge ${
|
|
327
|
+
`# ${projName} — merge ${safeBranches.length} PR branch(es) into one worktree`,
|
|
291
328
|
`cd "${p.localPath.replace(/\\/g, '/')}"`,
|
|
292
|
-
|
|
293
|
-
? `git fetch origin ${
|
|
294
|
-
: `git fetch origin "${
|
|
295
|
-
`git worktree add "${wtPath}" "origin/${
|
|
329
|
+
safeBranches.length > 0
|
|
330
|
+
? `git fetch origin ${safeBranches.map(({ branch }) => `"${branch}"`).join(' ')} "${safeMainBranch}"`
|
|
331
|
+
: `git fetch origin "${safeMainBranch}"`,
|
|
332
|
+
`git worktree add "${wtPath}" "origin/${safeMainBranch}" 2>/dev/null || (cd "${wtPath}" && git checkout "${safeMainBranch}" && git pull origin "${safeMainBranch}")`,
|
|
296
333
|
`cd "${wtPath}"`,
|
|
297
|
-
...
|
|
334
|
+
...safeBranches.map(({ id, branch }) => `git merge "origin/${branch}" --no-edit # ${id}`),
|
|
298
335
|
].join('\n');
|
|
299
336
|
}
|
|
300
337
|
|
|
@@ -2104,7 +2141,11 @@ async function rebaseBranchOntoMain(pr, project, config) {
|
|
|
2104
2141
|
}
|
|
2105
2142
|
const wtRoot = path.resolve(root, config.engine?.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot);
|
|
2106
2143
|
const tmpWt = path.join(wtRoot, `rebase-${shared.sanitizeBranch(branch)}-${Date.now()}`).replace(/\\/g, '/');
|
|
2107
|
-
|
|
2144
|
+
// W-mpcuc8i80003a7b3 — thread ADO bearer-token args through every remote-touching
|
|
2145
|
+
// git op in the rebase flow so headless post-merge rebase against ADO origin
|
|
2146
|
+
// survives missing GCM creds. For GitHub/local projects this is a no-op.
|
|
2147
|
+
const _gitExtraArgs = adoGitAuth.getAdoGitExtraArgs(project);
|
|
2148
|
+
const _gitOpts = { cwd: root, timeout: 30000, windowsHide: true, gitExtraArgs: _gitExtraArgs };
|
|
2108
2149
|
|
|
2109
2150
|
// Refuse to create a worktree nested in the project root — would cause
|
|
2110
2151
|
// glob/grep tools running with cwd=root to match both copies of every file
|
|
@@ -2135,8 +2176,8 @@ async function rebaseBranchOntoMain(pr, project, config) {
|
|
|
2135
2176
|
}
|
|
2136
2177
|
|
|
2137
2178
|
try {
|
|
2138
|
-
await shared.shellSafeGit(['rebase', `origin/${validatedMain}`], { cwd: tmpWt, timeout: 120000, windowsHide: true });
|
|
2139
|
-
await shared.shellSafeGit(['push', '--force-with-lease', 'origin', validatedBranch], { cwd: tmpWt, timeout: 30000, windowsHide: true });
|
|
2179
|
+
await shared.shellSafeGit(['rebase', `origin/${validatedMain}`], { cwd: tmpWt, timeout: 120000, windowsHide: true, gitExtraArgs: _gitExtraArgs });
|
|
2180
|
+
await shared.shellSafeGit(['push', '--force-with-lease', 'origin', validatedBranch], { cwd: tmpWt, timeout: 30000, windowsHide: true, gitExtraArgs: _gitExtraArgs });
|
|
2140
2181
|
log('info', `Post-merge rebase: rebased ${branch} onto ${mainBranch} and force-pushed`);
|
|
2141
2182
|
return { success: true };
|
|
2142
2183
|
} catch (err) {
|
package/engine/llm.js
CHANGED
|
@@ -319,10 +319,13 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
|
|
|
319
319
|
} = callOpts;
|
|
320
320
|
|
|
321
321
|
const id = uid();
|
|
322
|
-
|
|
323
|
-
|
|
322
|
+
// P-f6-tmp-toctou: each direct/indirect LLM call gets its own unguessable
|
|
323
|
+
// tmp dir (mkdtempSync + chmod 0o700 on POSIX). The whole dir is rm'd by
|
|
324
|
+
// cleanupFiles consumers via shared.removeDispatchTmpDir.
|
|
325
|
+
const llmTmpDir = shared.createDispatchTmpDir(`llm-${label || 'call'}-${id}`);
|
|
324
326
|
|
|
325
327
|
const cleanupFiles = [];
|
|
328
|
+
const cleanupDirs = [llmTmpDir];
|
|
326
329
|
const caps = (runtime && runtime.capabilities) || {};
|
|
327
330
|
const adapterOpts = {
|
|
328
331
|
model, maxTurns, allowedTools, effort, sessionId,
|
|
@@ -346,7 +349,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
|
|
|
346
349
|
// via --system-prompt-file (Claude) AND we're not resuming (resumed sessions
|
|
347
350
|
// already have the sys prompt baked in).
|
|
348
351
|
if (!sessionId && sysPromptText && caps.systemPromptFile) {
|
|
349
|
-
sysTmpPath = path.join(
|
|
352
|
+
sysTmpPath = path.join(llmTmpDir, `direct-sys-${id}.md`);
|
|
350
353
|
fs.writeFileSync(sysTmpPath, sysPromptText);
|
|
351
354
|
cleanupFiles.push(sysTmpPath);
|
|
352
355
|
adapterOpts.sysPromptFile = sysTmpPath;
|
|
@@ -369,12 +372,12 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
|
|
|
369
372
|
} else {
|
|
370
373
|
try { proc.stdin.write(finalPrompt); proc.stdin.end(); } catch { /* broken pipe */ }
|
|
371
374
|
}
|
|
372
|
-
return { proc, cleanupFiles };
|
|
375
|
+
return { proc, cleanupFiles, cleanupDirs };
|
|
373
376
|
}
|
|
374
377
|
|
|
375
378
|
// Indirect: use spawn-agent.js (when direct=false or binary cache miss)
|
|
376
|
-
const promptPath = path.join(
|
|
377
|
-
const sysPath = path.join(
|
|
379
|
+
const promptPath = path.join(llmTmpDir, `${label}-prompt-${id}.md`);
|
|
380
|
+
const sysPath = path.join(llmTmpDir, `${label}-sys-${id}.md`);
|
|
378
381
|
// The wrapper merges sys prompt into the user prompt for runtimes without
|
|
379
382
|
// --system-prompt-file (Copilot) — write the user prompt as `finalPrompt`
|
|
380
383
|
// (system block already prepended by buildPrompt) for those, and just the
|
|
@@ -402,7 +405,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
|
|
|
402
405
|
const proc = runFile(process.execPath, args, {
|
|
403
406
|
cwd: MINIONS_DIR, stdio: ['pipe', 'pipe', 'pipe'], env: cleanChildEnv(),
|
|
404
407
|
});
|
|
405
|
-
return { proc, cleanupFiles };
|
|
408
|
+
return { proc, cleanupFiles, cleanupDirs };
|
|
406
409
|
}
|
|
407
410
|
|
|
408
411
|
// ─── Streaming Accumulator ───────────────────────────────────────────────────
|
|
@@ -621,7 +624,7 @@ function callLLM(promptText, sysPromptText, opts = {}) {
|
|
|
621
624
|
let _abort = null;
|
|
622
625
|
const promise = new Promise((resolve) => {
|
|
623
626
|
const _startMs = Date.now();
|
|
624
|
-
const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
|
|
627
|
+
const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
|
|
625
628
|
direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
|
|
626
629
|
maxBudget, bare, fallbackModel,
|
|
627
630
|
...runtimeFeatureOpts,
|
|
@@ -657,6 +660,7 @@ function callLLM(promptText, sysPromptText, opts = {}) {
|
|
|
657
660
|
clearTimeout(timer);
|
|
658
661
|
if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
|
|
659
662
|
for (const f of cleanupFiles) safeUnlink(f);
|
|
663
|
+
for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
|
|
660
664
|
const parsed = acc.finalize();
|
|
661
665
|
const durationMs = Date.now() - _startMs;
|
|
662
666
|
const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
|
|
@@ -694,6 +698,7 @@ function callLLM(promptText, sysPromptText, opts = {}) {
|
|
|
694
698
|
clearTimeout(timer);
|
|
695
699
|
if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
|
|
696
700
|
for (const f of cleanupFiles) safeUnlink(f);
|
|
701
|
+
for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
|
|
697
702
|
shared.log('error', `LLM spawn error (${label}): ${err.message}`);
|
|
698
703
|
resolve({
|
|
699
704
|
text: '', usage: null, sessionId: null, code: 1,
|
|
@@ -733,7 +738,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
|
|
|
733
738
|
let _abort = null;
|
|
734
739
|
const promise = new Promise((resolve) => {
|
|
735
740
|
const _startMs = Date.now();
|
|
736
|
-
const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, {
|
|
741
|
+
const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
|
|
737
742
|
direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
|
|
738
743
|
maxBudget, bare, fallbackModel,
|
|
739
744
|
...runtimeFeatureOpts,
|
|
@@ -772,6 +777,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
|
|
|
772
777
|
clearTimeout(timer);
|
|
773
778
|
if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
|
|
774
779
|
for (const f of cleanupFiles) safeUnlink(f);
|
|
780
|
+
for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
|
|
775
781
|
const parsed = acc.finalize();
|
|
776
782
|
const durationMs = Date.now() - _startMs;
|
|
777
783
|
const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
|
|
@@ -806,6 +812,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
|
|
|
806
812
|
clearTimeout(timer);
|
|
807
813
|
if (exitFallbackTimer) { clearTimeout(exitFallbackTimer); exitFallbackTimer = null; }
|
|
808
814
|
for (const f of cleanupFiles) safeUnlink(f);
|
|
815
|
+
for (const d of (cleanupDirs || [])) shared.removeDispatchTmpDir(d);
|
|
809
816
|
shared.log('error', `LLM-stream spawn error (${label}): ${err.message}`);
|
|
810
817
|
resolve({
|
|
811
818
|
text: '', usage: null, sessionId: null, code: 1,
|
package/engine/meeting.js
CHANGED
|
@@ -694,6 +694,7 @@ function _killMeetingDispatches(meetingId) {
|
|
|
694
694
|
const tmpDir = path.join(shared.MINIONS_DIR, 'engine', 'tmp');
|
|
695
695
|
const entriesToStop = [];
|
|
696
696
|
const filesToDelete = [];
|
|
697
|
+
const dispatchDirsToRemove = [];
|
|
697
698
|
shared.mutateJsonFileLocked(DISPATCH_PATH, (dp) => {
|
|
698
699
|
dp.pending = Array.isArray(dp.pending) ? dp.pending : [];
|
|
699
700
|
dp.active = Array.isArray(dp.active) ? dp.active : [];
|
|
@@ -707,10 +708,17 @@ function _killMeetingDispatches(meetingId) {
|
|
|
707
708
|
continue;
|
|
708
709
|
}
|
|
709
710
|
entriesToStop.push(d);
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
711
|
+
// P-f6-tmp-toctou: prefer per-dispatch dir cleanup. Fall back to
|
|
712
|
+
// legacy individual-file cleanup for entries that pre-date the
|
|
713
|
+
// per-dispatch-dir layout.
|
|
714
|
+
if (d.tmpDir && shared.validateDispatchTmpDir(d.tmpDir)) {
|
|
715
|
+
dispatchDirsToRemove.push(d.tmpDir);
|
|
716
|
+
} else {
|
|
717
|
+
filesToDelete.push(path.join(tmpDir, `pid-${d.id}.pid`));
|
|
718
|
+
filesToDelete.push(path.join(tmpDir, `prompt-${d.id}.md`));
|
|
719
|
+
filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md`));
|
|
720
|
+
filesToDelete.push(path.join(tmpDir, `sysprompt-${d.id}.md.tmp`));
|
|
721
|
+
}
|
|
714
722
|
}
|
|
715
723
|
dp[queue] = kept;
|
|
716
724
|
}
|
|
@@ -725,7 +733,7 @@ function _killMeetingDispatches(meetingId) {
|
|
|
725
733
|
const pidsToKill = [];
|
|
726
734
|
for (const d of entriesToStop) {
|
|
727
735
|
try {
|
|
728
|
-
const pidFile = path.join(tmpDir, `pid-${d.id}.pid`);
|
|
736
|
+
const pidFile = shared.findDispatchPidFile(d) || path.join(tmpDir, `pid-${d.id}.pid`);
|
|
729
737
|
const pid = shared.validatePid(fs.readFileSync(pidFile, 'utf8').trim());
|
|
730
738
|
pidsToKill.push(pid);
|
|
731
739
|
} catch { /* pending entries and already-finished agents may not have PID files */ }
|
|
@@ -736,6 +744,9 @@ function _killMeetingDispatches(meetingId) {
|
|
|
736
744
|
for (const fp of filesToDelete) {
|
|
737
745
|
try { fs.unlinkSync(fp); } catch { /* sidecar may not exist */ }
|
|
738
746
|
}
|
|
747
|
+
for (const dir of dispatchDirsToRemove) {
|
|
748
|
+
shared.removeDispatchTmpDir(dir);
|
|
749
|
+
}
|
|
739
750
|
|
|
740
751
|
if (entriesToStop.length > 0) log('info', `Killed ${entriesToStop.length} meeting dispatch(es) for ${meetingId}`);
|
|
741
752
|
return entriesToStop.length;
|
package/engine/queries.js
CHANGED
|
@@ -1527,73 +1527,142 @@ function resetPrdInfoCache() {
|
|
|
1527
1527
|
}
|
|
1528
1528
|
|
|
1529
1529
|
// ── Project git status (current branch + dirty/detached) ──────────────────
|
|
1530
|
-
// Cached per resolved localPath with a
|
|
1531
|
-
//
|
|
1532
|
-
//
|
|
1533
|
-
//
|
|
1534
|
-
//
|
|
1530
|
+
// Cached per resolved localPath with a 5-minute TTL. `getProjectGitStatus` is
|
|
1531
|
+
// synchronous (returns the cached value or a pending placeholder) and NEVER
|
|
1532
|
+
// blocks the event loop — the actual git probes run asynchronously via
|
|
1533
|
+
// child_process.execFile and write back into the cache when they settle. This
|
|
1534
|
+
// is critical for /api/status responsiveness: under the previous synchronous
|
|
1535
|
+
// implementation, four projects × three git invocations was ~12 sync shell-outs
|
|
1536
|
+
// per slow-state rebuild, which on Windows + Defender real-time scanning could
|
|
1537
|
+
// block the event loop long enough to starve SSE heartbeats and drop the CC
|
|
1538
|
+
// drawer's "Dashboard connection lost". Mirrors the cache shape used by
|
|
1539
|
+
// getDiskVersion in dashboard.js (TTL + cached map). All git invocations pipe
|
|
1540
|
+
// stderr to suppress the `fatal: not a git repository` noise on non-git project
|
|
1541
|
+
// paths — same requirement enforced for install/boot paths in
|
|
1535
1542
|
// test/unit/runtime-fleet-helpers.test.js:465.
|
|
1543
|
+
// Single map keyed by normalized localPath. Each entry holds `{ts, value, promise}`:
|
|
1544
|
+
// ts: last-settled timestamp (Date.now()), or 0 if never settled
|
|
1545
|
+
// value: last-settled status object (or PROJECT_GIT_STATUS_PENDING placeholder)
|
|
1546
|
+
// promise: in-flight refresh Promise, or null when idle
|
|
1547
|
+
// Folding state + in-flight into one map keeps reads/writes atomic and removes
|
|
1548
|
+
// the second-Map sync hazard.
|
|
1536
1549
|
const _projectGitStatusCache = new Map();
|
|
1537
|
-
const PROJECT_GIT_STATUS_TTL =
|
|
1538
|
-
|
|
1550
|
+
const PROJECT_GIT_STATUS_TTL = 300000; // 5 minutes
|
|
1551
|
+
const PROJECT_GIT_STATUS_PENDING = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'pending' });
|
|
1552
|
+
const PROJECT_GIT_STATUS_MISSING = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' });
|
|
1553
|
+
const PROJECT_GIT_STATUS_NON_GIT = Object.freeze({ gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'non-git' });
|
|
1554
|
+
|
|
1555
|
+
// Async git invocation. Promise-returning so getProjectGitStatus can fire
|
|
1556
|
+
// background refreshes without blocking the event loop. Pipes stderr to keep
|
|
1557
|
+
// `fatal: not a git repository` from leaking to the dashboard console.
|
|
1539
1558
|
function _gitExec(localPath, args) {
|
|
1540
|
-
const {
|
|
1541
|
-
return
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1559
|
+
const { execFile } = require('child_process');
|
|
1560
|
+
return new Promise((resolve, reject) => {
|
|
1561
|
+
execFile('git', ['-C', localPath, ...args], {
|
|
1562
|
+
encoding: 'utf8',
|
|
1563
|
+
timeout: 10000,
|
|
1564
|
+
windowsHide: true,
|
|
1565
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1566
|
+
}, (err, stdout) => {
|
|
1567
|
+
if (err) reject(err);
|
|
1568
|
+
else resolve(stdout);
|
|
1569
|
+
});
|
|
1570
|
+
});
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
// Probe a single project. Returns the resolved status value. Used both by the
|
|
1574
|
+
// background refresh path inside getProjectGitStatus and by test code that
|
|
1575
|
+
// wants to drive a probe to completion deterministically.
|
|
1576
|
+
async function _probeProjectGitStatus(localPath) {
|
|
1577
|
+
try {
|
|
1578
|
+
if (!fs.existsSync(localPath)) return PROJECT_GIT_STATUS_MISSING;
|
|
1579
|
+
let isRepo = false;
|
|
1580
|
+
try {
|
|
1581
|
+
const out = (await _gitExec(localPath, ['rev-parse', '--is-inside-work-tree'])).trim();
|
|
1582
|
+
isRepo = out === 'true';
|
|
1583
|
+
} catch { isRepo = false; }
|
|
1584
|
+
if (!isRepo) return PROJECT_GIT_STATUS_NON_GIT;
|
|
1585
|
+
let branch = null;
|
|
1586
|
+
let detached = false;
|
|
1587
|
+
try {
|
|
1588
|
+
branch = (await _gitExec(localPath, ['rev-parse', '--abbrev-ref', 'HEAD'])).trim() || null;
|
|
1589
|
+
} catch { branch = null; }
|
|
1590
|
+
if (branch === 'HEAD') {
|
|
1591
|
+
detached = true;
|
|
1592
|
+
try { branch = (await _gitExec(localPath, ['rev-parse', '--short', 'HEAD'])).trim() || null; }
|
|
1593
|
+
catch { branch = null; }
|
|
1594
|
+
}
|
|
1595
|
+
let dirty = false;
|
|
1596
|
+
try {
|
|
1597
|
+
const status = await _gitExec(localPath, ['status', '--porcelain', '--untracked-files=no']);
|
|
1598
|
+
dirty = status.length > 0;
|
|
1599
|
+
} catch { dirty = false; }
|
|
1600
|
+
return { gitBranch: branch, gitDetached: detached, gitDirty: dirty, gitState: 'ok' };
|
|
1601
|
+
} catch {
|
|
1602
|
+
// Defensive — never let a git probe failure escape this helper.
|
|
1603
|
+
return PROJECT_GIT_STATUS_MISSING;
|
|
1604
|
+
}
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
function _scheduleProjectGitStatusRefresh(localPath, key) {
|
|
1608
|
+
const existing = _projectGitStatusCache.get(key);
|
|
1609
|
+
if (existing && existing.promise) return existing.promise;
|
|
1610
|
+
const entry = existing || { ts: 0, value: PROJECT_GIT_STATUS_PENDING, promise: null };
|
|
1611
|
+
entry.promise = _probeProjectGitStatus(localPath).then(value => {
|
|
1612
|
+
entry.ts = Date.now();
|
|
1613
|
+
entry.value = value;
|
|
1614
|
+
entry.promise = null;
|
|
1615
|
+
return value;
|
|
1616
|
+
}, () => {
|
|
1617
|
+
entry.promise = null;
|
|
1618
|
+
return null;
|
|
1546
1619
|
});
|
|
1620
|
+
_projectGitStatusCache.set(key, entry);
|
|
1621
|
+
return entry.promise;
|
|
1547
1622
|
}
|
|
1548
1623
|
|
|
1549
1624
|
function getProjectGitStatus(localPath) {
|
|
1550
1625
|
const key = String(localPath || '').replace(/\\/g, '/');
|
|
1551
|
-
if (!key) return
|
|
1626
|
+
if (!key) return PROJECT_GIT_STATUS_MISSING;
|
|
1552
1627
|
const now = Date.now();
|
|
1553
1628
|
const cached = _projectGitStatusCache.get(key);
|
|
1554
|
-
if (cached && (now - cached.ts) < PROJECT_GIT_STATUS_TTL) return cached.value;
|
|
1629
|
+
if (cached && cached.ts && (now - cached.ts) < PROJECT_GIT_STATUS_TTL) return cached.value;
|
|
1630
|
+
// Cheap synchronous existsSync — short-circuits a path that just disappeared
|
|
1631
|
+
// (project removed) without scheduling a useless git probe.
|
|
1632
|
+
if (!fs.existsSync(localPath)) {
|
|
1633
|
+
_projectGitStatusCache.set(key, { ts: now, value: PROJECT_GIT_STATUS_MISSING, promise: null });
|
|
1634
|
+
return PROJECT_GIT_STATUS_MISSING;
|
|
1635
|
+
}
|
|
1636
|
+
// Stale or never-populated — kick off a background refresh and return the
|
|
1637
|
+
// previous value (or pending placeholder on the very first call). The next
|
|
1638
|
+
// /api/status response after the refresh settles will have fresh data.
|
|
1639
|
+
_scheduleProjectGitStatusRefresh(localPath, key);
|
|
1640
|
+
return cached ? cached.value : PROJECT_GIT_STATUS_PENDING;
|
|
1641
|
+
}
|
|
1555
1642
|
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
try {
|
|
1572
|
-
branch = _gitExec(localPath, ['rev-parse', '--abbrev-ref', 'HEAD']).trim() || null;
|
|
1573
|
-
} catch { branch = null; }
|
|
1574
|
-
if (branch === 'HEAD') {
|
|
1575
|
-
detached = true;
|
|
1576
|
-
try { branch = _gitExec(localPath, ['rev-parse', '--short', 'HEAD']).trim() || null; }
|
|
1577
|
-
catch { branch = null; }
|
|
1578
|
-
}
|
|
1579
|
-
let dirty = false;
|
|
1580
|
-
try {
|
|
1581
|
-
const status = _gitExec(localPath, ['status', '--porcelain', '--untracked-files=no']);
|
|
1582
|
-
dirty = status.length > 0;
|
|
1583
|
-
} catch { dirty = false; }
|
|
1584
|
-
value = { gitBranch: branch, gitDetached: detached, gitDirty: dirty, gitState: 'ok' };
|
|
1585
|
-
}
|
|
1586
|
-
}
|
|
1587
|
-
} catch {
|
|
1588
|
-
// Defensive — never let a git probe failure break /api/status
|
|
1589
|
-
value = { gitBranch: null, gitDetached: false, gitDirty: false, gitState: 'missing' };
|
|
1643
|
+
// Force a refresh now and wait for completion. Called from engine boot to
|
|
1644
|
+
// pre-warm the cache for every configured project so the first /api/status
|
|
1645
|
+
// after restart already has data. Also used by tests to settle the async
|
|
1646
|
+
// probe deterministically.
|
|
1647
|
+
function warmProjectGitStatus(localPath) {
|
|
1648
|
+
const key = String(localPath || '').replace(/\\/g, '/');
|
|
1649
|
+
if (!key) return Promise.resolve();
|
|
1650
|
+
return _scheduleProjectGitStatusRefresh(localPath, key);
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
// Wait for every in-flight probe to settle. Test helper.
|
|
1654
|
+
function _awaitPendingProjectGitStatusProbes() {
|
|
1655
|
+
const promises = [];
|
|
1656
|
+
for (const entry of _projectGitStatusCache.values()) {
|
|
1657
|
+
if (entry && entry.promise) promises.push(entry.promise);
|
|
1590
1658
|
}
|
|
1591
|
-
|
|
1592
|
-
return value;
|
|
1659
|
+
return Promise.all(promises);
|
|
1593
1660
|
}
|
|
1594
1661
|
|
|
1595
1662
|
/** Reset project git-status cache — exported for testing */
|
|
1596
|
-
function resetProjectGitStatusCache() {
|
|
1663
|
+
function resetProjectGitStatusCache() {
|
|
1664
|
+
_projectGitStatusCache.clear();
|
|
1665
|
+
}
|
|
1597
1666
|
|
|
1598
1667
|
// ── Exports ─────────────────────────────────────────────────────────────────
|
|
1599
1668
|
|
|
@@ -1610,6 +1679,8 @@ module.exports = {
|
|
|
1610
1679
|
resetProjectGitStatusCache,
|
|
1611
1680
|
invalidateKnowledgeBaseCache,
|
|
1612
1681
|
getProjectGitStatus,
|
|
1682
|
+
warmProjectGitStatus,
|
|
1683
|
+
_awaitPendingProjectGitStatusProbes,
|
|
1613
1684
|
|
|
1614
1685
|
// Core state
|
|
1615
1686
|
getConfig, getControl, getDispatch, getDispatchQueue, getDispatchCompletionReport, invalidateDispatchCache,
|