@yemi33/minions 0.1.2383 → 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/dashboard/js/refresh.js +2 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard.js +11 -7
- 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 +7 -4
- package/docs/worktree-lifecycle.md +22 -0
- package/engine/acp-transport.js +216 -29
- package/engine/agent-worker-pool.js +268 -51
- package/engine/cc-worker-pool.js +8 -1
- package/engine/cleanup.js +15 -11
- package/engine/cli.js +47 -27
- package/engine/create-pr-worktree.js +57 -79
- package/engine/keep-process-sweep.js +131 -9
- package/engine/lifecycle.js +1 -1
- package/engine/live-checkout.js +4 -2
- package/engine/managed-spawn.js +112 -5
- package/engine/pooled-agent-process.js +486 -21
- package/engine/pr-action.js +6 -8
- package/engine/process-utils.js +154 -18
- package/engine/runtimes/copilot.js +22 -4
- package/engine/shared.js +254 -61
- package/engine/spawn-agent.js +6 -3
- package/engine/timeout.js +14 -10
- package/engine/worktree-gc.js +55 -46
- package/engine.js +237 -134
- package/package.json +1 -1
- package/playbooks/shared-rules.md +2 -2
- package/prompts/cc-system.md +11 -11
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;
|
|
@@ -5483,6 +5629,11 @@ function isWorktreeRootInfraEntry(name) {
|
|
|
5483
5629
|
return typeof name === 'string' && name.length > 0 && name.charCodeAt(0) === 46; // '.'
|
|
5484
5630
|
}
|
|
5485
5631
|
|
|
5632
|
+
function _pathForComparison(filePath) {
|
|
5633
|
+
const resolved = realPathForComparison(String(filePath));
|
|
5634
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
5635
|
+
}
|
|
5636
|
+
|
|
5486
5637
|
/**
|
|
5487
5638
|
* True when `childPath` is strictly nested within `parentPath` (descendant,
|
|
5488
5639
|
* NOT the same path). Cross-platform via `path.relative`; resilient to mixed
|
|
@@ -5491,8 +5642,8 @@ function isWorktreeRootInfraEntry(name) {
|
|
|
5491
5642
|
*/
|
|
5492
5643
|
function isPathInside(childPath, parentPath) {
|
|
5493
5644
|
if (!childPath || !parentPath) return false;
|
|
5494
|
-
const childAbs =
|
|
5495
|
-
const parentAbs =
|
|
5645
|
+
const childAbs = _pathForComparison(childPath);
|
|
5646
|
+
const parentAbs = _pathForComparison(parentPath);
|
|
5496
5647
|
const rel = path.relative(parentAbs, childAbs);
|
|
5497
5648
|
if (rel === '') return false;
|
|
5498
5649
|
if (rel.startsWith('..')) return false;
|
|
@@ -5512,12 +5663,17 @@ function isPathInside(childPath, parentPath) {
|
|
|
5512
5663
|
*/
|
|
5513
5664
|
function isPathInsideOrEqual(childPath, parentPath) {
|
|
5514
5665
|
if (!childPath || !parentPath) return false;
|
|
5515
|
-
const childAbs =
|
|
5516
|
-
const parentAbs =
|
|
5666
|
+
const childAbs = _pathForComparison(childPath);
|
|
5667
|
+
const parentAbs = _pathForComparison(parentPath);
|
|
5517
5668
|
if (childAbs === parentAbs) return true;
|
|
5518
5669
|
return isPathInside(childAbs, parentAbs);
|
|
5519
5670
|
}
|
|
5520
5671
|
|
|
5672
|
+
function pathsOverlap(firstPath, secondPath) {
|
|
5673
|
+
return isPathInsideOrEqual(firstPath, secondPath)
|
|
5674
|
+
|| isPathInsideOrEqual(secondPath, firstPath);
|
|
5675
|
+
}
|
|
5676
|
+
|
|
5521
5677
|
/**
|
|
5522
5678
|
* Parse `git worktree list --porcelain` output. Pure — callers run the
|
|
5523
5679
|
* subprocess (sync `execSilent` or async `execAsync`) and feed stdout in.
|
|
@@ -5572,6 +5728,22 @@ function assertWorktreeOutsideProject(worktreePath, projectRoot) {
|
|
|
5572
5728
|
throw err;
|
|
5573
5729
|
}
|
|
5574
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
|
+
|
|
5575
5747
|
/**
|
|
5576
5748
|
* Resolve the project root directory used as the parent for git worktree paths
|
|
5577
5749
|
* during dispatch. Centralizes the fallback that engine spawnAgent used to do
|
|
@@ -5712,9 +5884,11 @@ const READ_ONLY_ROOT_TASK_TYPES = new Set(['meeting', 'ask', 'explore', 'plan-to
|
|
|
5712
5884
|
* @param {string} type — work type (e.g. 'fix', 'explore', 'meeting')
|
|
5713
5885
|
* @param {string} minionsDir — MINIONS_DIR fallback anchor (ignored in live mode)
|
|
5714
5886
|
* @param {{ workdir?: string|null }} [options] — optional per-WI overrides
|
|
5715
|
-
* @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 }}
|
|
5716
5888
|
* - For live mode (any type): { cwd: <abs localPath[/workdir]>, worktreeRootDir: null, liveMode: true }
|
|
5717
|
-
* - 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 }
|
|
5718
5892
|
* - For isolated code-mutating types: { cwd: null, worktreeRootDir: <project root>, workdir: <subpath|null> }
|
|
5719
5893
|
* (caller defaults cwd to worktreeRootDir before worktree creation,
|
|
5720
5894
|
* then runs shared.applyWorkdir(worktreePath, result.workdir) to land
|
|
@@ -5757,14 +5931,21 @@ function resolveSpawnPaths(project, type, minionsDir, options) {
|
|
|
5757
5931
|
|
|
5758
5932
|
const isReadOnly = READ_ONLY_ROOT_TASK_TYPES.has(type);
|
|
5759
5933
|
if (isReadOnly) {
|
|
5760
|
-
|
|
5761
|
-
|
|
5762
|
-
|
|
5763
|
-
|
|
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) {
|
|
5764
5944
|
const err = new Error('Cannot resolve cwd for read-only spawn: no project.localPath and no MINIONS_DIR provided.');
|
|
5765
5945
|
err.code = 'WORKTREE_ROOTDIR_MISSING_BASE';
|
|
5766
5946
|
throw err;
|
|
5767
5947
|
}
|
|
5948
|
+
const base = path.resolve(String(minionsDir));
|
|
5768
5949
|
const roCwd = _applyWorkdirOrThrow(base, workdir);
|
|
5769
5950
|
return { cwd: roCwd, worktreeRootDir: null };
|
|
5770
5951
|
}
|
|
@@ -5878,7 +6059,7 @@ function applyWorkdir(base, workdir) {
|
|
|
5878
6059
|
const baseAbs = path.resolve(String(base));
|
|
5879
6060
|
const joined = path.resolve(baseAbs, workdir);
|
|
5880
6061
|
// Containment guard: resolved cwd must equal base or live strictly inside.
|
|
5881
|
-
if (joined
|
|
6062
|
+
if (!isPathInsideOrEqual(joined, baseAbs)) {
|
|
5882
6063
|
return {
|
|
5883
6064
|
cwd: baseAbs,
|
|
5884
6065
|
error: 'workdir resolved outside base (containment escape): "' + workdir + '" → "' + joined + '" not inside "' + baseAbs + '"',
|
|
@@ -8065,6 +8246,14 @@ function _writeWorktreeSkipLiveInboxNote(worktreePath, callerTag, blockingInfo)
|
|
|
8065
8246
|
function removeWorktree(wtPath, gitRoot, worktreeRoot, opts = {}) {
|
|
8066
8247
|
const resolved = path.resolve(wtPath);
|
|
8067
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
|
+
}
|
|
8068
8257
|
if (!resolved.startsWith(resolvedRoot)) {
|
|
8069
8258
|
log('warn', `removeWorktree: refusing to remove ${wtPath} — not under ${worktreeRoot}`);
|
|
8070
8259
|
return false;
|
|
@@ -8679,14 +8868,18 @@ module.exports = {
|
|
|
8679
8868
|
WORKTREE_SCRATCH_DIR_NAME,
|
|
8680
8869
|
isPathInside,
|
|
8681
8870
|
isPathInsideOrEqual,
|
|
8871
|
+
pathsOverlap,
|
|
8682
8872
|
parseWorktreePorcelain,
|
|
8683
8873
|
assertWorktreeOutsideProject,
|
|
8874
|
+
assertWorktreeOutsideProjects,
|
|
8684
8875
|
resolveProjectRootDir,
|
|
8685
8876
|
resolveAgentTempBaseDir,
|
|
8686
8877
|
resolveSpawnPaths,
|
|
8687
8878
|
validateWorkItemWorkdir,
|
|
8688
8879
|
applyWorkdir,
|
|
8689
8880
|
READ_ONLY_ROOT_TASK_TYPES,
|
|
8881
|
+
isLiveCheckoutDispatchRecord,
|
|
8882
|
+
isDispatchExecutionPathSafe,
|
|
8690
8883
|
isLiveCommandCenterPath,
|
|
8691
8884
|
describeCcProtectedPaths,
|
|
8692
8885
|
renderCcSystemPrompt,
|
package/engine/spawn-agent.js
CHANGED
|
@@ -626,9 +626,12 @@ function main() {
|
|
|
626
626
|
// meta.keep_processes_skip_workdir_check is true) bypasses the
|
|
627
627
|
// requireGitWorkdir check so legitimate non-git keep_processes
|
|
628
628
|
// use cases (e.g., a daemon under /tmp) still anchor.
|
|
629
|
-
const reapOpts =
|
|
630
|
-
|
|
631
|
-
|
|
629
|
+
const reapOpts = {
|
|
630
|
+
requireGitWorkdir: process.env.MINIONS_KEEP_PROCESSES_SKIP_WORKDIR_CHECK !== '1',
|
|
631
|
+
};
|
|
632
|
+
if (process.env.MINIONS_KEEP_PROCESSES_ALLOWED_CWD_ROOT) {
|
|
633
|
+
reapOpts.allowedCwdRoot = process.env.MINIONS_KEEP_PROCESSES_ALLOWED_CWD_ROOT;
|
|
634
|
+
}
|
|
632
635
|
const plan = keepProcessSweep.computeReapPlan(toKillPids, agentId, reapOpts);
|
|
633
636
|
toKillPids = plan.toKill;
|
|
634
637
|
kept = plan.kept;
|
package/engine/timeout.js
CHANGED
|
@@ -25,14 +25,16 @@ let _engine = null;
|
|
|
25
25
|
function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
|
|
26
26
|
const _pendingBranchPreservations = new Set();
|
|
27
27
|
|
|
28
|
-
function resolveDispatchBranchPath(item) {
|
|
28
|
+
function resolveDispatchBranchPath(item, config) {
|
|
29
29
|
if (item?.worktreePath) return item.worktreePath;
|
|
30
|
-
if (item
|
|
30
|
+
if (shared.isLiveCheckoutDispatchRecord(item, config) && item.meta?.project?.localPath) {
|
|
31
|
+
return item.meta.project.localPath;
|
|
32
|
+
}
|
|
31
33
|
return null;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
function preserveDispatchBranch(item, config) {
|
|
35
|
-
const branchPath = resolveDispatchBranchPath(item);
|
|
37
|
+
const branchPath = resolveDispatchBranchPath(item, config);
|
|
36
38
|
if (!branchPath || !item.meta?.branch) {
|
|
37
39
|
return Promise.resolve({ pushed: false, reason: 'invalid-worktree' });
|
|
38
40
|
}
|
|
@@ -55,17 +57,18 @@ async function _finalizeDeadDispatch(item, reason, failureClass, config, deps =
|
|
|
55
57
|
require('./live-checkout').maybeRestoreLiveCheckoutFromRecord;
|
|
56
58
|
const complete = deps.complete || dispatch().completeDispatch;
|
|
57
59
|
|
|
58
|
-
if (resolveDispatchBranchPath(item) && item.meta?.branch) {
|
|
60
|
+
if (resolveDispatchBranchPath(item, config) && item.meta?.branch) {
|
|
59
61
|
try {
|
|
60
62
|
await preserveBranch(item, config);
|
|
61
63
|
} catch (err) {
|
|
62
64
|
log('warn', `Orphan branch preservation failed for ${item.id}: ${err.message}`);
|
|
63
65
|
}
|
|
64
66
|
}
|
|
65
|
-
if (
|
|
67
|
+
if (shared.isLiveCheckoutDispatchRecord(item, config)) {
|
|
66
68
|
try {
|
|
67
69
|
await restoreLiveCheckout({
|
|
68
70
|
item,
|
|
71
|
+
config,
|
|
69
72
|
isTerminalFailure: true,
|
|
70
73
|
resultLabel: 'orphaned',
|
|
71
74
|
log: (lvl, msg) => log(lvl, msg),
|
|
@@ -719,7 +722,7 @@ function checkTimeouts(config) {
|
|
|
719
722
|
const legacyAnnotationClears = new Set();
|
|
720
723
|
|
|
721
724
|
function completeFromOutput(item, liveLogPath, processExitCode, detectedLogText, hasProcess, preservationDone = false, expectedProcInfo = activeProcesses.get(item.id)) {
|
|
722
|
-
if (!preservationDone && resolveDispatchBranchPath(item) && item.meta?.branch) {
|
|
725
|
+
if (!preservationDone && resolveDispatchBranchPath(item, config) && item.meta?.branch) {
|
|
723
726
|
if (_pendingBranchPreservations.has(item.id)) return;
|
|
724
727
|
_pendingBranchPreservations.add(item.id);
|
|
725
728
|
preserveDispatchBranch(item, config)
|
|
@@ -776,12 +779,13 @@ function checkTimeouts(config) {
|
|
|
776
779
|
// for a live-mode dispatch that finished AFTER an engine restart. This path
|
|
777
780
|
// (output-detected completion of a re-attached process) has no in-memory
|
|
778
781
|
// onAgentClose closure, so without this the tree is stranded on the agent
|
|
779
|
-
// branch.
|
|
780
|
-
//
|
|
781
|
-
if (
|
|
782
|
+
// branch. Explicit dispatch provenance plus current config are the live-mode
|
|
783
|
+
// signal; worktree-mode records fail closed.
|
|
784
|
+
if (shared.isLiveCheckoutDispatchRecord(item, config)) {
|
|
782
785
|
try {
|
|
783
786
|
require('./live-checkout').maybeRestoreLiveCheckoutFromRecord({
|
|
784
787
|
item,
|
|
788
|
+
config,
|
|
785
789
|
isTerminalFailure: _completedAsError,
|
|
786
790
|
resultLabel: _completedAsError ? 'error' : 'success',
|
|
787
791
|
log: (lvl, msg) => log(lvl, msg),
|
|
@@ -1033,7 +1037,7 @@ function checkTimeouts(config) {
|
|
|
1033
1037
|
// #853 — stale orphans have no process close event, so preserve their
|
|
1034
1038
|
// clean committed branch explicitly before completeDispatch makes the
|
|
1035
1039
|
// worktree eligible for teardown/quarantine.
|
|
1036
|
-
if (resolveDispatchBranchPath(item) && item.meta?.branch) {
|
|
1040
|
+
if (resolveDispatchBranchPath(item, config) && item.meta?.branch) {
|
|
1037
1041
|
if (_pendingBranchPreservations.has(item.id)) continue;
|
|
1038
1042
|
_pendingBranchPreservations.add(item.id);
|
|
1039
1043
|
_finalizeDeadDispatch(item, reason, failureClass, config, { complete: completeDispatch })
|