@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.js CHANGED
@@ -445,6 +445,25 @@ function _buildAgentSpawnFlags(runtime, opts = {}) {
445
445
  return buildSpawnFlags(runtime, opts);
446
446
  }
447
447
 
448
+ function _syncAgentWorkerPoolConfig(engineConfig = {}) {
449
+ const enabled = (engineConfig.agentUseWorkerPool ?? ENGINE_DEFAULTS.agentUseWorkerPool) === true;
450
+ agentWorkerPool.setEnabled(enabled);
451
+ if (enabled) agentWorkerPool.setPoolSize(shared.resolveAgentAcpPoolSize(engineConfig));
452
+ return enabled;
453
+ }
454
+
455
+ function _resolveAgentPoolProcessCwd(project, engineConfig) {
456
+ const scratchRoot = shared.resolveAgentTempBaseDir(project, engineConfig, MINIONS_DIR)
457
+ || path.join(os.tmpdir(), 'minions-agent-temp');
458
+ const processCwd = path.join(
459
+ scratchRoot,
460
+ 'acp-workers',
461
+ shared.safeSlugComponent(project?.name || project?.repoName || 'project')
462
+ );
463
+ fs.mkdirSync(processCwd, { recursive: true });
464
+ return processCwd;
465
+ }
466
+
448
467
  function _runtimeLogger() {
449
468
  return {
450
469
  info: (msg) => log('info', msg),
@@ -995,15 +1014,6 @@ async function _probeBranchLocally(rootDir, branchName, gitOpts) {
995
1014
  }
996
1015
  }
997
1016
 
998
- // W-mr3tayu4 — thin wrapper around shared.removeStaleIndexLock. The age-gated
999
- // (>300000ms) stale-lock removal now lives in engine/shared.js so both the
1000
- // worktree-add retry paths here and engine/live-checkout.js#prepareLiveCheckout
1001
- // share one implementation (no circular import). Kept as a named local because
1002
- // three internal call sites and the test harness reference `removeStaleIndexLock`.
1003
- function removeStaleIndexLock(rootDir) {
1004
- return shared.removeStaleIndexLock(rootDir);
1005
- }
1006
-
1007
1017
  async function runWorktreeAdd(rootDir, worktreePath, addArgs, gitOpts, worktreeCreateRetries) {
1008
1018
  // P-a7c4d2e8 (F3): argv-form `git worktree add` — `addArgs` is an array of
1009
1019
  // additional arguments (typically a branch name, optionally preceded by
@@ -1018,7 +1028,6 @@ async function runWorktreeAdd(rootDir, worktreePath, addArgs, gitOpts, worktreeC
1018
1028
  try {
1019
1029
  if (attempt > 0) {
1020
1030
  try { await shared.shellSafeGit(['worktree', 'prune'], { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch (e) { log('warn', 'git: ' + e.message); }
1021
- removeStaleIndexLock(rootDir);
1022
1031
  log('warn', `Retrying git worktree add (attempt ${attempt + 1}/${retries + 1}) for ${path.basename(worktreePath)}`);
1023
1032
  }
1024
1033
  await shared.shellSafeGit(['worktree', 'add', worktreePath, ...addArgs], { ...gitOpts, cwd: rootDir });
@@ -2388,8 +2397,12 @@ async function spawnAgent(dispatchItem, config) {
2388
2397
  updateAgentStatus(id, AGENT_STATUS.FAILED, err.message);
2389
2398
  throw err;
2390
2399
  }
2391
- const metaProjectFields = metaProject && typeof metaProject === 'object' ? metaProject : {};
2392
- const project = projectResolution.project ? { ...projectResolution.project, ...metaProjectFields } : {};
2400
+ // Persisted dispatch metadata is an identity hint only. Never merge stale
2401
+ // checkout configuration back into the current configured project.
2402
+ const project = projectResolution.project ? { ...projectResolution.project } : {};
2403
+ const configuredProjects = getProjects(config);
2404
+ const assertSafeWorktreePath = (candidate) =>
2405
+ shared.assertWorktreeOutsideProjects(candidate, configuredProjects);
2393
2406
 
2394
2407
  // ── Workspace manifest repo gate (W-mq07avbk000m5543) ────────────────────
2395
2408
  // Dispatch-time enforcement of `agents.<id>.workspace_manifest.allowed_repos`.
@@ -2423,19 +2436,14 @@ async function spawnAgent(dispatchItem, config) {
2423
2436
 
2424
2437
  // W-mp73x32w000l143d: decouple agent cwd from worktree placement.
2425
2438
  // resolveSpawnPaths returns:
2426
- // - read-only types: { cwd: <project dir or MINIONS_DIR>, worktreeRootDir: null }
2427
- // (no drive-root preflight these tasks don't need a worktree)
2439
+ // - project-bound read-only types: detached worktree placement
2440
+ // - project-less read-only types: { cwd: MINIONS_DIR, worktreeRootDir: null }
2428
2441
  // - code-mutating types: { cwd: null, worktreeRootDir: <project root> }
2429
2442
  // (caller defaults cwd to worktreeRootDir; drive-root collapse throws
2430
2443
  // WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT — same fail-fast behavior as
2431
2444
  // the legacy resolveProjectRootDir call this replaced).
2432
- // W-mp8ho6w500034a58: read-only task types (meeting/ask/explore/plan/plan-to-prd)
2433
- // never need a worktree — even when carrying a pipeline branch. Pipeline branches
2434
- // on read-only stages are a no-op label; the stage doesn't commit anything, so
2435
- // the worktree had no functional purpose and was only there to absorb a drive-
2436
- // root preflight that fired against MINIONS_DIR's parent. Read-only pipeline
2437
- // stages now short-circuit alongside any other read-only WI (see the gate at
2438
- // `if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type))` below).
2445
+ // Pipeline branch labels remain irrelevant to read-only tasks, so they are
2446
+ // discarded before detached placement.
2439
2447
  const _preBranchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
2440
2448
 
2441
2449
  // ── Per-WI meta.workdir override (P-714ef144) ─────────────────────────
@@ -2482,9 +2490,10 @@ async function spawnAgent(dispatchItem, config) {
2482
2490
  }
2483
2491
  const validatedWorkdir = _wdValidation.value; // null when unset / empty
2484
2492
 
2485
- let cwd, worktreeRootDir, liveMode = false;
2493
+ let cwd, worktreeRootDir, liveMode = false, readOnlyWorktree = false;
2486
2494
  try {
2487
- ({ cwd, worktreeRootDir, liveMode = false } = shared.resolveSpawnPaths(project, type, MINIONS_DIR, { workdir: validatedWorkdir }));
2495
+ ({ cwd, worktreeRootDir, liveMode = false, readOnlyWorktree = false } =
2496
+ shared.resolveSpawnPaths(project, type, MINIONS_DIR, { workdir: validatedWorkdir }));
2488
2497
  } catch (rootErr) {
2489
2498
  if (rootErr?.code === 'WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT' || rootErr?.code === 'WORKTREE_ROOTDIR_MISSING_BASE') {
2490
2499
  log('error', `spawnAgent: project rootDir resolution failed for ${id}: ${rootErr.message}`);
@@ -2533,6 +2542,29 @@ async function spawnAgent(dispatchItem, config) {
2533
2542
  }
2534
2543
  throw rootErr;
2535
2544
  }
2545
+ const checkoutModeAtDispatch = liveMode
2546
+ ? shared.CHECKOUT_MODES.LIVE
2547
+ : shared.CHECKOUT_MODES.WORKTREE;
2548
+ const resolvedProjectMeta = project?.name || project?.localPath
2549
+ ? { name: project.name, localPath: project.localPath }
2550
+ : null;
2551
+ dispatchItem.checkoutMode = checkoutModeAtDispatch;
2552
+ if (resolvedProjectMeta && dispatchItem.meta) {
2553
+ dispatchItem.meta.project = resolvedProjectMeta;
2554
+ }
2555
+ try {
2556
+ mutateDispatch((dispatch) => {
2557
+ for (const queue of ['pending', 'active']) {
2558
+ const found = (dispatch?.[queue] || []).find(d => d && d.id === id);
2559
+ if (!found) continue;
2560
+ found.checkoutMode = checkoutModeAtDispatch;
2561
+ if (resolvedProjectMeta && found.meta) found.meta.project = resolvedProjectMeta;
2562
+ }
2563
+ return dispatch;
2564
+ });
2565
+ } catch (e) {
2566
+ log('warn', `spawnAgent: failed to persist checkout context for ${id}: ${e.message}`);
2567
+ }
2536
2568
  // Legacy local alias: downstream git ops (worktree add, prune, fetch) and
2537
2569
  // the `cwd === rootDir` safety warn at line ~1387 reference `rootDir`. For
2538
2570
  // read-only rootless tasks (no worktree, no branch) this is null — the
@@ -2752,17 +2784,11 @@ async function spawnAgent(dispatchItem, config) {
2752
2784
  _phaseT.afterPrompt = Date.now();
2753
2785
 
2754
2786
  if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type)) {
2755
- // W-mp7havqf0007ce6b: read-only types (meeting/ask/explore/plan/plan-to-prd)
2756
- // short-circuit BEFORE the worktree-creation block. resolveSpawnPaths returns
2757
- // worktreeRootDir=null for read-only types, and path.resolve(null, ...) throws
2758
- // ("paths[0] must be of type string. Received null"). Pipeline branches used
2759
- // to be exempt — they don't need to be (W-mp8ho6w500034a58): read-only stages
2760
- // never commit, so a pipeline-branch label is meaningless for them and the
2761
- // forced worktree only existed to feed the drive-root preflight that this
2762
- // short-circuit now correctly avoids.
2763
- log('info', `${type}: read-only task with branch ${branchName} — skipping worktree, running in cwd ${cwd}`);
2787
+ // Read-only dispatches never own a branch. Project-bound worktree-mode
2788
+ // dispatches still get a detached worktree below; project-less/live tasks
2789
+ // keep their resolved cwd.
2790
+ log('info', `${type}: ignoring branch label ${branchName} for read-only dispatch`);
2764
2791
  branchName = null;
2765
- worktreePath = null;
2766
2792
  }
2767
2793
 
2768
2794
  // ── Live-checkout mode handling (P-a3f9b204) ─────────────────────────────
@@ -3521,6 +3547,55 @@ async function spawnAgent(dispatchItem, config) {
3521
3547
  }
3522
3548
  }
3523
3549
 
3550
+ const needsDetachedProjectWorktree = !liveMode && !!rootDir && !!project?.localPath && !branchName;
3551
+ if (needsDetachedProjectWorktree) {
3552
+ const detachedKind = readOnlyWorktree ? 'read-only' : 'branchless';
3553
+ updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up detached ${detachedKind} worktree for ${project.name || 'project'}`);
3554
+ const wtDirName = shared.buildWorktreeDirName({
3555
+ dispatchId: id,
3556
+ projectName: project.name || 'default',
3557
+ branchName: detachedKind,
3558
+ });
3559
+ const worktreeRoot = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees');
3560
+ worktreePath = path.resolve(worktreeRoot, wtDirName);
3561
+ try {
3562
+ assertSafeWorktreePath(worktreePath);
3563
+ if (fs.existsSync(worktreePath)) {
3564
+ shared.removeWorktree(worktreePath, rootDir, worktreeRoot, { excludeDispatchId: id });
3565
+ }
3566
+ const mainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
3567
+ await runWorktreeAdd(
3568
+ rootDir,
3569
+ worktreePath,
3570
+ ['--detach', mainRef],
3571
+ _worktreeGitOpts,
3572
+ worktreeCreateRetries,
3573
+ );
3574
+ cwd = worktreePath;
3575
+ log('info', `${type}: running in detached ${detachedKind} worktree ${worktreePath}; operator checkout ${project.localPath} is protected`);
3576
+ } catch (readOnlyWtErr) {
3577
+ try {
3578
+ if (worktreePath && fs.existsSync(worktreePath)) {
3579
+ shared.removeWorktree(worktreePath, rootDir, worktreeRoot, { excludeDispatchId: id });
3580
+ }
3581
+ } catch (cleanupErr) {
3582
+ log('warn', `read-only worktree cleanup failed for ${id}: ${cleanupErr.message}`);
3583
+ }
3584
+ const summary = `Detached ${detachedKind} worktree setup failed: ${readOnlyWtErr.message}`;
3585
+ log('error', `spawnAgent: ${summary}`);
3586
+ _cleanupPromptFiles();
3587
+ completeDispatch(
3588
+ id,
3589
+ DISPATCH_RESULT.ERROR,
3590
+ summary.slice(0, 800),
3591
+ 'The operator checkout was not used. Fix the worktree setup failure and retry.',
3592
+ { failureClass: FAILURE_CLASS.WORKTREE_PREFLIGHT, agentRetryable: true },
3593
+ );
3594
+ cleanupTempAgent(agentId);
3595
+ return null;
3596
+ }
3597
+ }
3598
+
3524
3599
  if (branchName && !liveMode) {
3525
3600
  updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
3526
3601
  // W-mpwy3mp5 (#2996): track whether we ended up reusing an existing
@@ -3538,7 +3613,7 @@ async function spawnAgent(dispatchItem, config) {
3538
3613
  // nested worktrees cause glob/grep to match both copies (mirror writes).
3539
3614
  // WORKTREE_NESTED_IN_PROJECT is non-retryable: the recompute on the next
3540
3615
  // tick will produce the same path. Fail fast (W-mp62taw2000ubcc3).
3541
- try { shared.assertWorktreeOutsideProject(worktreePath, rootDir); }
3616
+ try { assertSafeWorktreePath(worktreePath); }
3542
3617
  catch (assertErr) {
3543
3618
  if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
3544
3619
  throw assertErr;
@@ -3552,7 +3627,7 @@ async function spawnAgent(dispatchItem, config) {
3552
3627
  // Same guard for reuse — a previously-created bad worktree must not
3553
3628
  // be silently reused either; the cleanup sweep flags these so the
3554
3629
  // operator can remove them.
3555
- try { shared.assertWorktreeOutsideProject(existingWt, rootDir); }
3630
+ try { assertSafeWorktreePath(existingWt); }
3556
3631
  catch (assertErr) {
3557
3632
  if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
3558
3633
  throw assertErr;
@@ -3604,7 +3679,7 @@ async function spawnAgent(dispatchItem, config) {
3604
3679
  if (!_branchOnRemote) {
3605
3680
  const borrowed = worktreePool.tryBorrow(_poolProject, id);
3606
3681
  if (borrowed && borrowed.path && fs.existsSync(borrowed.path)) {
3607
- try { shared.assertWorktreeOutsideProject(borrowed.path, rootDir); }
3682
+ try { assertSafeWorktreePath(borrowed.path); }
3608
3683
  catch (assertErr) {
3609
3684
  // Always evict before deciding what to do — leaves no BORROWED
3610
3685
  // orphan tied to a dispatch that won't complete normally.
@@ -3666,9 +3741,6 @@ async function spawnAgent(dispatchItem, config) {
3666
3741
  const isSharedBranch = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
3667
3742
  // Prune stale worktree entries before creating (handles leftover entries from crashed runs)
3668
3743
  try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
3669
- // Remove stale index.lock before creating worktree (Windows crashes can leave this behind)
3670
- removeStaleIndexLock(rootDir);
3671
-
3672
3744
  if (isSharedBranch) {
3673
3745
  log('info', `Creating worktree for shared branch: ${worktreePath} on ${branchName}`);
3674
3746
  await _fetchWithTransientRetry(['origin', branchName], { ..._gitOpts, cwd: rootDir }, `origin/${branchName} (shared-branch pre-create)`);
@@ -3678,7 +3750,7 @@ async function spawnAgent(dispatchItem, config) {
3678
3750
  if (eShared.message?.includes('already used by worktree') || eShared.message?.includes('already checked out')) {
3679
3751
  const existingWtPath = await findExistingWorktree(rootDir, branchName);
3680
3752
  if (existingWtPath && fs.existsSync(existingWtPath)) {
3681
- try { shared.assertWorktreeOutsideProject(existingWtPath, rootDir); }
3753
+ try { assertSafeWorktreePath(existingWtPath); }
3682
3754
  catch (assertErr) {
3683
3755
  if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
3684
3756
  throw assertErr;
@@ -3763,7 +3835,7 @@ async function spawnAgent(dispatchItem, config) {
3763
3835
  log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
3764
3836
  throw eRemote;
3765
3837
  }
3766
- try { shared.assertWorktreeOutsideProject(existingWtPath, rootDir); }
3838
+ try { assertSafeWorktreePath(existingWtPath); }
3767
3839
  catch (assertErr) {
3768
3840
  if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
3769
3841
  throw assertErr;
@@ -3829,10 +3901,10 @@ async function spawnAgent(dispatchItem, config) {
3829
3901
  const branchExists = e1.message?.includes('already exists');
3830
3902
  log('warn', `Worktree -b failed for ${branchName}: ${e1.message?.split('\n')[0]}`);
3831
3903
  if (!branchExists) {
3832
- // Transient error (lock, timeout) — prune, clean, and retry -b once more
3904
+ // Transient error (lock, timeout) — prune and retry -b once more.
3905
+ // Never delete the operator checkout's .git/index.lock.
3833
3906
  log('info', `Retrying -b create after prune for ${branchName}`);
3834
3907
  try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 15000 }); } catch { /* optional */ }
3835
- removeStaleIndexLock(rootDir);
3836
3908
  // Clean up partial worktree directory from the failed -b
3837
3909
  // attempt. This husk pre-dates `git worktree add`, so git
3838
3910
  // does not yet own it and shared.removeWorktree's
@@ -3894,7 +3966,7 @@ async function spawnAgent(dispatchItem, config) {
3894
3966
  log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
3895
3967
  throw e2;
3896
3968
  }
3897
- try { shared.assertWorktreeOutsideProject(existingWtPath, rootDir); }
3969
+ try { assertSafeWorktreePath(existingWtPath); }
3898
3970
  catch (assertErr) {
3899
3971
  if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
3900
3972
  throw assertErr;
@@ -4604,6 +4676,21 @@ async function spawnAgent(dispatchItem, config) {
4604
4676
  }
4605
4677
  }
4606
4678
 
4679
+ if (worktreePath) {
4680
+ dispatchItem.worktreePath = worktreePath;
4681
+ try {
4682
+ mutateDispatch((dispatch) => {
4683
+ for (const queue of ['pending', 'active']) {
4684
+ const found = (dispatch?.[queue] || []).find(d => d && d.id === id);
4685
+ if (found) found.worktreePath = worktreePath;
4686
+ }
4687
+ return dispatch;
4688
+ });
4689
+ } catch (e) {
4690
+ log('warn', `spawnAgent: failed to persist pre-spawn worktree path for ${id}: ${e.message}`);
4691
+ }
4692
+ }
4693
+
4607
4694
  updateAgentStatus(id, AGENT_STATUS.READY, 'Worktree ready, preparing to spawn process');
4608
4695
 
4609
4696
  if (worktreePath && meta?.source === 'work-item' && meta?.item?.branchStrategy === 'shared-branch') {
@@ -4721,13 +4808,14 @@ async function spawnAgent(dispatchItem, config) {
4721
4808
  const resolvedMaxBudget = shared.resolveAgentMaxBudget(agentConfig, engineConfig);
4722
4809
  const resolvedBare = shared.resolveAgentBareMode(agentConfig, engineConfig);
4723
4810
  // Pool through the persistent ACP worker transport when the selected adapter
4724
- // opts into that capability and config allows it.
4725
- const useAgentPool = shared.resolveAgentUseWorkerPool(engineConfig, runtime);
4726
- if (useAgentPool) {
4727
- // Idempotent cheap to call on every pooled dispatch so a live config
4728
- // change (agentAcpPoolSize / maxConcurrent) takes effect without a
4729
- // separate engine-restart-only wiring hook.
4730
- agentWorkerPool.setPoolSize(shared.resolveAgentAcpPoolSize(engineConfig));
4811
+ // opts into that capability and config allows it. keep_processes requires
4812
+ // the cold wrapper so descendant ownership remains enforceable.
4813
+ _syncAgentWorkerPoolConfig(engineConfig);
4814
+ const keepProcessesRequested = !!meta?.item?.meta?.keep_processes || !!dispatchItem.meta?.keep_processes;
4815
+ const agentPoolRequested = shared.resolveAgentUseWorkerPool(engineConfig, runtime);
4816
+ const useAgentPool = agentPoolRequested && !keepProcessesRequested;
4817
+ if (keepProcessesRequested && agentPoolRequested) {
4818
+ log('info', `spawnAgent: disabling ACP worker pool for keep_processes dispatch ${id} so descendants can be reaped safely`);
4731
4819
  }
4732
4820
 
4733
4821
  const prevFailureClass = meta?.item?._lastFailureClass || null;
@@ -4792,6 +4880,18 @@ async function spawnAgent(dispatchItem, config) {
4792
4880
 
4793
4881
  log('info', `Spawning agent: ${agentId} (${id}) in ${cwd}`);
4794
4882
  log('info', `Task type: ${type} | Branch: ${branchName || 'none'}`);
4883
+ dispatchItem.executionCwd = cwd;
4884
+ try {
4885
+ mutateDispatch((dispatch) => {
4886
+ for (const queue of ['pending', 'active']) {
4887
+ const found = (dispatch?.[queue] || []).find(d => d && d.id === id);
4888
+ if (found) found.executionCwd = cwd;
4889
+ }
4890
+ return dispatch;
4891
+ });
4892
+ } catch (e) {
4893
+ log('warn', `spawnAgent: failed to persist execution cwd for ${id}: ${e.message}`);
4894
+ }
4795
4895
 
4796
4896
  // Agent status is derived from dispatch state.
4797
4897
 
@@ -4808,6 +4908,11 @@ async function spawnAgent(dispatchItem, config) {
4808
4908
  || dispatchItem.meta?.keep_processes_skip_workdir_check) {
4809
4909
  childEnv.MINIONS_KEEP_PROCESSES_SKIP_WORKDIR_CHECK = '1';
4810
4910
  }
4911
+ const keepProcessesAllowedCwdRoot = worktreePath
4912
+ || (liveMode && project?.localPath ? path.resolve(String(project.localPath)) : cwd);
4913
+ if (keepProcessesAllowedCwdRoot) {
4914
+ childEnv.MINIONS_KEEP_PROCESSES_ALLOWED_CWD_ROOT = keepProcessesAllowedCwdRoot;
4915
+ }
4811
4916
 
4812
4917
  // W-mpg54mi2000n7b7e — suppress Git's interactive credential prompts and
4813
4918
  // Git Credential Manager's GUI dialog for every agent spawn. Without these,
@@ -4918,9 +5023,9 @@ async function spawnAgent(dispatchItem, config) {
4918
5023
  }
4919
5024
 
4920
5025
  _phaseT.spawnCallStart = Date.now();
4921
- // P-c5a1f3b8 (Item D — live-mode push/PR-create cwd guard): in live mode
4922
- // worktreePath stays null, so `cwd` here is project.localPath (it is never
4923
- // reassigned to a worktree by the `if (worktreePath ...)` block above). The
5026
+ // P-c5a1f3b8 (Item D — live-mode push/PR-create cwd guard): only live mode
5027
+ // leaves worktreePath null with cwd=project.localPath. Worktree-mode
5028
+ // project dispatches, including read-only types, have an isolated path. The
4924
5029
  // agent's `git push -u origin <branch>` and PR-create therefore run in-place
4925
5030
  // against the operator's checkout. This cwd MUST NEVER be null for a mutating
4926
5031
  // live dispatch — shared.resolveSpawnPaths returns cwd === project.localPath
@@ -4959,6 +5064,7 @@ async function spawnAgent(dispatchItem, config) {
4959
5064
  'MINIONS_LIVE_OUTPUT_PATH',
4960
5065
  'MINIONS_STEERING_ACK_DIR',
4961
5066
  'MINIONS_AGENT_CWD',
5067
+ 'MINIONS_KEEP_PROCESSES_ALLOWED_CWD_ROOT',
4962
5068
  ];
4963
5069
  const pooledProcessEnv = { ...childEnv };
4964
5070
  const pooledSessionContext = {};
@@ -4971,16 +5077,27 @@ async function spawnAgent(dispatchItem, config) {
4971
5077
  runtime,
4972
5078
  dispatchId: id,
4973
5079
  cwd,
4974
- model: effectiveModel,
4975
- effort: requestedEffort,
5080
+ processCwd: _resolveAgentPoolProcessCwd(project, engineConfig),
5081
+ model: runtimeOptions.model,
5082
+ effort: runtimeOptions.effort,
4976
5083
  // Mirrors the CC worker pool's convention (dashboard.js) — a single
4977
5084
  // flat top-level config array, not per-agent-resolved.
4978
5085
  mcpServers: (engineConfig && engineConfig.mcpServers) || [],
4979
5086
  hermeticDirs: addDirs,
4980
5087
  processEnv: pooledProcessEnv,
5088
+ workerOptions: runtimeOptions,
5089
+ sessionId: cachedSessionId,
4981
5090
  sessionContext: pooledSessionContext,
5091
+ agentId,
5092
+ reapDescendants: true,
5093
+ keepProcessesSkipWorkdirCheck: !!(
5094
+ meta?.item?.meta?.keep_processes_skip_workdir_check
5095
+ || dispatchItem.meta?.keep_processes_skip_workdir_check
5096
+ ),
4982
5097
  systemPromptText: systemPrompt,
4983
- promptText: fullTaskPrompt,
5098
+ // The prompt file may have gained dirty-file or cross-repo dependency
5099
+ // sections after `fullTaskPrompt` was first built.
5100
+ promptText: fs.readFileSync(promptPath, 'utf8'),
4984
5101
  });
4985
5102
  // The child_process PID file is normally written asynchronously by
4986
5103
  // spawn-agent.js itself (engine/spawn-agent.js:606) once it starts.
@@ -5336,6 +5453,24 @@ async function spawnAgent(dispatchItem, config) {
5336
5453
  // Re-expose the engine-resolved cwd on steering resume (matches the
5337
5454
  // initial spawn path) so $MINIONS_AGENT_CWD stays valid across resumes.
5338
5455
  childEnv.MINIONS_AGENT_CWD = cwd;
5456
+ if (keepProcessesAllowedCwdRoot) {
5457
+ childEnv.MINIONS_KEEP_PROCESSES_ALLOWED_CWD_ROOT = keepProcessesAllowedCwdRoot;
5458
+ }
5459
+ try {
5460
+ mutateDispatch((dp) => {
5461
+ const active = dp.active.find((entry) => entry.id === id);
5462
+ if (active) delete active.pooled;
5463
+ return dp;
5464
+ });
5465
+ delete dispatchItem.pooled;
5466
+ } catch (e) {
5467
+ log('warn', `Steering: failed to persist cold-resume mode for ${agentId}: ${e.message}`);
5468
+ try { fs.unlinkSync(steerPromptPath); } catch { /* cleanup */ }
5469
+ activeProcesses.delete(id);
5470
+ completeDispatch(id, DISPATCH_RESULT.ERROR, `Steering state update failed: ${e.message}`);
5471
+ cleanupTempAgent(agentId);
5472
+ return;
5473
+ }
5339
5474
  let resumeProc;
5340
5475
  try {
5341
5476
  // detached so the resumed steering session also survives engine death (matches initial spawn)
@@ -5354,6 +5489,12 @@ async function spawnAgent(dispatchItem, config) {
5354
5489
  cleanupTempAgent(agentId);
5355
5490
  return;
5356
5491
  }
5492
+ const writeResumePid = () => {
5493
+ if (!resumeProc.pid) return;
5494
+ try { fs.writeFileSync(pidFilePath, String(resumeProc.pid)); } catch { /* best-effort */ }
5495
+ };
5496
+ if (resumeProc.pid) writeResumePid();
5497
+ else if (typeof resumeProc.once === 'function') resumeProc.once('spawn', writeResumePid);
5357
5498
 
5358
5499
  // Re-attach to existing tracking — do NOT carry _steeringAt forward (#1052).
5359
5500
  // The kill watcher in timeout.js fires 30s after _steeringAt is set. If we carry it
@@ -5576,8 +5717,12 @@ async function spawnAgent(dispatchItem, config) {
5576
5717
  let evalResult = schemaResult;
5577
5718
  let isWorkdirRejection = false;
5578
5719
  const workdirGateOn = !_kpSkipWorkdir && ENGINE_DEFAULTS.keepProcesses?.requireGitWorkdir !== false;
5579
- if (schemaResult.exists && schemaResult.accepted && workdirGateOn) {
5580
- const workdirResult = keepProcessSweep.evaluateKeepPidsAcceptance(agentId, { requireGitWorkdir: true });
5720
+ const allowedCwdRoot = keepProcessesAllowedCwdRoot;
5721
+ if (schemaResult.exists && schemaResult.accepted && (workdirGateOn || allowedCwdRoot)) {
5722
+ const workdirResult = keepProcessSweep.evaluateKeepPidsAcceptance(agentId, {
5723
+ requireGitWorkdir: workdirGateOn,
5724
+ ...(allowedCwdRoot ? { allowedCwdRoot } : {}),
5725
+ });
5581
5726
  if (workdirResult.exists && !workdirResult.accepted && workdirResult.isWorkdirRejection) {
5582
5727
  evalResult = workdirResult;
5583
5728
  isWorkdirRejection = true;
@@ -5672,6 +5817,22 @@ async function spawnAgent(dispatchItem, config) {
5672
5817
  } catch (alertErr) {
5673
5818
  log('warn', `keep-processes acceptance: failed to emit inbox alert for ${agentId}: ${alertErr.message}`);
5674
5819
  }
5820
+ } else if (evalResult.exists && evalResult.accepted) {
5821
+ try {
5822
+ const enriched = {
5823
+ ...(evalResult.parsedRaw || {}),
5824
+ cwd: evalResult.recordedCwd || '',
5825
+ project: project?.name || '',
5826
+ work_type: type,
5827
+ checkout_mode: checkoutModeAtDispatch,
5828
+ dispatch_root: allowedCwdRoot
5829
+ ? shared.realPathForComparison(allowedCwdRoot)
5830
+ : '',
5831
+ };
5832
+ fs.writeFileSync(evalResult.filePath, JSON.stringify(enriched, null, 2));
5833
+ } catch (e) {
5834
+ log('warn', `keep-processes acceptance: could not persist checkout provenance for ${agentId}: ${e.message}`);
5835
+ }
5675
5836
  }
5676
5837
  } catch (e) {
5677
5838
  log('warn', `keep-processes acceptance check failed for ${agentId} (${id}): ${e.message}`);
@@ -5699,7 +5860,12 @@ async function spawnAgent(dispatchItem, config) {
5699
5860
  if (_msEnabled) {
5700
5861
  try {
5701
5862
  const managedSpawn = require('./engine/managed-spawn');
5702
- const evalResult = managedSpawn.evaluateManagedSpawnAcceptance(agentId);
5863
+ const allowedCwdRoot = keepProcessesAllowedCwdRoot;
5864
+ const evalResult = managedSpawn.evaluateManagedSpawnAcceptance(agentId, {
5865
+ projects: getProjects(config),
5866
+ project,
5867
+ ...(allowedCwdRoot ? { allowedCwdRoot } : {}),
5868
+ });
5703
5869
  if (evalResult.exists && !evalResult.accepted) {
5704
5870
  managedSpawnAcceptanceFailure = {
5705
5871
  reason: evalResult.reason,
@@ -5757,6 +5923,9 @@ async function spawnAgent(dispatchItem, config) {
5757
5923
  owner_agent: agentId,
5758
5924
  owner_wi: dispatchItem.meta?.item?.id || '',
5759
5925
  owner_project: project?.name || '',
5926
+ owner_work_type: type,
5927
+ dispatch_root: allowedCwdRoot || '',
5928
+ checkout_mode: checkoutModeAtDispatch,
5760
5929
  };
5761
5930
  const spawnedItems = [];
5762
5931
  let spawnFailureReason = null;
@@ -6097,7 +6266,7 @@ async function spawnAgent(dispatchItem, config) {
6097
6266
  // crashed dispatch leaks `work/W-<id>` worktree entries that block
6098
6267
  // every retry with `fatal: 'work/W-<id>' is already used by worktree
6099
6268
  // at …` until the 2-hour age sweep in cleanup.js eventually catches up.
6100
- if (worktreePath && rootDir && branchName) {
6269
+ if (worktreePath && rootDir) {
6101
6270
  try {
6102
6271
  const _wgc = require('./engine/worktree-gc');
6103
6272
  const _wtRoot = path.resolve(rootDir, engineConfig.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot);
@@ -6144,7 +6313,7 @@ async function spawnAgent(dispatchItem, config) {
6144
6313
  // restore is a plain `git checkout <originalRef>` — never --force/reset/
6145
6314
  // clean/stash — and is best-effort: a thrown restore never alters the
6146
6315
  // dispatch result.
6147
- if (liveMode && branchName) {
6316
+ if (liveMode && branchName && shared.isLiveCheckoutDispatchRecord(dispatchItem, getConfig())) {
6148
6317
  try {
6149
6318
  const _liveRestore = require('./engine/live-checkout');
6150
6319
  await _liveRestore.restoreLiveCheckoutAtDispatchEnd({
@@ -6335,6 +6504,10 @@ async function spawnAgent(dispatchItem, config) {
6335
6504
  // ACP worker isn't spawned detached and a fresh pool cold-starts empty —
6336
6505
  // a live worker PID must never be mistaken for a resumable dispatch.
6337
6506
  if (useAgentPool) item.pooled = true;
6507
+ else delete item.pooled;
6508
+ item.checkoutMode = checkoutModeAtDispatch;
6509
+ item.executionCwd = cwd;
6510
+ if (resolvedProjectMeta && item.meta) item.meta.project = resolvedProjectMeta;
6338
6511
  // W-mq5rwwss000f30a7 — persist the worktree path so the live-worktree
6339
6512
  // guard (shared.isWorktreePathLive) can correlate destructive callers
6340
6513
  // (removeWorktree, cleanup orphan-dir sweep, pool-return, quarantine)
@@ -6613,73 +6786,6 @@ function safePrdProjectSlug(projectName) {
6613
6786
  return slug || 'project';
6614
6787
  }
6615
6788
 
6616
- // Fleet branches the engine/CC create. A worktree-mode operator checkout should
6617
- // NEVER sit on one of these (or any branch); when it does, a flow ran raw
6618
- // `git checkout`/`merge` in the live checkout instead of a worktree.
6619
- const _FLEET_BRANCH_RE = /^(cc-pr|backport|e2e|verify|sched-|work)\//;
6620
-
6621
- // Self-heal: a worktree-mode project's operator checkout (project.localPath) must
6622
- // always sit on its mainBranch — agents use isolated worktrees, CC Create-PR uses
6623
- // prepare-create-pr-worktree. But fleet flows (CC raw git, backport, sync)
6624
- // sometimes run `git checkout <branch>` + `git merge` THERE despite the rules,
6625
- // leaving it stuck on a feature branch (often mid-conflict) — which restores
6626
- // git-tracked dead PRDs and pollutes the operator's main, then the materializer
6627
- // re-runs finished work. Detect + restore. Conservative: only acts on a stuck
6628
- // merge/rebase OR a recognizably fleet-created branch, so a human's deliberate
6629
- // `git checkout my-feature` in their checkout is left alone. Branch commits are
6630
- // preserved on their ref (reset --hard only drops the uncommitted hijack state).
6631
- function restoreHijackedOperatorCheckouts(config) {
6632
- if (config?.engine?.autoRestoreOperatorCheckout === false) return;
6633
- let projects;
6634
- try { projects = getProjects(config); } catch { return; }
6635
- for (const project of projects) {
6636
- try {
6637
- const localPath = project?.localPath;
6638
- if (!localPath || !fs.existsSync(localPath)) continue;
6639
- if (shared.resolveCheckoutMode(project) === shared.CHECKOUT_MODES.LIVE) continue; // live-mode: feature branch is intentional
6640
- const main = project.mainBranch || 'main';
6641
- const opts = { cwd: localPath };
6642
- // W-mrcvh5hw000o6cf1: use the argv-form (shell: false) git helper instead of
6643
- // execSilent(). execSilent() shells out via cmd.exe, which cannot chdir into
6644
- // a UNC path (`//wsl.localhost/...`, `\\server\share\...`) — it silently
6645
- // falls back to the Windows directory and every git call then fails with
6646
- // "fatal: not a git repository", every tick, forever, for WSL/UNC-hosted
6647
- // projects. shellSafeGitSync spawns git.exe directly (execFileSync,
6648
- // shell:false) so `cwd` is honored even for UNC paths.
6649
- let isRepo;
6650
- try { isRepo = String(shared.shellSafeGitSync(['rev-parse', '--is-inside-work-tree'], opts) || '').trim(); }
6651
- catch { continue; } // not a git repo (or transiently unreachable) — nothing to restore, skip quietly
6652
- if (isRepo !== 'true') continue;
6653
- const cur = String(shared.shellSafeGitSync(['rev-parse', '--abbrev-ref', 'HEAD'], opts) || '').trim();
6654
- if (!cur || cur === 'HEAD' || cur === main) continue; // on main or detached — fine
6655
- const gitDir = String(shared.shellSafeGitSync(['rev-parse', '--git-dir'], opts) || '').trim();
6656
- const absGitDir = gitDir ? (path.isAbsolute(gitDir) ? gitDir : path.join(localPath, gitDir)) : path.join(localPath, '.git');
6657
- const mergeStuck = fs.existsSync(path.join(absGitDir, 'MERGE_HEAD'));
6658
- const rebaseStuck = fs.existsSync(path.join(absGitDir, 'rebase-merge')) || fs.existsSync(path.join(absGitDir, 'rebase-apply'));
6659
- if (!mergeStuck && !rebaseStuck && !_FLEET_BRANCH_RE.test(cur)) continue; // likely a human's deliberate checkout — leave it
6660
- const why = mergeStuck ? ' (stuck merge)' : rebaseStuck ? ' (stuck rebase)' : ' (fleet branch in live checkout)';
6661
- log('warn', `Operator checkout for ${project.name} is on '${cur}'${why}; worktree-mode requires ${main} — restoring`);
6662
- if (mergeStuck) shared.shellSafeGitSync(['merge', '--abort'], opts);
6663
- if (rebaseStuck) shared.shellSafeGitSync(['rebase', '--abort'], opts);
6664
- shared.shellSafeGitSync(['reset', '--hard', 'HEAD'], opts); // drop uncommitted hijack state; branch commits stay on the ref
6665
- try { shared.validateGitRef(main); } catch (ve) { throw new Error(`restoreHijackedOperatorCheckouts: invalid mainBranch ${JSON.stringify(main)}: ${ve.message}`); }
6666
- shared.shellSafeGitSync(['checkout', main], opts);
6667
- const now = String(shared.shellSafeGitSync(['rev-parse', '--abbrev-ref', 'HEAD'], opts) || '').trim();
6668
- if (now === main) {
6669
- try {
6670
- writeInboxAlert(`operator-checkout-restored-${project.name}`,
6671
- `Operator checkout ${localPath} was hijacked onto '${cur}'${why} and restored to ${main}. ` +
6672
- 'A fleet flow ran git checkout/merge in the LIVE operator checkout instead of an isolated worktree ' +
6673
- '(playbooks/shared-rules.md: "Do NOT checkout branches in the main working tree — use worktrees"). ' +
6674
- `The '${cur}' branch commits are preserved on its ref.`);
6675
- } catch { /* alert is best-effort */ }
6676
- } else {
6677
- log('warn', `Operator checkout restore for ${project.name} did not reach ${main} (now '${now}') — manual intervention needed`);
6678
- }
6679
- } catch (e) { log('warn', `restoreHijackedOperatorCheckouts ${project?.name || '?'}: ${e?.message || e}`); }
6680
- }
6681
- }
6682
-
6683
6789
  function materializePlansAsWorkItems(config) {
6684
6790
  const writePrdLocked = (fileName, data) => {
6685
6791
  return prdStore.writePrd(fileName, data);
@@ -10556,6 +10662,7 @@ async function tickInner() {
10556
10662
  catch (e) { log('warn', `lastTickAt write: ${e.message}`); }
10557
10663
 
10558
10664
  const config = getConfig();
10665
+ _syncAgentWorkerPoolConfig(config.engine);
10559
10666
  tickCount++;
10560
10667
  const now = Date.now();
10561
10668
  const tickIntervalMs = Math.max(1, Number(config.engine?.tickInterval) || ENGINE_DEFAULTS.tickInterval);
@@ -11588,9 +11695,6 @@ async function tickInner() {
11588
11695
  const maxC = resolveMaxConcurrent(config);
11589
11696
  setTempBudget(Math.max(0, maxC - activeCountPre));
11590
11697
  }
11591
- // Self-heal a hijacked operator checkout BEFORE discovery/materialization, so a
11592
- // dead PRD restored by a stray `git checkout` can't be re-materialized this tick.
11593
- try { restoreHijackedOperatorCheckouts(config); } catch (e) { log('warn', 'restoreHijackedOperatorCheckouts: ' + e.message); }
11594
11698
  let discoveryOk = true;
11595
11699
  try { await withTickTimeout(() => discoverWork(config), 'discoverWork', tickOpTimeoutMs); } catch (e) { log('warn', 'discoverWork: ' + e.message); discoveryOk = false; }
11596
11700
  if (_isTickStale(myGeneration)) return;
@@ -11691,7 +11795,6 @@ module.exports = {
11691
11795
  discoverWork, discoverFromPrs, discoverFromWorkItems, discoverCentralWorkItems,
11692
11796
  materializePlansAsWorkItems,
11693
11797
  materializeSpecsAsWorkItems, // exported for testing (P-f7-git-log)
11694
- restoreHijackedOperatorCheckouts, // exported for testing — operator-checkout self-heal
11695
11798
 
11696
11799
  // Shared helpers (used by lifecycle.js and tests)
11697
11800
  reconcileItemsWithPrs, detectDependencyCycles,
@@ -11704,7 +11807,7 @@ module.exports = {
11704
11807
  buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
11705
11808
  runWorktreeAdd, // exported for testing (W-mqvaxv65000m76f2 — GVFS partial-checkout behavioral test)
11706
11809
  _fetchWithTransientRetry, _classifyFreshBaseFetchFailure, _probeBranchLocally, // exported for testing
11707
- isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, pushCleanAheadBranch, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
11810
+ isWorktreeRetryableError, syncReusedWorktree, pushCleanAheadBranch, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
11708
11811
  _statusPorcelainCmd, _killGitDescendantsForWorktree, _bumpQuarantineOutcome, // exported for testing (W-mq5n1zx5)
11709
11812
  _reapWorktreeHolders, _findTerminalWorktreeOwners, // exported for testing (W-mqila0t5 — CWD-pinned holder reap)
11710
11813
  pruneStaleWorktreeForBranch, // exported for testing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2383",
3
+ "version": "0.1.2384",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"