@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/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 < 5; 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 < 4) {
938
- const delay = 50 * (attempt + 1); // 50, 100, 150, 200ms
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 5 rename attempts failed for ${p}`);
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
- while (Date.now() - start < timeoutMs) {
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({ pid: process.pid, ts: Date.now() }));
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
- // PID-owned locks are safe to reap as soon as their owner is dead.
1451
- // Alive owners retain the stale window; legacy/partial lockfiles
1452
- // without a readable PID retain the mtime-only stale window.
1453
- try {
1454
- const stat = fs.statSync(lockPath);
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
- } catch (staleErr) {
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
- sleepMs(retryDelayMs);
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
- try { fs.closeSync(fd); } catch { /* cleanup */ }
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
- try { fs.closeSync(fd); } catch { /* cleanup */ }
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 = path.resolve(String(childPath));
5495
- const parentAbs = path.resolve(String(parentPath));
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 = path.resolve(String(childPath));
5516
- const parentAbs = path.resolve(String(parentPath));
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: { cwd: <project dir[/workdir] or MINIONS_DIR>, worktreeRootDir: null }
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
- let base = null;
5761
- if (project?.localPath) base = path.resolve(String(project.localPath));
5762
- else if (minionsDir) base = path.resolve(String(minionsDir));
5763
- else {
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 !== baseAbs && !joined.startsWith(baseAbs + path.sep)) {
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,
@@ -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 = process.env.MINIONS_KEEP_PROCESSES_SKIP_WORKDIR_CHECK === '1'
630
- ? { requireGitWorkdir: false }
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?.originalRef && item.meta?.project?.localPath) return item.meta.project.localPath;
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 (item.originalRef && item.meta?.branch && item.meta?.project?.localPath) {
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. originalRef's presence is the live-mode signal; the helper no-ops
780
- // for non-live records. Fire-and-forget + best-effort.
781
- if (item.originalRef && item.meta?.branch && item.meta?.project?.localPath) {
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 })