@yemi33/minions 0.1.2382 → 0.1.2384
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/minions.js +1 -0
- package/dashboard/js/refresh.js +2 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard.js +37 -19
- package/docs/completion-reports.md +20 -1
- package/docs/keep-processes.md +7 -2
- package/docs/live-checkout-mode.md +7 -7
- package/docs/managed-spawn.md +14 -1
- package/docs/runtime-adapters.md +61 -26
- package/docs/skills.md +2 -0
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +36 -0
- package/engine/acp-transport.js +273 -58
- package/engine/ado-comment.js +3 -1
- package/engine/agent-worker-pool.js +278 -54
- package/engine/cc-worker-pool.js +12 -3
- package/engine/cleanup.js +15 -11
- package/engine/cli.js +56 -28
- package/engine/comment-format.js +51 -14
- package/engine/create-pr-worktree.js +57 -79
- package/engine/dispatch.js +7 -2
- package/engine/execution-model.js +68 -0
- package/engine/gh-comment.js +6 -3
- package/engine/github.js +6 -7
- package/engine/keep-process-sweep.js +131 -9
- package/engine/lifecycle.js +126 -45
- package/engine/live-checkout.js +4 -2
- package/engine/llm.js +59 -83
- package/engine/managed-spawn.js +112 -5
- package/engine/memory-store.js +3 -1
- package/engine/playbook.js +4 -6
- package/engine/pooled-agent-process.js +512 -45
- package/engine/pr-action.js +6 -8
- package/engine/process-utils.js +154 -18
- package/engine/runtimes/claude.js +94 -25
- package/engine/runtimes/codex.js +1 -0
- package/engine/runtimes/contract.js +239 -0
- package/engine/runtimes/copilot.js +97 -9
- package/engine/runtimes/index.js +37 -9
- package/engine/shared.js +349 -82
- package/engine/spawn-agent.js +85 -116
- package/engine/spawn-phase-watchdog.js +8 -37
- package/engine/timeout.js +14 -10
- package/engine/worktree-gc.js +55 -46
- package/engine.js +418 -241
- package/package.json +1 -1
- package/playbooks/fix.md +20 -0
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +2 -2
- package/prompts/cc-system.md +11 -11
- package/skills/check-self-authored-review-comment/SKILL.md +34 -0
package/engine/shared.js
CHANGED
|
@@ -920,6 +920,9 @@ function safeJsonNoRestore(p, defaultValue = null) {
|
|
|
920
920
|
* avoid temp file name collisions.
|
|
921
921
|
*/
|
|
922
922
|
let _tmpCounter = 0;
|
|
923
|
+
const SAFE_WRITE_RENAME_ATTEMPTS = 20;
|
|
924
|
+
const SAFE_WRITE_RENAME_RETRY_BASE_MS = 50;
|
|
925
|
+
const SAFE_WRITE_RENAME_RETRY_MAX_MS = 250;
|
|
923
926
|
|
|
924
927
|
function safeWrite(p, data) {
|
|
925
928
|
const dir = path.dirname(p);
|
|
@@ -929,13 +932,16 @@ function safeWrite(p, data) {
|
|
|
929
932
|
try {
|
|
930
933
|
fs.writeFileSync(tmp, content);
|
|
931
934
|
// Atomic rename — retry on Windows EPERM (file locking)
|
|
932
|
-
for (let attempt = 0; attempt <
|
|
935
|
+
for (let attempt = 0; attempt < SAFE_WRITE_RENAME_ATTEMPTS; attempt++) {
|
|
933
936
|
try {
|
|
934
937
|
fs.renameSync(tmp, p);
|
|
935
938
|
return;
|
|
936
939
|
} catch (e) {
|
|
937
|
-
if (e.code === 'EPERM' && attempt <
|
|
938
|
-
const delay =
|
|
940
|
+
if (e.code === 'EPERM' && attempt < SAFE_WRITE_RENAME_ATTEMPTS - 1) {
|
|
941
|
+
const delay = Math.min(
|
|
942
|
+
SAFE_WRITE_RENAME_RETRY_BASE_MS * (attempt + 1),
|
|
943
|
+
SAFE_WRITE_RENAME_RETRY_MAX_MS
|
|
944
|
+
);
|
|
939
945
|
sleepMs(delay);
|
|
940
946
|
continue;
|
|
941
947
|
}
|
|
@@ -946,7 +952,7 @@ function safeWrite(p, data) {
|
|
|
946
952
|
}
|
|
947
953
|
// All rename attempts exhausted without throw — should not happen, but clean up
|
|
948
954
|
try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
|
|
949
|
-
throw new Error(`[safeWrite] All
|
|
955
|
+
throw new Error(`[safeWrite] All ${SAFE_WRITE_RENAME_ATTEMPTS} rename attempts failed for ${p}`);
|
|
950
956
|
} catch (err) {
|
|
951
957
|
// Clean up tmp if it still exists, then re-throw — never silently swallow
|
|
952
958
|
try { fs.unlinkSync(tmp); } catch { /* cleanup */ }
|
|
@@ -1406,6 +1412,104 @@ function pruneCrashDiagnosticsReports(config) {
|
|
|
1406
1412
|
return removed;
|
|
1407
1413
|
}
|
|
1408
1414
|
|
|
1415
|
+
const FILE_LOCK_RELEASE_ATTEMPTS = 5;
|
|
1416
|
+
const FILE_LOCK_RELEASE_RETRY_DELAY_MS = 25;
|
|
1417
|
+
const FILE_LOCK_RETRY_MAX_DELAY_MS = 250;
|
|
1418
|
+
|
|
1419
|
+
function _releaseFileLock(lockPath, fd) {
|
|
1420
|
+
try { fs.closeSync(fd); } catch { /* continue to path cleanup */ }
|
|
1421
|
+
|
|
1422
|
+
const attempts = process.platform === 'win32' ? FILE_LOCK_RELEASE_ATTEMPTS : 1;
|
|
1423
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
1424
|
+
try {
|
|
1425
|
+
fs.unlinkSync(lockPath);
|
|
1426
|
+
return true;
|
|
1427
|
+
} catch (err) {
|
|
1428
|
+
if (err && err.code === 'ENOENT') return true;
|
|
1429
|
+
const retryable = process.platform === 'win32'
|
|
1430
|
+
&& err && (err.code === 'EPERM' || err.code === 'EBUSY' || err.code === 'EACCES');
|
|
1431
|
+
if (!retryable || attempt === attempts - 1) {
|
|
1432
|
+
console.warn(`[withFileLock] Failed to release ${lockPath}: ${err?.message || err}`);
|
|
1433
|
+
return false;
|
|
1434
|
+
}
|
|
1435
|
+
sleepMs(FILE_LOCK_RELEASE_RETRY_DELAY_MS * (attempt + 1));
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
return false;
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function _isTransientFileLockPathError(err) {
|
|
1442
|
+
return !!err && (err.code === 'ENOENT'
|
|
1443
|
+
|| (process.platform === 'win32' && err.code === 'EPERM'));
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
function _readFileLockState(lockPath) {
|
|
1447
|
+
try {
|
|
1448
|
+
const stat = fs.statSync(lockPath);
|
|
1449
|
+
const raw = fs.readFileSync(lockPath, 'utf8');
|
|
1450
|
+
let holderPid = null;
|
|
1451
|
+
try {
|
|
1452
|
+
const parsed = JSON.parse(raw);
|
|
1453
|
+
if (parsed && Number.isFinite(parsed.pid) && parsed.pid > 0) holderPid = parsed.pid;
|
|
1454
|
+
} catch { /* legacy/empty/corrupt lock */ }
|
|
1455
|
+
return { raw, holderPid, mtimeAge: Date.now() - stat.mtimeMs };
|
|
1456
|
+
} catch (err) {
|
|
1457
|
+
if (_isTransientFileLockPathError(err)) return null;
|
|
1458
|
+
throw err;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function _readFileLockIdentity(lockPath) {
|
|
1463
|
+
try {
|
|
1464
|
+
return fs.readFileSync(lockPath, 'utf8');
|
|
1465
|
+
} catch (err) {
|
|
1466
|
+
if (_isTransientFileLockPathError(err)) return null;
|
|
1467
|
+
throw err;
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
function _tryReapFileLock(lockPath) {
|
|
1472
|
+
const claimPath = `${lockPath}.reap`;
|
|
1473
|
+
let claimFd;
|
|
1474
|
+
try {
|
|
1475
|
+
claimFd = fs.openSync(claimPath, 'wx');
|
|
1476
|
+
try {
|
|
1477
|
+
fs.writeSync(claimFd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
1478
|
+
} catch { /* claim existence provides serialization */ }
|
|
1479
|
+
} catch (err) {
|
|
1480
|
+
const contended = err && (err.code === 'EEXIST'
|
|
1481
|
+
|| (process.platform === 'win32' && err.code === 'EPERM'));
|
|
1482
|
+
if (contended) return false;
|
|
1483
|
+
throw err;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
try {
|
|
1487
|
+
const observed = _readFileLockState(lockPath);
|
|
1488
|
+
if (!observed) return false;
|
|
1489
|
+
const shouldReap = observed.holderPid !== null
|
|
1490
|
+
? !processUtils.isPidAlive(observed.holderPid) || observed.mtimeAge > LOCK_STALE_MS * 5
|
|
1491
|
+
: observed.mtimeAge > LOCK_STALE_MS;
|
|
1492
|
+
if (!shouldReap) return false;
|
|
1493
|
+
|
|
1494
|
+
// A holder can release and a successor can acquire between the PID probe
|
|
1495
|
+
// and deletion. The reap claim excludes competing reapers; this identity
|
|
1496
|
+
// check prevents the winner from deleting that successor's lock.
|
|
1497
|
+
const currentRaw = _readFileLockIdentity(lockPath);
|
|
1498
|
+
if (currentRaw === null) return false;
|
|
1499
|
+
if (currentRaw !== observed.raw) return false;
|
|
1500
|
+
|
|
1501
|
+
try {
|
|
1502
|
+
fs.unlinkSync(lockPath);
|
|
1503
|
+
return true;
|
|
1504
|
+
} catch (err) {
|
|
1505
|
+
if (_isTransientFileLockPathError(err)) return false;
|
|
1506
|
+
throw err;
|
|
1507
|
+
}
|
|
1508
|
+
} finally {
|
|
1509
|
+
_releaseFileLock(claimPath, claimFd);
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1409
1513
|
function withFileLock(lockPath, fn, {
|
|
1410
1514
|
timeoutMs = 5000,
|
|
1411
1515
|
retryDelayMs = 25,
|
|
@@ -1420,9 +1524,13 @@ function withFileLock(lockPath, fn, {
|
|
|
1420
1524
|
const backoff = retryBackoffMs * Math.pow(2, attempt - 1);
|
|
1421
1525
|
sleepMs(backoff);
|
|
1422
1526
|
}
|
|
1423
|
-
const start = Date.now();
|
|
1424
1527
|
let fd = null;
|
|
1425
|
-
|
|
1528
|
+
const pollDelayMs = Math.max(1, Number(retryDelayMs) || 1);
|
|
1529
|
+
const waitBudgetMs = Math.max(0, Number(timeoutMs) || 0);
|
|
1530
|
+
let waitedMs = 0;
|
|
1531
|
+
let contentionCount = 0;
|
|
1532
|
+
let lastOwnerIdentity;
|
|
1533
|
+
while (true) {
|
|
1426
1534
|
try {
|
|
1427
1535
|
const dir = path.dirname(lockPath);
|
|
1428
1536
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
@@ -1432,7 +1540,11 @@ function withFileLock(lockPath, fn, {
|
|
|
1432
1540
|
// the lock's existence (not its contents) provides mutual exclusion, so
|
|
1433
1541
|
// a write failure here must NOT abort acquisition.
|
|
1434
1542
|
try {
|
|
1435
|
-
fs.writeSync(fd, JSON.stringify({
|
|
1543
|
+
fs.writeSync(fd, JSON.stringify({
|
|
1544
|
+
pid: process.pid,
|
|
1545
|
+
ts: Date.now(),
|
|
1546
|
+
owner: crypto.randomBytes(8).toString('hex'),
|
|
1547
|
+
}));
|
|
1436
1548
|
} catch { /* payload is advisory; lock semantics unaffected */ }
|
|
1437
1549
|
break;
|
|
1438
1550
|
} catch (err) {
|
|
@@ -1447,47 +1559,27 @@ function withFileLock(lockPath, fn, {
|
|
|
1447
1559
|
const isLockRaceErr = err.code === 'EEXIST' ||
|
|
1448
1560
|
(process.platform === 'win32' && err.code === 'EPERM');
|
|
1449
1561
|
if (!isLockRaceErr) throw err;
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
const mtimeAge = Date.now() - stat.mtimeMs;
|
|
1456
|
-
let holderPid = null;
|
|
1457
|
-
try {
|
|
1458
|
-
const raw = fs.readFileSync(lockPath, 'utf8');
|
|
1459
|
-
const parsed = JSON.parse(raw);
|
|
1460
|
-
if (parsed && Number.isFinite(parsed.pid) && parsed.pid > 0) {
|
|
1461
|
-
holderPid = parsed.pid;
|
|
1462
|
-
}
|
|
1463
|
-
} catch { /* legacy/empty/corrupt lock → mtime-only fallback */ }
|
|
1464
|
-
|
|
1465
|
-
const shouldReap = holderPid !== null
|
|
1466
|
-
? !processUtils.isPidAlive(holderPid) || mtimeAge > LOCK_STALE_MS * 5
|
|
1467
|
-
: mtimeAge > LOCK_STALE_MS;
|
|
1468
|
-
|
|
1469
|
-
if (shouldReap) {
|
|
1470
|
-
try {
|
|
1471
|
-
fs.unlinkSync(lockPath);
|
|
1472
|
-
} catch (unlinkErr) {
|
|
1473
|
-
// ENOENT: another process deleted the lock between stat and unlink — safe to retry.
|
|
1474
|
-
// EPERM on Windows: file is in 'pending delete' state from a concurrent
|
|
1475
|
-
// reaper — the OS will finalize the delete shortly; retry will succeed.
|
|
1476
|
-
const tolerable = unlinkErr.code === 'ENOENT' ||
|
|
1477
|
-
(process.platform === 'win32' && unlinkErr.code === 'EPERM');
|
|
1478
|
-
if (!tolerable) throw unlinkErr;
|
|
1479
|
-
}
|
|
1480
|
-
continue; // lock just removed — retry immediately
|
|
1562
|
+
const ownerIdentity = _readFileLockIdentity(lockPath);
|
|
1563
|
+
if (ownerIdentity !== null && ownerIdentity !== lastOwnerIdentity) {
|
|
1564
|
+
if (lastOwnerIdentity !== undefined) {
|
|
1565
|
+
waitedMs = 0;
|
|
1566
|
+
contentionCount = 0;
|
|
1481
1567
|
}
|
|
1482
|
-
|
|
1483
|
-
// ENOENT from statSync: lock file disappeared between EEXIST and stat — retry will succeed.
|
|
1484
|
-
// EPERM on Windows: file is in 'pending delete' state — statSync itself
|
|
1485
|
-
// can fail; fall through to the normal retry sleep (W-mpd6lm1g000x195e).
|
|
1486
|
-
const tolerable = staleErr.code === 'ENOENT' ||
|
|
1487
|
-
(process.platform === 'win32' && staleErr.code === 'EPERM');
|
|
1488
|
-
if (!tolerable) throw staleErr;
|
|
1568
|
+
lastOwnerIdentity = ownerIdentity;
|
|
1489
1569
|
}
|
|
1490
|
-
|
|
1570
|
+
if (_tryReapFileLock(lockPath)) continue;
|
|
1571
|
+
// Count active retry sleeps rather than wall time. Host suspension must
|
|
1572
|
+
// not consume the whole budget without another acquisition attempt.
|
|
1573
|
+
if (waitedMs >= waitBudgetMs) break;
|
|
1574
|
+
const backoffMs = Math.min(
|
|
1575
|
+
pollDelayMs * Math.pow(2, Math.min(contentionCount, 4)),
|
|
1576
|
+
FILE_LOCK_RETRY_MAX_DELAY_MS
|
|
1577
|
+
);
|
|
1578
|
+
const jitterMs = contentionCount === 0 ? 0 : Math.floor(Math.random() * pollDelayMs);
|
|
1579
|
+
const delayMs = Math.min(waitBudgetMs - waitedMs, backoffMs + jitterMs);
|
|
1580
|
+
sleepMs(delayMs);
|
|
1581
|
+
waitedMs += delayMs;
|
|
1582
|
+
contentionCount += 1;
|
|
1491
1583
|
}
|
|
1492
1584
|
}
|
|
1493
1585
|
if (fd === null) {
|
|
@@ -1503,16 +1595,14 @@ function withFileLock(lockPath, fn, {
|
|
|
1503
1595
|
// completes, silently breaking mutual exclusion. Clean up our own fd /
|
|
1504
1596
|
// lock first, then throw so the caller cannot ignore the failure.
|
|
1505
1597
|
if (result && typeof result.then === 'function') {
|
|
1506
|
-
|
|
1507
|
-
try { fs.unlinkSync(lockPath); } catch { /* cleanup */ }
|
|
1598
|
+
_releaseFileLock(lockPath, fd);
|
|
1508
1599
|
fd = null; // suppress double-cleanup in `finally`
|
|
1509
1600
|
throw new Error('withFileLock: fn must be synchronous; got Promise. Use synchronous operations only.');
|
|
1510
1601
|
}
|
|
1511
1602
|
return result;
|
|
1512
1603
|
} finally {
|
|
1513
1604
|
if (fd !== null) {
|
|
1514
|
-
|
|
1515
|
-
try { fs.unlinkSync(lockPath); } catch { /* cleanup */ }
|
|
1605
|
+
_releaseFileLock(lockPath, fd);
|
|
1516
1606
|
}
|
|
1517
1607
|
}
|
|
1518
1608
|
}
|
|
@@ -2602,6 +2692,62 @@ function isLiveCheckoutProject(project) {
|
|
|
2602
2692
|
return resolveCheckoutMode(project) === CHECKOUT_MODES.LIVE;
|
|
2603
2693
|
}
|
|
2604
2694
|
|
|
2695
|
+
// Persisted dispatch records must carry explicit checkout-mode provenance before
|
|
2696
|
+
// restart/orphan recovery may operate on project.localPath. Legacy live records
|
|
2697
|
+
// are accepted only while the current configured project still resolves live for
|
|
2698
|
+
// that work-item type. Any explicit worktree marker or current worktree config
|
|
2699
|
+
// fails closed.
|
|
2700
|
+
function isLiveCheckoutDispatchRecord(item, config) {
|
|
2701
|
+
if (!item || typeof item !== 'object') return false;
|
|
2702
|
+
const hasCheckoutMarker = Object.prototype.hasOwnProperty.call(item, 'checkoutMode');
|
|
2703
|
+
const hasLegacyMarker = Object.prototype.hasOwnProperty.call(item, 'liveMode');
|
|
2704
|
+
let explicitlyLive = false;
|
|
2705
|
+
if (hasCheckoutMarker) {
|
|
2706
|
+
if (item.checkoutMode !== CHECKOUT_MODES.LIVE) return false;
|
|
2707
|
+
explicitlyLive = true;
|
|
2708
|
+
} else if (hasLegacyMarker) {
|
|
2709
|
+
if (item.liveMode !== true) return false;
|
|
2710
|
+
explicitlyLive = true;
|
|
2711
|
+
}
|
|
2712
|
+
|
|
2713
|
+
if (config !== undefined && config !== null) {
|
|
2714
|
+
let projects;
|
|
2715
|
+
try { projects = getProjects(config); } catch { return false; }
|
|
2716
|
+
const projectRef = item.meta?.project;
|
|
2717
|
+
const resolved = resolveProjectSource(projectRef, projects, { allowCentral: false }).project;
|
|
2718
|
+
if (!resolved || resolveCheckoutMode(resolved, item.type) !== CHECKOUT_MODES.LIVE) return false;
|
|
2719
|
+
if (!projectRef?.localPath || !resolved.localPath
|
|
2720
|
+
|| !sameResolvedPath(projectRef.localPath, resolved.localPath)) return false;
|
|
2721
|
+
return explicitlyLive || !!item.originalRef;
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
return explicitlyLive;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
function isDispatchExecutionPathSafe(item, config) {
|
|
2728
|
+
if (!item || typeof item !== 'object') return false;
|
|
2729
|
+
let projects;
|
|
2730
|
+
try { projects = getProjects(config); } catch { return false; }
|
|
2731
|
+
|
|
2732
|
+
if (item.worktreePath) {
|
|
2733
|
+
try {
|
|
2734
|
+
assertWorktreeOutsideProjects(item.worktreePath, projects);
|
|
2735
|
+
return isValidGitWorktree(item.worktreePath).ok;
|
|
2736
|
+
} catch {
|
|
2737
|
+
return false;
|
|
2738
|
+
}
|
|
2739
|
+
}
|
|
2740
|
+
if (isLiveCheckoutDispatchRecord(item, config)) return true;
|
|
2741
|
+
if (item.meta?.project) return false;
|
|
2742
|
+
if (!item.executionCwd) return false;
|
|
2743
|
+
try {
|
|
2744
|
+
assertWorktreeOutsideProjects(item.executionCwd, projects);
|
|
2745
|
+
return fs.existsSync(item.executionCwd);
|
|
2746
|
+
} catch {
|
|
2747
|
+
return false;
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
|
|
2605
2751
|
// W-mqvejug6000eeb20 — resolve the effective live-checkout auto-reset decision.
|
|
2606
2752
|
// Precedence: an explicit per-project boolean (`project.liveCheckoutAutoReset`)
|
|
2607
2753
|
// wins; otherwise the fleet-wide `engine.liveCheckoutAutoReset` applies;
|
|
@@ -3428,9 +3574,9 @@ function resolvePollFlag(engineCfg, granularKey, legacyMacroKey) {
|
|
|
3428
3574
|
|
|
3429
3575
|
// ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
|
|
3430
3576
|
//
|
|
3431
|
-
//
|
|
3432
|
-
// + budget + bare-mode applies to this spawn?". Engine code MUST
|
|
3433
|
-
// these — never read `agent.cli`, `engine.defaultCli`, etc. directly. Future
|
|
3577
|
+
// Runtime helpers are the single source of truth for "which CLI runtime + model
|
|
3578
|
+
// + tool ceiling + budget + bare-mode applies to this spawn?". Engine code MUST
|
|
3579
|
+
// go through these — never read `agent.cli`, `engine.defaultCli`, etc. directly. Future
|
|
3434
3580
|
// agents adding new resolution rules should extend these helpers, not bypass
|
|
3435
3581
|
// them.
|
|
3436
3582
|
//
|
|
@@ -3476,28 +3622,27 @@ function resolveCcCli(engine) {
|
|
|
3476
3622
|
/**
|
|
3477
3623
|
* Resolve whether the Command Center / doc-chat path should route through the
|
|
3478
3624
|
* persistent ACP worker pool. Priority:
|
|
3479
|
-
* 1.
|
|
3480
|
-
*
|
|
3481
|
-
* switch
|
|
3482
|
-
* Refuse the pool when CC runtime is not copilot, even if explicit-true is
|
|
3483
|
-
* set. Future runtimes that don't implement ACP fall into the same bucket.
|
|
3625
|
+
* 1. Runtime capability guard. Refuse the pool unless the selected adapter
|
|
3626
|
+
* declares `acpWorkerPool`, even if explicit-true is set, so pooling can
|
|
3627
|
+
* never switch the configured harness.
|
|
3484
3628
|
* (W-mphlriic00095f69)
|
|
3485
3629
|
* 2. `engine.ccUseWorkerPool` explicit true/false — operator override wins
|
|
3486
|
-
*
|
|
3487
|
-
* 3. Runtime-aware default: ON
|
|
3488
|
-
* (Copilot's cold-spawn is 18-21s on Windows — pool turns subsequent turns
|
|
3489
|
-
* from 47-140s back into a few seconds).
|
|
3630
|
+
* within an ACP-capable runtime.
|
|
3631
|
+
* 3. Runtime-aware default: ON for any ACP-capable adapter.
|
|
3490
3632
|
* 4. ENGINE_DEFAULTS.ccUseWorkerPool — final fallback
|
|
3491
3633
|
*
|
|
3492
3634
|
* Strict boolean check on the override so a literal `false` opts out even on
|
|
3493
|
-
*
|
|
3635
|
+
* ACP runtimes, matching the "treat empty/null/undefined as unset" semantics used
|
|
3494
3636
|
* throughout the resolve* helpers for boolean flags.
|
|
3495
3637
|
*/
|
|
3496
3638
|
function resolveCcUseWorkerPool(engine) {
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3639
|
+
let runtime;
|
|
3640
|
+
try {
|
|
3641
|
+
runtime = require('./runtimes').resolveRuntime(resolveCcCli(engine));
|
|
3642
|
+
} catch {
|
|
3643
|
+
return false;
|
|
3644
|
+
}
|
|
3645
|
+
if (runtime?.capabilities?.acpWorkerPool !== true) return false;
|
|
3501
3646
|
if (engine && (engine.ccUseWorkerPool === true || engine.ccUseWorkerPool === false)) {
|
|
3502
3647
|
return engine.ccUseWorkerPool;
|
|
3503
3648
|
}
|
|
@@ -3511,7 +3656,7 @@ function resolveCcUseWorkerPool(engine) {
|
|
|
3511
3656
|
* adapter (as returned by `resolveRuntime(resolveAgentCli(agent, engine))`)
|
|
3512
3657
|
* rather than re-deriving it, since callers on the agent-spawn path already
|
|
3513
3658
|
* have it in hand. Priority:
|
|
3514
|
-
* 1. Capability guard — pool transport is ACP-only
|
|
3659
|
+
* 1. Capability guard — pool transport is ACP-only. Hard
|
|
3515
3660
|
* false whenever `runtime.capabilities.acpWorkerPool` isn't `true`,
|
|
3516
3661
|
* regardless of operator override, so a future non-ACP runtime can never
|
|
3517
3662
|
* be silently routed through the pool (same rationale as
|
|
@@ -3585,6 +3730,17 @@ function resolveCcModel(engine) {
|
|
|
3585
3730
|
return undefined;
|
|
3586
3731
|
}
|
|
3587
3732
|
|
|
3733
|
+
/**
|
|
3734
|
+
* Resolve the fleet tool ceiling for an agent spawn.
|
|
3735
|
+
*
|
|
3736
|
+
* `config.claude.allowedTools` predates runtime adapters but has always applied
|
|
3737
|
+
* to every selected runtime. Keep that cross-runtime contract centralized here
|
|
3738
|
+
* until the field is migrated to a runtime-neutral config namespace.
|
|
3739
|
+
*/
|
|
3740
|
+
function resolveAgentAllowedTools(config) {
|
|
3741
|
+
return config?.claude?.allowedTools;
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3588
3744
|
/**
|
|
3589
3745
|
* Resolve the per-spawn USD budget cap. Priority:
|
|
3590
3746
|
* 1. `agent.maxBudgetUsd` — per-agent override
|
|
@@ -5473,6 +5629,11 @@ function isWorktreeRootInfraEntry(name) {
|
|
|
5473
5629
|
return typeof name === 'string' && name.length > 0 && name.charCodeAt(0) === 46; // '.'
|
|
5474
5630
|
}
|
|
5475
5631
|
|
|
5632
|
+
function _pathForComparison(filePath) {
|
|
5633
|
+
const resolved = realPathForComparison(String(filePath));
|
|
5634
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
5635
|
+
}
|
|
5636
|
+
|
|
5476
5637
|
/**
|
|
5477
5638
|
* True when `childPath` is strictly nested within `parentPath` (descendant,
|
|
5478
5639
|
* NOT the same path). Cross-platform via `path.relative`; resilient to mixed
|
|
@@ -5481,8 +5642,8 @@ function isWorktreeRootInfraEntry(name) {
|
|
|
5481
5642
|
*/
|
|
5482
5643
|
function isPathInside(childPath, parentPath) {
|
|
5483
5644
|
if (!childPath || !parentPath) return false;
|
|
5484
|
-
const childAbs =
|
|
5485
|
-
const parentAbs =
|
|
5645
|
+
const childAbs = _pathForComparison(childPath);
|
|
5646
|
+
const parentAbs = _pathForComparison(parentPath);
|
|
5486
5647
|
const rel = path.relative(parentAbs, childAbs);
|
|
5487
5648
|
if (rel === '') return false;
|
|
5488
5649
|
if (rel.startsWith('..')) return false;
|
|
@@ -5502,12 +5663,17 @@ function isPathInside(childPath, parentPath) {
|
|
|
5502
5663
|
*/
|
|
5503
5664
|
function isPathInsideOrEqual(childPath, parentPath) {
|
|
5504
5665
|
if (!childPath || !parentPath) return false;
|
|
5505
|
-
const childAbs =
|
|
5506
|
-
const parentAbs =
|
|
5666
|
+
const childAbs = _pathForComparison(childPath);
|
|
5667
|
+
const parentAbs = _pathForComparison(parentPath);
|
|
5507
5668
|
if (childAbs === parentAbs) return true;
|
|
5508
5669
|
return isPathInside(childAbs, parentAbs);
|
|
5509
5670
|
}
|
|
5510
5671
|
|
|
5672
|
+
function pathsOverlap(firstPath, secondPath) {
|
|
5673
|
+
return isPathInsideOrEqual(firstPath, secondPath)
|
|
5674
|
+
|| isPathInsideOrEqual(secondPath, firstPath);
|
|
5675
|
+
}
|
|
5676
|
+
|
|
5511
5677
|
/**
|
|
5512
5678
|
* Parse `git worktree list --porcelain` output. Pure — callers run the
|
|
5513
5679
|
* subprocess (sync `execSilent` or async `execAsync`) and feed stdout in.
|
|
@@ -5562,6 +5728,22 @@ function assertWorktreeOutsideProject(worktreePath, projectRoot) {
|
|
|
5562
5728
|
throw err;
|
|
5563
5729
|
}
|
|
5564
5730
|
|
|
5731
|
+
function assertWorktreeOutsideProjects(worktreePath, projectsOrConfig) {
|
|
5732
|
+
const projects = Array.isArray(projectsOrConfig)
|
|
5733
|
+
? projectsOrConfig
|
|
5734
|
+
: getProjects(projectsOrConfig);
|
|
5735
|
+
for (const project of projects) {
|
|
5736
|
+
if (!project?.localPath) continue;
|
|
5737
|
+
if (!pathsOverlap(worktreePath, project.localPath)) continue;
|
|
5738
|
+
const err = new Error(
|
|
5739
|
+
`Refusing worktree path "${worktreePath}" — it overlaps configured operator checkout "${project.localPath}".`
|
|
5740
|
+
);
|
|
5741
|
+
err.code = 'WORKTREE_NESTED_IN_PROJECT';
|
|
5742
|
+
err.protectedProject = project.name || '';
|
|
5743
|
+
throw err;
|
|
5744
|
+
}
|
|
5745
|
+
}
|
|
5746
|
+
|
|
5565
5747
|
/**
|
|
5566
5748
|
* Resolve the project root directory used as the parent for git worktree paths
|
|
5567
5749
|
* during dispatch. Centralizes the fallback that engine spawnAgent used to do
|
|
@@ -5702,9 +5884,11 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
|
|
|
5702
5884
|
* @param {string} type — work type (e.g. 'fix', 'explore', 'meeting')
|
|
5703
5885
|
* @param {string} minionsDir — MINIONS_DIR fallback anchor (ignored in live mode)
|
|
5704
5886
|
* @param {{ workdir?: string|null }} [options] — optional per-WI overrides
|
|
5705
|
-
* @returns {{ cwd: string|null, worktreeRootDir: string|null, liveMode?: boolean, workdir?: string|null }}
|
|
5887
|
+
* @returns {{ cwd: string|null, worktreeRootDir: string|null, liveMode?: boolean, readOnlyWorktree?: boolean, workdir?: string|null }}
|
|
5706
5888
|
* - For live mode (any type): { cwd: <abs localPath[/workdir]>, worktreeRootDir: null, liveMode: true }
|
|
5707
|
-
* - For isolated read-only types:
|
|
5889
|
+
* - For project-bound isolated read-only types:
|
|
5890
|
+
* { cwd: null, worktreeRootDir: <project root>, readOnlyWorktree: true, workdir }
|
|
5891
|
+
* - For project-less read-only types: { cwd: <MINIONS_DIR[/workdir]>, worktreeRootDir: null }
|
|
5708
5892
|
* - For isolated code-mutating types: { cwd: null, worktreeRootDir: <project root>, workdir: <subpath|null> }
|
|
5709
5893
|
* (caller defaults cwd to worktreeRootDir before worktree creation,
|
|
5710
5894
|
* then runs shared.applyWorkdir(worktreePath, result.workdir) to land
|
|
@@ -5747,14 +5931,21 @@ function resolveSpawnPaths(project, type, minionsDir, options) {
|
|
|
5747
5931
|
|
|
5748
5932
|
const isReadOnly = READ_ONLY_ROOT_TASK_TYPES.has(type);
|
|
5749
5933
|
if (isReadOnly) {
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5934
|
+
if (project?.localPath) {
|
|
5935
|
+
if (workdir) _applyWorkdirOrThrow(path.resolve(String(project.localPath)), workdir);
|
|
5936
|
+
return {
|
|
5937
|
+
cwd: null,
|
|
5938
|
+
worktreeRootDir: resolveProjectRootDir(project.localPath, minionsDir),
|
|
5939
|
+
readOnlyWorktree: true,
|
|
5940
|
+
workdir,
|
|
5941
|
+
};
|
|
5942
|
+
}
|
|
5943
|
+
if (!minionsDir) {
|
|
5754
5944
|
const err = new Error('Cannot resolve cwd for read-only spawn: no project.localPath and no MINIONS_DIR provided.');
|
|
5755
5945
|
err.code = 'WORKTREE_ROOTDIR_MISSING_BASE';
|
|
5756
5946
|
throw err;
|
|
5757
5947
|
}
|
|
5948
|
+
const base = path.resolve(String(minionsDir));
|
|
5758
5949
|
const roCwd = _applyWorkdirOrThrow(base, workdir);
|
|
5759
5950
|
return { cwd: roCwd, worktreeRootDir: null };
|
|
5760
5951
|
}
|
|
@@ -5868,7 +6059,7 @@ function applyWorkdir(base, workdir) {
|
|
|
5868
6059
|
const baseAbs = path.resolve(String(base));
|
|
5869
6060
|
const joined = path.resolve(baseAbs, workdir);
|
|
5870
6061
|
// Containment guard: resolved cwd must equal base or live strictly inside.
|
|
5871
|
-
if (joined
|
|
6062
|
+
if (!isPathInsideOrEqual(joined, baseAbs)) {
|
|
5872
6063
|
return {
|
|
5873
6064
|
cwd: baseAbs,
|
|
5874
6065
|
error: 'workdir resolved outside base (containment escape): "' + workdir + '" → "' + joined + '" not inside "' + baseAbs + '"',
|
|
@@ -6043,7 +6234,7 @@ function validateProjectPath(pathStr, options = {}) {
|
|
|
6043
6234
|
|
|
6044
6235
|
function parseSkillFrontmatter(content, filename) {
|
|
6045
6236
|
let name = filename.replace('.md', '');
|
|
6046
|
-
let trigger = '', description = '', project = 'any', author = '', created = '', allowedTools = '';
|
|
6237
|
+
let trigger = '', description = '', project = 'any', author = '', created = '', allowedTools = '', scope = 'minions';
|
|
6047
6238
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
6048
6239
|
if (fmMatch) {
|
|
6049
6240
|
const fm = fmMatch[1];
|
|
@@ -6055,8 +6246,52 @@ function parseSkillFrontmatter(content, filename) {
|
|
|
6055
6246
|
author = m('author');
|
|
6056
6247
|
created = m('created');
|
|
6057
6248
|
allowedTools = m('allowed-tools');
|
|
6249
|
+
scope = m('scope') || scope;
|
|
6250
|
+
}
|
|
6251
|
+
return { name, trigger, description, project, author, created, allowedTools, scope };
|
|
6252
|
+
}
|
|
6253
|
+
|
|
6254
|
+
function syncBundledPersonalSkills(skillsDir, opts = {}) {
|
|
6255
|
+
const result = { created: [], updated: [], unchanged: [] };
|
|
6256
|
+
if (!skillsDir || !fs.existsSync(skillsDir)) return result;
|
|
6257
|
+
|
|
6258
|
+
const homeDir = opts.homeDir || os.homedir();
|
|
6259
|
+
const { listRuntimes, resolveRuntime } = require('./runtimes');
|
|
6260
|
+
const personalRoots = new Set();
|
|
6261
|
+
for (const runtimeName of listRuntimes()) {
|
|
6262
|
+
const runtime = resolveRuntime(runtimeName);
|
|
6263
|
+
if (typeof runtime.getSkillWriteTargets !== 'function') continue;
|
|
6264
|
+
const personal = runtime.getSkillWriteTargets({ homeDir }).personal;
|
|
6265
|
+
if (typeof personal === 'string' && personal.trim()) {
|
|
6266
|
+
personalRoots.add(path.resolve(personal));
|
|
6267
|
+
}
|
|
6268
|
+
}
|
|
6269
|
+
|
|
6270
|
+
const entries = fs.readdirSync(skillsDir, { withFileTypes: true })
|
|
6271
|
+
.filter(entry => entry.isDirectory())
|
|
6272
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
6273
|
+
for (const entry of entries) {
|
|
6274
|
+
const sourcePath = path.join(skillsDir, entry.name, 'SKILL.md');
|
|
6275
|
+
if (!fs.existsSync(sourcePath)) continue;
|
|
6276
|
+
const content = fs.readFileSync(sourcePath, 'utf8');
|
|
6277
|
+
const skill = parseSkillFrontmatter(content, `${entry.name}.md`);
|
|
6278
|
+
if (skill.scope !== 'minions') continue;
|
|
6279
|
+
const skillName = String(skill.name || entry.name).replace(/[^a-z0-9-]/g, '-');
|
|
6280
|
+
if (!skillName) throw new Error(`Bundled skill has an invalid name: ${sourcePath}`);
|
|
6281
|
+
|
|
6282
|
+
for (const personalRoot of personalRoots) {
|
|
6283
|
+
const targetPath = path.join(personalRoot, skillName, 'SKILL.md');
|
|
6284
|
+
const exists = fs.existsSync(targetPath);
|
|
6285
|
+
if (exists && fs.readFileSync(targetPath, 'utf8') === content) {
|
|
6286
|
+
result.unchanged.push(targetPath);
|
|
6287
|
+
continue;
|
|
6288
|
+
}
|
|
6289
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
6290
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
6291
|
+
result[exists ? 'updated' : 'created'].push(targetPath);
|
|
6292
|
+
}
|
|
6058
6293
|
}
|
|
6059
|
-
return
|
|
6294
|
+
return result;
|
|
6060
6295
|
}
|
|
6061
6296
|
|
|
6062
6297
|
// ── PR → PRD Links ────────────────────────────────────────────────────────────
|
|
@@ -7740,6 +7975,7 @@ function prFixEvidenceFingerprint(pr, cause = PR_FIX_CAUSE.UNKNOWN) {
|
|
|
7740
7975
|
evidence.lastReviewedAt = pr?.lastReviewedAt || '';
|
|
7741
7976
|
evidence.reviewedAt = review.reviewedAt || '';
|
|
7742
7977
|
evidence.reviewNote = review.note || pr?.reviewNote || '';
|
|
7978
|
+
evidence.findingId = prReviewFindingIdentity(pr);
|
|
7743
7979
|
// #2979 — same rationale as BUILD_FAILURE: review feedback fingerprints
|
|
7744
7980
|
// were sticky across force-push because reviewStatus / reviewedAt /
|
|
7745
7981
|
// reviewNote don't change when the author rebases. Adding the head SHA
|
|
@@ -7751,6 +7987,23 @@ function prFixEvidenceFingerprint(pr, cause = PR_FIX_CAUSE.UNKNOWN) {
|
|
|
7751
7987
|
return crypto.createHash('sha1').update(JSON.stringify(evidence)).digest('hex').slice(0, 16);
|
|
7752
7988
|
}
|
|
7753
7989
|
|
|
7990
|
+
function prReviewFindingIdentity(pr) {
|
|
7991
|
+
const review = pr?.minionsReview || {};
|
|
7992
|
+
const threads = Array.isArray(review.threads)
|
|
7993
|
+
? review.threads.map(value => String(value)).filter(Boolean).sort()
|
|
7994
|
+
: [];
|
|
7995
|
+
const evidence = {
|
|
7996
|
+
pr: pr?.id || pr?.url || '',
|
|
7997
|
+
sourceItem: review.sourceItem || '',
|
|
7998
|
+
dispatchId: review.dispatchId || '',
|
|
7999
|
+
reviewer: review.reviewer || '',
|
|
8000
|
+
reviewedAt: review.reviewedAt || pr?.lastReviewedAt || '',
|
|
8001
|
+
threads,
|
|
8002
|
+
note: review.note || pr?.reviewNote || '',
|
|
8003
|
+
};
|
|
8004
|
+
return `review-${crypto.createHash('sha1').update(JSON.stringify(evidence)).digest('hex').slice(0, 16)}`;
|
|
8005
|
+
}
|
|
8006
|
+
|
|
7754
8007
|
function getPrNoOpFixRecord(pr, cause) {
|
|
7755
8008
|
if (!pr || !cause || !pr._noOpFixes || typeof pr._noOpFixes !== 'object') return null;
|
|
7756
8009
|
const record = pr._noOpFixes[cause];
|
|
@@ -7993,6 +8246,14 @@ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag, blockingInfo)
|
|
|
7993
8246
|
function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
|
|
7994
8247
|
const resolved = path.resolve(wtPath);
|
|
7995
8248
|
const resolvedRoot = path.resolve(worktreeRoot) + path.sep;
|
|
8249
|
+
let protectedProjects;
|
|
8250
|
+
try {
|
|
8251
|
+
protectedProjects = Array.isArray(opts.projects) ? opts.projects : getProjects(opts.config);
|
|
8252
|
+
assertWorktreeOutsideProjects(resolved, protectedProjects);
|
|
8253
|
+
} catch (err) {
|
|
8254
|
+
log('warn', `removeWorktree: refusing to remove ${wtPath} — path is inside configured operator checkout${err?.protectedProject ? ` ${err.protectedProject}` : ''}`);
|
|
8255
|
+
return false;
|
|
8256
|
+
}
|
|
7996
8257
|
if (!resolved.startsWith(resolvedRoot)) {
|
|
7997
8258
|
log('warn', `removeWorktree: refusing to remove ${wtPath} — not under ${worktreeRoot}`);
|
|
7998
8259
|
return false;
|
|
@@ -8510,7 +8771,7 @@ module.exports = {
|
|
|
8510
8771
|
ENGINE_DEFAULTS,
|
|
8511
8772
|
resolvePollFlag, // P-c4d8e1a3 — granular per-poller flag resolution
|
|
8512
8773
|
resolveAgentCli, resolveCcCli, resolveCcUseWorkerPool, resolveAgentUseWorkerPool, resolveAgentAcpPoolSize, resolveAgentModel, resolveCcModel,
|
|
8513
|
-
resolveAgentMaxBudget, resolveAgentBareMode, resolvePropagateClaudeMdForNonClaudeRuntimes,
|
|
8774
|
+
resolveAgentAllowedTools, resolveAgentMaxBudget, resolveAgentBareMode, resolvePropagateClaudeMdForNonClaudeRuntimes,
|
|
8514
8775
|
applyLegacyCcModelMigration, _resetLegacyCcModelMigrationFlag,
|
|
8515
8776
|
runtimeConfigWarnings,
|
|
8516
8777
|
projectWorkSourceWarnings,
|
|
@@ -8607,14 +8868,18 @@ module.exports = {
|
|
|
8607
8868
|
WORKTREE_SCRATCH_DIR_NAME,
|
|
8608
8869
|
isPathInside,
|
|
8609
8870
|
isPathInsideOrEqual,
|
|
8871
|
+
pathsOverlap,
|
|
8610
8872
|
parseWorktreePorcelain,
|
|
8611
8873
|
assertWorktreeOutsideProject,
|
|
8874
|
+
assertWorktreeOutsideProjects,
|
|
8612
8875
|
resolveProjectRootDir,
|
|
8613
8876
|
resolveAgentTempBaseDir,
|
|
8614
8877
|
resolveSpawnPaths,
|
|
8615
8878
|
validateWorkItemWorkdir,
|
|
8616
8879
|
applyWorkdir,
|
|
8617
8880
|
READ_ONLY_ROOT_TASK_TYPES,
|
|
8881
|
+
isLiveCheckoutDispatchRecord,
|
|
8882
|
+
isDispatchExecutionPathSafe,
|
|
8618
8883
|
isLiveCommandCenterPath,
|
|
8619
8884
|
describeCcProtectedPaths,
|
|
8620
8885
|
renderCcSystemPrompt,
|
|
@@ -8648,6 +8913,7 @@ module.exports = {
|
|
|
8648
8913
|
PR_FIX_CAUSE,
|
|
8649
8914
|
getPrFixAutomationCause,
|
|
8650
8915
|
prFixEvidenceFingerprint,
|
|
8916
|
+
prReviewFindingIdentity,
|
|
8651
8917
|
prMergeConflictGuardKey,
|
|
8652
8918
|
resetMergeConflictStateOnRetarget,
|
|
8653
8919
|
getPrNoOpFixRecord,
|
|
@@ -8655,6 +8921,7 @@ module.exports = {
|
|
|
8655
8921
|
getPrPausedCauses,
|
|
8656
8922
|
isBuildFixIneffectivePaused,
|
|
8657
8923
|
parseSkillFrontmatter,
|
|
8924
|
+
syncBundledPersonalSkills,
|
|
8658
8925
|
sleepMs,
|
|
8659
8926
|
clearWorktreeFailureCache,
|
|
8660
8927
|
removeWorktree,
|