@yemi33/minions 0.1.2372 → 0.1.2373
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/settings.js +1 -0
- package/dashboard.js +127 -112
- package/docs/live-checkout-mode.md +27 -0
- package/docs/runtime-adapters.md +103 -0
- package/engine/acp-transport.js +594 -0
- package/engine/agent-worker-pool.js +367 -0
- package/engine/cc-worker-pool.js +38 -563
- package/engine/cleanup.js +34 -19
- package/engine/cli.js +19 -0
- package/engine/live-checkout.js +168 -0
- package/engine/pooled-agent-process.js +158 -0
- package/engine/runtimes/copilot.js +5 -0
- package/engine/shared.js +75 -1
- package/engine/timeout.js +51 -7
- package/engine.js +81 -7
- package/package.json +1 -1
package/engine/cleanup.js
CHANGED
|
@@ -1387,25 +1387,37 @@ async function runCleanup(config, verbose = false) {
|
|
|
1387
1387
|
log('info', `Cleanup: cleared ${cleaned.doneRetryCounts} stale retry count(s) from done work items`);
|
|
1388
1388
|
}
|
|
1389
1389
|
// PRD items (missing_features[].status)
|
|
1390
|
+
// Read+mutate+write happens inside prdStore.mutatePrd's single transaction
|
|
1391
|
+
// (not readPrd -> mutate-in-JS -> writePrd) so a concurrent writer (another
|
|
1392
|
+
// engine tick, a dashboard API request, or a second process) can't have its
|
|
1393
|
+
// changes clobbered by this pass's stale in-memory copy (lost-update race —
|
|
1394
|
+
// writePrd's _upsertPrd does a full-column data=? replace, not a merge).
|
|
1390
1395
|
try {
|
|
1391
1396
|
const prdStore = require('./prd-store');
|
|
1392
1397
|
const prdFiles = prdStore.listPrdRows().filter(row => !row.archived).map(row => row.filename);
|
|
1393
1398
|
for (const pf of prdFiles) {
|
|
1394
1399
|
const prdPath = path.join(PRD_DIR, pf);
|
|
1395
|
-
|
|
1400
|
+
// Cheap out-of-transaction peek to skip PRDs that clearly need no work;
|
|
1401
|
+
// the authoritative decision (and the write) happens inside mutatePrd.
|
|
1402
|
+
const peek = prdStore.readPrd(prdPath);
|
|
1403
|
+
const needsMigration = peek?.missing_features?.some(feat =>
|
|
1404
|
+
LEGACY_DONE_ALIASES.has(feat.status) || feat.status === LEGACY_NEEDS_REVIEW_STATUS);
|
|
1405
|
+
if (!needsMigration) continue;
|
|
1396
1406
|
let migrated = 0;
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
feat.status
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
feat.status
|
|
1404
|
-
|
|
1407
|
+
prdStore.mutatePrd(prdPath, (prd) => {
|
|
1408
|
+
if (prd?.missing_features) {
|
|
1409
|
+
for (const feat of prd.missing_features) {
|
|
1410
|
+
if (LEGACY_DONE_ALIASES.has(feat.status)) {
|
|
1411
|
+
feat.status = shared.WI_STATUS.DONE;
|
|
1412
|
+
migrated++;
|
|
1413
|
+
} else if (feat.status === LEGACY_NEEDS_REVIEW_STATUS) {
|
|
1414
|
+
feat.status = shared.WI_STATUS.FAILED;
|
|
1415
|
+
migrated++;
|
|
1416
|
+
}
|
|
1405
1417
|
}
|
|
1406
1418
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1419
|
+
return prd;
|
|
1420
|
+
});
|
|
1409
1421
|
if (migrated > 0) {
|
|
1410
1422
|
log('info', `Migrated ${migrated} legacy PRD item status(es) in ${pf}`);
|
|
1411
1423
|
}
|
|
@@ -1413,6 +1425,8 @@ async function runCleanup(config, verbose = false) {
|
|
|
1413
1425
|
} catch (e) { log('warn', 'migrate PRD legacy statuses: ' + e.message); }
|
|
1414
1426
|
|
|
1415
1427
|
// Reset orphaned PRD item statuses — dispatched/failed with no matching work item (#779)
|
|
1428
|
+
// Same lost-update concern as above: use prdStore.mutatePrd so the read,
|
|
1429
|
+
// in-JS mutation, and write are one atomic transaction.
|
|
1416
1430
|
cleaned.orphanedPrdStatuses = 0;
|
|
1417
1431
|
try {
|
|
1418
1432
|
const wiIds = new Set(
|
|
@@ -1426,16 +1440,17 @@ async function runCleanup(config, verbose = false) {
|
|
|
1426
1440
|
const peek = prdStore.readPrd(prdPath);
|
|
1427
1441
|
if (!peek?.missing_features) continue;
|
|
1428
1442
|
let reset = 0;
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1443
|
+
prdStore.mutatePrd(prdPath, (prd) => {
|
|
1444
|
+
if (prd?.missing_features) {
|
|
1445
|
+
for (const feat of prd.missing_features) {
|
|
1446
|
+
if ((feat.status === shared.WI_STATUS.DISPATCHED || feat.status === shared.WI_STATUS.FAILED) && !wiIds.has(feat.id)) {
|
|
1447
|
+
feat.status = shared.WI_STATUS.PENDING;
|
|
1448
|
+
reset++;
|
|
1449
|
+
}
|
|
1435
1450
|
}
|
|
1436
1451
|
}
|
|
1437
|
-
|
|
1438
|
-
|
|
1452
|
+
return prd;
|
|
1453
|
+
});
|
|
1439
1454
|
if (reset > 0) {
|
|
1440
1455
|
log('info', `Reset ${reset} orphaned PRD item status(es) → pending in ${pf}`);
|
|
1441
1456
|
cleaned.orphanedPrdStatuses += reset;
|
package/engine/cli.js
CHANGED
|
@@ -614,6 +614,25 @@ const commands = {
|
|
|
614
614
|
let reattached = 0;
|
|
615
615
|
for (const item of activeOnStart) {
|
|
616
616
|
const agentId = item.agent;
|
|
617
|
+
|
|
618
|
+
// P-3f5b7d26 — a pooled dispatch (engine.js#spawnAgent, item.pooled
|
|
619
|
+
// persisted at dispatch-time) can NEVER be reattached, regardless of
|
|
620
|
+
// what the PID file says. The ACP worker child
|
|
621
|
+
// (engine/acp-transport.js#spawnAcp) is not spawned with
|
|
622
|
+
// `detached: true` like the cold-spawn path, and even on the rare
|
|
623
|
+
// chance its OS process survived the restart, the freshly-started
|
|
624
|
+
// engine's engine/agent-worker-pool.js always cold-starts with an
|
|
625
|
+
// empty worker set — there is no in-process handle whose stdio could
|
|
626
|
+
// be reconnected to that PID. Treating a live-but-orphaned worker PID
|
|
627
|
+
// as "still running" here would silently hang the dispatch forever
|
|
628
|
+
// instead of routing it to the standard "agent died mid-run, mark for
|
|
629
|
+
// retry" path (timeout.js's confirmedDeadAtRestart handling below).
|
|
630
|
+
if (item.pooled) {
|
|
631
|
+
e.engineRestartGraceExempt.add(item.id);
|
|
632
|
+
e.log('info', `Restart: ${agentId} (${item.id}) was a pooled dispatch — not reattachable, marking for retry`);
|
|
633
|
+
continue;
|
|
634
|
+
}
|
|
635
|
+
|
|
617
636
|
let agentPid = readDispatchPid(item.id);
|
|
618
637
|
|
|
619
638
|
if (!agentPid) {
|
package/engine/live-checkout.js
CHANGED
|
@@ -395,6 +395,27 @@ function _defaultResolveAutoBaseRepair(localPath) {
|
|
|
395
395
|
}
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
// W-mrdlcud2000e630e — production default for resolving the live-checkout
|
|
399
|
+
// auto-freshen decision when the caller (engine.js spawnAgent) does not pass an
|
|
400
|
+
// explicit `autoFreshen` boolean. Mirrors `_defaultResolveAutoReset` /
|
|
401
|
+
// `_defaultResolveAutoBaseRepair`: self-resolve from config.json by matching the
|
|
402
|
+
// project on `localPath`, defer to `shared.resolveLiveCheckoutAutoFreshen`. Runs
|
|
403
|
+
// only on the new-branch-fork path when HEAD is on mainRef (cold, not hot).
|
|
404
|
+
// Fails CLOSED — any config read error returns false so a glitch can never
|
|
405
|
+
// silently trigger the one sanctioned live-mode fetch.
|
|
406
|
+
function _defaultResolveAutoFreshen(localPath) {
|
|
407
|
+
try {
|
|
408
|
+
const config = shared.safeJson(path.join(shared.MINIONS_DIR, 'config.json')) || {};
|
|
409
|
+
const projects = shared.getProjects(config);
|
|
410
|
+
const norm = (p) => path.resolve(String(p || '')).replace(/\\/g, '/').toLowerCase();
|
|
411
|
+
const target = norm(localPath);
|
|
412
|
+
const project = projects.find((p) => p && p.localPath && norm(p.localPath) === target) || null;
|
|
413
|
+
return shared.resolveLiveCheckoutAutoFreshen(project, config.engine);
|
|
414
|
+
} catch {
|
|
415
|
+
return false;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
398
419
|
// W-mqvejug6000eeb20 — production default inbox-note writer for the auto-reset
|
|
399
420
|
// audit trail. Lazily required to avoid a load-order cycle (dispatch.js does not
|
|
400
421
|
// require live-checkout.js, but the lazy require keeps this module importable in
|
|
@@ -443,6 +464,110 @@ async function _resolveMainRefForRestore({ git, baseOpts, mainRef, autoBaseRepai
|
|
|
443
464
|
}
|
|
444
465
|
}
|
|
445
466
|
|
|
467
|
+
// W-mrdlcud2000e630e — AUTO-FRESHEN (opt-in). The ONE sanctioned live-mode fetch.
|
|
468
|
+
// Called from prepareLiveCheckout BEFORE the STALE-BASE GUARD, only when: the
|
|
469
|
+
// tree is clean (Step 1 already bailed on a dirty tree), no mid-operation state
|
|
470
|
+
// (Step 2), HEAD is on `mainRef`, and this is NOT an allowNonMainBase
|
|
471
|
+
// (PR-targeted / shared-branch) dispatch. Fetches `origin <mainRef>` then
|
|
472
|
+
// fast-forwards the local `mainRef` to `origin/<mainRef>` ONLY in the strictly
|
|
473
|
+
// safe case — ZERO commits ahead of origin (i.e. NOT the stale-base-with-
|
|
474
|
+
// unpushed-commits case, which must keep hitting the guard's refusal) AND
|
|
475
|
+
// strictly behind. A `--ff-only` merge can never rewrite history or drop local
|
|
476
|
+
// commits (there are none ahead). Every failure path (fetch error, divergence
|
|
477
|
+
// unknown, ahead>0, up-to-date, ff refusal) returns WITHOUT mutating so the
|
|
478
|
+
// caller falls through to the pre-existing stale-base check unchanged
|
|
479
|
+
// ("fail open"). Best-effort audit: a low-noise inbox note + info log record the
|
|
480
|
+
// before/after SHA only when a freshen actually happened (never on every
|
|
481
|
+
// dispatch). Never throws.
|
|
482
|
+
async function _maybeAutoFreshenLocalMain(opts = {}) {
|
|
483
|
+
const { git, baseOpts, mainRef, branchName, logFn, wiId, dispatchId, writeNote } = opts;
|
|
484
|
+
const log = (typeof logFn === 'function') ? logFn : () => {};
|
|
485
|
+
// Step 1: the ONE sanctioned live-mode fetch. Fail OPEN on any error.
|
|
486
|
+
try {
|
|
487
|
+
await git(['fetch', 'origin', mainRef], baseOpts);
|
|
488
|
+
} catch (fetchErr) {
|
|
489
|
+
log(
|
|
490
|
+
`[live-checkout] auto-freshen: 'git fetch origin ${mainRef}' failed ` +
|
|
491
|
+
`(${String((fetchErr && fetchErr.message) || fetchErr).slice(0, 200)}); ` +
|
|
492
|
+
`failing open to the existing stale-base check with the un-fetched local ref.`,
|
|
493
|
+
'warn'
|
|
494
|
+
);
|
|
495
|
+
return { freshened: false, reason: 'fetch-failed' };
|
|
496
|
+
}
|
|
497
|
+
// Step 2: compute divergence against the freshly-updated origin/<mainRef>.
|
|
498
|
+
let ahead = -1;
|
|
499
|
+
let behind = -1;
|
|
500
|
+
try {
|
|
501
|
+
const aheadRaw = await git(['rev-list', '--count', `refs/remotes/origin/${mainRef}..${mainRef}`], baseOpts);
|
|
502
|
+
ahead = parseInt((typeof aheadRaw === 'string' ? aheadRaw : '').trim(), 10);
|
|
503
|
+
if (Number.isNaN(ahead)) ahead = -1;
|
|
504
|
+
const behindRaw = await git(['rev-list', '--count', `${mainRef}..refs/remotes/origin/${mainRef}`], baseOpts);
|
|
505
|
+
behind = parseInt((typeof behindRaw === 'string' ? behindRaw : '').trim(), 10);
|
|
506
|
+
if (Number.isNaN(behind)) behind = -1;
|
|
507
|
+
} catch {
|
|
508
|
+
log(
|
|
509
|
+
`[live-checkout] auto-freshen: could not compute divergence of local '${mainRef}' vs 'origin/${mainRef}' ` +
|
|
510
|
+
`after fetch; proceeding to the existing stale-base check.`,
|
|
511
|
+
'debug'
|
|
512
|
+
);
|
|
513
|
+
return { freshened: false, reason: 'divergence-unknown' };
|
|
514
|
+
}
|
|
515
|
+
// Step 3: only the strictly-safe case. ahead>0 must fall through UNTOUCHED to
|
|
516
|
+
// the STALE-BASE GUARD refusal; up-to-date/behind-unknown needs no freshen.
|
|
517
|
+
if (ahead !== 0 || behind <= 0) {
|
|
518
|
+
return { freshened: false, reason: ahead > 0 ? 'ahead' : 'up-to-date' };
|
|
519
|
+
}
|
|
520
|
+
let beforeSha = '';
|
|
521
|
+
let afterSha = '';
|
|
522
|
+
try {
|
|
523
|
+
const beforeRaw = await git(['rev-parse', '--verify', '--quiet', `refs/heads/${mainRef}`], baseOpts);
|
|
524
|
+
beforeSha = (typeof beforeRaw === 'string' ? beforeRaw : '').trim();
|
|
525
|
+
} catch { /* best-effort — informational only */ }
|
|
526
|
+
// Step 4: fast-forward-only. HEAD is on mainRef (caller-gated), so a plain
|
|
527
|
+
// `git merge --ff-only origin/<mainRef>` advances the checked-out base. If it
|
|
528
|
+
// unexpectedly fails (e.g. a race committed onto local mainRef between the
|
|
529
|
+
// ahead-count and here), FAIL OPEN — the stale-base guard below still protects
|
|
530
|
+
// against forking off a contaminated base.
|
|
531
|
+
try {
|
|
532
|
+
await git(['merge', '--ff-only', `refs/remotes/origin/${mainRef}`], baseOpts);
|
|
533
|
+
} catch (ffErr) {
|
|
534
|
+
log(
|
|
535
|
+
`[live-checkout] auto-freshen: fast-forward of '${mainRef}' to 'origin/${mainRef}' failed ` +
|
|
536
|
+
`(${String((ffErr && ffErr.message) || ffErr).slice(0, 200)}); failing open to the existing stale-base check.`,
|
|
537
|
+
'warn'
|
|
538
|
+
);
|
|
539
|
+
return { freshened: false, reason: 'ff-failed' };
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
const afterRaw = await git(['rev-parse', '--verify', '--quiet', `refs/heads/${mainRef}`], baseOpts);
|
|
543
|
+
afterSha = (typeof afterRaw === 'string' ? afterRaw : '').trim();
|
|
544
|
+
} catch { /* best-effort — informational only */ }
|
|
545
|
+
log(
|
|
546
|
+
`[live-checkout] auto-freshen: fast-forwarded local '${mainRef}' ${behind} commit(s) ` +
|
|
547
|
+
`from ${beforeSha.slice(0, 12) || '(unknown)'} to ${afterSha.slice(0, 12) || '(unknown)'} ` +
|
|
548
|
+
`(origin/${mainRef}) before forking '${branchName}' (liveCheckoutAutoFreshen).`,
|
|
549
|
+
'info'
|
|
550
|
+
);
|
|
551
|
+
// Best-effort low-noise audit trail. Only fires on an actual freshen (rare),
|
|
552
|
+
// so it never alert-spams a healthy dispatch. Never throws.
|
|
553
|
+
try {
|
|
554
|
+
const write = (typeof writeNote === 'function') ? writeNote : _defaultWriteInboxNote;
|
|
555
|
+
const slug = `live-checkout-autofreshen-${wiId || dispatchId || 'unknown'}`;
|
|
556
|
+
const body = [
|
|
557
|
+
`# Live-checkout auto-freshen of '${mainRef}'`,
|
|
558
|
+
'',
|
|
559
|
+
`The live-checkout base branch \`${mainRef}\` was behind \`origin/${mainRef}\` by ${behind} commit(s)`,
|
|
560
|
+
`with no local-ahead commits, so \`liveCheckoutAutoFreshen\` fast-forwarded it before forking`,
|
|
561
|
+
`\`${branchName}\`. This is a non-destructive \`--ff-only\` update (no history rewrite, no lost work):`,
|
|
562
|
+
'',
|
|
563
|
+
`- before: \`${beforeSha || '(unknown)'}\``,
|
|
564
|
+
`- after: \`${afterSha || '(unknown)'}\` (= \`origin/${mainRef}\`)`,
|
|
565
|
+
].join('\n');
|
|
566
|
+
write(slug, body);
|
|
567
|
+
} catch { /* best-effort audit note */ }
|
|
568
|
+
return { freshened: true, before: beforeSha, after: afterSha, behind };
|
|
569
|
+
}
|
|
570
|
+
|
|
446
571
|
async function prepareLiveCheckout(opts = {}) {
|
|
447
572
|
const {
|
|
448
573
|
localPath,
|
|
@@ -466,11 +591,18 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
466
591
|
// defers to `_resolveAutoBaseRepair`. When enabled, the
|
|
467
592
|
// wrong-base recovery may materialize a local base branch from
|
|
468
593
|
// `refs/remotes/origin/<mainRef>` (no fetch) instead of failing.
|
|
594
|
+
autoFreshen, // W-mrdlcud2000e630e: tri-state. `true`/`false` short-circuits
|
|
595
|
+
// config resolution; `undefined` (the engine.js call shape)
|
|
596
|
+
// defers to `_resolveAutoFreshen`. When enabled AND HEAD is on
|
|
597
|
+
// mainRef with zero local-ahead commits but behind origin, the
|
|
598
|
+
// ONE sanctioned live-mode `git fetch origin <mainRef>` +
|
|
599
|
+
// `--ff-only` fast-forward runs before the stale-base guard.
|
|
469
600
|
_git, // private injection for testing — defaults to shared.shellSafeGit
|
|
470
601
|
_exists, // private injection for testing — defaults to fs.existsSync
|
|
471
602
|
_rm, // private injection for testing — defaults to fs.rmSync (recursive, force)
|
|
472
603
|
_resolveAutoReset, // private injection for testing — defaults to config-based resolver
|
|
473
604
|
_resolveAutoBaseRepair, // private injection for testing — defaults to config-based resolver
|
|
605
|
+
_resolveAutoFreshen, // private injection for testing — defaults to config-based resolver
|
|
474
606
|
_writeInboxNote, // private injection for testing — defaults to dispatch.writeInboxAlert
|
|
475
607
|
_sleep, // private injection for testing — defaults to a real setTimeout-based sleep
|
|
476
608
|
} = opts;
|
|
@@ -1082,6 +1214,41 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
1082
1214
|
}
|
|
1083
1215
|
|
|
1084
1216
|
|
|
1217
|
+
// ── Step 4d-freshen: AUTO-FRESHEN (opt-in, W-mrdlcud2000e630e). ────────
|
|
1218
|
+
// BEFORE the STALE-BASE GUARD, optionally fast-forward the local base branch
|
|
1219
|
+
// to origin/<mainRef> so the new branch forks off the freshest safe base.
|
|
1220
|
+
// This is the ONE sanctioned live-mode fetch (issue #226 otherwise forbids
|
|
1221
|
+
// fetching live checkouts to preserve operator control over history rewrites).
|
|
1222
|
+
//
|
|
1223
|
+
// Scope mirrors the STALE-BASE GUARD's skip conditions EXACTLY: it runs only
|
|
1224
|
+
// when HEAD is on mainRef (`curBranch === mainRef` — the base we fork off) and
|
|
1225
|
+
// this is NOT an allowNonMainBase (PR-targeted fix / shared-branch
|
|
1226
|
+
// continuation) dispatch. The dirty-tree bail (Step 1) and mid-operation
|
|
1227
|
+
// preflight (Step 2) have already run, so a dirty or mid-operation tree never
|
|
1228
|
+
// reaches here — the freshen is never attempted on one.
|
|
1229
|
+
//
|
|
1230
|
+
// The helper only fast-forwards in the strictly-safe case (zero commits ahead
|
|
1231
|
+
// of origin AND behind origin); the ahead>0 stale-base case is left UNTOUCHED
|
|
1232
|
+
// so the guard below still refuses it. A `--ff-only` update can never rewrite
|
|
1233
|
+
// history or drop local commits, and any fetch/network/ff failure fails OPEN
|
|
1234
|
+
// to the un-freshened stale-base check. Applies uniformly to plain live mode
|
|
1235
|
+
// and hybrid mode's liveValidation dispatch (both reuse this preflight).
|
|
1236
|
+
if (!allowNonMainBase && curBranch && curBranch === mainRef) {
|
|
1237
|
+
let wantFreshen = false;
|
|
1238
|
+
if (typeof autoFreshen === 'boolean') {
|
|
1239
|
+
wantFreshen = autoFreshen;
|
|
1240
|
+
} else {
|
|
1241
|
+
const resolver = (typeof _resolveAutoFreshen === 'function') ? _resolveAutoFreshen : _defaultResolveAutoFreshen;
|
|
1242
|
+
try { wantFreshen = !!resolver(localPath); } catch { wantFreshen = false; }
|
|
1243
|
+
}
|
|
1244
|
+
if (wantFreshen) {
|
|
1245
|
+
await _maybeAutoFreshenLocalMain({
|
|
1246
|
+
git, baseOpts, mainRef, branchName, logFn, wiId, dispatchId,
|
|
1247
|
+
writeNote: (typeof _writeInboxNote === 'function') ? _writeInboxNote : undefined,
|
|
1248
|
+
});
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1085
1252
|
// ── Step 4d: STALE-BASE GUARD (W-mr98op8w000ma4ad). ───────────────────
|
|
1086
1253
|
// We are about to fork a NEW branch off the CURRENT HEAD. In the common
|
|
1087
1254
|
// live-mode case HEAD sits on the project base ref (mainRef). Step 1 (dirty
|
|
@@ -1877,6 +2044,7 @@ module.exports = {
|
|
|
1877
2044
|
_looksLikeAgentBranch,
|
|
1878
2045
|
_extractBranchFromPorcelainHeader,
|
|
1879
2046
|
_commitAgentWipWithRetry,
|
|
2047
|
+
_maybeAutoFreshenLocalMain,
|
|
1880
2048
|
capDirtyFileLines,
|
|
1881
2049
|
DIRTY_ALERT_MAX_LINES,
|
|
1882
2050
|
LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/pooled-agent-process.js — child_process-shaped facade over a leased
|
|
3
|
+
* engine/agent-worker-pool.js worker (P-1d8f0b93).
|
|
4
|
+
*
|
|
5
|
+
* engine.js#spawnAgent's downstream code (stdout/stderr `.on('data', ...)`
|
|
6
|
+
* handlers, the `onAgentClose` completion listener, PID-file/timeout
|
|
7
|
+
* bookkeeping, restart-reattach) is written entirely against the Node
|
|
8
|
+
* ChildProcess contract. Per the plan's explicit constraint ("do not fork
|
|
9
|
+
* spawnAgent into separate end-to-end completion implementations"),
|
|
10
|
+
* PooledAgentProcess wraps an agent-worker-pool.js lease behind that SAME
|
|
11
|
+
* shape — `.pid`, `.stdout`/`.stderr` (readable streams), `.kill()`,
|
|
12
|
+
* `.killed`, `.exitCode`, and `'close'`/`'error'` events — so spawnAgent's
|
|
13
|
+
* existing listener code (stdout/stderr capture, live-output.log writing,
|
|
14
|
+
* session-id capture, steering, trust-gate detection, archival) runs
|
|
15
|
+
* completely unmodified whether `proc` is a real child_process or a pooled
|
|
16
|
+
* ACP lease.
|
|
17
|
+
*
|
|
18
|
+
* Output translation (parity, not full fidelity): ACP `session/update` text
|
|
19
|
+
* chunks are re-encoded as the SAME Copilot JSONL event shape
|
|
20
|
+
* `engine/runtimes/copilot.js#parseOutput` already parses from a real
|
|
21
|
+
* `copilot` CLI stdout stream — `{"type":"assistant.message_delta","data":
|
|
22
|
+
* {"deltaContent":"..."}}` per chunk, plus a terminal `{"type":"result",
|
|
23
|
+
* "sessionId":...,"usage":{...}}` line once the turn completes. This is the
|
|
24
|
+
* minimum subset `parseOutput` needs to recover an equivalent `{text,
|
|
25
|
+
* sessionId, usage}` to the cold-spawn path (deltas alone populate its
|
|
26
|
+
* `pendingDeltaContent` accumulator; no `assistant.message`/`session.
|
|
27
|
+
* tools_updated`/`tool.execution_*` events are required for that recovery).
|
|
28
|
+
* Tool-call and model-announcement events are intentionally NOT synthesized
|
|
29
|
+
* — a deliberate, documented scope limit, not an oversight: the live-output
|
|
30
|
+
* log still gets the real text in real time either way, and `usage`'s
|
|
31
|
+
* cost/token fields are already null-for-copilot on the cold path (see
|
|
32
|
+
* `runtimes/copilot.js#parseOutput`), so nothing measurable is lost.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const { EventEmitter } = require('events');
|
|
36
|
+
const { PassThrough } = require('stream');
|
|
37
|
+
|
|
38
|
+
class PooledAgentProcess extends EventEmitter {
|
|
39
|
+
/**
|
|
40
|
+
* @param {object} opts
|
|
41
|
+
* @param {object} opts.pool - engine/agent-worker-pool.js module (or a test double)
|
|
42
|
+
* @param {string} opts.dispatchId
|
|
43
|
+
* @param {string} opts.cwd
|
|
44
|
+
* @param {string} [opts.model]
|
|
45
|
+
* @param {string} [opts.effort]
|
|
46
|
+
* @param {Array} [opts.mcpServers]
|
|
47
|
+
* @param {Array<string>} [opts.hermeticDirs]
|
|
48
|
+
* @param {string} opts.promptText - fully-built prompt (runtime.buildPrompt output)
|
|
49
|
+
*/
|
|
50
|
+
constructor({ pool, dispatchId, cwd, model, effort, mcpServers, hermeticDirs, promptText }) {
|
|
51
|
+
super();
|
|
52
|
+
this.pid = null;
|
|
53
|
+
this.killed = false;
|
|
54
|
+
this.exitCode = null;
|
|
55
|
+
this.stdout = new PassThrough();
|
|
56
|
+
this.stderr = new PassThrough();
|
|
57
|
+
this._pool = pool;
|
|
58
|
+
this._dispatchId = dispatchId;
|
|
59
|
+
this._handle = null;
|
|
60
|
+
this._closed = false;
|
|
61
|
+
this._startedAt = Date.now();
|
|
62
|
+
|
|
63
|
+
// Fire-and-forget, mirroring child_process.spawn()'s async nature — the
|
|
64
|
+
// facade is usable (event listeners can be attached) immediately, before
|
|
65
|
+
// the underlying ACP worker has actually been acquired.
|
|
66
|
+
this._start({ cwd, model, effort, mcpServers, hermeticDirs, promptText });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async _start({ cwd, model, effort, mcpServers, hermeticDirs, promptText }) {
|
|
70
|
+
let handle;
|
|
71
|
+
try {
|
|
72
|
+
handle = await this._pool.acquireWorker({
|
|
73
|
+
dispatchId: this._dispatchId, cwd, model, effort, mcpServers, hermeticDirs,
|
|
74
|
+
});
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// Mirrors child_process's 'error' event (spawn failed) — engine.js's
|
|
77
|
+
// existing `proc.on('error', ...)` handler already does the right
|
|
78
|
+
// thing (completeDispatch ERROR + cleanup) unmodified.
|
|
79
|
+
this.emit('error', err);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (this.killed) {
|
|
84
|
+
// kill() landed while acquisition was still in flight. No OS process
|
|
85
|
+
// was ever visible to anything downstream — just return the worker.
|
|
86
|
+
try { handle.release(); } catch { /* best-effort */ }
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this._handle = handle;
|
|
91
|
+
this.pid = typeof handle.pid === 'number' ? handle.pid : null;
|
|
92
|
+
// Lets engine.js write the PID-file equivalent (spawn-agent.js normally
|
|
93
|
+
// does this itself, from inside the child process) as soon as a real OS
|
|
94
|
+
// PID is known — see engine.js's pooled branch in spawnAgent().
|
|
95
|
+
this.emit('acquired');
|
|
96
|
+
|
|
97
|
+
let sawError = false;
|
|
98
|
+
await handle.stream(promptText, {
|
|
99
|
+
onChunk: (text) => {
|
|
100
|
+
if (!text) return;
|
|
101
|
+
this._writeStdoutEvent({ type: 'assistant.message_delta', data: { deltaContent: text } });
|
|
102
|
+
},
|
|
103
|
+
onDone: (result) => {
|
|
104
|
+
this._writeStdoutEvent({
|
|
105
|
+
type: 'result',
|
|
106
|
+
sessionId: handle.sessionId || null,
|
|
107
|
+
usage: {
|
|
108
|
+
totalApiDurationMs: Date.now() - this._startedAt,
|
|
109
|
+
stopReason: (result && result.stopReason) || null,
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
},
|
|
113
|
+
onError: (err) => {
|
|
114
|
+
sawError = true;
|
|
115
|
+
try { this.stderr.write(String((err && err.message) || err) + '\n'); } catch { /* best-effort */ }
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
try { handle.release(); } catch { /* best-effort */ }
|
|
120
|
+
this._finish(sawError ? 1 : 0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_writeStdoutEvent(obj) {
|
|
124
|
+
try { this.stdout.write(JSON.stringify(obj) + '\n'); } catch { /* best-effort */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
_finish(code) {
|
|
128
|
+
if (this._closed) return;
|
|
129
|
+
this._closed = true;
|
|
130
|
+
this.exitCode = code;
|
|
131
|
+
try { this.stdout.end(); } catch { /* best-effort */ }
|
|
132
|
+
try { this.stderr.end(); } catch { /* best-effort */ }
|
|
133
|
+
this.emit('close', code);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// child_process.kill()-shaped primitive. Cancels the in-flight ACP turn
|
|
137
|
+
// (if any) and releases the worker back to the pool rather than killing
|
|
138
|
+
// the shared OS process — killing it would collaterally affect whatever
|
|
139
|
+
// OTHER dispatch reuses that worker next (never a concurrent sibling, per
|
|
140
|
+
// the pool's 1-worker:1-active-dispatch invariant, but still a real
|
|
141
|
+
// process other queued dispatches may be waiting to reuse).
|
|
142
|
+
// Full timeout/steering-kill cancel-vs-kill wiring in engine/timeout.js is
|
|
143
|
+
// P-6e2a4c15 — this method is the primitive that work item calls into.
|
|
144
|
+
kill() {
|
|
145
|
+
if (this.killed) return true;
|
|
146
|
+
this.killed = true;
|
|
147
|
+
if (this._handle) {
|
|
148
|
+
try { this._handle.cancel(); } catch { /* best-effort */ }
|
|
149
|
+
try { this._handle.release(); } catch { /* best-effort */ }
|
|
150
|
+
this._finish(this.exitCode == null ? 143 : this.exitCode);
|
|
151
|
+
}
|
|
152
|
+
// If acquisition is still in flight, `_start` observes `this.killed` and
|
|
153
|
+
// releases the worker itself as soon as it lands — nothing more to do.
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
module.exports = { PooledAgentProcess };
|
|
@@ -1285,6 +1285,11 @@ const capabilities = {
|
|
|
1285
1285
|
// `--attachment <path>` works in non-interactive mode (live-tested; see W-mqv7324u0021db5d).
|
|
1286
1286
|
// Base64 payloads are materialized to tmp files inside llmTmpDir before spawn.
|
|
1287
1287
|
imageInput: true,
|
|
1288
|
+
// P-9b3d5f61: `copilot --acp` speaks the Agent Client Protocol, so per-agent
|
|
1289
|
+
// dispatches can route through a persistent ACP worker pool
|
|
1290
|
+
// (engine/agent-worker-pool.js) instead of cold-spawning per dispatch.
|
|
1291
|
+
// Claude/Codex do not implement ACP — this flag is absent (falsy) there.
|
|
1292
|
+
acpWorkerPool: true,
|
|
1288
1293
|
};
|
|
1289
1294
|
|
|
1290
1295
|
// Install hint surfaced when `resolveBinary()` returns null. Covers all
|
package/engine/shared.js
CHANGED
|
@@ -2945,6 +2945,26 @@ function resolveLiveCheckoutAutoBaseRepair(project, engine) {
|
|
|
2945
2945
|
return false;
|
|
2946
2946
|
}
|
|
2947
2947
|
|
|
2948
|
+
// W-mrdlcud2000e630e (companion to the stale-base guard) — resolve the
|
|
2949
|
+
// live-checkout auto-freshen decision. Per-project `liveCheckoutAutoFreshen`
|
|
2950
|
+
// wins, else fleet-wide `engine.liveCheckoutAutoFreshen`, else false (opt-in).
|
|
2951
|
+
// When ON, prepareLiveCheckout — BEFORE the stale-base guard, and ONLY when the
|
|
2952
|
+
// tree is clean, HEAD is on mainRef, and the local mainRef has ZERO commits
|
|
2953
|
+
// ahead of origin/<mainRef> but IS behind it — runs `git fetch origin <mainRef>`
|
|
2954
|
+
// + a fast-forward-only update of the local mainRef so the new branch forks off
|
|
2955
|
+
// the freshest safe base. This is the ONLY live-mode path that fetches/mutates
|
|
2956
|
+
// local mainRef; the ahead>0 stale-base refusal is untouched, and a fetch
|
|
2957
|
+
// failure fails open to the pre-existing (un-freshened) behavior. Default OFF.
|
|
2958
|
+
function resolveLiveCheckoutAutoFreshen(project, engine) {
|
|
2959
|
+
if (project && typeof project === 'object' && typeof project.liveCheckoutAutoFreshen === 'boolean') {
|
|
2960
|
+
return project.liveCheckoutAutoFreshen;
|
|
2961
|
+
}
|
|
2962
|
+
if (engine && typeof engine === 'object' && typeof engine.liveCheckoutAutoFreshen === 'boolean') {
|
|
2963
|
+
return engine.liveCheckoutAutoFreshen;
|
|
2964
|
+
}
|
|
2965
|
+
return false;
|
|
2966
|
+
}
|
|
2967
|
+
|
|
2948
2968
|
function validateCheckoutMode(value) {
|
|
2949
2969
|
if (value === undefined || value === null || value === '') return undefined;
|
|
2950
2970
|
if (typeof value !== 'string') {
|
|
@@ -3466,6 +3486,7 @@ const ENGINE_DEFAULTS = {
|
|
|
3466
3486
|
copilotStreamMode: 'on', // Copilot --stream <on|off>: 'on' streams assistant.message_delta events live; 'off' batches them
|
|
3467
3487
|
copilotReasoningSummaries: false, // Copilot --enable-reasoning-summaries (Anthropic-family models only)
|
|
3468
3488
|
ccUseWorkerPool: false, // Sub-task C of W-mp2w003600196c51 (CC perf): when true AND CC runtime is copilot, _invokeCcStream routes through engine/cc-worker-pool.js (persistent `copilot --acp` per CC tab) instead of spawning a fresh CLI per turn. Off by default — opt-in feature flag. **Structurally copilot-only**: the pool spawns `copilot --acp` (Agent Client Protocol); Claude Code does not implement ACP, so resolveCcUseWorkerPool returns false on non-copilot CC runtimes even with explicit-true (W-mphlriic00095f69 — prevents silent runtime switch). Engine/agent dispatch path stays per-process regardless.
|
|
3489
|
+
agentUseWorkerPool: false, // P-9b3d5f61: mirrors ccUseWorkerPool's shape but for the fleet agent-dispatch path (engine/agent-worker-pool.js). Opt-in, default OFF — see resolveAgentUseWorkerPool in engine/shared.js for the full priority chain and rationale.
|
|
3469
3490
|
// PR #321 — "kill auth-popup storm". Copilot spawns and authenticates EVERY
|
|
3470
3491
|
// MCP server in `$COPILOT_HOME/mcp-config.json` on every process start, even
|
|
3471
3492
|
// when the corresponding tool is hidden from the model via
|
|
@@ -3845,6 +3866,58 @@ function resolveCcUseWorkerPool(engine) {
|
|
|
3845
3866
|
return true; // CC runtime is copilot — default ON (cold-start savings)
|
|
3846
3867
|
}
|
|
3847
3868
|
|
|
3869
|
+
/**
|
|
3870
|
+
* P-9b3d5f61 — Resolve whether a per-agent spawn should route through the
|
|
3871
|
+
* persistent ACP worker pool (engine/agent-worker-pool.js). Mirrors
|
|
3872
|
+
* resolveCcUseWorkerPool's shape but takes the already-resolved runtime
|
|
3873
|
+
* adapter (as returned by `resolveRuntime(resolveAgentCli(agent, engine))`)
|
|
3874
|
+
* rather than re-deriving it, since callers on the agent-spawn path already
|
|
3875
|
+
* have it in hand. Priority:
|
|
3876
|
+
* 1. Capability guard — pool transport is ACP-only (`copilot --acp`). Hard
|
|
3877
|
+
* false whenever `runtime.capabilities.acpWorkerPool` isn't `true`,
|
|
3878
|
+
* regardless of operator override, so a future non-ACP runtime can never
|
|
3879
|
+
* be silently routed through the pool (same rationale as
|
|
3880
|
+
* resolveCcUseWorkerPool's CC-runtime guard, W-mphlriic00095f69).
|
|
3881
|
+
* 2. `engine.agentUseWorkerPool` explicit true/false — operator override
|
|
3882
|
+
* wins *within* an ACP-capable runtime.
|
|
3883
|
+
* 3. `ENGINE_DEFAULTS.agentUseWorkerPool` (false) — opt-in, default OFF.
|
|
3884
|
+
* Deliberately different from CC's default-true: the fleet dispatch path
|
|
3885
|
+
* has a much larger MCP-auth-popup / isolation surface than the single
|
|
3886
|
+
* CC singleton, so this stays off until proven safe.
|
|
3887
|
+
*/
|
|
3888
|
+
function resolveAgentUseWorkerPool(engine, runtime) {
|
|
3889
|
+
if (!runtime || !runtime.capabilities || runtime.capabilities.acpWorkerPool !== true) return false;
|
|
3890
|
+
if (engine && (engine.agentUseWorkerPool === true || engine.agentUseWorkerPool === false)) {
|
|
3891
|
+
return engine.agentUseWorkerPool;
|
|
3892
|
+
}
|
|
3893
|
+
return ENGINE_DEFAULTS.agentUseWorkerPool;
|
|
3894
|
+
}
|
|
3895
|
+
|
|
3896
|
+
/**
|
|
3897
|
+
* P-9b3d5f61 — Resolve the max concurrent ACP workers the per-agent pool
|
|
3898
|
+
* (engine/agent-worker-pool.js) should keep warm. Priority:
|
|
3899
|
+
* 1. `engine.agentAcpPoolSize` — explicit operator override
|
|
3900
|
+
* 2. `engine.maxConcurrent` — fleet dispatch concurrency cap (so the
|
|
3901
|
+
* pool never queues more than the engine would have dispatched anyway)
|
|
3902
|
+
* 3. `ENGINE_DEFAULTS.maxConcurrent` — hardcoded fallback
|
|
3903
|
+
*
|
|
3904
|
+
* Non-numeric or non-positive overrides are ignored and fall through to the
|
|
3905
|
+
* next tier (defensive against bad Settings-UI/env input).
|
|
3906
|
+
*/
|
|
3907
|
+
function resolveAgentAcpPoolSize(engine) {
|
|
3908
|
+
const explicit = engine ? engine.agentAcpPoolSize : undefined;
|
|
3909
|
+
if (explicit !== undefined && explicit !== null) {
|
|
3910
|
+
const n = typeof explicit === 'number' ? explicit : Number(explicit);
|
|
3911
|
+
if (!Number.isNaN(n) && n > 0) return n;
|
|
3912
|
+
}
|
|
3913
|
+
const fleet = engine ? engine.maxConcurrent : undefined;
|
|
3914
|
+
if (fleet !== undefined && fleet !== null) {
|
|
3915
|
+
const n = typeof fleet === 'number' ? fleet : Number(fleet);
|
|
3916
|
+
if (!Number.isNaN(n) && n > 0) return n;
|
|
3917
|
+
}
|
|
3918
|
+
return ENGINE_DEFAULTS.maxConcurrent;
|
|
3919
|
+
}
|
|
3920
|
+
|
|
3848
3921
|
/**
|
|
3849
3922
|
* Resolve the model for a per-agent spawn. Priority:
|
|
3850
3923
|
* 1. `agent.model` — per-agent override
|
|
@@ -8981,7 +9054,7 @@ module.exports = {
|
|
|
8981
9054
|
isEngineSystemAlert,
|
|
8982
9055
|
ENGINE_DEFAULTS,
|
|
8983
9056
|
resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
|
|
8984
|
-
resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentModel, resolveCcModel,
|
|
9057
|
+
resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentUseWorkerPool, resolveAgentAcpPoolSize, resolveAgentModel, resolveCcModel,
|
|
8985
9058
|
resolveAgentMaxBudget, resolveAgentBareMode,
|
|
8986
9059
|
resolveCopilotAgentDisabledMcpServers,
|
|
8987
9060
|
applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
|
|
@@ -9120,6 +9193,7 @@ module.exports = {
|
|
|
9120
9193
|
isLiveCheckoutProject,
|
|
9121
9194
|
resolveLiveCheckoutAutoReset,
|
|
9122
9195
|
resolveLiveCheckoutAutoBaseRepair,
|
|
9196
|
+
resolveLiveCheckoutAutoFreshen,
|
|
9123
9197
|
validatePid,
|
|
9124
9198
|
PR_FIX_CAUSE,
|
|
9125
9199
|
getPrFixAutomationCause,
|