@yemi33/minions 0.1.2383 → 0.1.2385
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/js/refresh.js +2 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard.js +133 -46
- package/docs/keep-processes.md +7 -2
- package/docs/live-checkout-mode.md +7 -7
- package/docs/managed-spawn.md +14 -1
- package/docs/runtime-adapters.md +7 -4
- package/docs/worktree-lifecycle.md +22 -0
- package/engine/acp-transport.js +216 -29
- package/engine/agent-worker-pool.js +268 -51
- package/engine/cc-worker-pool.js +8 -1
- package/engine/cleanup.js +15 -11
- package/engine/cli.js +47 -27
- package/engine/create-pr-worktree.js +57 -79
- package/engine/keep-process-sweep.js +131 -9
- package/engine/lifecycle.js +1 -1
- package/engine/live-checkout.js +4 -2
- package/engine/managed-spawn.js +112 -5
- package/engine/pooled-agent-process.js +486 -21
- package/engine/pr-action.js +6 -8
- package/engine/process-utils.js +154 -18
- package/engine/runtimes/copilot.js +22 -4
- package/engine/shared.js +254 -61
- package/engine/spawn-agent.js +6 -3
- package/engine/timeout.js +14 -10
- package/engine/worktree-gc.js +55 -46
- package/engine.js +237 -134
- package/package.json +1 -1
- package/playbooks/shared-rules.md +2 -2
- package/prompts/cc-system.md +11 -11
package/dashboard/js/refresh.js
CHANGED
|
@@ -175,7 +175,8 @@ const RENDER_VERSIONS = {
|
|
|
175
175
|
// Bumped to 3 by W-mqk2s8q1 (Advanced → Diagnostics: relocated the diag button).
|
|
176
176
|
// Bumped to 4 by W-mqrdavob (Agents table: per-agent charter editor column).
|
|
177
177
|
// Bumped to 5 by W-mr0qs0vw (Worker Pool: CC session idle-timeout field).
|
|
178
|
-
|
|
178
|
+
// Bumped to 6 for the fleet ACP pool opt-in default and warning copy.
|
|
179
|
+
settings: 6,
|
|
179
180
|
};
|
|
180
181
|
const _sectionCache = {};
|
|
181
182
|
const _lastValueByKey = {};
|
package/dashboard/js/settings.js
CHANGED
|
@@ -448,7 +448,7 @@ async function openSettings() {
|
|
|
448
448
|
'<div class="settings-pane-sub">Persistent CC worker pool and git worktree provisioning. Tune these only if you see worktree-create timeouts or slow CC cold-spawns.</div>' +
|
|
449
449
|
'<div class="settings-stack" style="margin-bottom:12px">' +
|
|
450
450
|
settingsToggle('CC Worker Pool', 'set-ccUseWorkerPool', (e.ccUseWorkerPool === undefined ? ((e.ccCli || e.defaultCli) === 'copilot') : !!e.ccUseWorkerPool), 'Route Command Center / doc-chat through a persistent copilot --acp worker per tab instead of spawning a fresh CLI per turn. Copilot-only (Agent Client Protocol transport); Claude does not implement ACP, so this toggle has no effect when CC runtime is Claude. Default ON for copilot (cold-spawn ~20s on Windows); forced OFF for non-copilot CC runtimes regardless of this toggle.') +
|
|
451
|
-
settingsToggle('Agent Worker Pool', 'set-agentUseWorkerPool', e.agentUseWorkerPool
|
|
451
|
+
settingsToggle('Agent Worker Pool', 'set-agentUseWorkerPool', e.agentUseWorkerPool === true, 'Route fleet Copilot dispatches through persistent copilot --acp workers. Default OFF while the newer fleet-pool lifecycle path stabilizes; runtimes without ACP support ignore this setting.') +
|
|
452
452
|
// W-mq6f2fe0000557fa — opt-in: auto-kill orphan spawn-agent processes
|
|
453
453
|
// holding a stuck worktree's cwd. Gated by safe-reap criteria (cmdline
|
|
454
454
|
// matches spawn-agent.js + basename + age > agentTimeout * 2). Default
|
package/dashboard.js
CHANGED
|
@@ -1965,51 +1965,125 @@ function getEngineState() { return queries.getControl(); }
|
|
|
1965
1965
|
|
|
1966
1966
|
let _worktreeCountCache = 0;
|
|
1967
1967
|
let _worktreeCountCacheTs = 0;
|
|
1968
|
+
let _worktreeCountRefreshPromise = null;
|
|
1969
|
+
const WORKTREE_COUNT_SCAN_CONCURRENCY = 8;
|
|
1968
1970
|
|
|
1969
|
-
function
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1971
|
+
function _isMissingWorktreePathError(error) {
|
|
1972
|
+
return !!(error && (error.code === 'ENOENT' || error.code === 'ENOTDIR'));
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
async function _readWorktreeDirectory(dirPath) {
|
|
1976
|
+
try {
|
|
1977
|
+
return await fs.promises.readdir(dirPath, { withFileTypes: true });
|
|
1978
|
+
} catch (error) {
|
|
1979
|
+
if (_isMissingWorktreePathError(error)) return [];
|
|
1980
|
+
throw error;
|
|
1973
1981
|
}
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
async function _hasGitEntry(dirPath) {
|
|
1974
1985
|
try {
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
+
await fs.promises.access(path.join(dirPath, '.git'), fs.constants.F_OK);
|
|
1987
|
+
return true;
|
|
1988
|
+
} catch (error) {
|
|
1989
|
+
if (_isMissingWorktreePathError(error)) return false;
|
|
1990
|
+
throw error;
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
async function _mapWorktreeEntriesBounded(entries, worker) {
|
|
1995
|
+
if (entries.length === 0) return [];
|
|
1996
|
+
const results = new Array(entries.length);
|
|
1997
|
+
let nextIndex = 0;
|
|
1998
|
+
|
|
1999
|
+
async function runWorker() {
|
|
2000
|
+
while (nextIndex < entries.length) {
|
|
2001
|
+
const index = nextIndex++;
|
|
2002
|
+
results[index] = await worker(entries[index]);
|
|
1986
2003
|
}
|
|
2004
|
+
}
|
|
1987
2005
|
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2006
|
+
const workerCount = Math.min(WORKTREE_COUNT_SCAN_CONCURRENCY, entries.length);
|
|
2007
|
+
await Promise.all(Array.from({ length: workerCount }, () => runWorker()));
|
|
2008
|
+
return results;
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
async function _countWorktreesInRoot(worktreeRoot) {
|
|
2012
|
+
const entries = await _readWorktreeDirectory(worktreeRoot);
|
|
2013
|
+
const directories = entries.filter(entry => entry.isDirectory());
|
|
2014
|
+
const inspected = await _mapWorktreeEntriesBounded(directories, async entry => {
|
|
2015
|
+
const dirPath = path.join(worktreeRoot, entry.name);
|
|
2016
|
+
if (await _hasGitEntry(dirPath)) return { direct: 1, nested: [] };
|
|
2017
|
+
|
|
2018
|
+
const nestedEntries = await _readWorktreeDirectory(dirPath);
|
|
2019
|
+
return {
|
|
2020
|
+
direct: 0,
|
|
2021
|
+
nested: nestedEntries
|
|
2022
|
+
.filter(nestedEntry => nestedEntry.isDirectory())
|
|
2023
|
+
.map(nestedEntry => path.join(dirPath, nestedEntry.name)),
|
|
2024
|
+
};
|
|
2025
|
+
});
|
|
2026
|
+
const directCount = inspected.reduce((total, result) => total + result.direct, 0);
|
|
2027
|
+
const nestedPaths = inspected.flatMap(result => result.nested);
|
|
2028
|
+
const nestedMatches = await _mapWorktreeEntriesBounded(nestedPaths, _hasGitEntry);
|
|
2029
|
+
return directCount + nestedMatches.filter(Boolean).length;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
async function _scanWorktreeCount() {
|
|
2033
|
+
const config = queries.getConfig();
|
|
2034
|
+
const worktreeRoots = new Map();
|
|
2035
|
+
for (const project of shared.getProjects(config)) {
|
|
2036
|
+
if (!project.localPath) continue;
|
|
2037
|
+
const worktreeRoot = path.resolve(
|
|
2038
|
+
project.localPath,
|
|
2039
|
+
config.engine?.worktreeRoot || shared.ENGINE_DEFAULTS.worktreeRoot,
|
|
2040
|
+
);
|
|
2041
|
+
const rootKey = shared._normalizeWorktreePath(worktreeRoot);
|
|
2042
|
+
if (!worktreeRoots.has(rootKey)) worktreeRoots.set(rootKey, worktreeRoot);
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
let count = 0;
|
|
2046
|
+
for (const worktreeRoot of worktreeRoots.values()) {
|
|
2047
|
+
count += await _countWorktreesInRoot(worktreeRoot);
|
|
2048
|
+
}
|
|
2049
|
+
return count;
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function _refreshWorktreeCount() {
|
|
2053
|
+
if (_worktreeCountRefreshPromise) return _worktreeCountRefreshPromise;
|
|
2054
|
+
|
|
2055
|
+
_worktreeCountRefreshPromise = (async () => {
|
|
2056
|
+
try {
|
|
2057
|
+
const count = await _scanWorktreeCount();
|
|
2058
|
+
const changed = count !== _worktreeCountCache;
|
|
2059
|
+
_worktreeCountCache = count;
|
|
2060
|
+
_worktreeCountCacheTs = Date.now();
|
|
2061
|
+
// A cold outer rebuild may already have captured the previous count.
|
|
2062
|
+
if (changed && (_statusCache || _statusRebuildPromise)) invalidateStatusCache();
|
|
2063
|
+
return count;
|
|
2064
|
+
} catch (error) {
|
|
2065
|
+
_worktreeCountCacheTs = Date.now();
|
|
2066
|
+
console.warn(`[dashboard] worktree count refresh failed: ${error.message}`);
|
|
2067
|
+
return _worktreeCountCache;
|
|
2068
|
+
} finally {
|
|
2069
|
+
_worktreeCountRefreshPromise = null;
|
|
2008
2070
|
}
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2071
|
+
})();
|
|
2072
|
+
return _worktreeCountRefreshPromise;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
function _countWorktrees() {
|
|
2076
|
+
const now = Date.now();
|
|
2077
|
+
if (!_worktreeCountCacheTs || (now - _worktreeCountCacheTs) >= shared.ENGINE_DEFAULTS.worktreeCountCacheTtl) {
|
|
2078
|
+
_refreshWorktreeCount();
|
|
2079
|
+
}
|
|
2080
|
+
return _worktreeCountCache;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
function _resetWorktreeCountCacheForTesting() {
|
|
2084
|
+
_worktreeCountCache = 0;
|
|
2085
|
+
_worktreeCountCacheTs = 0;
|
|
2086
|
+
_worktreeCountRefreshPromise = null;
|
|
2013
2087
|
}
|
|
2014
2088
|
|
|
2015
2089
|
// ── npm update check ────────────────────────────────────────────────────────
|
|
@@ -13637,7 +13711,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13637
13711
|
}
|
|
13638
13712
|
}},
|
|
13639
13713
|
|
|
13640
|
-
{ method: 'POST', path: '/api/pr-action/offer-create-pr', desc: 'Offer a "Create PR" follow-up chip
|
|
13714
|
+
{ method: 'POST', path: '/api/pr-action/offer-create-pr', desc: 'Offer a "Create PR" follow-up chip for pre-existing operator changes in a configured project. Validates the project has uncommitted working-tree changes (git status --porcelain in project.localPath) and returns a create-pr follow-up chip. Returns {ok, hasChanges, branch, changedFileCount, followups}; followups is [] when there is nothing to PR. READ-ONLY: never stages/commits/pushes.', params: 'project (configured project name), branch (optional — defaults to the working-tree current branch), contextOnly (optional bool — link the new PR as context-only instead of auto-managed)', handler: async (req, res) => {
|
|
13641
13715
|
const body = await readBody(req);
|
|
13642
13716
|
try {
|
|
13643
13717
|
reloadConfig();
|
|
@@ -13658,10 +13732,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13658
13732
|
const hasChanges = changedFileCount > 0;
|
|
13659
13733
|
const branch = (typeof body?.branch === 'string' && body.branch.trim()) || currentBranch || '';
|
|
13660
13734
|
// The Create-PR action follows the SAME checkout pattern as a dispatch
|
|
13661
|
-
// for this project: 'live'
|
|
13662
|
-
//
|
|
13663
|
-
|
|
13664
|
-
const checkoutMode = shared.resolveCheckoutMode(project);
|
|
13735
|
+
// for this project: 'live' uses the live-checkout flow; 'worktree'
|
|
13736
|
+
// copies changes into an isolated worktree and preserves the source.
|
|
13737
|
+
const checkoutMode = shared.resolveCheckoutMode(project, shared.WORK_TYPE.IMPLEMENT);
|
|
13665
13738
|
// Resolve the project's actual main/base branch instead of trusting
|
|
13666
13739
|
// whatever `git rev-parse --abbrev-ref HEAD` returned as a safe base —
|
|
13667
13740
|
// the live checkout may be sitting on a stale/topic branch whose
|
|
@@ -13677,7 +13750,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13677
13750
|
}
|
|
13678
13751
|
}},
|
|
13679
13752
|
|
|
13680
|
-
{ method: 'POST', path: '/api/pr-action/prepare-create-pr-worktree', desc: '
|
|
13753
|
+
{ method: 'POST', path: '/api/pr-action/prepare-create-pr-worktree', desc: 'Copy a worktree-mode project\'s pre-existing operator changes into an ISOLATED git worktree without modifying the source checkout. Subsequent commit/push/PR operations happen only in the returned worktree. Returns {ok, worktreePath, branch, baseBranch, baseSha, trackedChanged, untrackedCount, sourceCheckoutPreserved}; {ok:false, reason:"no-changes"} when the tree is clean.', params: 'project (configured project name), branch (optional preferred branch name)', handler: async (req, res) => {
|
|
13681
13754
|
const body = await readBody(req);
|
|
13682
13755
|
try {
|
|
13683
13756
|
reloadConfig();
|
|
@@ -13688,6 +13761,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13688
13761
|
if (!project.localPath) return jsonReply(res, 400, { error: `project ${projectName} has no localPath` }, req);
|
|
13689
13762
|
const result = await createPrWorktree.prepareCreatePrWorktree({
|
|
13690
13763
|
project,
|
|
13764
|
+
projects: shared.getProjects(CONFIG),
|
|
13691
13765
|
branch: typeof body?.branch === 'string' ? body.branch : undefined,
|
|
13692
13766
|
minionsDir: MINIONS_DIR,
|
|
13693
13767
|
engine: CONFIG.engine,
|
|
@@ -13707,7 +13781,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13707
13781
|
const project = projectName ? shared.getProjects(CONFIG).find(p => p && p.name === projectName) : null;
|
|
13708
13782
|
const worktreePath = typeof body?.worktreePath === 'string' ? body.worktreePath.trim() : '';
|
|
13709
13783
|
if (!worktreePath) return jsonReply(res, 400, { error: 'worktreePath is required' }, req);
|
|
13710
|
-
const result = await createPrWorktree.cleanupCreatePrWorktree({
|
|
13784
|
+
const result = await createPrWorktree.cleanupCreatePrWorktree({
|
|
13785
|
+
worktreePath,
|
|
13786
|
+
project,
|
|
13787
|
+
projects: shared.getProjects(CONFIG),
|
|
13788
|
+
});
|
|
13711
13789
|
return jsonReply(res, 200, result, req);
|
|
13712
13790
|
} catch (e) {
|
|
13713
13791
|
return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
|
|
@@ -14766,6 +14844,7 @@ module.exports = {
|
|
|
14766
14844
|
_setStatusRefreshHook,
|
|
14767
14845
|
_resetStatusCacheForTesting,
|
|
14768
14846
|
_ifNoneMatchHasEtag,
|
|
14847
|
+
_countWorktrees, _refreshWorktreeCount, _scanWorktreeCount, _resetWorktreeCountCacheForTesting, // exported for testing
|
|
14769
14848
|
// W-status-swr — exported for direct unit tests of the subprocess-free git
|
|
14770
14849
|
// HEAD parser and the prefix-tolerant short-SHA comparison. No production
|
|
14771
14850
|
// caller imports these; they are test seams.
|
|
@@ -14829,6 +14908,14 @@ if (require.main === module) {
|
|
|
14829
14908
|
// block boot.
|
|
14830
14909
|
(async () => {
|
|
14831
14910
|
const _warmStartedAt = Date.now();
|
|
14911
|
+
// This reporting-only scan must never delay port binding.
|
|
14912
|
+
_refreshWorktreeCount()
|
|
14913
|
+
.then(worktreeCount => {
|
|
14914
|
+
console.log(`[boot] pre-warmed worktree count (${worktreeCount}) off the request path`);
|
|
14915
|
+
})
|
|
14916
|
+
.catch(error => {
|
|
14917
|
+
console.warn(`[boot] worktree count pre-warm failed: ${error && error.message}`);
|
|
14918
|
+
});
|
|
14832
14919
|
try {
|
|
14833
14920
|
await warmProjectGitStatusCache();
|
|
14834
14921
|
const ms = Date.now() - _warmStartedAt;
|
package/docs/keep-processes.md
CHANGED
|
@@ -22,7 +22,7 @@ Set `meta.keep_processes: true` on the WI; agent writes `agents/<id>/keep-pids.j
|
|
|
22
22
|
{
|
|
23
23
|
"pids": [12345], // ≤5 entries
|
|
24
24
|
"purpose": "gradle daemon for follow-up test WI",
|
|
25
|
-
"cwd": "
|
|
25
|
+
"cwd": "<MINIONS_AGENT_CWD>", // assigned dispatch root or a subdirectory
|
|
26
26
|
"ports": [8080], // advisory, engine doesn't bind
|
|
27
27
|
"expires_at": "2026-04-29T00:00:00Z", // TTL ≤1440 min; default 60
|
|
28
28
|
"written_by": "ripley",
|
|
@@ -42,6 +42,11 @@ Set `meta.keep_processes: true` on the WI; agent writes `agents/<id>/keep-pids.j
|
|
|
42
42
|
|
|
43
43
|
- **PIDs:** ≤5 per sidecar.
|
|
44
44
|
- **TTL:** ≤1440 min (24h). Default 60.
|
|
45
|
-
- **Cwd:** must exist
|
|
45
|
+
- **Cwd:** must exist, resolve inside a git worktree, and stay at or below the current dispatch root from `MINIONS_AGENT_CWD`. Monorepo subdirs are allowed. In worktree mode, pointing at `project.localPath` is rejected before the wrapper decides which descendants survive. `keep_processes` dispatches use the cold spawn wrapper rather than the ACP pool so descendant ownership remains enforceable.
|
|
46
|
+
|
|
47
|
+
After acceptance, the engine adds project/work-type/checkout-mode/dispatch-root
|
|
48
|
+
provenance to the sidecar. Boot sweep uses it to kill and unlink any retained
|
|
49
|
+
process whose cwd now overlaps a worktree-mode operator checkout (including
|
|
50
|
+
legacy sidecars from the old read-only-in-`localPath` behavior).
|
|
46
51
|
|
|
47
52
|
See also: [`CLAUDE.md`](../CLAUDE.md) → **Agent Spawn**, [managed-spawn.md](managed-spawn.md).
|
|
@@ -78,7 +78,7 @@ This is **DESTRUCTIVE** — tracked WIP and untracked files can be permanently u
|
|
|
78
78
|
|
|
79
79
|
#### 2c. Stale `.git/index.lock` self-heal (W-mr3tayu4)
|
|
80
80
|
|
|
81
|
-
Before any preflight git command runs, `prepareLiveCheckout` calls the shared, age-gated `shared.removeStaleIndexLock(localPath)` to clear a leftover `.git/index.lock` from a crashed/killed git process. Live mode never resets/cleans the operator tree, so nothing else would clear it, and — since `LIVE_CHECKOUT_FAILED` is retryable — every automatic retry used to hit the same stale lock and fail identically forever until an operator deleted it by hand. The remover only touches locks older than 5 minutes, so a lock held by a currently-running git process is never removed out from under it.
|
|
81
|
+
Before any preflight git command runs, `prepareLiveCheckout` calls the shared, age-gated `shared.removeStaleIndexLock(localPath)` to clear a leftover `.git/index.lock` from a crashed/killed git process. Live mode never resets/cleans the operator tree, so nothing else would clear it, and — since `LIVE_CHECKOUT_FAILED` is retryable — every automatic retry used to hit the same stale lock and fail identically forever until an operator deleted it by hand. The remover only touches locks older than 5 minutes, so a lock held by a currently-running git process is never removed out from under it. Worktree-mode setup never calls this helper against `project.localPath`.
|
|
82
82
|
|
|
83
83
|
### 3. No implicit remote sync or force checkout
|
|
84
84
|
|
|
@@ -214,7 +214,7 @@ Enable it fleet-wide via **Settings → "Live-checkout auto-base-repair (fleet-w
|
|
|
214
214
|
|
|
215
215
|
## Dispatch-end auto-restore
|
|
216
216
|
|
|
217
|
-
Live-mode agents run **in-place** in the operator's checkout, so when a dispatch ends the engine switches the tree back to the ref it was on before the agent ran. This runs on
|
|
217
|
+
Live-mode agents run **in-place** in the operator's checkout, so when a dispatch ends the engine switches the tree back to the ref it was on before the agent ran. This runs on terminal results and restart-recovery reaping paths through `maybeRestoreLiveCheckoutFromRecord`. Restore now requires both persisted `checkoutMode: "live"` provenance (legacy records may use `originalRef`) and a current configured mode that still resolves live for the dispatch type. A worktree marker or current worktree mode fails closed before any Git probe.
|
|
218
218
|
|
|
219
219
|
- **Original-ref capture.** `prepareLiveCheckout` records the operator's starting ref *before* the first checkout: `git symbolic-ref --short HEAD` → `{ originalRef:<branch>, originalRefType:'branch' }`, falling back to `git rev-parse HEAD` → `{ originalRef:<sha>, originalRefType:'detached' }`. `spawnAgent` persists `originalRef` / `originalRefType` onto the dispatch record via `mutateDispatch`, so the restore survives an engine restart, where the in-memory spawn closure is gone and only the persisted record remains.
|
|
220
220
|
- **Self-healing dirty recovery (PL-live-checkout-reliability-hardening).** Before the plain checkout, if the tree is **dirty AND HEAD is on the agent branch** (and that branch differs from `originalRef`), the dirt is provably **agent-authored** — the engine created that branch and verified the tree clean before switching to it. The leftovers are committed onto the **agent branch** (`git add -A` + `git commit --no-verify -m "minions: auto-save agent WIP (dispatch …)"`), so the plain `git checkout <originalRef>` then succeeds and the operator tree returns clean. This is the fix for the *"no recovery from dirty checkout"* deadlock, where an agent's crash-leftover WIP refused the restore, stranded the tree dirty on the agent branch, and then made **every future WI** for that project fail `LIVE_CHECKOUT_DIRTY` until a human cleaned it. It only ever mutates the engine-created branch, never the operator's branch, never discards (the WIP lands as a visible, revertable commit / on its PR), and gitignored artifacts are never staged (so a tree dirty only with ignored build output never reaches here). Best-effort: a failed auto-commit falls through to the manual-recovery alert below.
|
|
@@ -229,7 +229,7 @@ The core invariant holds end-to-end through restore: **the engine only ever swit
|
|
|
229
229
|
|
|
230
230
|
## Create-PR chip safety (CC-initiated, separate from the dispatch path)
|
|
231
231
|
|
|
232
|
-
|
|
232
|
+
Command Center never creates edits inside a configured project's `localPath`. The **"Create PR"** chip is only for pre-existing operator changes. In worktree mode, `prepareCreatePrWorktree` creates a fresh branch from `origin/<mainBranch>` (falling back to local main when no remote exists), applies only the uncommitted diff/untracked files, and leaves the source checkout unchanged. Topic commits from the operator's current branch cannot leak into the PR. Live-mode Create-PR remains a deliberate in-place flow and retains the base-branch protections below.
|
|
233
233
|
|
|
234
234
|
**The bug it fixes (W-mr3jbpru).** The old live-mode chip message keyed its branch instruction off `git rev-parse --abbrev-ref HEAD` (the *current* branch) and only told the agent to branch off cleanly **if** that current branch already happened to be the project's main branch. If the checkout was on any other branch, the message told the agent to commit the new changes **directly onto that branch** — silently carrying all of that branch's unrelated commits into the new PR. There was no engine-side verification that the current branch was clean or at parity with main; it was trusted blindly. A user hit exactly this: a Create-PR chip PR ended up built on top of a leftover topic branch and carried unrelated diffs.
|
|
235
235
|
|
|
@@ -240,7 +240,7 @@ The Command Center **"Create PR"** chip (`dashboard.js` → `POST /api/pr-action
|
|
|
240
240
|
3. If `git stash pop` reports a merge conflict (the stashed diff doesn't cleanly apply onto the fresh main tip), the message instructs the agent to **stop and surface the conflict** to the user rather than force-resolving it silently.
|
|
241
241
|
4. The message **names the resolved main branch explicitly** (e.g. `origin/android`) instead of leaving "the project main branch" vague, so the always-branch-off-resolved-main rule is stated unambiguously.
|
|
242
242
|
|
|
243
|
-
This mirrors, in CC-instruction form, what `prepareLiveCheckout` enforces engine-side for the dispatch path: never reuse whatever branch the live checkout happened to be on as the base for new work. The **worktree-mode** branch
|
|
243
|
+
This mirrors, in CC-instruction form, what `prepareLiveCheckout` enforces engine-side for the dispatch path: never reuse whatever branch the live checkout happened to be on as the base for new work. The **worktree-mode** branch copies existing changes to an isolated worktree without modifying the operator checkout.
|
|
244
244
|
|
|
245
245
|
## Operator workflow
|
|
246
246
|
|
|
@@ -362,11 +362,11 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
|
|
|
362
362
|
|---|---|
|
|
363
363
|
| `engine/shared.js` — `CHECKOUT_MODES`, `validateCheckoutMode`, `resolveCheckoutMode`, `isLiveCheckoutProject` | Enum + validator + back-compat resolver (P-a3f9b201; consolidated W-mqiaw974). |
|
|
364
364
|
| `engine/shared.js` — `resolveLiveCheckoutAutoReset` + `ENGINE_DEFAULTS.liveCheckoutAutoReset` | Pure precedence resolver (per-project boolean > fleet-wide engine default > false) + the fleet-wide default (OFF as of W-mrawgw4q000a6bff — was ON as of W-mqzbbhn2). Gates the dirty-tree auto-reset in `prepareLiveCheckout` (W-mqvejug6000eeb20); in `engine.js spawnAgent` only fires as a fallback after auto-stash fails/is disabled (W-mrawgw4q000a6bff). |
|
|
365
|
-
| `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live
|
|
365
|
+
| `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live dispatches; project-bound worktree-mode read-only types request detached worktree placement. |
|
|
366
366
|
| `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper by default: pre-mutation mid-operation / detached-HEAD preflight, original-ref capture, dirty check, **already-on-branch fast path**, `refs/heads/<branch>` existence check, branch resolution from HEAD (no fetch — issue #226), **no-half-switch + `blob-fetch`/`worktree-conflict` classification**, and opt-in destructive WIP cleanup (`reset --hard HEAD` + `clean -fd` + re-check + audit note) that preserves the branch and committed history. Git probes use a **50 MB maxBuffer**. |
|
|
367
367
|
| `engine/live-checkout.js` — `restoreLiveCheckoutAtDispatchEnd` | Dispatch-end auto-restore (plain `git checkout <originalRef>`, never `--force`/reset/clean/stash, best-effort) + **self-healing dirty recovery** (auto-commit agent WIP onto the agent branch) + `live-checkout-failed-<dispatchId>` terminal-failure alert + `live-checkout-branch-<dispatchId>` fallback notify (now also on unexpected restore errors) (P-d9e6b2c4; self-heal PL-live-checkout-reliability-hardening). |
|
|
368
368
|
| `engine/live-checkout.js` — `resolveLiveCheckoutAutoStash`, `performLiveCheckoutAutoStash`, `applyLiveCheckoutAutoStash` | Auto-stash resolver (per-project boolean, then fleet setting, then default true) + `git stash push --include-untracked` runner + stash/re-preflight orchestration. Engine never auto-pops (W-mqtvnnj1000357fa). |
|
|
369
|
-
| `engine/live-checkout.js` — `maybeRestoreLiveCheckoutFromRecord` | Shared
|
|
369
|
+
| `engine/live-checkout.js` — `maybeRestoreLiveCheckoutFromRecord` | Shared persisted-record restore wrapper; requires explicit live provenance plus current live configuration and fails closed for worktree mode. |
|
|
370
370
|
| `engine.js` — `spawnAgent` live-mode block | Calls `prepareLiveCheckout`, delegates opt-in auto-stash to `applyLiveCheckoutAutoStash` on a dirty tree (W-mqtvnnj1000357fa), handles dirty / throw branches, gates `git worktree add` on `!liveMode` (P-a3f9b204). |
|
|
371
371
|
| `engine.js` — `spawnAgent` mid-op / detached-HEAD refusal block | Emits `LIVE_CHECKOUT_MID_OPERATION`, writes `live-checkout-blocked-<wi-id>` alert, stamps `_pendingReason: 'live_checkout_mid_operation'` / `'live_checkout_detached_head'` (P-c5a1f3b8). |
|
|
372
372
|
| `engine.js` — `spawnAgent` originalRef persistence | Persists `originalRef` / `originalRefType` onto the dispatch record via `mutateDispatch` so restore survives an engine restart (P-c5a1f3b8). |
|
|
@@ -376,7 +376,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
|
|
|
376
376
|
| `engine.js` — dispatcher `liveProjectsInUse` set | Per-project mutating-concurrency cap (P-a3f9b205). |
|
|
377
377
|
| `engine.js` — worktree-pool / orphan-GC short-circuits | `worktreePath===null` no-ops in live mode (P-a3f9b206). |
|
|
378
378
|
| `engine/cleanup.js` — `runPeriodicWorktreeSweep` live filter | Excludes live-checkout projects from the registry-derived periodic worktree GC so the operator's primary checkout never enters the GC decision surface (PL-live-checkout-reliability-hardening). |
|
|
379
|
-
| `engine/create-pr-worktree.js` — `prepareCreatePrWorktree`
|
|
379
|
+
| `engine/create-pr-worktree.js` — `prepareCreatePrWorktree` | Copies pre-existing operator changes into an isolated worktree and returns `sourceCheckoutPreserved: true`; never resets, cleans, checks out, or deletes source files. |
|
|
380
380
|
| `engine/pr-action.js` — `buildCreatePrFollowups({ …, mainBranch })` live-mode message | CC "Create PR" chip instruction. In live mode the PR is ALWAYS built on a fresh branch off `origin/<mainBranch>` (stash → fetch → `checkout -B` → pop → commit → push → PR → restore), never reusing the current branch as the base; stop-on-stash-pop-conflict. `dashboard.js` `POST /api/pr-action/offer-create-pr` resolves `mainBranch` via `shared.resolveMainBranch` and threads it in (W-mr3jbpru). |
|
|
381
381
|
| `dashboard/js/settings.js` — checkoutMode dropdown + chip + `set-liveCheckoutAutoReset` fleet toggle | Operator-facing UI; the fleet-wide auto-reset toggle persists to `engine.liveCheckoutAutoReset` (per-project UI deferred — config.json only) (P-a3f9b207; auto-reset toggle W-mqvejug6000eeb20). |
|
|
382
382
|
| `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring,live-checkout-auto-stash,live-checkout-stash-before-reset}.test.js` | Wiring and contract tests (P-a3f9b208; auto-stash helpers W-mqtvnnj1000357fa; stash-before-reset ordering W-mrawgw4q000a6bff). |
|
package/docs/managed-spawn.md
CHANGED
|
@@ -35,7 +35,7 @@ The sidecar lives at `<MINIONS_DIR>/agents/<agentId>/managed-spawn.json` and is
|
|
|
35
35
|
"name": "constellation-host", // kebab-case, ≤64 chars, unique within file
|
|
36
36
|
"cmd": "bun", // must be on engine.managedSpawn.executableAllowlist
|
|
37
37
|
"args": ["run", "dev"], // ≤64 entries
|
|
38
|
-
"cwd": "
|
|
38
|
+
"cwd": "<MINIONS_AGENT_CWD>", // assigned dispatch root or a subdirectory
|
|
39
39
|
"env": { "CONSTELLATION_SERVER": "http://localhost:3000" }, // ≤32 keys; POSIX-shape + denylist enforced
|
|
40
40
|
"ports": [3001], // 1024-65535; ≤20 per spec; advisory only (engine doesn't bind)
|
|
41
41
|
"ttl_minutes": 240, // ≤1440 (24h hard cap); defaults to 720 (12h)
|
|
@@ -59,6 +59,19 @@ The sidecar lives at `<MINIONS_DIR>/agents/<agentId>/managed-spawn.json` and is
|
|
|
59
59
|
|
|
60
60
|
The renderer in [`buildManagedSpawnHint`](../engine/managed-spawn.js) at `engine/managed-spawn.js:419` emits this exact shape (with allowlist + cap reminders) into the agent's prompt whenever the work item has `meta.managed_spawn: true`. Treat the rendered hint as the source of truth — if this doc and the hint drift, the hint wins.
|
|
61
61
|
|
|
62
|
+
The engine also confines every spec to the dispatch root: `cwd` must resolve at
|
|
63
|
+
or below `MINIONS_AGENT_CWD`. In worktree mode, `project.localPath` is rejected
|
|
64
|
+
even though it is a valid Git checkout. Project-specific env deny patterns are
|
|
65
|
+
resolved from the dispatch's configured project, not inferred from the sibling
|
|
66
|
+
worktree path.
|
|
67
|
+
|
|
68
|
+
Persisted managed-process rows carry the canonical dispatch root and originating
|
|
69
|
+
work type. Manual restart and boot reconciliation revalidate that provenance
|
|
70
|
+
against current project checkout mode; a legacy or live-to-worktree record that
|
|
71
|
+
would restart inside `project.localPath` is killed and removed instead. The
|
|
72
|
+
periodic sweep applies the same check, so mode drift is enforced immediately
|
|
73
|
+
rather than waiting for TTL or another engine restart.
|
|
74
|
+
|
|
62
75
|
### Smoke-test before writing the sidecar (mandatory, W-mpbpexrg00110661)
|
|
63
76
|
|
|
64
77
|
The hint mandates that agents run each `cmd args` in the declared `cwd` for at least 5 seconds AND confirm the healthcheck endpoint returns the expected status BEFORE writing `managed-spawn.json`. Sidecars written from guessed-at commands are how dispatches silently lose specs: the engine spawns what the agent declared; if the command crashes on first launch, the engine kills the spec, removes it from state, and (for partial failures) marks the WI with `_managedSpawnPartial` rather than demoting it — the agent's primary work succeeded by validator standards. The cost of guessing wrong is the operator finding out hours later that half the stack is down.
|
package/docs/runtime-adapters.md
CHANGED
|
@@ -159,12 +159,15 @@ handlers, PID-file writing, live-output.log, timeout/steering cancel-vs-kill,
|
|
|
159
159
|
restart-reattach) runs unmodified regardless of whether `proc` is a real OS
|
|
160
160
|
process or a pooled lease. Assignment strategy, in order:
|
|
161
161
|
|
|
162
|
-
1. A **free** worker whose MCP-server set
|
|
163
|
-
via a fresh `session/new`, no
|
|
162
|
+
1. A **free** worker whose MCP-server set, process environment, and canonical
|
|
163
|
+
process cwd already match wins first — reused via a fresh `session/new`, no
|
|
164
|
+
respawn.
|
|
164
165
|
2. Otherwise another free worker is **evicted** (closed) and a replacement is
|
|
165
166
|
spawned fresh with the requested MCP servers/cwd (MCP servers are resolved
|
|
166
|
-
at `copilot --acp` process boot, so a changed server set
|
|
167
|
-
respawn, not just a new session).
|
|
167
|
+
at `copilot --acp` process boot, so a changed server set or process cwd
|
|
168
|
+
requires a full respawn, not just a new session). This prevents a worker OS
|
|
169
|
+
process rooted in a live operator checkout from carrying that cwd into an
|
|
170
|
+
isolated worktree dispatch.
|
|
168
171
|
3. Otherwise, if the pool has room (fewer than `agentAcpPoolSize` workers
|
|
169
172
|
allocated across free + busy + in-flight-spawn), a brand-new worker spawns.
|
|
170
173
|
4. Otherwise the request queues FIFO until a `release()` or worker crash frees
|
|
@@ -11,6 +11,28 @@ Lifecycle keeps the cross-cutting invariants; the detail lives here.
|
|
|
11
11
|
> `_killGitDescendantsForWorktree`, `pruneOrphanWorktrees*`, `gcDispatchWorktreeIfOrphan`),
|
|
12
12
|
> `engine/cleanup.js`. Last verified: 2026-06-09.
|
|
13
13
|
|
|
14
|
+
## Operator-checkout boundary
|
|
15
|
+
|
|
16
|
+
For `checkoutMode: "worktree"`, `project.localPath` is an operator-owned source
|
|
17
|
+
checkout, not an execution directory. Project-bound read-only and mutating
|
|
18
|
+
dispatches both run in engine-owned worktrees; Command Center delegates writes;
|
|
19
|
+
Create-PR staging copies existing operator changes without resetting or deleting
|
|
20
|
+
the source; restart restore requires explicit live dispatch provenance plus
|
|
21
|
+
current live configuration. Branchless project dispatches receive detached
|
|
22
|
+
worktrees, and path containment resolves symlinks/junctions before accepting a
|
|
23
|
+
worktree or `meta.workdir`. Placement and removal are checked against **every**
|
|
24
|
+
configured `localPath`, so a nested project's worktree cannot land inside a
|
|
25
|
+
parent project's operator checkout. Restart reattachment requires either a
|
|
26
|
+
validated persisted worktree path or current live-mode provenance; unsafe
|
|
27
|
+
legacy processes are terminated and retried. Worktree setup may update shared Git refs/metadata,
|
|
28
|
+
but it never checks out, resets, cleans, writes files, or removes
|
|
29
|
+
`.git/index.lock` in the operator checkout.
|
|
30
|
+
|
|
31
|
+
Worktrees that host live `managed_spawn` or `keep_processes` cwd records are
|
|
32
|
+
anchored across dispatch-end, periodic, boot, in-root, and out-of-root GC.
|
|
33
|
+
Anchor comparisons use canonical filesystem paths so junction/case aliases do
|
|
34
|
+
not make a live service's worktree look orphaned.
|
|
35
|
+
|
|
14
36
|
## Live-worktree guard (W-mq5rwwss000f30a7)
|
|
15
37
|
|
|
16
38
|
**Invariant:** every code path that wipes, renames, or recycles a worktree
|