@yemi33/minions 0.1.2380 → 0.1.2382
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 +41 -2
- package/dashboard/js/refresh.js +9 -3
- package/dashboard/js/render-other.js +81 -23
- package/dashboard/js/render-work-items.js +61 -20
- package/dashboard/js/settings.js +47 -25
- package/dashboard/pages/engine.html +6 -0
- package/dashboard.js +126 -62
- package/docs/README.md +1 -0
- package/docs/claude-md-propagation.md +118 -0
- package/docs/engine-restart.md +10 -0
- package/docs/live-checkout-mode.md +45 -26
- package/docs/worktree-lifecycle.md +9 -0
- package/engine/claude-md-context.js +195 -0
- package/engine/consolidation.js +47 -3
- package/engine/db/migrations/026-pre-dispatch-rejections.js +55 -0
- package/engine/dispatch.js +78 -24
- package/engine/lifecycle.js +129 -84
- package/engine/live-checkout.js +193 -149
- package/engine/pipeline.js +147 -20
- package/engine/playbook.js +47 -15
- package/engine/prd-store.js +14 -6
- package/engine/quarantine-refs.js +33 -0
- package/engine/queries.js +8 -6
- package/engine/restart-health.js +69 -4
- package/engine/routing.js +1 -1
- package/engine/runtimes/claude.js +6 -0
- package/engine/runtimes/codex.js +18 -0
- package/engine/runtimes/copilot.js +7 -0
- package/engine/shared.js +199 -106
- package/engine/supervisor.js +72 -16
- package/engine/timeout.js +87 -16
- package/engine/untrusted-fence.js +2 -1
- package/engine.js +301 -31
- package/package.json +1 -1
- package/prompts/cc-system.md +7 -7
package/engine.js
CHANGED
|
@@ -1430,15 +1430,15 @@ const _quarantinePathLocks = new Map();
|
|
|
1430
1430
|
// (Pure shared-branch callers pass quarantineOnUnsafe=false and DO NOT
|
|
1431
1431
|
// treat ahead-of-origin as dirty — that path's existing semantics are
|
|
1432
1432
|
// preserved. PR-targeted reused worktrees pass quarantineOnUnsafe=true
|
|
1433
|
-
// and
|
|
1434
|
-
// (#
|
|
1433
|
+
// and preserve clean strictly-ahead commits by pushing them before any
|
|
1434
|
+
// quarantine fallback (#853).)
|
|
1435
1435
|
// 3. If unsafe, decide:
|
|
1436
1436
|
// - Block (preserve dirty tree for inspection) when ANOTHER active
|
|
1437
1437
|
// dispatch claims the same branch (ownership ambiguity — NEVER
|
|
1438
1438
|
// quarantine, even when quarantineOnUnsafe=true).
|
|
1439
|
-
// -
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1439
|
+
// - When clean local commits are strictly ahead, push them to origin.
|
|
1440
|
+
// If that push fails or the branch is genuinely diverged, quarantine:
|
|
1441
|
+
// rename worktree to <path>-quarantine-<ts>,
|
|
1442
1442
|
// prune git's stale metadata, back up the local branch HEAD to
|
|
1443
1443
|
// refs/minions/quarantine/<sanitized>/<ts>, reset
|
|
1444
1444
|
// refs/heads/<branch> → origin/<branch>, and write a notes/inbox/
|
|
@@ -1458,6 +1458,124 @@ const _quarantinePathLocks = new Map();
|
|
|
1458
1458
|
// quarantined, quarantinedPath, backupRef } so the caller can fail
|
|
1459
1459
|
// fast with a first-class DIRTY_WORKTREE / WORKTREE_DIRTY /
|
|
1460
1460
|
// WORKTREE_DIVERGENT reason and retry semantics.
|
|
1461
|
+
async function pushCleanAheadBranch({ rootDir, worktreePath, branchName, mainBranch, project, gitOpts = {} } = {}) {
|
|
1462
|
+
const result = { pushed: false, reason: null, ahead: 0, behind: 0 };
|
|
1463
|
+
if (!rootDir || !worktreePath || !branchName || !fs.existsSync(worktreePath)) {
|
|
1464
|
+
return { ...result, reason: 'invalid-worktree' };
|
|
1465
|
+
}
|
|
1466
|
+
if (mainBranch && sanitizeBranch(branchName) === sanitizeBranch(mainBranch)) {
|
|
1467
|
+
return { ...result, reason: 'main-branch' };
|
|
1468
|
+
}
|
|
1469
|
+
const { gitExtraArgs: _staleGitExtraArgs, ...networkGitOpts } = gitOpts;
|
|
1470
|
+
const runNetworkGit = (args, opts) => project
|
|
1471
|
+
? adoGitAuth.runAdoGit(project, args, { ...networkGitOpts, ...opts })
|
|
1472
|
+
: shared.shellSafeGit(args, { ...gitOpts, ...opts });
|
|
1473
|
+
try {
|
|
1474
|
+
const current = String(await shared.shellSafeGit(
|
|
1475
|
+
['symbolic-ref', '--quiet', '--short', 'HEAD'],
|
|
1476
|
+
{ ...gitOpts, cwd: worktreePath, timeout: 10000 },
|
|
1477
|
+
) || '').trim();
|
|
1478
|
+
if (current !== branchName) return { ...result, reason: 'branch-mismatch' };
|
|
1479
|
+
|
|
1480
|
+
const status = String(await shared.shellSafeGit(
|
|
1481
|
+
['--no-optional-locks', 'status', '--porcelain'],
|
|
1482
|
+
{ ...gitOpts, cwd: worktreePath, timeout: 15000 },
|
|
1483
|
+
) || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean)
|
|
1484
|
+
.filter(line => !shared.isWorktreeOwnerMarkerStatusLine(line));
|
|
1485
|
+
if (status.length > 0) return { ...result, reason: 'dirty' };
|
|
1486
|
+
|
|
1487
|
+
let remoteExists = false;
|
|
1488
|
+
let trackingRefExists = false;
|
|
1489
|
+
try {
|
|
1490
|
+
await shared.shellSafeGit(
|
|
1491
|
+
['show-ref', '--verify', '--quiet', `refs/remotes/origin/${branchName}`],
|
|
1492
|
+
{ ...gitOpts, cwd: rootDir, timeout: 10000 },
|
|
1493
|
+
);
|
|
1494
|
+
remoteExists = true;
|
|
1495
|
+
trackingRefExists = true;
|
|
1496
|
+
} catch {
|
|
1497
|
+
// A first-attempt branch may not have a local remote-tracking ref. Only
|
|
1498
|
+
// pay for ls-remote in that uncommon case; aligned dispatch closes stay
|
|
1499
|
+
// local-only and do not add a network round trip.
|
|
1500
|
+
try {
|
|
1501
|
+
const remote = String(await runNetworkGit(
|
|
1502
|
+
['ls-remote', '--exit-code', '--heads', 'origin', branchName],
|
|
1503
|
+
{ cwd: rootDir, timeout: 15000 },
|
|
1504
|
+
) || '').trim();
|
|
1505
|
+
remoteExists = !!remote;
|
|
1506
|
+
} catch (err) {
|
|
1507
|
+
if (err?.code !== 2 && err?.status !== 2) {
|
|
1508
|
+
return { ...result, reason: 'remote-probe-failed', error: err.message };
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
if (remoteExists) {
|
|
1514
|
+
if (trackingRefExists) {
|
|
1515
|
+
const localCounts = String(await shared.shellSafeGit(
|
|
1516
|
+
['rev-list', '--left-right', '--count', `refs/remotes/origin/${branchName}...HEAD`],
|
|
1517
|
+
{ ...gitOpts, cwd: worktreePath, timeout: 15000 },
|
|
1518
|
+
) || '').trim().split(/\s+/).map(Number);
|
|
1519
|
+
const locallyAhead = Number.isFinite(localCounts[1]) ? localCounts[1] : 0;
|
|
1520
|
+
if (locallyAhead === 0) {
|
|
1521
|
+
return {
|
|
1522
|
+
...result,
|
|
1523
|
+
behind: Number.isFinite(localCounts[0]) ? localCounts[0] : 0,
|
|
1524
|
+
reason: 'aligned',
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
try {
|
|
1529
|
+
await runNetworkGit(
|
|
1530
|
+
['fetch', 'origin', branchName],
|
|
1531
|
+
{ cwd: rootDir, timeout: 30000 },
|
|
1532
|
+
);
|
|
1533
|
+
} catch (err) {
|
|
1534
|
+
return { ...result, reason: 'fetch-failed', error: err.message };
|
|
1535
|
+
}
|
|
1536
|
+
const counts = String(await shared.shellSafeGit(
|
|
1537
|
+
['rev-list', '--left-right', '--count', `refs/remotes/origin/${branchName}...HEAD`],
|
|
1538
|
+
{ ...gitOpts, cwd: worktreePath, timeout: 15000 },
|
|
1539
|
+
) || '').trim().split(/\s+/).map(Number);
|
|
1540
|
+
result.behind = Number.isFinite(counts[0]) ? counts[0] : 0;
|
|
1541
|
+
result.ahead = Number.isFinite(counts[1]) ? counts[1] : 0;
|
|
1542
|
+
if (result.behind > 0) return { ...result, reason: 'diverged' };
|
|
1543
|
+
if (result.ahead === 0) return { ...result, reason: 'aligned' };
|
|
1544
|
+
} else {
|
|
1545
|
+
if (!mainBranch) return { ...result, reason: 'no-upstream-no-main' };
|
|
1546
|
+
try {
|
|
1547
|
+
result.ahead = Number(String(await shared.shellSafeGit(
|
|
1548
|
+
['rev-list', '--count', `refs/remotes/origin/${mainBranch}..HEAD`],
|
|
1549
|
+
{ ...gitOpts, cwd: worktreePath, timeout: 15000 },
|
|
1550
|
+
) || '').trim());
|
|
1551
|
+
} catch (err) {
|
|
1552
|
+
return { ...result, reason: 'main-compare-failed', error: err.message };
|
|
1553
|
+
}
|
|
1554
|
+
if (!Number.isFinite(result.ahead) || result.ahead <= 0) {
|
|
1555
|
+
return { ...result, ahead: 0, reason: 'no-local-commits' };
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
try {
|
|
1560
|
+
await runNetworkGit(
|
|
1561
|
+
['push', 'origin', `HEAD:refs/heads/${branchName}`],
|
|
1562
|
+
{ cwd: worktreePath, timeout: 30000 },
|
|
1563
|
+
);
|
|
1564
|
+
log('info', `Preserved ${result.ahead} clean local commit(s) by pushing ${branchName} to origin`);
|
|
1565
|
+
return {
|
|
1566
|
+
...result,
|
|
1567
|
+
pushed: true,
|
|
1568
|
+
reason: remoteExists ? 'pushed-ahead' : 'pushed-new-branch',
|
|
1569
|
+
};
|
|
1570
|
+
} catch (err) {
|
|
1571
|
+
log('warn', `Failed to preserve clean ahead branch ${branchName}: ${err.message}`);
|
|
1572
|
+
return { ...result, reason: 'push-failed', error: err.message };
|
|
1573
|
+
}
|
|
1574
|
+
} catch (err) {
|
|
1575
|
+
return { ...result, reason: 'probe-failed', error: err.message };
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1461
1579
|
async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, dispatchId, gitOpts = {}, opts = {}) {
|
|
1462
1580
|
const quarantineOnUnsafe = !!opts.quarantineOnUnsafe;
|
|
1463
1581
|
// W-mq1habhf: status probe timeout is now configurable so large/GVFS repos
|
|
@@ -1638,6 +1756,41 @@ async function assertCleanSharedWorktree(rootDir, worktreePath, branchName, disp
|
|
|
1638
1756
|
}
|
|
1639
1757
|
}
|
|
1640
1758
|
|
|
1759
|
+
// #853 — a clean branch that is strictly ahead contains completed agent
|
|
1760
|
+
// commits, not contamination. Preserve them on the PR/work branch before
|
|
1761
|
+
// quarantine can move the work to a local-only backup ref. A rejected or
|
|
1762
|
+
// non-fast-forward push falls through to the existing quarantine path.
|
|
1763
|
+
if (!filesystemDirty && quarantineOnUnsafe && (result.ahead > 0 || !upstreamKnown)) {
|
|
1764
|
+
const preservation = await pushCleanAheadBranch({
|
|
1765
|
+
rootDir,
|
|
1766
|
+
worktreePath,
|
|
1767
|
+
branchName,
|
|
1768
|
+
mainBranch: opts.mainBranch,
|
|
1769
|
+
project: opts.project,
|
|
1770
|
+
gitOpts,
|
|
1771
|
+
});
|
|
1772
|
+
const preservationProvedClean = preservation.pushed ||
|
|
1773
|
+
['aligned', 'no-local-commits'].includes(preservation.reason);
|
|
1774
|
+
if (preservationProvedClean) {
|
|
1775
|
+
result.clean = true;
|
|
1776
|
+
result.pushed = preservation.pushed;
|
|
1777
|
+
result.ahead = preservation.ahead;
|
|
1778
|
+
result.behind = preservation.behind;
|
|
1779
|
+
result.reason = preservation.reason;
|
|
1780
|
+
return result;
|
|
1781
|
+
}
|
|
1782
|
+
result.pushFailure = preservation.reason;
|
|
1783
|
+
result.pushError = preservation.error;
|
|
1784
|
+
if (preservation.reason !== 'diverged') {
|
|
1785
|
+
// A network/auth failure does not prove the branch is unsafe. Leave the
|
|
1786
|
+
// clean ahead worktree intact so a later retry can push the same commits.
|
|
1787
|
+
result.reason = preservation.reason;
|
|
1788
|
+
return result;
|
|
1789
|
+
}
|
|
1790
|
+
result.ahead = preservation.ahead;
|
|
1791
|
+
result.behind = preservation.behind;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1641
1794
|
// 5. Unpushed-commit check — refuse to reset when local work would be lost.
|
|
1642
1795
|
// Use the ahead count we already computed (cheaper + correct vs.
|
|
1643
1796
|
// `git log @{u}..HEAD` which needs an upstream config and silently
|
|
@@ -2711,7 +2864,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2711
2864
|
// auto-stash is disabled, or because `applyLiveCheckoutAutoStash` itself
|
|
2712
2865
|
// reported `outcome:'unchanged'` (the `git stash push` call failed) — and
|
|
2713
2866
|
// the fleet/project auto-reset policy resolves true, NOW perform the
|
|
2714
|
-
// destructive `
|
|
2867
|
+
// destructive `reset --hard HEAD` + `clean -fd` WIP cleanup as a fallback, reusing
|
|
2715
2868
|
// prepareLiveCheckout's existing reset logic by re-invoking it with an
|
|
2716
2869
|
// explicit `autoReset: true` (bypassing config resolution, which we've
|
|
2717
2870
|
// already evaluated). This only fires when the first prepareLiveCheckout
|
|
@@ -2761,6 +2914,27 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2761
2914
|
if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'dirty') {
|
|
2762
2915
|
const _dirtyFiles = Array.isArray(_liveResult.dirtyFiles) ? _liveResult.dirtyFiles : [];
|
|
2763
2916
|
const _branchInfo = typeof _liveResult.branchInfo === 'string' ? _liveResult.branchInfo : '';
|
|
2917
|
+
const _autoResetAttempt = _liveResult.autoResetAttempt;
|
|
2918
|
+
const _resetAuditSlug = typeof _autoResetAttempt?.auditSlug === 'string'
|
|
2919
|
+
? _autoResetAttempt.auditSlug
|
|
2920
|
+
: `live-checkout-autoreset-${id}`;
|
|
2921
|
+
let _cleanAttemptStatus = 'not run';
|
|
2922
|
+
let _statusCheckStatus = 'failed';
|
|
2923
|
+
if (_autoResetAttempt?.cleanAttempted) {
|
|
2924
|
+
_cleanAttemptStatus = _autoResetAttempt.cleanSucceeded ? 'succeeded' : 'failed or partially applied';
|
|
2925
|
+
}
|
|
2926
|
+
if (_autoResetAttempt?.recheckSucceeded) {
|
|
2927
|
+
_statusCheckStatus = `${_dirtyFiles.length} dirty path(s) remain`;
|
|
2928
|
+
}
|
|
2929
|
+
const _resetAttemptSummary = _autoResetAttempt
|
|
2930
|
+
? [
|
|
2931
|
+
'Automatic reset cleanup was attempted before this refusal:',
|
|
2932
|
+
`- \`git reset --hard HEAD\`: ${_autoResetAttempt.resetSucceeded ? 'succeeded' : 'failed'}`,
|
|
2933
|
+
`- \`git clean -fd\`: ${_cleanAttemptStatus}`,
|
|
2934
|
+
`- post-cleanup status check: ${_statusCheckStatus}`,
|
|
2935
|
+
`See the \`${_resetAuditSlug}\` inbox note for the full before/after audit.`,
|
|
2936
|
+
].join('\n')
|
|
2937
|
+
: 'No automatic reset/clean recovery was attempted before this refusal.';
|
|
2764
2938
|
// #329 + PL-live-checkout-reliability-hardening: two-strike dirty guard.
|
|
2765
2939
|
// First dirty failure is retried once (a transient or engine-owned dirty
|
|
2766
2940
|
// state — e.g. an agent's own leftover WIP, which dispatch-end restore now
|
|
@@ -2800,7 +2974,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2800
2974
|
`**Dispatch:** ${id}`,
|
|
2801
2975
|
...(_branchInfo ? ['', `**Current checkout:** \`${_branchInfo}\``] : []),
|
|
2802
2976
|
'',
|
|
2803
|
-
`The engine refused to spawn the agent because \`${cwd}\` has uncommitted changes. Live-checkout mode runs in your project directly
|
|
2977
|
+
`The engine refused to spawn the agent because \`${cwd}\` has uncommitted changes. Live-checkout mode runs in your project directly.`,
|
|
2804
2978
|
'',
|
|
2805
2979
|
'## Dirty files (`git status --porcelain=v1 -b`)',
|
|
2806
2980
|
'',
|
|
@@ -2808,6 +2982,8 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2808
2982
|
...(_dirtyFiles.length > 0 ? _liveCheckout.capDirtyFileLines(_dirtyFiles) : ['(none reported)']),
|
|
2809
2983
|
'```',
|
|
2810
2984
|
'',
|
|
2985
|
+
_resetAttemptSummary,
|
|
2986
|
+
'',
|
|
2811
2987
|
_autoCleanupEnabled
|
|
2812
2988
|
? 'Auto-cleanup (`liveCheckoutAutoReset`/`liveCheckoutAutoStash`) is active — the engine re-cleans the tree on each attempt, so this dispatch will be retried automatically (subject to the normal retry cap).'
|
|
2813
2989
|
: (_alreadyDirtyFailed
|
|
@@ -3764,12 +3940,14 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3764
3940
|
{
|
|
3765
3941
|
quarantineOnUnsafe: true,
|
|
3766
3942
|
mainBranch: shared.resolveMainBranch(rootDir, project.mainBranch),
|
|
3943
|
+
project,
|
|
3767
3944
|
statusTimeoutMs: engineConfig.assertCleanStatusTimeoutMs ?? ENGINE_DEFAULTS.assertCleanStatusTimeoutMs,
|
|
3768
3945
|
},
|
|
3769
3946
|
);
|
|
3770
3947
|
_phaseT.dirtyReusedCheckEnd = Date.now();
|
|
3771
3948
|
if (!cleanResult.clean) {
|
|
3772
3949
|
const previewFiles = (cleanResult.dirtyFiles || []).slice(0, 5).join(', ');
|
|
3950
|
+
const isPushBlocked = ['remote-probe-failed', 'fetch-failed', 'push-failed'].includes(cleanResult.reason);
|
|
3773
3951
|
const isDivergent = (cleanResult.ahead || 0) > 0 || cleanResult.reason === 'no-upstream';
|
|
3774
3952
|
// W-mq1habhf: status-failed is a different beast than
|
|
3775
3953
|
// has-unpushed-commits / no-upstream. We couldn't even read the
|
|
@@ -3792,12 +3970,21 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3792
3970
|
// the existing _quarantineRecoveryCount cap. This stops env failures
|
|
3793
3971
|
// from burning the per-agent retry budget the way they did before.
|
|
3794
3972
|
const isQuarantineEnvBlocked = !!(cleanResult.quarantineError && !cleanResult.quarantined);
|
|
3795
|
-
const
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3973
|
+
const isPushAuthBlocked = isPushBlocked && adoGitAuth.isAdoAuthFailure(
|
|
3974
|
+
new Error(cleanResult.pushError || ''),
|
|
3975
|
+
);
|
|
3976
|
+
let failureClassValue = FAILURE_CLASS.WORKTREE_DIRTY;
|
|
3977
|
+
let failureClassName = 'WORKTREE_DIRTY';
|
|
3978
|
+
if (isPushBlocked) {
|
|
3979
|
+
failureClassValue = isPushAuthBlocked ? FAILURE_CLASS.AUTH : FAILURE_CLASS.NETWORK_ERROR;
|
|
3980
|
+
failureClassName = isPushAuthBlocked ? 'AUTH' : 'NETWORK_ERROR';
|
|
3981
|
+
} else if (isQuarantineEnvBlocked) {
|
|
3982
|
+
failureClassValue = FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED;
|
|
3983
|
+
failureClassName = 'WORKTREE_QUARANTINE_ENV_BLOCKED';
|
|
3984
|
+
} else if (isDivergent) {
|
|
3985
|
+
failureClassValue = FAILURE_CLASS.WORKTREE_DIVERGENT;
|
|
3986
|
+
failureClassName = 'WORKTREE_DIVERGENT';
|
|
3987
|
+
}
|
|
3801
3988
|
// #284: distinguish the ORIGINAL worktree-preflight failure from a
|
|
3802
3989
|
// failure on the automatic fresh-worktree retry. The quarantine
|
|
3803
3990
|
// auto-recovery loop (discoverFromWorkItems) flips a failed
|
|
@@ -3810,7 +3997,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3810
3997
|
const retryLabel = priorQuarantineRetry > 0
|
|
3811
3998
|
? ` [retry-in-fresh-worktree #${priorQuarantineRetry} also failed]`
|
|
3812
3999
|
: ' [original worktree-preflight issue]';
|
|
3813
|
-
const reasonMsg =
|
|
4000
|
+
const reasonMsg = isPushBlocked
|
|
4001
|
+
? `${failureClassName}: clean ahead worktree at ${worktreePath} could not be pushed (${cleanResult.reason}: ${cleanResult.pushError || 'unknown error'}). The worktree and local commits were preserved for retry.`
|
|
4002
|
+
: cleanResult.quarantined
|
|
3814
4003
|
? `${failureClassName}: reused worktree at ${worktreePath} was dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} dirty file(s)${previewFiles ? ': ' + previewFiles : ''}) — quarantined to ${cleanResult.quarantinedPath}. Next dispatch will start fresh.`
|
|
3815
4004
|
: `${failureClassName}: reused worktree at ${worktreePath} is dirty/divergent (${cleanResult.reason}; ${cleanResult.ahead || 0} ahead, ${cleanResult.behind || 0} behind, ${cleanResult.dirtyFiles?.length || 0} file(s)${previewFiles ? ': ' + previewFiles : ''}). Quarantine ${cleanResult.quarantineError ? 'errored: ' + cleanResult.quarantineError : (cleanResult.quarantineSkipped ? 'was skipped — another live dispatch claims the worktree (see notes/inbox/ engine-worktree-skip-live note).' : 'was not attempted (' + cleanResult.reason + ').')}`;
|
|
3816
4005
|
log('error', reasonMsg);
|
|
@@ -3820,7 +4009,11 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3820
4009
|
DISPATCH_RESULT.ERROR,
|
|
3821
4010
|
reasonMsg.slice(0, 500),
|
|
3822
4011
|
`Engine preflight refused to dispatch into a dirty/divergent reused worktree (#2996).${retryLabel} Reason: ${cleanResult.reason}.${cleanResult.quarantined ? ` Worktree quarantined to ${cleanResult.quarantinedPath}; backup ref ${cleanResult.backupRef || '(skipped)'}. See notes/inbox/ for recovery instructions.` : (cleanResult.quarantineSkipped ? ' Quarantine was skipped because another live dispatch claims this worktree path; this dispatch will not auto-retry until the live claimant clears.' : '')}${isQuarantineEnvBlocked ? ' Environmental quarantine failure (Windows EBUSY); WI auto-recovery loop will re-queue without bumping per-agent retry counter.' : ''}`,
|
|
3823
|
-
{
|
|
4012
|
+
{
|
|
4013
|
+
agentRetryable: (isStatusProbeFailed && cleanResult.quarantined) ||
|
|
4014
|
+
(isPushBlocked && !isPushAuthBlocked),
|
|
4015
|
+
failureClass: failureClassValue,
|
|
4016
|
+
},
|
|
3824
4017
|
);
|
|
3825
4018
|
cleanupTempAgent(agentId);
|
|
3826
4019
|
return null;
|
|
@@ -4394,7 +4587,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4394
4587
|
// agent never gets a chance to push over the rebased tip.
|
|
4395
4588
|
// Read-only and non-fix dispatches are out of scope — implement tasks cut
|
|
4396
4589
|
// their own branch from main, and review/verify don't push.
|
|
4397
|
-
if (type
|
|
4590
|
+
if (shared.isFixLikeWorkType(type) && branchName && worktreePath && cwd === worktreePath) {
|
|
4398
4591
|
_phaseT.staleHeadStart = Date.now();
|
|
4399
4592
|
try {
|
|
4400
4593
|
const guard = await assertStaleHeadOk({
|
|
@@ -4920,6 +5113,25 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4920
5113
|
ackPendingSteeringFiles(agentId, procInfo, steeringAckStdout);
|
|
4921
5114
|
promoteCheckpointSteeringForClose(agentId, procInfo, runtime, liveOutputPath);
|
|
4922
5115
|
|
|
5116
|
+
// #853 — the child is gone, so its clean committed state is stable. Push
|
|
5117
|
+
// any strictly-ahead dispatch branch before steering resume, pool return,
|
|
5118
|
+
// quarantine, or dispatch-end GC can detach/remove the worktree.
|
|
5119
|
+
const branchPath = worktreePath || (liveMode ? project?.localPath : null);
|
|
5120
|
+
const branchRootDir = liveMode ? project?.localPath : rootDir;
|
|
5121
|
+
if (branchPath && branchRootDir && branchName && fs.existsSync(branchPath)) {
|
|
5122
|
+
const preservation = await pushCleanAheadBranch({
|
|
5123
|
+
rootDir: branchRootDir,
|
|
5124
|
+
worktreePath: branchPath,
|
|
5125
|
+
branchName,
|
|
5126
|
+
mainBranch: shared.resolveMainBranch(branchRootDir, project?.mainBranch),
|
|
5127
|
+
project,
|
|
5128
|
+
gitOpts: _gitOpts,
|
|
5129
|
+
});
|
|
5130
|
+
if (!preservation.pushed && !['aligned', 'dirty', 'no-local-commits'].includes(preservation.reason)) {
|
|
5131
|
+
log('warn', `Dispatch-close branch preservation skipped for ${id}: ${preservation.reason}`);
|
|
5132
|
+
}
|
|
5133
|
+
}
|
|
5134
|
+
|
|
4923
5135
|
// Check if this was a steering kill — re-spawn with resume
|
|
4924
5136
|
if (procInfo?._steeringMessage) {
|
|
4925
5137
|
const steerMsg = procInfo._steeringMessage;
|
|
@@ -5249,7 +5461,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5249
5461
|
// delete clears the entry.
|
|
5250
5462
|
const expectedNonce = activeProcesses.get(id)?._completionNonce || null;
|
|
5251
5463
|
const completionNonceRequired = engineConfig.completionNonceRequired ?? ENGINE_DEFAULTS.completionNonceRequired;
|
|
5252
|
-
const { resultSummary, autoRecovered, completionContractFailure, structuredCompletion, agentReportedFailure, agentRetryable, nonceMismatch } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config, { expectedNonce, completionNonceRequired });
|
|
5464
|
+
const { resultSummary, autoRecovered, completionContractFailure, structuredCompletion, agentReportedFailure, agentRetryable, nonceMismatch, benignNoop } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config, { expectedNonce, completionNonceRequired });
|
|
5253
5465
|
const retryableDecision = typeof agentRetryable === 'boolean' ? agentRetryable : failureInfo.retryable;
|
|
5254
5466
|
|
|
5255
5467
|
// W-mp6k7ywi000fa33c — keep_processes acceptance gate. When the work
|
|
@@ -5655,7 +5867,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
5655
5867
|
const managedSpawnHealthcheckFail = !!managedSpawnHealthcheckFailure;
|
|
5656
5868
|
const effectiveResult = (hardContractFail || nonceFail || keepProcessesAcceptanceFail || managedSpawnAcceptanceFail || managedSpawnHealthcheckFail)
|
|
5657
5869
|
? DISPATCH_RESULT.ERROR
|
|
5658
|
-
: (((code === 0 && !agentReportedFailure) || autoRecovered) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
|
|
5870
|
+
: (((code === 0 && !agentReportedFailure) || autoRecovered || benignNoop) ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
|
|
5659
5871
|
const finalCompletionReportPath = structuredCompletion?._path || dispatchItem.meta?.completionReportPath || shared.dispatchCompletionReportPath(id);
|
|
5660
5872
|
const completionOpts = {
|
|
5661
5873
|
...(finalCompletionReportPath ? { completionReportPath: finalCompletionReportPath } : {}),
|
|
@@ -8151,6 +8363,35 @@ function _buildRunnerBriefVars(item, project) {
|
|
|
8151
8363
|
/**
|
|
8152
8364
|
* Scan a project work-item scope for manually queued tasks.
|
|
8153
8365
|
*/
|
|
8366
|
+
/**
|
|
8367
|
+
* W-mrdon0pe000l045a — collect directory/file path hints for CLAUDE.md
|
|
8368
|
+
* propagation. These are the paths most relevant to a dispatch; the
|
|
8369
|
+
* CLAUDE.md-context module walks UP from each toward the project root collecting
|
|
8370
|
+
* the nearest applicable CLAUDE.md files. Sources (best-effort, cheap):
|
|
8371
|
+
* - item.meta.workdir (explicit sub-project directory)
|
|
8372
|
+
* - item.references[*].path (changed/relevant files)
|
|
8373
|
+
* Returns a de-duplicated array of repo-relative path strings. Empty when no
|
|
8374
|
+
* hints exist — the module still falls back to the project-root CLAUDE.md.
|
|
8375
|
+
*/
|
|
8376
|
+
function _deriveClaudeMdPathHints(item) {
|
|
8377
|
+
const hints = [];
|
|
8378
|
+
const seen = new Set();
|
|
8379
|
+
const add = (p) => {
|
|
8380
|
+
if (typeof p !== 'string') return;
|
|
8381
|
+
const v = p.trim();
|
|
8382
|
+
if (!v || seen.has(v)) return;
|
|
8383
|
+
seen.add(v);
|
|
8384
|
+
hints.push(v);
|
|
8385
|
+
};
|
|
8386
|
+
if (item && item.meta && typeof item.meta.workdir === 'string') add(item.meta.workdir);
|
|
8387
|
+
if (item && Array.isArray(item.references)) {
|
|
8388
|
+
for (const r of item.references) {
|
|
8389
|
+
if (r && typeof r.path === 'string') add(r.path);
|
|
8390
|
+
}
|
|
8391
|
+
}
|
|
8392
|
+
return hints;
|
|
8393
|
+
}
|
|
8394
|
+
|
|
8154
8395
|
function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, project, root, branchName, options = {}) {
|
|
8155
8396
|
const worktreePath = options.worktreePath || path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`);
|
|
8156
8397
|
const vars = {
|
|
@@ -8304,6 +8545,14 @@ function renderProjectWorkItemPromptForAgent(item, workType, agentId, config, pr
|
|
|
8304
8545
|
// failure via the qa-session-draft-failed / qa-session-execute-failed
|
|
8305
8546
|
// path. (See playbooks/qa-session-draft.md → "Failure path" section.)
|
|
8306
8547
|
..._buildRunnerBriefVars(item, project),
|
|
8548
|
+
// W-mrdon0pe000l045a — CLAUDE.md propagation context for non-Claude runtimes.
|
|
8549
|
+
// renderPlaybook resolves the runtime capability + config flag and, when the
|
|
8550
|
+
// runtime doesn't auto-load CLAUDE.md (Copilot/Codex), bounded-discovers the
|
|
8551
|
+
// nearest applicable CLAUDE.md files walking up from these path hints toward
|
|
8552
|
+
// the project root. runtime_cli lets renderPlaybook check the adapter's
|
|
8553
|
+
// claudeMdNativeDiscovery capability without re-resolving the agent config.
|
|
8554
|
+
runtime_cli: shared.resolveAgentCli(config.agents?.[agentId] || null, config.engine),
|
|
8555
|
+
claude_md_path_hints: _deriveClaudeMdPathHints(item),
|
|
8307
8556
|
// M006 — typed handoff envelope. Set on follow-up dispatch metas by
|
|
8308
8557
|
// lifecycle.js when queuing an item after a completing agent. The
|
|
8309
8558
|
// renderPlaybook path injects a "Handoff Context" section when truthy
|
|
@@ -8548,6 +8797,15 @@ function discoverFromWorkItems(config, project) {
|
|
|
8548
8797
|
}
|
|
8549
8798
|
|
|
8550
8799
|
if (item.status !== WI_STATUS.QUEUED && item.status !== WI_STATUS.PENDING) continue;
|
|
8800
|
+
if (item._preDispatchEval?.exhausted === true
|
|
8801
|
+
&& item._preDispatchEval.description === String(item.description || '')) {
|
|
8802
|
+
if (item._pendingReason !== 'pre_dispatch_eval_rejected') {
|
|
8803
|
+
item._pendingReason = 'pre_dispatch_eval_rejected';
|
|
8804
|
+
needsWrite = true;
|
|
8805
|
+
}
|
|
8806
|
+
skipped.gated++;
|
|
8807
|
+
continue;
|
|
8808
|
+
}
|
|
8551
8809
|
|
|
8552
8810
|
// Dependency gate: skip items whose depends_on are not yet met; propagate failure
|
|
8553
8811
|
if (item.depends_on && item.depends_on.length > 0) {
|
|
@@ -8681,7 +8939,7 @@ function discoverFromWorkItems(config, project) {
|
|
|
8681
8939
|
return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
|
|
8682
8940
|
});
|
|
8683
8941
|
if (!agentId) {
|
|
8684
|
-
if (!budgetBlocked && !hardPinRequested && workType
|
|
8942
|
+
if (!budgetBlocked && !hardPinRequested && !shared.isFixLikeWorkType(workType)) {
|
|
8685
8943
|
reservedAgentId = resolveAgentReservation(workType, config, { agentHints });
|
|
8686
8944
|
agentId = reservedAgentId && !hasAgentHints ? routing.ANY_AGENT : reservedAgentId;
|
|
8687
8945
|
}
|
|
@@ -8723,7 +8981,7 @@ function discoverFromWorkItems(config, project) {
|
|
|
8723
8981
|
const linkedPr = item.skipPr ? null : resolveWorkItemPrRecord(item, project);
|
|
8724
8982
|
const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
|
|
8725
8983
|
const prBranch = linkedPr?.branch || '';
|
|
8726
|
-
const isPrTargeted = !!(linkedPr && (workType
|
|
8984
|
+
const isPrTargeted = !!(linkedPr && (shared.isFixLikeWorkType(workType) || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
|
|
8727
8985
|
// Issue #607 — `linkedPr` above resolves through the LOOSE
|
|
8728
8986
|
// extractWorkItemPrRef (title/first-paragraph-of-description text scan),
|
|
8729
8987
|
// which is fine for prompt-context enrichment (promptItem, above) that
|
|
@@ -8745,7 +9003,7 @@ function discoverFromWorkItems(config, project) {
|
|
|
8745
9003
|
// create-time stamp path keeps using the loose form — gate uses
|
|
8746
9004
|
// structured-only; stamp uses loose. See engine/shared.js
|
|
8747
9005
|
// extractStructuredWorkItemPrRef for the structured-source list.
|
|
8748
|
-
if (!linkedPr && getStructuredWorkItemPrRef(item) && (workType
|
|
9006
|
+
if (!linkedPr && getStructuredWorkItemPrRef(item) && (shared.isFixLikeWorkType(workType) || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST)) {
|
|
8749
9007
|
if (item._pendingReason !== 'pr_not_found') { item._pendingReason = 'pr_not_found'; needsWrite = true; }
|
|
8750
9008
|
log('warn', `Work item ${item.id} references PR ${getStructuredWorkItemPrRef(item)} but no tracked PR record was found`);
|
|
8751
9009
|
continue;
|
|
@@ -9048,7 +9306,7 @@ function buildWorkItemDispatchVars(item, vars, config, options = {}) {
|
|
|
9048
9306
|
// SHERLOC (P-mqyp0008v022w3x4): inject prior explore context for fix dispatches.
|
|
9049
9307
|
// When a completed explore WI has a reference to this fix WI, surface its
|
|
9050
9308
|
// resultSummary so the fix agent starts with pre-localized fault context.
|
|
9051
|
-
if (workType
|
|
9309
|
+
if (shared.isFixLikeWorkType(workType)) {
|
|
9052
9310
|
vars.prior_explore_context = resolvePriorExploreContext(item, config);
|
|
9053
9311
|
}
|
|
9054
9312
|
|
|
@@ -9375,7 +9633,7 @@ function discoverCentralWorkItems(config) {
|
|
|
9375
9633
|
const hardPinRequested = !!hardPinnedAgent;
|
|
9376
9634
|
const agentId = hardPinnedAgent
|
|
9377
9635
|
|| resolveAgent(workType, config, { agentHints })
|
|
9378
|
-
|| (!hardPinRequested && workType
|
|
9636
|
+
|| (!hardPinRequested && !shared.isFixLikeWorkType(workType) ? resolveAgentReservation(workType, config, { agentHints }) : null);
|
|
9379
9637
|
if (!agentId) continue;
|
|
9380
9638
|
|
|
9381
9639
|
const agentName = config.agents[agentId]?.name || agentId;
|
|
@@ -9757,7 +10015,7 @@ async function discoverWork(config) {
|
|
|
9757
10015
|
// type-only filter below would silently start gating those
|
|
9758
10016
|
// previously-ungated sources too. Scope the gate back down to
|
|
9759
10017
|
// `meta.source === 'pr'` to preserve the original behavior exactly.
|
|
9760
|
-
const isGateEligible = (w) => w.meta?.source === 'pr' && (w.type
|
|
10018
|
+
const isGateEligible = (w) => w.meta?.source === 'pr' && (shared.isFixLikeWorkType(w.type) || w.type === WORK_TYPE.REVIEW);
|
|
9761
10019
|
const gated = items.filter(isGateEligible);
|
|
9762
10020
|
if (gated.length > 0) {
|
|
9763
10021
|
log('info', `Gating ${gated.length} fixes/reviews — implement items in progress and no free slots`);
|
|
@@ -9799,7 +10057,7 @@ async function discoverWork(config) {
|
|
|
9799
10057
|
}));
|
|
9800
10058
|
}
|
|
9801
10059
|
for (const w of toDispatch) {
|
|
9802
|
-
if (w.type
|
|
10060
|
+
if (shared.isFixLikeWorkType(w.type)) persistedFixes++;
|
|
9803
10061
|
else if (w.type === WORK_TYPE.REVIEW) persistedReviews++;
|
|
9804
10062
|
else persistedOther++;
|
|
9805
10063
|
}
|
|
@@ -9818,7 +10076,7 @@ async function discoverWork(config) {
|
|
|
9818
10076
|
if (config.engine?.prDiscoveryEnabled !== false) {
|
|
9819
10077
|
const prWork = await discoverFromPrs(config, project);
|
|
9820
10078
|
projectWork.push(...prWork.filter(w =>
|
|
9821
|
-
w.type
|
|
10079
|
+
shared.isFixLikeWorkType(w.type) || w.type === WORK_TYPE.REVIEW || w.type === WORK_TYPE.TEST));
|
|
9822
10080
|
}
|
|
9823
10081
|
|
|
9824
10082
|
// Side-effect: specs → work items (picked up below)
|
|
@@ -10038,7 +10296,7 @@ function persistPendingDispatchAgent(item) {
|
|
|
10038
10296
|
}
|
|
10039
10297
|
|
|
10040
10298
|
function isSoftFixDispatch(item) {
|
|
10041
|
-
return item?.type
|
|
10299
|
+
return shared.isFixLikeWorkType(item?.type) && !routing.isAgentHardPinned(item.meta?.item);
|
|
10042
10300
|
}
|
|
10043
10301
|
|
|
10044
10302
|
function resolveMaxConcurrent(config) {
|
|
@@ -10945,7 +11203,15 @@ async function tickInner() {
|
|
|
10945
11203
|
skipDirtyCheck: !!_precheckProjCfg.skipLiveCheckoutDirtyCheck,
|
|
10946
11204
|
});
|
|
10947
11205
|
} catch (e) { log('warn', `live-checkout precheck (dirty) failed for ${item.id}: ${e.message}`); }
|
|
10948
|
-
|
|
11206
|
+
const _autoStashEnabled = _liveCheckoutPrecheck.resolveLiveCheckoutAutoStash({
|
|
11207
|
+
project: _precheckProjCfg,
|
|
11208
|
+
engine: config.engine || {},
|
|
11209
|
+
});
|
|
11210
|
+
const _autoResetEnabled = shared.resolveLiveCheckoutAutoReset(
|
|
11211
|
+
_precheckProjCfg,
|
|
11212
|
+
config.engine || {},
|
|
11213
|
+
);
|
|
11214
|
+
if (_dirtyResult && _dirtyResult.dirty && !_autoStashEnabled && !_autoResetEnabled) {
|
|
10949
11215
|
_persistPendingReason('live_checkout_dirty');
|
|
10950
11216
|
try {
|
|
10951
11217
|
const _alertBody = [
|
|
@@ -11131,11 +11397,12 @@ async function tickInner() {
|
|
|
11131
11397
|
// post-dispatch active list so the annotation loop's reason is consistent
|
|
11132
11398
|
// with whatever actually got spawned this tick.
|
|
11133
11399
|
const postLiveProjectsInUse = new Set();
|
|
11400
|
+
const postProjects = shared.getProjects(config);
|
|
11134
11401
|
for (const d of (postDispatch.active || [])) {
|
|
11135
11402
|
if (READ_ONLY_ROOT_TASK_TYPES.has(d.type)) continue;
|
|
11136
11403
|
const projName = d.project || d.meta?.project?.name || null;
|
|
11137
11404
|
if (!projName) continue;
|
|
11138
|
-
const projCfg = shared.findProjectByName(
|
|
11405
|
+
const projCfg = shared.findProjectByName(postProjects, projName);
|
|
11139
11406
|
if (shared.resolveCheckoutMode(projCfg, d.type) === 'live') {
|
|
11140
11407
|
postLiveProjectsInUse.add(projName);
|
|
11141
11408
|
}
|
|
@@ -11162,7 +11429,10 @@ async function tickInner() {
|
|
|
11162
11429
|
&& !READ_ONLY_ROOT_TASK_TYPES.has(item.type)
|
|
11163
11430
|
&& postLiveProjectsInUse.has(itemProjName)
|
|
11164
11431
|
) {
|
|
11165
|
-
|
|
11432
|
+
const projCfg = shared.findProjectByName(postProjects, itemProjName);
|
|
11433
|
+
if (shared.resolveCheckoutMode(projCfg, item.type) === 'live') {
|
|
11434
|
+
reason = 'live_checkout_busy';
|
|
11435
|
+
}
|
|
11166
11436
|
}
|
|
11167
11437
|
}
|
|
11168
11438
|
}
|
|
@@ -11361,7 +11631,7 @@ module.exports = {
|
|
|
11361
11631
|
gitOutputToString, gitErrorOutput, classifyDepMergeFailureOutput, listUnmergedFiles, // exported for testing
|
|
11362
11632
|
buildDepConflictFixItem, deriveConflictFixKey, // exported for testing (W-mpcwojgr000a0244)
|
|
11363
11633
|
runWorktreeAdd, // exported for testing (W-mqvaxv65000m76f2 — GVFS partial-checkout behavioral test)
|
|
11364
|
-
isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
|
|
11634
|
+
isWorktreeRetryableError, removeStaleIndexLock, syncReusedWorktree, pushCleanAheadBranch, assertCleanSharedWorktree, _quarantineDirtyWorktree, // exported for testing
|
|
11365
11635
|
_statusPorcelainCmd, _killGitDescendantsForWorktree, _bumpQuarantineOutcome, // exported for testing (W-mq5n1zx5)
|
|
11366
11636
|
_reapWorktreeHolders, _findTerminalWorktreeOwners, // exported for testing (W-mqila0t5 — CWD-pinned holder reap)
|
|
11367
11637
|
pruneStaleWorktreeForBranch, // exported for testing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2382",
|
|
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"
|
package/prompts/cc-system.md
CHANGED
|
@@ -92,7 +92,7 @@ When genuinely in doubt about the size, delegate — agents have isolated worktr
|
|
|
92
92
|
### HARD STOP — never edit files yourself in a live/hybrid project
|
|
93
93
|
This overrides everything above, **including the direct-handling override**. For any project whose effective checkout mode is `live` (or `hybrid` for a code-authoring work-item type — `implement`/`fix`/`docs`/`decompose`), you MUST NOT use `Edit`/`Write`/`Bash` (or any tool) to mutate files inside that project's `localPath`, **regardless of task size** — not even a Small 1-line change, and not even when the human explicitly says to do it yourself. The Step-3 "do it yourself" allowance and the Step-1 direct-handling override do **not** apply to writes inside a live/hybrid checkout.
|
|
94
94
|
|
|
95
|
-
Why: live-mode projects have no worktree isolation — the engine runs agents in-place inside `localPath
|
|
95
|
+
Why: live-mode projects have no worktree isolation — the engine runs agents in-place inside `localPath` and caps dispatch to one mutating operation at a time. A stray CC edit is outside dispatch ownership: with dirty recovery disabled it causes a `live-checkout-dirty` refusal that blocks live work, while the default auto-stash policy can silently park that edit before the next dispatch, so the requested change is no longer present for the agent to validate or commit. Auto-stash/reset is a dispatch recovery mechanism, not ownership bookkeeping for CC edits.
|
|
96
96
|
|
|
97
97
|
Instead, when a change is needed in a live/hybrid project, **dispatch a normal work item** (`POST /api/work-items`, `type: "implement"` or `"fix"` as usual) and let the engine own the live-checkout dispatch path (dirty-tree refusal, single-mutating-dispatch cap, auto-stash/auto-reset). Do not try to shortcut it with your own edit.
|
|
98
98
|
|
|
@@ -333,23 +333,23 @@ The `X-CC-Turn-Id` header is the audit trail — the handler stamps it onto the
|
|
|
333
333
|
Every configured project has an effective **checkout mode** — surfaced in your state snapshot next to the project as `[worktree]`, `[live]`, or `[hybrid (live-validation: <type>)]`. It controls where dispatched agents run:
|
|
334
334
|
|
|
335
335
|
- **`worktree`** (default) — each dispatch gets its own isolated `git worktree`. Agents run fully in parallel; nothing touches the operator's working tree. Use for normal repos.
|
|
336
|
-
- **`live`** — agents run **in-place** inside the project's `localPath` (no worktree). The engine caps this to **one mutating dispatch at a time** per project
|
|
337
|
-
- **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block.
|
|
336
|
+
- **`live`** — agents run **in-place** inside the project's `localPath` (no worktree). The engine caps this to **one mutating dispatch at a time** per project. Dirty-tree behavior follows the configured auto-stash/reset policy (auto-stash defaults on); with recovery disabled, the item remains pending until the tree is clean. Use only when worktrees are unworkable.
|
|
337
|
+
- **`hybrid`** — `live` **plus** a `liveValidation: { type, autoDispatch }` block. Work items not listed in `type` use isolated worktrees; listed live-capable canonical work-item types run in-place. The normal validation type is `test`. `build-and-test` is a playbook name, not a work-item type. `type` accepts one string or an array. When `autoDispatch:true`, `test` must be included; the engine creates one PR-targeted `test` WI with the `build-and-test` playbook after each eligible coding WI completes.
|
|
338
338
|
|
|
339
|
-
**CC never writes into a live/hybrid checkout itself.**
|
|
339
|
+
**CC never writes into a live/hybrid checkout itself.** A one-off CC edit leaves untracked operator dirt that the next dispatch may unexpectedly stash or may block on when recovery is disabled. For any needed change, **dispatch a work item** and let the engine own the live-checkout path; never `Edit`/`Write`/`Bash`-mutate files there yourself. Read-only inspection remains fine.
|
|
340
340
|
|
|
341
341
|
**When to recommend hybrid:** the project's build/test/validation genuinely cannot run in an isolated worktree (it needs the real checkout — submodules, a `repo`-managed tree, an emulator/dev-server bound to `localPath`, deep-path tooling), **but** you still want coding agents to work in parallel rather than serialize through the single live checkout. If the *whole* workflow must run on the real tree, use plain `live`. If nothing needs the real tree, stay on `worktree`.
|
|
342
342
|
|
|
343
343
|
**Configuring it** (via the projects array on `POST /api/settings` — never hand-edit `config.json`):
|
|
344
344
|
```bash
|
|
345
|
-
# Switch a project to hybrid mode (live checkout + deferred
|
|
345
|
+
# Switch a project to hybrid mode (live checkout + deferred test validation)
|
|
346
346
|
curl -s -X POST http://localhost:{{dashboard_port}}/api/settings \
|
|
347
347
|
-H 'Content-Type: application/json' -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
|
|
348
|
-
-d '{"projects":[{"name":"<project>","checkoutMode":"live","liveValidation":{"type":"
|
|
348
|
+
-d '{"projects":[{"name":"<project>","checkoutMode":"live","liveValidation":{"type":"test","autoDispatch":true}}]}'
|
|
349
349
|
# Clear hybrid (back to plain live): pass "liveValidation": null
|
|
350
350
|
# Clear live entirely (back to worktree): pass "checkoutMode": "worktree" (also clear liveValidation)
|
|
351
351
|
```
|
|
352
|
-
The server validates: `liveValidation` requires `checkoutMode:"live"
|
|
352
|
+
The server validates: `liveValidation` requires `checkoutMode:"live"`, canonical live-capable work-item type(s), and a boolean `autoDispatch`; automatic validation additionally requires `test` in `type`. **Always confirm the mode switch with the user before applying it** — it changes how future dispatches run.
|
|
353
353
|
|
|
354
354
|
## GitHub auth
|
|
355
355
|
|