@yemi33/minions 0.1.2382 → 0.1.2384
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/minions.js +1 -0
- package/dashboard/js/refresh.js +2 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard.js +37 -19
- package/docs/completion-reports.md +20 -1
- package/docs/keep-processes.md +7 -2
- package/docs/live-checkout-mode.md +7 -7
- package/docs/managed-spawn.md +14 -1
- package/docs/runtime-adapters.md +61 -26
- package/docs/skills.md +2 -0
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +36 -0
- package/engine/acp-transport.js +273 -58
- package/engine/ado-comment.js +3 -1
- package/engine/agent-worker-pool.js +278 -54
- package/engine/cc-worker-pool.js +12 -3
- package/engine/cleanup.js +15 -11
- package/engine/cli.js +56 -28
- package/engine/comment-format.js +51 -14
- package/engine/create-pr-worktree.js +57 -79
- package/engine/dispatch.js +7 -2
- package/engine/execution-model.js +68 -0
- package/engine/gh-comment.js +6 -3
- package/engine/github.js +6 -7
- package/engine/keep-process-sweep.js +131 -9
- package/engine/lifecycle.js +126 -45
- package/engine/live-checkout.js +4 -2
- package/engine/llm.js +59 -83
- package/engine/managed-spawn.js +112 -5
- package/engine/memory-store.js +3 -1
- package/engine/playbook.js +4 -6
- package/engine/pooled-agent-process.js +512 -45
- package/engine/pr-action.js +6 -8
- package/engine/process-utils.js +154 -18
- package/engine/runtimes/claude.js +94 -25
- package/engine/runtimes/codex.js +1 -0
- package/engine/runtimes/contract.js +239 -0
- package/engine/runtimes/copilot.js +97 -9
- package/engine/runtimes/index.js +37 -9
- package/engine/shared.js +349 -82
- package/engine/spawn-agent.js +85 -116
- package/engine/spawn-phase-watchdog.js +8 -37
- package/engine/timeout.js +14 -10
- package/engine/worktree-gc.js +55 -46
- package/engine.js +418 -241
- package/package.json +1 -1
- package/playbooks/fix.md +20 -0
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +2 -2
- package/prompts/cc-system.md +11 -11
- package/skills/check-self-authored-review-comment/SKILL.md +34 -0
package/engine.js
CHANGED
|
@@ -34,14 +34,20 @@ const shared = require('./engine/shared');
|
|
|
34
34
|
const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
|
|
35
35
|
WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, AGENT_STATUS,
|
|
36
36
|
FAILURE_CLASS, resolvePollFlag } = shared;
|
|
37
|
-
const {
|
|
38
|
-
|
|
37
|
+
const {
|
|
38
|
+
resolveRuntime,
|
|
39
|
+
resolveInvocationOptions,
|
|
40
|
+
buildSpawnFlags,
|
|
41
|
+
prepareWorkspace,
|
|
42
|
+
} = require('./engine/runtimes');
|
|
43
|
+
const { assertStaleHeadOk } = require('./engine/spawn-agent');
|
|
39
44
|
// P-1d8f0b93 — fleet ACP worker pool + its child_process-shaped facade.
|
|
40
45
|
// NOTE: engine/agent-worker-pool.js is a DIFFERENT module from the
|
|
41
46
|
// dashboard's single-tab CC pool (engine.js must never import that one —
|
|
42
47
|
// see the cc-tab-pool wiring regression guard test).
|
|
43
48
|
const agentWorkerPool = require('./engine/agent-worker-pool');
|
|
44
49
|
const { PooledAgentProcess } = require('./engine/pooled-agent-process');
|
|
50
|
+
const { normalizeExecutionModel } = require('./engine/execution-model');
|
|
45
51
|
const adoGitAuth = require('./engine/ado-git-auth');
|
|
46
52
|
const queries = require('./engine/queries');
|
|
47
53
|
const dispatchEvents = require('./engine/dispatch-events');
|
|
@@ -436,22 +442,26 @@ function _maxTurnsForType(type, engineConfig = {}) {
|
|
|
436
442
|
// ─── Runtime adapter integration (P-2a6d9c4f) ────────────────────────────────
|
|
437
443
|
|
|
438
444
|
function _buildAgentSpawnFlags(runtime, opts = {}) {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
if (
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
445
|
+
return buildSpawnFlags(runtime, opts);
|
|
446
|
+
}
|
|
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;
|
|
455
465
|
}
|
|
456
466
|
|
|
457
467
|
function _runtimeLogger() {
|
|
@@ -603,10 +613,48 @@ function captureSessionIdFromStdoutChunk(agentId, dispatchId, branchName, runtim
|
|
|
603
613
|
logger: _runtimeLogger(),
|
|
604
614
|
});
|
|
605
615
|
}
|
|
616
|
+
|
|
606
617
|
return;
|
|
607
618
|
}
|
|
608
619
|
}
|
|
609
620
|
|
|
621
|
+
function _runtimeModelFromOutputLine(runtime, line) {
|
|
622
|
+
if (!runtime || typeof runtime.parseOutput !== 'function') return null;
|
|
623
|
+
try {
|
|
624
|
+
return normalizeExecutionModel(runtime.parseOutput(String(line), { maxTextLength: 0 })?.model);
|
|
625
|
+
} catch {
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function captureExecutionModelFromStdoutChunk(dispatchItem, dispatchId, runtime, procInfo, chunk, state) {
|
|
631
|
+
if (!procInfo || procInfo.executionModel || !chunk) return null;
|
|
632
|
+
const text = String(state.modelLineBuffer || '') + String(chunk);
|
|
633
|
+
const lines = text.split('\n');
|
|
634
|
+
state.modelLineBuffer = /[\r\n]$/.test(text) ? '' : (lines.pop() || '');
|
|
635
|
+
if (state.modelLineBuffer.length > 65536) state.modelLineBuffer = '';
|
|
636
|
+
|
|
637
|
+
for (const line of lines) {
|
|
638
|
+
const model = _runtimeModelFromOutputLine(runtime, line);
|
|
639
|
+
if (!model) continue;
|
|
640
|
+
procInfo.executionModel = model;
|
|
641
|
+
dispatchItem.executionModel = model;
|
|
642
|
+
try {
|
|
643
|
+
mutateDispatch((dispatch) => {
|
|
644
|
+
for (const section of ['pending', 'active']) {
|
|
645
|
+
const item = (dispatch[section] || []).find(entry => entry.id === dispatchId);
|
|
646
|
+
if (item) item.executionModel = model;
|
|
647
|
+
}
|
|
648
|
+
return dispatch;
|
|
649
|
+
});
|
|
650
|
+
} catch (err) {
|
|
651
|
+
log('warn', `Could not persist runtime model for ${dispatchId}: ${err.message}`);
|
|
652
|
+
}
|
|
653
|
+
return model;
|
|
654
|
+
}
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
|
|
610
658
|
function mergePendingSteeringEntries(...groups) {
|
|
611
659
|
const merged = [];
|
|
612
660
|
const seen = new Set();
|
|
@@ -905,16 +953,14 @@ function _isTransientGitNetworkError(err) {
|
|
|
905
953
|
}
|
|
906
954
|
|
|
907
955
|
// Wraps shellSafeGit(['fetch', ...]) with a single retry on transient
|
|
908
|
-
// network errors.
|
|
909
|
-
//
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
// most "couldn't find remote ref" cascades downstream caused by a 0-second
|
|
913
|
-
// network blip during the initial fetch.
|
|
956
|
+
// network errors. Most existing callers keep the historical non-fatal
|
|
957
|
+
// behavior and receive false on terminal failure. Callers that cannot safely
|
|
958
|
+
// use a stale local ref (ordinary fresh-branch creation) pass throwOnFailure
|
|
959
|
+
// so the terminal git error remains available for accurate classification.
|
|
914
960
|
//
|
|
915
961
|
// `args` is the array passed to git AFTER 'fetch' (e.g. ['origin', branch]).
|
|
916
962
|
// `label` is a short string used in the log line for triage.
|
|
917
|
-
async function _fetchWithTransientRetry(args, opts, label) {
|
|
963
|
+
async function _fetchWithTransientRetry(args, opts, label, { throwOnFailure = false } = {}) {
|
|
918
964
|
const _attempt = async () => shared.shellSafeGit(['fetch', ...args], opts);
|
|
919
965
|
try {
|
|
920
966
|
await _attempt();
|
|
@@ -930,6 +976,7 @@ async function _fetchWithTransientRetry(args, opts, label) {
|
|
|
930
976
|
// incident. _redactBearer keeps any bearer header echoed in the failing
|
|
931
977
|
// git command out of engine logs.
|
|
932
978
|
log('warn', `git fetch ${label}: ${adoGitAuth.redactBearer(String(e.message || e))}`);
|
|
979
|
+
if (throwOnFailure) throw e;
|
|
933
980
|
return false; // swallow non-transient — caller falls back to local ref
|
|
934
981
|
}
|
|
935
982
|
const transientMsg = adoGitAuth.redactBearer(String(e.message || e));
|
|
@@ -941,19 +988,30 @@ async function _fetchWithTransientRetry(args, opts, label) {
|
|
|
941
988
|
return true;
|
|
942
989
|
} catch (e2) {
|
|
943
990
|
const retryMsg = adoGitAuth.redactBearer(String(e2.message || e2));
|
|
944
|
-
log('warn', `git fetch ${label}: retry also failed (${retryMsg}) — falling back to local ref`);
|
|
991
|
+
log('warn', `git fetch ${label}: retry also failed (${retryMsg})${throwOnFailure ? ' — surfacing error' : ' — falling back to local ref'}`);
|
|
992
|
+
if (throwOnFailure) throw e2;
|
|
945
993
|
return false;
|
|
946
994
|
}
|
|
947
995
|
}
|
|
948
996
|
}
|
|
949
997
|
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
998
|
+
function _classifyFreshBaseFetchFailure(err) {
|
|
999
|
+
return adoGitAuth.isAdoAuthFailure(err)
|
|
1000
|
+
? FAILURE_CLASS.AUTH
|
|
1001
|
+
: FAILURE_CLASS.NETWORK_ERROR;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
async function _probeBranchLocally(rootDir, branchName, gitOpts) {
|
|
1005
|
+
try {
|
|
1006
|
+
await shared.shellSafeGit(
|
|
1007
|
+
['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}`],
|
|
1008
|
+
{ ...gitOpts, cwd: rootDir, timeout: 10000 },
|
|
1009
|
+
);
|
|
1010
|
+
return true;
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
if (e?.code === 1) return false;
|
|
1013
|
+
throw e;
|
|
1014
|
+
}
|
|
957
1015
|
}
|
|
958
1016
|
|
|
959
1017
|
async function runWorktreeAdd(rootDir, worktreePath, addArgs, gitOpts, worktreeCreateRetries) {
|
|
@@ -970,7 +1028,6 @@ async function runWorktreeAdd(rootDir, worktreePath, addArgs, gitOpts, worktreeC
|
|
|
970
1028
|
try {
|
|
971
1029
|
if (attempt > 0) {
|
|
972
1030
|
try { await shared.shellSafeGit(['worktree', 'prune'], { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
973
|
-
removeStaleIndexLock(rootDir);
|
|
974
1031
|
log('warn', `Retrying git worktree add (attempt ${attempt + 1}/${retries + 1}) for ${path.basename(worktreePath)}`);
|
|
975
1032
|
}
|
|
976
1033
|
await shared.shellSafeGit(['worktree', 'add', worktreePath, ...addArgs], { ...gitOpts, cwd: rootDir });
|
|
@@ -2315,7 +2372,6 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2315
2372
|
// (large prompts are written to engine/contexts/<id>.md to keep dispatch rows
|
|
2316
2373
|
// small — see shared.sidecarDispatchPrompt / #1167).
|
|
2317
2374
|
let taskPrompt = shared.resolveDispatchPrompt(dispatchItem);
|
|
2318
|
-
const claudeConfig = config.claude || {};
|
|
2319
2375
|
const engineConfig = config.engine || {};
|
|
2320
2376
|
const startedAt = ts();
|
|
2321
2377
|
// Phase timing — raw timestamps, emitted as one structured `[spawn-timing]`
|
|
@@ -2341,8 +2397,12 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2341
2397
|
updateAgentStatus(id, AGENT_STATUS.FAILED, err.message);
|
|
2342
2398
|
throw err;
|
|
2343
2399
|
}
|
|
2344
|
-
|
|
2345
|
-
|
|
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);
|
|
2346
2406
|
|
|
2347
2407
|
// ── Workspace manifest repo gate (W-mq07avbk000m5543) ────────────────────
|
|
2348
2408
|
// Dispatch-time enforcement of `agents.<id>.workspace_manifest.allowed_repos`.
|
|
@@ -2376,19 +2436,14 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2376
2436
|
|
|
2377
2437
|
// W-mp73x32w000l143d: decouple agent cwd from worktree placement.
|
|
2378
2438
|
// resolveSpawnPaths returns:
|
|
2379
|
-
// - read-only types:
|
|
2380
|
-
//
|
|
2439
|
+
// - project-bound read-only types: detached worktree placement
|
|
2440
|
+
// - project-less read-only types: { cwd: MINIONS_DIR, worktreeRootDir: null }
|
|
2381
2441
|
// - code-mutating types: { cwd: null, worktreeRootDir: <project root> }
|
|
2382
2442
|
// (caller defaults cwd to worktreeRootDir; drive-root collapse throws
|
|
2383
2443
|
// WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT — same fail-fast behavior as
|
|
2384
2444
|
// the legacy resolveProjectRootDir call this replaced).
|
|
2385
|
-
//
|
|
2386
|
-
//
|
|
2387
|
-
// on read-only stages are a no-op label; the stage doesn't commit anything, so
|
|
2388
|
-
// the worktree had no functional purpose and was only there to absorb a drive-
|
|
2389
|
-
// root preflight that fired against MINIONS_DIR's parent. Read-only pipeline
|
|
2390
|
-
// stages now short-circuit alongside any other read-only WI (see the gate at
|
|
2391
|
-
// `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.
|
|
2392
2447
|
const _preBranchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
|
|
2393
2448
|
|
|
2394
2449
|
// ── Per-WI meta.workdir override (P-714ef144) ─────────────────────────
|
|
@@ -2435,9 +2490,10 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2435
2490
|
}
|
|
2436
2491
|
const validatedWorkdir = _wdValidation.value; // null when unset / empty
|
|
2437
2492
|
|
|
2438
|
-
let cwd, worktreeRootDir, liveMode = false;
|
|
2493
|
+
let cwd, worktreeRootDir, liveMode = false, readOnlyWorktree = false;
|
|
2439
2494
|
try {
|
|
2440
|
-
({ cwd, worktreeRootDir, liveMode = false
|
|
2495
|
+
({ cwd, worktreeRootDir, liveMode = false, readOnlyWorktree = false } =
|
|
2496
|
+
shared.resolveSpawnPaths(project, type, MINIONS_DIR, { workdir: validatedWorkdir }));
|
|
2441
2497
|
} catch (rootErr) {
|
|
2442
2498
|
if (rootErr?.code === 'WORKTREE_ROOTDIR_COLLAPSED_TO_DRIVE_ROOT' || rootErr?.code === 'WORKTREE_ROOTDIR_MISSING_BASE') {
|
|
2443
2499
|
log('error', `spawnAgent: project rootDir resolution failed for ${id}: ${rootErr.message}`);
|
|
@@ -2486,6 +2542,29 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2486
2542
|
}
|
|
2487
2543
|
throw rootErr;
|
|
2488
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
|
+
}
|
|
2489
2568
|
// Legacy local alias: downstream git ops (worktree add, prune, fetch) and
|
|
2490
2569
|
// the `cwd === rootDir` safety warn at line ~1387 reference `rootDir`. For
|
|
2491
2570
|
// read-only rootless tasks (no worktree, no branch) this is null — the
|
|
@@ -2583,6 +2662,28 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2583
2662
|
});
|
|
2584
2663
|
} catch (e) { log('warn', `spawnAgent: failed to persist tmpDir for ${id}: ${e.message}`); }
|
|
2585
2664
|
const _cleanupPromptFiles = () => { shared.removeDispatchTmpDir(dispatchTmpDir); };
|
|
2665
|
+
const _failWorktreeRemotePreparation = (remoteErr, reasonContext, unavailableContext) => {
|
|
2666
|
+
const classificationErr = remoteErr?._probeFailed && remoteErr._cause
|
|
2667
|
+
? remoteErr._cause
|
|
2668
|
+
: remoteErr;
|
|
2669
|
+
const failureClass = _classifyFreshBaseFetchFailure(classificationErr);
|
|
2670
|
+
const safeRemoteError = adoGitAuth.redactBearer(String(remoteErr?.message || remoteErr));
|
|
2671
|
+
const reason = `${failureClass.toUpperCase().replace(/-/g, '_')}: ${reasonContext}: ${safeRemoteError}`;
|
|
2672
|
+
log('error', `spawnAgent: ${reason}`);
|
|
2673
|
+
_cleanupPromptFiles();
|
|
2674
|
+
completeDispatch(
|
|
2675
|
+
id,
|
|
2676
|
+
DISPATCH_RESULT.ERROR,
|
|
2677
|
+
reason.slice(0, 800),
|
|
2678
|
+
`No worktree or agent was started. The work item remains retryable and will not count against the selected agent because ${unavailableContext}.`,
|
|
2679
|
+
{
|
|
2680
|
+
failureClass,
|
|
2681
|
+
agentRetryable: true,
|
|
2682
|
+
countAgentRetryFailure: false,
|
|
2683
|
+
},
|
|
2684
|
+
);
|
|
2685
|
+
cleanupTempAgent(agentId);
|
|
2686
|
+
};
|
|
2586
2687
|
// Convert a WORKTREE_NESTED_IN_PROJECT throw into a fail-fast non-retryable
|
|
2587
2688
|
// dispatch failure (W-mp62taw2000ubcc3). The error's `.code` is set by
|
|
2588
2689
|
// shared.assertWorktreeOutsideProject so we don't have to parse the message.
|
|
@@ -2683,17 +2784,11 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2683
2784
|
_phaseT.afterPrompt = Date.now();
|
|
2684
2785
|
|
|
2685
2786
|
if (branchName && READ_ONLY_ROOT_TASK_TYPES.has(type)) {
|
|
2686
|
-
//
|
|
2687
|
-
//
|
|
2688
|
-
//
|
|
2689
|
-
|
|
2690
|
-
// to be exempt — they don't need to be (W-mp8ho6w500034a58): read-only stages
|
|
2691
|
-
// never commit, so a pipeline-branch label is meaningless for them and the
|
|
2692
|
-
// forced worktree only existed to feed the drive-root preflight that this
|
|
2693
|
-
// short-circuit now correctly avoids.
|
|
2694
|
-
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`);
|
|
2695
2791
|
branchName = null;
|
|
2696
|
-
worktreePath = null;
|
|
2697
2792
|
}
|
|
2698
2793
|
|
|
2699
2794
|
// ── Live-checkout mode handling (P-a3f9b204) ─────────────────────────────
|
|
@@ -3452,6 +3547,55 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3452
3547
|
}
|
|
3453
3548
|
}
|
|
3454
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
|
+
|
|
3455
3599
|
if (branchName && !liveMode) {
|
|
3456
3600
|
updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
|
|
3457
3601
|
// W-mpwy3mp5 (#2996): track whether we ended up reusing an existing
|
|
@@ -3469,7 +3613,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3469
3613
|
// nested worktrees cause glob/grep to match both copies (mirror writes).
|
|
3470
3614
|
// WORKTREE_NESTED_IN_PROJECT is non-retryable: the recompute on the next
|
|
3471
3615
|
// tick will produce the same path. Fail fast (W-mp62taw2000ubcc3).
|
|
3472
|
-
try {
|
|
3616
|
+
try { assertSafeWorktreePath(worktreePath); }
|
|
3473
3617
|
catch (assertErr) {
|
|
3474
3618
|
if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
|
|
3475
3619
|
throw assertErr;
|
|
@@ -3483,7 +3627,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3483
3627
|
// Same guard for reuse — a previously-created bad worktree must not
|
|
3484
3628
|
// be silently reused either; the cleanup sweep flags these so the
|
|
3485
3629
|
// operator can remove them.
|
|
3486
|
-
try {
|
|
3630
|
+
try { assertSafeWorktreePath(existingWt); }
|
|
3487
3631
|
catch (assertErr) {
|
|
3488
3632
|
if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
|
|
3489
3633
|
throw assertErr;
|
|
@@ -3535,7 +3679,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3535
3679
|
if (!_branchOnRemote) {
|
|
3536
3680
|
const borrowed = worktreePool.tryBorrow(_poolProject, id);
|
|
3537
3681
|
if (borrowed && borrowed.path && fs.existsSync(borrowed.path)) {
|
|
3538
|
-
try {
|
|
3682
|
+
try { assertSafeWorktreePath(borrowed.path); }
|
|
3539
3683
|
catch (assertErr) {
|
|
3540
3684
|
// Always evict before deciding what to do — leaves no BORROWED
|
|
3541
3685
|
// orphan tied to a dispatch that won't complete normally.
|
|
@@ -3597,9 +3741,6 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3597
3741
|
const isSharedBranch = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
|
|
3598
3742
|
// Prune stale worktree entries before creating (handles leftover entries from crashed runs)
|
|
3599
3743
|
try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
3600
|
-
// Remove stale index.lock before creating worktree (Windows crashes can leave this behind)
|
|
3601
|
-
removeStaleIndexLock(rootDir);
|
|
3602
|
-
|
|
3603
3744
|
if (isSharedBranch) {
|
|
3604
3745
|
log('info', `Creating worktree for shared branch: ${worktreePath} on ${branchName}`);
|
|
3605
3746
|
await _fetchWithTransientRetry(['origin', branchName], { ..._gitOpts, cwd: rootDir }, `origin/${branchName} (shared-branch pre-create)`);
|
|
@@ -3609,7 +3750,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3609
3750
|
if (eShared.message?.includes('already used by worktree') || eShared.message?.includes('already checked out')) {
|
|
3610
3751
|
const existingWtPath = await findExistingWorktree(rootDir, branchName);
|
|
3611
3752
|
if (existingWtPath && fs.existsSync(existingWtPath)) {
|
|
3612
|
-
try {
|
|
3753
|
+
try { assertSafeWorktreePath(existingWtPath); }
|
|
3613
3754
|
catch (assertErr) {
|
|
3614
3755
|
if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
|
|
3615
3756
|
throw assertErr;
|
|
@@ -3659,7 +3800,17 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3659
3800
|
// ahead of main) — two consecutive fix dispatches errored on
|
|
3660
3801
|
// STALE_HEAD over 10 min and the engine then starved the PR for
|
|
3661
3802
|
// the cooldown window (60 min effective).
|
|
3662
|
-
|
|
3803
|
+
let _branchOnRemote;
|
|
3804
|
+
try {
|
|
3805
|
+
_branchOnRemote = await probeBranchOnRemote(rootDir, branchName, _gitOpts);
|
|
3806
|
+
} catch (probeErr) {
|
|
3807
|
+
_failWorktreeRemotePreparation(
|
|
3808
|
+
probeErr,
|
|
3809
|
+
`could not determine whether origin has branch ${branchName}`,
|
|
3810
|
+
`its required origin/${branchName} existence check could not complete`,
|
|
3811
|
+
);
|
|
3812
|
+
return null;
|
|
3813
|
+
}
|
|
3663
3814
|
|
|
3664
3815
|
if (_branchOnRemote) {
|
|
3665
3816
|
// Mirror shared-branch fetch+add (~line 1157-1159).
|
|
@@ -3684,7 +3835,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3684
3835
|
log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
|
|
3685
3836
|
throw eRemote;
|
|
3686
3837
|
}
|
|
3687
|
-
try {
|
|
3838
|
+
try { assertSafeWorktreePath(existingWtPath); }
|
|
3688
3839
|
catch (assertErr) {
|
|
3689
3840
|
if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
|
|
3690
3841
|
throw assertErr;
|
|
@@ -3709,7 +3860,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3709
3860
|
} else { throw eRemote; }
|
|
3710
3861
|
}
|
|
3711
3862
|
} else {
|
|
3712
|
-
// Branch
|
|
3863
|
+
// Branch is confirmed absent on origin — start a fresh local
|
|
3713
3864
|
// branch from origin/<mainRef>. This is the dominant path for new
|
|
3714
3865
|
// implement/decompose dispatches.
|
|
3715
3866
|
// W-mph6n4p00006ce38: mirror the pool-borrow path (~line 1110-1114)
|
|
@@ -3717,27 +3868,43 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3717
3868
|
// not the local ref. Without this, fresh-create dispatches inherit
|
|
3718
3869
|
// whatever stale local master the engine clone happens to be on
|
|
3719
3870
|
// (most painful: long-lived engine processes between restarts).
|
|
3720
|
-
//
|
|
3721
|
-
//
|
|
3722
|
-
//
|
|
3723
|
-
//
|
|
3871
|
+
// Fail closed: an ordinary brand-new branch must never inherit a
|
|
3872
|
+
// stale local mainRef when origin/<mainRef> could not be refreshed.
|
|
3873
|
+
// Existing/PR/shared branches are handled above and retain their
|
|
3874
|
+
// continuation semantics.
|
|
3875
|
+
// A local branch with no upstream is an intentional retry/WIP
|
|
3876
|
+
// continuation. Preserve the old `-b` -> "already exists" fallback
|
|
3877
|
+
// below; only truly brand-new branches require a fresh base.
|
|
3878
|
+
const _branchExistsLocally = await _probeBranchLocally(rootDir, branchName, _gitOpts);
|
|
3724
3879
|
let _freshCreateBase = mainRef;
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3880
|
+
if (!_branchExistsLocally) {
|
|
3881
|
+
try {
|
|
3882
|
+
await _fetchWithTransientRetry(
|
|
3883
|
+
['origin', mainRef],
|
|
3884
|
+
{ ..._gitOpts, cwd: rootDir, timeout: 30000 },
|
|
3885
|
+
`origin/${mainRef} (fresh-create base for ${branchName})`,
|
|
3886
|
+
{ throwOnFailure: true },
|
|
3887
|
+
);
|
|
3888
|
+
_freshCreateBase = `origin/${mainRef}`;
|
|
3889
|
+
} catch (freshBaseErr) {
|
|
3890
|
+
_failWorktreeRemotePreparation(
|
|
3891
|
+
freshBaseErr,
|
|
3892
|
+
`could not refresh origin/${mainRef} before creating brand-new branch ${branchName}`,
|
|
3893
|
+
`its required fresh origin/${mainRef} base was unavailable`,
|
|
3894
|
+
);
|
|
3895
|
+
return null;
|
|
3896
|
+
}
|
|
3897
|
+
}
|
|
3731
3898
|
try {
|
|
3732
3899
|
await runWorktreeAdd(rootDir, worktreePath, ['-b', branchName, _freshCreateBase], _worktreeGitOpts, worktreeCreateRetries);
|
|
3733
3900
|
} catch (e1) {
|
|
3734
3901
|
const branchExists = e1.message?.includes('already exists');
|
|
3735
3902
|
log('warn', `Worktree -b failed for ${branchName}: ${e1.message?.split('\n')[0]}`);
|
|
3736
3903
|
if (!branchExists) {
|
|
3737
|
-
// Transient error (lock, timeout) — prune
|
|
3904
|
+
// Transient error (lock, timeout) — prune and retry -b once more.
|
|
3905
|
+
// Never delete the operator checkout's .git/index.lock.
|
|
3738
3906
|
log('info', `Retrying -b create after prune for ${branchName}`);
|
|
3739
3907
|
try { await shared.shellSafeGit(['worktree', 'prune'], { ..._gitOpts, cwd: rootDir, timeout: 15000 }); } catch { /* optional */ }
|
|
3740
|
-
removeStaleIndexLock(rootDir);
|
|
3741
3908
|
// Clean up partial worktree directory from the failed -b
|
|
3742
3909
|
// attempt. This husk pre-dates `git worktree add`, so git
|
|
3743
3910
|
// does not yet own it and shared.removeWorktree's
|
|
@@ -3799,7 +3966,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3799
3966
|
log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
|
|
3800
3967
|
throw e2;
|
|
3801
3968
|
}
|
|
3802
|
-
try {
|
|
3969
|
+
try { assertSafeWorktreePath(existingWtPath); }
|
|
3803
3970
|
catch (assertErr) {
|
|
3804
3971
|
if (assertErr?.code === 'WORKTREE_NESTED_IN_PROJECT') { _failWorktreePreflight(assertErr); return null; }
|
|
3805
3972
|
throw assertErr;
|
|
@@ -4509,6 +4676,21 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4509
4676
|
}
|
|
4510
4677
|
}
|
|
4511
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
|
+
|
|
4512
4694
|
updateAgentStatus(id, AGENT_STATUS.READY, 'Worktree ready, preparing to spawn process');
|
|
4513
4695
|
|
|
4514
4696
|
if (worktreePath && meta?.source === 'work-item' && meta?.item?.branchStrategy === 'shared-branch') {
|
|
@@ -4625,40 +4807,18 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4625
4807
|
const resolvedModel = runtime.resolveModel(shared.resolveAgentModel(agentConfig, engineConfig));
|
|
4626
4808
|
const resolvedMaxBudget = shared.resolveAgentMaxBudget(agentConfig, engineConfig);
|
|
4627
4809
|
const resolvedBare = shared.resolveAgentBareMode(agentConfig, engineConfig);
|
|
4628
|
-
//
|
|
4629
|
-
//
|
|
4630
|
-
//
|
|
4631
|
-
|
|
4632
|
-
|
|
4633
|
-
const
|
|
4634
|
-
|
|
4635
|
-
|
|
4636
|
-
|
|
4637
|
-
// separate engine-restart-only wiring hook.
|
|
4638
|
-
agentWorkerPool.setPoolSize(shared.resolveAgentAcpPoolSize(engineConfig));
|
|
4639
|
-
}
|
|
4640
|
-
|
|
4641
|
-
// W-mpg6isvy000xca4d — On retry after FAILURE_CLASS.MODEL_UNAVAILABLE, swap
|
|
4642
|
-
// to the runtime-appropriate fallback model. Two paths gated on
|
|
4643
|
-
// `runtime.capabilities.fallbackModel` (no `runtime.name === ...` branches):
|
|
4644
|
-
// - Capability TRUE (Claude): the CLI's own --fallback-model handles
|
|
4645
|
-
// the swap on 429. We keep `engineConfig.claudeFallbackModel` plumbed
|
|
4646
|
-
// unconditionally via the fallbackModel opt below; no model override
|
|
4647
|
-
// needed at the engine layer.
|
|
4648
|
-
// - Capability FALSE (Copilot): no --fallback-model flag exists, so we
|
|
4649
|
-
// OVERRIDE the --model arg directly with engine.copilotFallbackModel
|
|
4650
|
-
// for this retry attempt only. The work item's _lastFailureClass is
|
|
4651
|
-
// written by dispatch.js's retry block; the next normal dispatch loop
|
|
4652
|
-
// pick-up re-reads it through meta.item.
|
|
4653
|
-
let effectiveModel = resolvedModel;
|
|
4654
|
-
const prevFailureClass = meta?.item?._lastFailureClass || null;
|
|
4655
|
-
if (prevFailureClass === FAILURE_CLASS.MODEL_UNAVAILABLE
|
|
4656
|
-
&& runtime.capabilities?.fallbackModel === false
|
|
4657
|
-
&& engineConfig.copilotFallbackModel) {
|
|
4658
|
-
effectiveModel = engineConfig.copilotFallbackModel;
|
|
4659
|
-
log('info', `MODEL_UNAVAILABLE retry: ${runtimeName} ${id} — overriding --model to ${effectiveModel}`);
|
|
4810
|
+
// Pool through the persistent ACP worker transport when the selected adapter
|
|
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`);
|
|
4660
4819
|
}
|
|
4661
4820
|
|
|
4821
|
+
const prevFailureClass = meta?.item?._lastFailureClass || null;
|
|
4662
4822
|
const requestedEffort = engineConfig.agentEffort || null;
|
|
4663
4823
|
|
|
4664
4824
|
let cachedSessionId = null;
|
|
@@ -4667,53 +4827,49 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4667
4827
|
agentId,
|
|
4668
4828
|
branchName,
|
|
4669
4829
|
agentsDir: AGENTS_DIR,
|
|
4670
|
-
//
|
|
4671
|
-
//
|
|
4672
|
-
// the agent died before checkpoint (W-mouugzow00068741).
|
|
4830
|
+
// Adapters may use cwd to verify that their backing session still exists
|
|
4831
|
+
// before returning a resumable ID.
|
|
4673
4832
|
cwd,
|
|
4674
4833
|
logger: _runtimeLogger(),
|
|
4675
4834
|
});
|
|
4676
4835
|
}
|
|
4677
4836
|
|
|
4678
|
-
const
|
|
4679
|
-
model:
|
|
4837
|
+
const runtimeOptions = resolveInvocationOptions(runtime, {
|
|
4838
|
+
model: resolvedModel,
|
|
4680
4839
|
maxTurns: _maxTurnsForType(type, engineConfig),
|
|
4681
|
-
allowedTools: shared.
|
|
4682
|
-
claudeConfig.allowedTools,
|
|
4683
|
-
shared.resolveAgentManifest(_manifestAgent).allowed_tools,
|
|
4684
|
-
),
|
|
4840
|
+
allowedTools: shared.resolveAgentAllowedTools(config),
|
|
4685
4841
|
effort: requestedEffort,
|
|
4686
4842
|
sessionId: cachedSessionId,
|
|
4687
4843
|
maxBudget: resolvedMaxBudget,
|
|
4688
4844
|
bare: resolvedBare,
|
|
4689
|
-
|
|
4690
|
-
|
|
4691
|
-
|
|
4692
|
-
|
|
4845
|
+
}, {
|
|
4846
|
+
config,
|
|
4847
|
+
engineConfig,
|
|
4848
|
+
failureClass: prevFailureClass,
|
|
4693
4849
|
});
|
|
4850
|
+
runtimeOptions.allowedTools = shared.mergeManifestAllowedTools(
|
|
4851
|
+
runtimeOptions.allowedTools,
|
|
4852
|
+
shared.resolveAgentManifest(_manifestAgent).allowed_tools,
|
|
4853
|
+
);
|
|
4854
|
+
const effectiveModel = runtimeOptions.model;
|
|
4855
|
+
if (prevFailureClass === FAILURE_CLASS.MODEL_UNAVAILABLE
|
|
4856
|
+
&& effectiveModel && effectiveModel !== resolvedModel) {
|
|
4857
|
+
log('info', `MODEL_UNAVAILABLE retry: ${runtimeName} ${id} — adapter selected model ${effectiveModel}`);
|
|
4858
|
+
}
|
|
4859
|
+
const args = _buildAgentSpawnFlags(runtime, runtimeOptions);
|
|
4694
4860
|
|
|
4695
|
-
//
|
|
4696
|
-
//
|
|
4697
|
-
//
|
|
4698
|
-
// P-7d31a06b — When the runtime is Claude AND the worktree has a `.mcp.json`,
|
|
4699
|
-
// pre-warm `~/.claude.json` projects.<worktreePath>.enabledMcpjsonServers so
|
|
4700
|
-
// Claude's first call doesn't show the project-MCP trust prompt (which
|
|
4701
|
-
// --dangerously-skip-permissions silently suppresses → agent runs without the
|
|
4702
|
-
// workspace MCPs connected). Helper internally no-ops for non-Claude runtimes
|
|
4703
|
-
// and when engine.claudePreApproveWorkspaceMcps is false (no runtime.name
|
|
4704
|
-
// check at this call site, per CLAUDE.md rule). Best-effort: NEVER block dispatch.
|
|
4705
|
-
//
|
|
4861
|
+
// Let the adapter prepare any runtime-owned workspace state. Best-effort:
|
|
4862
|
+
// harness setup must never block dispatch.
|
|
4706
4863
|
try {
|
|
4707
|
-
const result =
|
|
4708
|
-
runtimeName,
|
|
4864
|
+
const result = prepareWorkspace(runtime, {
|
|
4709
4865
|
worktreePath,
|
|
4710
4866
|
homeDir: os.homedir(),
|
|
4711
4867
|
engineConfig,
|
|
4712
4868
|
mutateJsonFileLocked: shared.mutateJsonFileLocked,
|
|
4713
4869
|
});
|
|
4714
4870
|
if (result.wrote) {
|
|
4715
|
-
log('info', `
|
|
4716
|
-
} else if (result.reason !== 'no-workspace-mcp' && result.reason !== '
|
|
4871
|
+
log('info', `Runtime workspace prepared for ${id}${result.target ? ` (${result.target})` : ''}`);
|
|
4872
|
+
} else if (result.reason !== 'no-workspace-mcp' && result.reason !== 'unsupported-runtime' && result.reason !== 'no-worktree') {
|
|
4717
4873
|
log('debug', `Skipped workspace MCP pre-approval for ${id} (reason=${result.reason})`);
|
|
4718
4874
|
}
|
|
4719
4875
|
} catch (err) {
|
|
@@ -4724,10 +4880,22 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4724
4880
|
|
|
4725
4881
|
log('info', `Spawning agent: ${agentId} (${id}) in ${cwd}`);
|
|
4726
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
|
+
}
|
|
4727
4895
|
|
|
4728
4896
|
// Agent status is derived from dispatch state.
|
|
4729
4897
|
|
|
4730
|
-
// Spawn the
|
|
4898
|
+
// Spawn the selected harness runtime.
|
|
4731
4899
|
const childEnv = shared.cleanChildEnv();
|
|
4732
4900
|
if (completionReportPath) childEnv.MINIONS_COMPLETION_REPORT = completionReportPath;
|
|
4733
4901
|
if (completionNonce) childEnv.MINIONS_COMPLETION_NONCE = completionNonce;
|
|
@@ -4740,6 +4908,11 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4740
4908
|
|| dispatchItem.meta?.keep_processes_skip_workdir_check) {
|
|
4741
4909
|
childEnv.MINIONS_KEEP_PROCESSES_SKIP_WORKDIR_CHECK = '1';
|
|
4742
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
|
+
}
|
|
4743
4916
|
|
|
4744
4917
|
// W-mpg54mi2000n7b7e — suppress Git's interactive credential prompts and
|
|
4745
4918
|
// Git Credential Manager's GUI dialog for every agent spawn. Without these,
|
|
@@ -4787,7 +4960,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4787
4960
|
|
|
4788
4961
|
// Spawn via wrapper script — node directly (no bash intermediary)
|
|
4789
4962
|
// spawn-agent.js handles CLAUDECODE env cleanup and claude binary resolution
|
|
4790
|
-
const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
|
|
4963
|
+
const spawnScript = runtime.spawnScript || path.join(ENGINE_DIR, 'spawn-agent.js');
|
|
4791
4964
|
|
|
4792
4965
|
const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
|
|
4793
4966
|
// P-1d8f0b93 — the pooled-dispatch branch further down (agentWorkerPool
|
|
@@ -4850,9 +5023,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4850
5023
|
}
|
|
4851
5024
|
|
|
4852
5025
|
_phaseT.spawnCallStart = Date.now();
|
|
4853
|
-
// P-c5a1f3b8 (Item D — live-mode push/PR-create cwd guard):
|
|
4854
|
-
// worktreePath
|
|
4855
|
-
//
|
|
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
|
|
4856
5029
|
// agent's `git push -u origin <branch>` and PR-create therefore run in-place
|
|
4857
5030
|
// against the operator's checkout. This cwd MUST NEVER be null for a mutating
|
|
4858
5031
|
// live dispatch — shared.resolveSpawnPaths returns cwd === project.localPath
|
|
@@ -4891,6 +5064,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4891
5064
|
'MINIONS_LIVE_OUTPUT_PATH',
|
|
4892
5065
|
'MINIONS_STEERING_ACK_DIR',
|
|
4893
5066
|
'MINIONS_AGENT_CWD',
|
|
5067
|
+
'MINIONS_KEEP_PROCESSES_ALLOWED_CWD_ROOT',
|
|
4894
5068
|
];
|
|
4895
5069
|
const pooledProcessEnv = { ...childEnv };
|
|
4896
5070
|
const pooledSessionContext = {};
|
|
@@ -4900,18 +5074,30 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4900
5074
|
}
|
|
4901
5075
|
proc = new PooledAgentProcess({
|
|
4902
5076
|
pool: agentWorkerPool,
|
|
5077
|
+
runtime,
|
|
4903
5078
|
dispatchId: id,
|
|
4904
5079
|
cwd,
|
|
4905
|
-
|
|
4906
|
-
|
|
5080
|
+
processCwd: _resolveAgentPoolProcessCwd(project, engineConfig),
|
|
5081
|
+
model: runtimeOptions.model,
|
|
5082
|
+
effort: runtimeOptions.effort,
|
|
4907
5083
|
// Mirrors the CC worker pool's convention (dashboard.js) — a single
|
|
4908
5084
|
// flat top-level config array, not per-agent-resolved.
|
|
4909
5085
|
mcpServers: (engineConfig && engineConfig.mcpServers) || [],
|
|
4910
5086
|
hermeticDirs: addDirs,
|
|
4911
5087
|
processEnv: pooledProcessEnv,
|
|
5088
|
+
workerOptions: runtimeOptions,
|
|
5089
|
+
sessionId: cachedSessionId,
|
|
4912
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
|
+
),
|
|
4913
5097
|
systemPromptText: systemPrompt,
|
|
4914
|
-
|
|
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'),
|
|
4915
5101
|
});
|
|
4916
5102
|
// The child_process PID file is normally written asynchronously by
|
|
4917
5103
|
// spawn-agent.js itself (engine/spawn-agent.js:606) once it starts.
|
|
@@ -5043,7 +5229,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5043
5229
|
let stdout = '';
|
|
5044
5230
|
let stderr = '';
|
|
5045
5231
|
let steeringAckStdout = '';
|
|
5046
|
-
const sessionCaptureState = { sessionLineBuffer: '' };
|
|
5232
|
+
const sessionCaptureState = { sessionLineBuffer: '', modelLineBuffer: '' };
|
|
5047
5233
|
let _trustCheckDone = false;
|
|
5048
5234
|
const _spawnTime = Date.now();
|
|
5049
5235
|
|
|
@@ -5078,6 +5264,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5078
5264
|
// Copilot emits sessionId, so use the runtime-neutral steering helper.
|
|
5079
5265
|
const procInfo = activeProcesses.get(id);
|
|
5080
5266
|
markRuntimeResumeOutputSeen(procInfo);
|
|
5267
|
+
captureExecutionModelFromStdoutChunk(dispatchItem, id, runtime, procInfo, chunk, sessionCaptureState);
|
|
5081
5268
|
captureSessionIdFromStdoutChunk(agentId, id, branchName, runtime, procInfo, chunk, sessionCaptureState);
|
|
5082
5269
|
|
|
5083
5270
|
ackPendingSteeringFiles(agentId, procInfo, chunk);
|
|
@@ -5199,22 +5386,17 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5199
5386
|
return;
|
|
5200
5387
|
}
|
|
5201
5388
|
|
|
5202
|
-
const
|
|
5203
|
-
|
|
5389
|
+
const resumeRuntimeOptions = resolveInvocationOptions(runtime, {
|
|
5390
|
+
...runtimeOptions,
|
|
5204
5391
|
maxTurns: engineConfig?.maxTurns || ENGINE_DEFAULTS.maxTurns,
|
|
5205
|
-
allowedTools: shared.mergeManifestAllowedTools(
|
|
5206
|
-
claudeConfig?.allowedTools,
|
|
5207
|
-
shared.resolveAgentManifest(_manifestAgent).allowed_tools,
|
|
5208
|
-
),
|
|
5209
5392
|
sessionId: steerSessionId,
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
disableBuiltinMcps: engineConfig?.copilotDisableBuiltinMcps,
|
|
5215
|
-
reasoningSummaries: engineConfig?.copilotReasoningSummaries,
|
|
5393
|
+
}, {
|
|
5394
|
+
config,
|
|
5395
|
+
engineConfig,
|
|
5396
|
+
failureClass: prevFailureClass,
|
|
5216
5397
|
});
|
|
5217
|
-
|
|
5398
|
+
const resumeArgs = _buildAgentSpawnFlags(runtime, resumeRuntimeOptions);
|
|
5399
|
+
if (runtime.capabilities?.sessionResume !== true) {
|
|
5218
5400
|
log('warn', `Steering: runtime ${runtime.name} did not accept session resume — skipping for ${agentId}`);
|
|
5219
5401
|
try { fs.appendFileSync(liveOutputPath, `\n[steering-failed] Runtime ${runtime.name} does not support session resume. Message was: ${steerMsg}\n`); } catch {}
|
|
5220
5402
|
try { fs.unlinkSync(steerPromptPath); } catch {}
|
|
@@ -5224,7 +5406,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5224
5406
|
return;
|
|
5225
5407
|
}
|
|
5226
5408
|
|
|
5227
|
-
const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
|
|
5409
|
+
const spawnScript = runtime.spawnScript || path.join(ENGINE_DIR, 'spawn-agent.js');
|
|
5228
5410
|
const childEnv = shared.cleanChildEnv();
|
|
5229
5411
|
if (completionReportPath) childEnv.MINIONS_COMPLETION_REPORT = completionReportPath;
|
|
5230
5412
|
// P-d2a8f6c1: preserve the per-dispatch nonce across steering resume so
|
|
@@ -5271,6 +5453,24 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5271
5453
|
// Re-expose the engine-resolved cwd on steering resume (matches the
|
|
5272
5454
|
// initial spawn path) so $MINIONS_AGENT_CWD stays valid across resumes.
|
|
5273
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
|
+
}
|
|
5274
5474
|
let resumeProc;
|
|
5275
5475
|
try {
|
|
5276
5476
|
// detached so the resumed steering session also survives engine death (matches initial spawn)
|
|
@@ -5289,6 +5489,12 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5289
5489
|
cleanupTempAgent(agentId);
|
|
5290
5490
|
return;
|
|
5291
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);
|
|
5292
5498
|
|
|
5293
5499
|
// Re-attach to existing tracking — do NOT carry _steeringAt forward (#1052).
|
|
5294
5500
|
// The kill watcher in timeout.js fires 30s after _steeringAt is set. If we carry it
|
|
@@ -5321,6 +5527,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5321
5527
|
stderr = '';
|
|
5322
5528
|
}
|
|
5323
5529
|
sessionCaptureState.sessionLineBuffer = '';
|
|
5530
|
+
sessionCaptureState.modelLineBuffer = '';
|
|
5324
5531
|
// Re-wire stdout/stderr handlers (same as original)
|
|
5325
5532
|
resumeProc.stdout.on('data', (data) => {
|
|
5326
5533
|
const chunk = data.toString();
|
|
@@ -5348,6 +5555,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5348
5555
|
} catch { /* best-effort */ }
|
|
5349
5556
|
}
|
|
5350
5557
|
markRuntimeResumeOutputSeen(resumeInfo);
|
|
5558
|
+
captureExecutionModelFromStdoutChunk(dispatchItem, id, runtime, resumeInfo, chunk, sessionCaptureState);
|
|
5351
5559
|
captureSessionIdFromStdoutChunk(agentId, id, branchName, runtime, resumeInfo, chunk, sessionCaptureState);
|
|
5352
5560
|
ackPendingSteeringFiles(agentId, resumeInfo, chunk);
|
|
5353
5561
|
});
|
|
@@ -5509,8 +5717,12 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5509
5717
|
let evalResult = schemaResult;
|
|
5510
5718
|
let isWorkdirRejection = false;
|
|
5511
5719
|
const workdirGateOn = !_kpSkipWorkdir && ENGINE_DEFAULTS.keepProcesses?.requireGitWorkdir !== false;
|
|
5512
|
-
|
|
5513
|
-
|
|
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
|
+
});
|
|
5514
5726
|
if (workdirResult.exists && !workdirResult.accepted && workdirResult.isWorkdirRejection) {
|
|
5515
5727
|
evalResult = workdirResult;
|
|
5516
5728
|
isWorkdirRejection = true;
|
|
@@ -5605,6 +5817,22 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5605
5817
|
} catch (alertErr) {
|
|
5606
5818
|
log('warn', `keep-processes acceptance: failed to emit inbox alert for ${agentId}: ${alertErr.message}`);
|
|
5607
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
|
+
}
|
|
5608
5836
|
}
|
|
5609
5837
|
} catch (e) {
|
|
5610
5838
|
log('warn', `keep-processes acceptance check failed for ${agentId} (${id}): ${e.message}`);
|
|
@@ -5632,7 +5860,12 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5632
5860
|
if (_msEnabled) {
|
|
5633
5861
|
try {
|
|
5634
5862
|
const managedSpawn = require('./engine/managed-spawn');
|
|
5635
|
-
const
|
|
5863
|
+
const allowedCwdRoot = keepProcessesAllowedCwdRoot;
|
|
5864
|
+
const evalResult = managedSpawn.evaluateManagedSpawnAcceptance(agentId, {
|
|
5865
|
+
projects: getProjects(config),
|
|
5866
|
+
project,
|
|
5867
|
+
...(allowedCwdRoot ? { allowedCwdRoot } : {}),
|
|
5868
|
+
});
|
|
5636
5869
|
if (evalResult.exists && !evalResult.accepted) {
|
|
5637
5870
|
managedSpawnAcceptanceFailure = {
|
|
5638
5871
|
reason: evalResult.reason,
|
|
@@ -5690,6 +5923,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5690
5923
|
owner_agent: agentId,
|
|
5691
5924
|
owner_wi: dispatchItem.meta?.item?.id || '',
|
|
5692
5925
|
owner_project: project?.name || '',
|
|
5926
|
+
owner_work_type: type,
|
|
5927
|
+
dispatch_root: allowedCwdRoot || '',
|
|
5928
|
+
checkout_mode: checkoutModeAtDispatch,
|
|
5693
5929
|
};
|
|
5694
5930
|
const spawnedItems = [];
|
|
5695
5931
|
let spawnFailureReason = null;
|
|
@@ -6030,7 +6266,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
6030
6266
|
// crashed dispatch leaks `work/W-<id>` worktree entries that block
|
|
6031
6267
|
// every retry with `fatal: 'work/W-<id>' is already used by worktree
|
|
6032
6268
|
// at …` until the 2-hour age sweep in cleanup.js eventually catches up.
|
|
6033
|
-
if (worktreePath && rootDir
|
|
6269
|
+
if (worktreePath && rootDir) {
|
|
6034
6270
|
try {
|
|
6035
6271
|
const _wgc = require('./engine/worktree-gc');
|
|
6036
6272
|
const _wtRoot = path.resolve(rootDir, engineConfig.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot);
|
|
@@ -6077,7 +6313,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
6077
6313
|
// restore is a plain `git checkout <originalRef>` — never --force/reset/
|
|
6078
6314
|
// clean/stash — and is best-effort: a thrown restore never alters the
|
|
6079
6315
|
// dispatch result.
|
|
6080
|
-
if (liveMode && branchName) {
|
|
6316
|
+
if (liveMode && branchName && shared.isLiveCheckoutDispatchRecord(dispatchItem, getConfig())) {
|
|
6081
6317
|
try {
|
|
6082
6318
|
const _liveRestore = require('./engine/live-checkout');
|
|
6083
6319
|
await _liveRestore.restoreLiveCheckoutAtDispatchEnd({
|
|
@@ -6259,11 +6495,19 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
6259
6495
|
// route output parsing through the right adapter. Also surfaces the choice
|
|
6260
6496
|
// in dispatch state for debugging multi-runtime fleets.
|
|
6261
6497
|
item.runtimeName = runtimeName;
|
|
6498
|
+
if (effectiveModel) item.requestedModel = effectiveModel;
|
|
6499
|
+
else delete item.requestedModel;
|
|
6500
|
+
if (dispatchItem.executionModel) item.executionModel = dispatchItem.executionModel;
|
|
6501
|
+
else delete item.executionModel;
|
|
6262
6502
|
// P-3f5b7d26 — persist pooled on the DISK record (activeProcesses/_pooled
|
|
6263
6503
|
// is wiped on restart); cli.js's reattach path needs this since a pooled
|
|
6264
6504
|
// ACP worker isn't spawned detached and a fresh pool cold-starts empty —
|
|
6265
6505
|
// a live worker PID must never be mistaken for a resumable dispatch.
|
|
6266
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;
|
|
6267
6511
|
// W-mq5rwwss000f30a7 — persist the worktree path so the live-worktree
|
|
6268
6512
|
// guard (shared.isWorktreePathLive) can correlate destructive callers
|
|
6269
6513
|
// (removeWorktree, cleanup orphan-dir sweep, pool-return, quarantine)
|
|
@@ -6542,73 +6786,6 @@ function safePrdProjectSlug(projectName) {
|
|
|
6542
6786
|
return slug || 'project';
|
|
6543
6787
|
}
|
|
6544
6788
|
|
|
6545
|
-
// Fleet branches the engine/CC create. A worktree-mode operator checkout should
|
|
6546
|
-
// NEVER sit on one of these (or any branch); when it does, a flow ran raw
|
|
6547
|
-
// `git checkout`/`merge` in the live checkout instead of a worktree.
|
|
6548
|
-
const _FLEET_BRANCH_RE = /^(cc-pr|backport|e2e|verify|sched-|work)\//;
|
|
6549
|
-
|
|
6550
|
-
// Self-heal: a worktree-mode project's operator checkout (project.localPath) must
|
|
6551
|
-
// always sit on its mainBranch — agents use isolated worktrees, CC Create-PR uses
|
|
6552
|
-
// prepare-create-pr-worktree. But fleet flows (CC raw git, backport, sync)
|
|
6553
|
-
// sometimes run `git checkout <branch>` + `git merge` THERE despite the rules,
|
|
6554
|
-
// leaving it stuck on a feature branch (often mid-conflict) — which restores
|
|
6555
|
-
// git-tracked dead PRDs and pollutes the operator's main, then the materializer
|
|
6556
|
-
// re-runs finished work. Detect + restore. Conservative: only acts on a stuck
|
|
6557
|
-
// merge/rebase OR a recognizably fleet-created branch, so a human's deliberate
|
|
6558
|
-
// `git checkout my-feature` in their checkout is left alone. Branch commits are
|
|
6559
|
-
// preserved on their ref (reset --hard only drops the uncommitted hijack state).
|
|
6560
|
-
function restoreHijackedOperatorCheckouts(config) {
|
|
6561
|
-
if (config?.engine?.autoRestoreOperatorCheckout === false) return;
|
|
6562
|
-
let projects;
|
|
6563
|
-
try { projects = getProjects(config); } catch { return; }
|
|
6564
|
-
for (const project of projects) {
|
|
6565
|
-
try {
|
|
6566
|
-
const localPath = project?.localPath;
|
|
6567
|
-
if (!localPath || !fs.existsSync(localPath)) continue;
|
|
6568
|
-
if (shared.resolveCheckoutMode(project) === shared.CHECKOUT_MODES.LIVE) continue; // live-mode: feature branch is intentional
|
|
6569
|
-
const main = project.mainBranch || 'main';
|
|
6570
|
-
const opts = { cwd: localPath };
|
|
6571
|
-
// W-mrcvh5hw000o6cf1: use the argv-form (shell: false) git helper instead of
|
|
6572
|
-
// execSilent(). execSilent() shells out via cmd.exe, which cannot chdir into
|
|
6573
|
-
// a UNC path (`//wsl.localhost/...`, `\\server\share\...`) — it silently
|
|
6574
|
-
// falls back to the Windows directory and every git call then fails with
|
|
6575
|
-
// "fatal: not a git repository", every tick, forever, for WSL/UNC-hosted
|
|
6576
|
-
// projects. shellSafeGitSync spawns git.exe directly (execFileSync,
|
|
6577
|
-
// shell:false) so `cwd` is honored even for UNC paths.
|
|
6578
|
-
let isRepo;
|
|
6579
|
-
try { isRepo = String(shared.shellSafeGitSync(['rev-parse', '--is-inside-work-tree'], opts) || '').trim(); }
|
|
6580
|
-
catch { continue; } // not a git repo (or transiently unreachable) — nothing to restore, skip quietly
|
|
6581
|
-
if (isRepo !== 'true') continue;
|
|
6582
|
-
const cur = String(shared.shellSafeGitSync(['rev-parse', '--abbrev-ref', 'HEAD'], opts) || '').trim();
|
|
6583
|
-
if (!cur || cur === 'HEAD' || cur === main) continue; // on main or detached — fine
|
|
6584
|
-
const gitDir = String(shared.shellSafeGitSync(['rev-parse', '--git-dir'], opts) || '').trim();
|
|
6585
|
-
const absGitDir = gitDir ? (path.isAbsolute(gitDir) ? gitDir : path.join(localPath, gitDir)) : path.join(localPath, '.git');
|
|
6586
|
-
const mergeStuck = fs.existsSync(path.join(absGitDir, 'MERGE_HEAD'));
|
|
6587
|
-
const rebaseStuck = fs.existsSync(path.join(absGitDir, 'rebase-merge')) || fs.existsSync(path.join(absGitDir, 'rebase-apply'));
|
|
6588
|
-
if (!mergeStuck && !rebaseStuck && !_FLEET_BRANCH_RE.test(cur)) continue; // likely a human's deliberate checkout — leave it
|
|
6589
|
-
const why = mergeStuck ? ' (stuck merge)' : rebaseStuck ? ' (stuck rebase)' : ' (fleet branch in live checkout)';
|
|
6590
|
-
log('warn', `Operator checkout for ${project.name} is on '${cur}'${why}; worktree-mode requires ${main} — restoring`);
|
|
6591
|
-
if (mergeStuck) shared.shellSafeGitSync(['merge', '--abort'], opts);
|
|
6592
|
-
if (rebaseStuck) shared.shellSafeGitSync(['rebase', '--abort'], opts);
|
|
6593
|
-
shared.shellSafeGitSync(['reset', '--hard', 'HEAD'], opts); // drop uncommitted hijack state; branch commits stay on the ref
|
|
6594
|
-
try { shared.validateGitRef(main); } catch (ve) { throw new Error(`restoreHijackedOperatorCheckouts: invalid mainBranch ${JSON.stringify(main)}: ${ve.message}`); }
|
|
6595
|
-
shared.shellSafeGitSync(['checkout', main], opts);
|
|
6596
|
-
const now = String(shared.shellSafeGitSync(['rev-parse', '--abbrev-ref', 'HEAD'], opts) || '').trim();
|
|
6597
|
-
if (now === main) {
|
|
6598
|
-
try {
|
|
6599
|
-
writeInboxAlert(`operator-checkout-restored-${project.name}`,
|
|
6600
|
-
`Operator checkout ${localPath} was hijacked onto '${cur}'${why} and restored to ${main}. ` +
|
|
6601
|
-
'A fleet flow ran git checkout/merge in the LIVE operator checkout instead of an isolated worktree ' +
|
|
6602
|
-
'(playbooks/shared-rules.md: "Do NOT checkout branches in the main working tree — use worktrees"). ' +
|
|
6603
|
-
`The '${cur}' branch commits are preserved on its ref.`);
|
|
6604
|
-
} catch { /* alert is best-effort */ }
|
|
6605
|
-
} else {
|
|
6606
|
-
log('warn', `Operator checkout restore for ${project.name} did not reach ${main} (now '${now}') — manual intervention needed`);
|
|
6607
|
-
}
|
|
6608
|
-
} catch (e) { log('warn', `restoreHijackedOperatorCheckouts ${project?.name || '?'}: ${e?.message || e}`); }
|
|
6609
|
-
}
|
|
6610
|
-
}
|
|
6611
|
-
|
|
6612
6789
|
function materializePlansAsWorkItems(config) {
|
|
6613
6790
|
const writePrdLocked = (fileName, data) => {
|
|
6614
6791
|
return prdStore.writePrd(fileName, data);
|
|
@@ -7990,6 +8167,7 @@ async function discoverFromPrs(config, project) {
|
|
|
7990
8167
|
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
7991
8168
|
pr_id: pr.id, pr_branch: prBranch,
|
|
7992
8169
|
review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
|
|
8170
|
+
review_finding_id: shared.prReviewFindingIdentity(pr),
|
|
7993
8171
|
}, `Fix ${pr.id}: ${pr.title || ''} — review feedback`, {
|
|
7994
8172
|
dispatchKey: key, cooldownKey: key, automationCauseKey: reviewCauseKey, source: 'pr', pr, branch: prBranch, project: projMeta,
|
|
7995
8173
|
// W-mpg58wv3 — closure-loop binding. Carries the originating minion review
|
|
@@ -10484,6 +10662,7 @@ async function tickInner() {
|
|
|
10484
10662
|
catch (e) { log('warn', `lastTickAt write: ${e.message}`); }
|
|
10485
10663
|
|
|
10486
10664
|
const config = getConfig();
|
|
10665
|
+
_syncAgentWorkerPoolConfig(config.engine);
|
|
10487
10666
|
tickCount++;
|
|
10488
10667
|
const now = Date.now();
|
|
10489
10668
|
const tickIntervalMs = Math.max(1, Number(config.engine?.tickInterval) || ENGINE_DEFAULTS.tickInterval);
|
|
@@ -11516,9 +11695,6 @@ async function tickInner() {
|
|
|
11516
11695
|
const maxC = resolveMaxConcurrent(config);
|
|
11517
11696
|
setTempBudget(Math.max(0, maxC - activeCountPre));
|
|
11518
11697
|
}
|
|
11519
|
-
// Self-heal a hijacked operator checkout BEFORE discovery/materialization, so a
|
|
11520
|
-
// dead PRD restored by a stray `git checkout` can't be re-materialized this tick.
|
|
11521
|
-
try { restoreHijackedOperatorCheckouts(config); } catch (e) { log('warn', 'restoreHijackedOperatorCheckouts: ' + e.message); }
|
|
11522
11698
|
let discoveryOk = true;
|
|
11523
11699
|
try { await withTickTimeout(() => discoverWork(config), 'discoverWork', tickOpTimeoutMs); } catch (e) { log('warn', 'discoverWork: ' + e.message); discoveryOk = false; }
|
|
11524
11700
|
if (_isTickStale(myGeneration)) return;
|
|
@@ -11619,7 +11795,6 @@ module.exports = {
|
|
|
11619
11795
|
discoverWork, discoverFromPrs, discoverFromWorkItems, discoverCentralWorkItems,
|
|
11620
11796
|
materializePlansAsWorkItems,
|
|
11621
11797
|
materializeSpecsAsWorkItems, // exported for testing (P-f7-git-log)
|
|
11622
|
-
restoreHijackedOperatorCheckouts, // exported for testing — operator-checkout self-heal
|
|
11623
11798
|
|
|
11624
11799
|
// Shared helpers (used by lifecycle.js and tests)
|
|
11625
11800
|
reconcileItemsWithPrs, detectDependencyCycles,
|
|
@@ -11631,13 +11806,15 @@ module.exports = {
|
|
|
11631
11806
|
gitOutputToString, gitErrorOutput, classifyDepMergeFailureOutput, listUnmergedFiles, // exported for testing
|
|
11632
11807
|
buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
|
|
11633
11808
|
runWorktreeAdd, // exported for testing (W-mqvaxv65000m76f2 — GVFS partial-checkout behavioral test)
|
|
11634
|
-
|
|
11809
|
+
_fetchWithTransientRetry, _classifyFreshBaseFetchFailure, _probeBranchLocally, // exported for testing
|
|
11810
|
+
isWorktreeRetryableError, syncReusedWorktree, pushCleanAheadBranch, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
|
|
11635
11811
|
_statusPorcelainCmd, _killGitDescendantsForWorktree, _bumpQuarantineOutcome, // exported for testing (W-mq5n1zx5)
|
|
11636
11812
|
_reapWorktreeHolders, _findTerminalWorktreeOwners, // exported for testing (W-mqila0t5 — CWD-pinned holder reap)
|
|
11637
11813
|
pruneStaleWorktreeForBranch, // exported for testing
|
|
11638
11814
|
findExistingWorktree, // exported for testing
|
|
11639
11815
|
probeBranchOnRemote, // exported for testing (W-mphnm6a1000281b8)
|
|
11640
11816
|
_maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
|
|
11817
|
+
_runtimeModelFromOutputLine, captureExecutionModelFromStdoutChunk, // exported for testing
|
|
11641
11818
|
_tryAutoResolveLiveCheckoutWorktreeConflict, // exported for testing (W-mr28h2j2000y0de1 review — auto-resolve worktree conflicts)
|
|
11642
11819
|
_isOutputTruncated, AGENT_OUTPUT_CAP_BYTES, // P-8e4c2a17: exported for testing (OUTPUT_TRUNCATED detection)
|
|
11643
11820
|
promoteCheckpointSteeringForClose, // exported for testing
|