@yemi33/minions 0.1.2383 → 0.1.2384
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/js/refresh.js +2 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard.js +11 -7
- 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/engine/cli.js
CHANGED
|
@@ -653,6 +653,12 @@ const commands = {
|
|
|
653
653
|
|
|
654
654
|
const hadPid = agentPid && agentPid > 0; // track before liveness check
|
|
655
655
|
if (agentPid && agentPid > 0 && !isPidAlive(agentPid)) agentPid = null;
|
|
656
|
+
if (agentPid && !shared.isDispatchExecutionPathSafe(item, config)) {
|
|
657
|
+
e.log('warn', `Reattach: refusing ${item.id} — persisted execution path is not safe under current checkout config`);
|
|
658
|
+
try { shared.killImmediate({ pid: agentPid }); }
|
|
659
|
+
catch (err) { e.log('warn', `Reattach: failed to terminate unsafe dispatch ${item.id}: ${err.message}`); }
|
|
660
|
+
agentPid = null;
|
|
661
|
+
}
|
|
656
662
|
|
|
657
663
|
// PID was found but confirmed dead — exempt from restart grace period (#869)
|
|
658
664
|
if (hadPid && !agentPid) {
|
|
@@ -812,18 +818,14 @@ const commands = {
|
|
|
812
818
|
);
|
|
813
819
|
} catch (err) { e.log('warn', `Orphan dispatch complete: ${err.message}`); }
|
|
814
820
|
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
|
|
819
|
-
// carries no checkoutMode. Mirrors the in-process onAgentClose wiring
|
|
820
|
-
// (engine.js). Fire-and-forget + best-effort: the helper swallows its
|
|
821
|
-
// own errors and only ever issues a plain `git checkout <originalRef>`
|
|
822
|
-
// (never --force/reset/clean/stash) against the operator tree.
|
|
823
|
-
if (item.originalRef && item.meta?.branch && item.meta?.project?.localPath) {
|
|
821
|
+
// Restore only records explicitly dispatched live and still configured
|
|
822
|
+
// live. Worktree mode fails closed even if stale state carries
|
|
823
|
+
// originalRef fields.
|
|
824
|
+
if (shared.isLiveCheckoutDispatchRecord(item, config)) {
|
|
824
825
|
try {
|
|
825
826
|
require('./live-checkout').maybeRestoreLiveCheckoutFromRecord({
|
|
826
827
|
item,
|
|
828
|
+
config,
|
|
827
829
|
isTerminalFailure: !isSuccess,
|
|
828
830
|
resultLabel: result,
|
|
829
831
|
log: (lvl, msg) => e.log(lvl, msg),
|
|
@@ -1201,6 +1203,38 @@ const commands = {
|
|
|
1201
1203
|
const timeout = config.engine?.shutdownTimeout || shared.ENGINE_DEFAULTS.shutdownTimeout;
|
|
1202
1204
|
const deadline = Date.now() + timeout;
|
|
1203
1205
|
|
|
1206
|
+
async function cancelTimedOutPooled(ids) {
|
|
1207
|
+
const closing = [];
|
|
1208
|
+
for (const id of ids) {
|
|
1209
|
+
const procInfo = e.activeProcesses.get(id);
|
|
1210
|
+
if (!procInfo?._pooled) continue;
|
|
1211
|
+
try {
|
|
1212
|
+
procInfo.proc.kill();
|
|
1213
|
+
if (typeof procInfo.proc.waitForClose === 'function') {
|
|
1214
|
+
closing.push(procInfo.proc.waitForClose());
|
|
1215
|
+
}
|
|
1216
|
+
} catch (err) {
|
|
1217
|
+
e.log('warn', `Failed to cancel pooled dispatch ${id} during shutdown: ${err.message}`);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
await Promise.allSettled(closing);
|
|
1221
|
+
|
|
1222
|
+
const stillActive = new Set((getDispatch().active || []).map(item => item.id));
|
|
1223
|
+
for (const id of ids) {
|
|
1224
|
+
e.activeProcesses.delete(id);
|
|
1225
|
+
e.realActivityMap.delete(id);
|
|
1226
|
+
if (!stillActive.has(id)) continue;
|
|
1227
|
+
e.completeDispatch(
|
|
1228
|
+
id,
|
|
1229
|
+
DISPATCH_RESULT.ERROR,
|
|
1230
|
+
'Pooled dispatch cancelled after graceful shutdown timeout',
|
|
1231
|
+
'',
|
|
1232
|
+
{ failureClass: FAILURE_CLASS.TIMEOUT, retryable: true },
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
finishShutdown(`Shutdown timeout (${timeout / 1000}s) — cancelled ${ids.length} pooled agent(s) for retry.`, 1);
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1204
1238
|
const poll = setInterval(() => {
|
|
1205
1239
|
pooledIds = getOutstandingPooledIds();
|
|
1206
1240
|
if (pooledIds.length === 0 && agentWorkerPool.isDrained()) {
|
|
@@ -1210,24 +1244,10 @@ const commands = {
|
|
|
1210
1244
|
}
|
|
1211
1245
|
if (Date.now() >= deadline) {
|
|
1212
1246
|
clearInterval(poll);
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
e.log('warn', `Failed to cancel pooled dispatch ${id} during shutdown: ${err.message}`);
|
|
1218
|
-
}
|
|
1219
|
-
e.activeProcesses.delete(id);
|
|
1220
|
-
e.realActivityMap.delete(id);
|
|
1221
|
-
}
|
|
1222
|
-
e.completeDispatch(
|
|
1223
|
-
id,
|
|
1224
|
-
DISPATCH_RESULT.ERROR,
|
|
1225
|
-
'Pooled dispatch cancelled after graceful shutdown timeout',
|
|
1226
|
-
'',
|
|
1227
|
-
{ failureClass: FAILURE_CLASS.TIMEOUT, retryable: true },
|
|
1228
|
-
);
|
|
1229
|
-
}
|
|
1230
|
-
finishShutdown(`Shutdown timeout (${timeout / 1000}s) — cancelled ${pooledIds.length} pooled agent(s) for retry.`, 1);
|
|
1247
|
+
void cancelTimedOutPooled(pooledIds).catch((err) => {
|
|
1248
|
+
e.log('error', `Pooled shutdown cleanup failed: ${err.message}`);
|
|
1249
|
+
finishShutdown(`Shutdown cleanup failed after timeout: ${err.message}`, 1);
|
|
1250
|
+
});
|
|
1231
1251
|
}
|
|
1232
1252
|
}, 2000);
|
|
1233
1253
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* engine/create-pr-worktree.js — checkout-mode-aware Create-PR staging.
|
|
3
3
|
*
|
|
4
|
-
* Background (the bug this fixes): the CC "Create PR" flow
|
|
4
|
+
* Background (the bug this fixes): the CC "Create PR" flow used to have the LLM commit /
|
|
5
5
|
* branch / push directly in the project's LIVE operator checkout
|
|
6
6
|
* (`project.localPath`). For a `checkoutMode: "worktree"` project that is wrong —
|
|
7
7
|
* a misstep by the LLM (committing before branching, freelancing `git pull`)
|
|
@@ -10,16 +10,13 @@
|
|
|
10
10
|
* - `live` → operate in the live checkout (handled by the existing flow);
|
|
11
11
|
* - `worktree` → operate in an isolated git worktree, never the live checkout.
|
|
12
12
|
*
|
|
13
|
-
* This module implements the `worktree` path. `prepareCreatePrWorktree`
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* git correctly. CC is then told to commit / push / open the PR inside the
|
|
18
|
-
* returned worktree path, and to call `cleanupCreatePrWorktree` when done.
|
|
13
|
+
* This module implements the `worktree` path. `prepareCreatePrWorktree` copies
|
|
14
|
+
* pre-existing operator edits into a fresh worktree created from the configured
|
|
15
|
+
* main tip. The source checkout is never reset, cleaned, checked out, or deleted
|
|
16
|
+
* from; the operator remains the sole owner of that tree.
|
|
19
17
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
* the live checkout is left exactly as it was — the operator never loses work.
|
|
18
|
+
* If applying the diff fails, the worktree is removed and the source checkout is
|
|
19
|
+
* left exactly as it was.
|
|
23
20
|
*/
|
|
24
21
|
|
|
25
22
|
const fs = require('fs');
|
|
@@ -29,16 +26,15 @@ const shared = require('./shared');
|
|
|
29
26
|
const PATCH_APPLY_TIMEOUT_MS = 120000;
|
|
30
27
|
|
|
31
28
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
29
|
+
* Copy a worktree-mode project's uncommitted operator changes into an isolated
|
|
30
|
+
* worktree without modifying the source checkout.
|
|
34
31
|
*
|
|
35
32
|
* @returns {Promise<{ ok:true, worktreePath, branch, baseBranch, baseSha,
|
|
36
33
|
* trackedChanged:boolean, untrackedCount:number } | { ok:false, reason:string }>}
|
|
37
|
-
* @throws on a git/fs failure
|
|
38
|
-
* untouched (the operator keeps their work).
|
|
34
|
+
* @throws on a git/fs failure; the source checkout remains untouched.
|
|
39
35
|
*/
|
|
40
36
|
async function prepareCreatePrWorktree({
|
|
41
|
-
project, branch, minionsDir, engine, log = () => {}, _git, _fs,
|
|
37
|
+
project, projects, branch, minionsDir, engine, log = () => {}, _git, _fs,
|
|
42
38
|
} = {}) {
|
|
43
39
|
const git = _git || ((args, opts) => shared.shellSafeGit(args, opts));
|
|
44
40
|
const fsm = _fs || fs;
|
|
@@ -49,12 +45,15 @@ async function prepareCreatePrWorktree({
|
|
|
49
45
|
if (!localPath) {
|
|
50
46
|
throw new Error('prepareCreatePrWorktree: project.localPath required');
|
|
51
47
|
}
|
|
48
|
+
if (shared.resolveCheckoutMode(project, shared.WORK_TYPE.IMPLEMENT) !== shared.CHECKOUT_MODES.WORKTREE) {
|
|
49
|
+
throw new Error('prepareCreatePrWorktree: project must use checkoutMode "worktree"');
|
|
50
|
+
}
|
|
52
51
|
const mainBranch = (project.mainBranch && String(project.mainBranch).trim()) || 'main';
|
|
53
52
|
|
|
54
|
-
// 1. Capture the uncommitted state from the
|
|
55
|
-
const
|
|
56
|
-
if (!/^[0-9a-f]{7,40}$/i.test(
|
|
57
|
-
throw new Error(`prepareCreatePrWorktree: could not resolve HEAD in ${localPath} (got ${JSON.stringify(
|
|
53
|
+
// 1. Capture the uncommitted state from the protected operator checkout.
|
|
54
|
+
const sourceHeadSha = (await git(['-C', localPath, 'rev-parse', 'HEAD'])).trim();
|
|
55
|
+
if (!/^[0-9a-f]{7,40}$/i.test(sourceHeadSha)) {
|
|
56
|
+
throw new Error(`prepareCreatePrWorktree: could not resolve HEAD in ${localPath} (got ${JSON.stringify(sourceHeadSha)})`);
|
|
58
57
|
}
|
|
59
58
|
// `git diff HEAD --binary` captures staged + unstaged tracked changes
|
|
60
59
|
// (including deletions and binary deltas) as a single patch.
|
|
@@ -70,9 +69,29 @@ async function prepareCreatePrWorktree({
|
|
|
70
69
|
return { ok: false, reason: 'no-changes' };
|
|
71
70
|
}
|
|
72
71
|
|
|
73
|
-
// 2.
|
|
74
|
-
//
|
|
75
|
-
//
|
|
72
|
+
// 2. Resolve the configured main tip without changing the operator checkout.
|
|
73
|
+
// Prefer the fetched remote-tracking ref; local-only repos fall back to the
|
|
74
|
+
// local main branch.
|
|
75
|
+
try {
|
|
76
|
+
await git(['-C', localPath, 'fetch', 'origin', mainBranch], { timeout: 30000 });
|
|
77
|
+
} catch (e) {
|
|
78
|
+
log('warn', `[cc-create-pr] fetch origin ${mainBranch} failed; trying local ${mainBranch}: ${e.message}`);
|
|
79
|
+
}
|
|
80
|
+
let baseSha = '';
|
|
81
|
+
for (const ref of [`refs/remotes/origin/${mainBranch}`, `refs/heads/${mainBranch}`]) {
|
|
82
|
+
try {
|
|
83
|
+
baseSha = (await git(['-C', localPath, 'rev-parse', '--verify', ref])).trim();
|
|
84
|
+
} catch { /* try next base */ }
|
|
85
|
+
if (/^[0-9a-f]{7,40}$/i.test(baseSha)) break;
|
|
86
|
+
baseSha = '';
|
|
87
|
+
}
|
|
88
|
+
if (!baseSha) {
|
|
89
|
+
throw new Error(`prepareCreatePrWorktree: could not resolve origin/${mainBranch} or local ${mainBranch}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Create a fresh isolated branch from main, then apply only the captured
|
|
93
|
+
// uncommitted changes. Topic commits currently checked out by the operator
|
|
94
|
+
// never enter the PR.
|
|
76
95
|
const uid = shared.uid();
|
|
77
96
|
const sanitized = String(project.name || 'project').replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 40) || 'project';
|
|
78
97
|
const requested = branch && String(branch).trim();
|
|
@@ -86,6 +105,7 @@ async function prepareCreatePrWorktree({
|
|
|
86
105
|
const wtRel = (engine && engine.worktreeRoot) || shared.ENGINE_DEFAULTS.worktreeRoot;
|
|
87
106
|
const worktreesBase = path.resolve(projectRoot, wtRel);
|
|
88
107
|
const wtPath = path.join(worktreesBase, `cc-createpr-${sanitized}-${uid}`);
|
|
108
|
+
shared.assertWorktreeOutsideProjects(wtPath, Array.isArray(projects) ? projects : [project]);
|
|
89
109
|
try { fsm.mkdirSync(worktreesBase, { recursive: true }); } catch { /* best-effort; worktree add will surface a real failure */ }
|
|
90
110
|
|
|
91
111
|
await git(
|
|
@@ -94,8 +114,8 @@ async function prepareCreatePrWorktree({
|
|
|
94
114
|
);
|
|
95
115
|
try { shared.writeWorktreeOwnerMarker(wtPath, { source: 'cc-create-pr', project: project.name }); } catch { /* marker is best-effort */ }
|
|
96
116
|
|
|
97
|
-
//
|
|
98
|
-
// fails, tear the worktree down and leave the
|
|
117
|
+
// 4. Reproduce the operator-checkout changes inside the worktree. If anything
|
|
118
|
+
// here fails, tear the worktree down and leave the source untouched.
|
|
99
119
|
try {
|
|
100
120
|
if (hasTracked) {
|
|
101
121
|
const patchFile = path.join(wtPath, `.cc-createpr-${uid}.patch`);
|
|
@@ -111,64 +131,15 @@ async function prepareCreatePrWorktree({
|
|
|
111
131
|
}
|
|
112
132
|
} catch (e) {
|
|
113
133
|
// Tear the worktree down via shared.removeWorktree (EPERM/EBUSY retry +
|
|
114
|
-
// escalation + real-repo refusal — CLAUDE.md footgun #6).
|
|
115
|
-
// has NOT been touched yet at this point, so the operator keeps their work.
|
|
134
|
+
// escalation + real-repo refusal — CLAUDE.md footgun #6).
|
|
116
135
|
try { shared.removeWorktree(wtPath, projectRoot, worktreesBase); } catch { /* leak rather than throw a second error */ }
|
|
117
136
|
throw new Error(
|
|
118
137
|
`prepareCreatePrWorktree: failed to stage changes into the worktree — ${e.message}. ` +
|
|
119
|
-
'The
|
|
120
|
-
);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// 4. The worktree now holds the changes — restore the live checkout clean.
|
|
124
|
-
// The captured edits are SAFE in the worktree (step 3 verified), so the
|
|
125
|
-
// documented job here is to return the live checkout to a clean HEAD.
|
|
126
|
-
// Use `git reset --hard HEAD` (NOT `git checkout -- .`): the old
|
|
127
|
-
// working-tree-only revert left the STAGED INDEX intact, so any change CC
|
|
128
|
-
// had `git add`-ed stayed staged → the live checkout was reported "restored"
|
|
129
|
-
// while still dirty, wedging the next Create-PR / live dispatch (the
|
|
130
|
-
// operator's "no recovery from dirty checkout"). `reset --hard` reverts
|
|
131
|
-
// staged + unstaged tracked changes in one shot; untracked entries are
|
|
132
|
-
// removed surgically below (only the ones we captured — never ignored or
|
|
133
|
-
// pre-existing untracked files, which `git clean` would wrongly nuke).
|
|
134
|
-
// This `reset --hard` is intentional and scoped to THIS Create-PR-worktree
|
|
135
|
-
// staging flow whose explicit contract is to restore the live checkout
|
|
136
|
-
// clean — it is NOT the live-checkout DISPATCH mode (which never resets).
|
|
137
|
-
const residualUntracked = [];
|
|
138
|
-
try {
|
|
139
|
-
await git(['-C', localPath, 'reset', '--hard', 'HEAD']);
|
|
140
|
-
for (const rel of untracked) {
|
|
141
|
-
const target = path.join(localPath, rel.replace(/\/$/, ''));
|
|
142
|
-
try {
|
|
143
|
-
// Retry transient Windows file locks (EBUSY/EPERM/ENOTEMPTY) before
|
|
144
|
-
// giving up — a single failed unlink is what left the tree dirty. A few
|
|
145
|
-
// quick retries ride out a transient AV/indexer lock without a long stall.
|
|
146
|
-
shared._retryFsOp(() => fsm.rmSync(target, { recursive: true, force: true }), `cc-create-pr rm ${rel}`, { attempts: 4, baseMs: 100 });
|
|
147
|
-
} catch (rmErr) {
|
|
148
|
-
residualUntracked.push(rel);
|
|
149
|
-
log('warn', `[cc-create-pr] could not remove untracked ${rel} from live checkout after retries: ${rmErr.message}`);
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
} catch (e) {
|
|
153
|
-
// shared.removeWorktree carries the EPERM/EBUSY retry + escalation +
|
|
154
|
-
// real-repo refusal (CLAUDE.md footgun #6 — don't hand-roll force-remove).
|
|
155
|
-
try { shared.removeWorktree(wtPath, projectRoot, worktreesBase); } catch { /* leak rather than double-throw */ }
|
|
156
|
-
throw new Error(
|
|
157
|
-
`prepareCreatePrWorktree: failed to restore live checkout — ${e.message}. ` +
|
|
158
|
-
`Worktree at ${wtPath} may need manual cleanup.`,
|
|
138
|
+
'The operator checkout was left untouched.',
|
|
159
139
|
);
|
|
160
140
|
}
|
|
161
141
|
|
|
162
|
-
|
|
163
|
-
// return (liveTreeDirty) instead of silently reporting success — the caller
|
|
164
|
-
// can warn CC / the operator rather than letting the residue wedge the next
|
|
165
|
-
// dispatch with a phantom "dirty" refusal.
|
|
166
|
-
const liveTreeDirty = residualUntracked.length > 0;
|
|
167
|
-
if (liveTreeDirty) {
|
|
168
|
-
log('warn', `[cc-create-pr] live checkout ${localPath} left with ${residualUntracked.length} residual untracked path(s) after staging; surfacing liveTreeDirty`);
|
|
169
|
-
} else {
|
|
170
|
-
log('info', `[cc-create-pr] staged ${project.name} changes into isolated worktree ${wtPath} on branch ${branchName} (live checkout restored)`);
|
|
171
|
-
}
|
|
142
|
+
log('info', `[cc-create-pr] copied ${project.name} changes into isolated worktree ${wtPath} on branch ${branchName}; operator checkout preserved`);
|
|
172
143
|
return {
|
|
173
144
|
ok: true,
|
|
174
145
|
worktreePath: wtPath,
|
|
@@ -177,8 +148,7 @@ async function prepareCreatePrWorktree({
|
|
|
177
148
|
baseSha,
|
|
178
149
|
trackedChanged: hasTracked,
|
|
179
150
|
untrackedCount: untracked.length,
|
|
180
|
-
|
|
181
|
-
residualUntracked,
|
|
151
|
+
sourceCheckoutPreserved: true,
|
|
182
152
|
};
|
|
183
153
|
}
|
|
184
154
|
|
|
@@ -187,7 +157,7 @@ async function prepareCreatePrWorktree({
|
|
|
187
157
|
* touch a path that doesn't carry the minions ownership marker (so a bad/forged
|
|
188
158
|
* path can never delete an arbitrary directory).
|
|
189
159
|
*/
|
|
190
|
-
async function cleanupCreatePrWorktree({ worktreePath, project, _git, _fs } = {}) {
|
|
160
|
+
async function cleanupCreatePrWorktree({ worktreePath, project, projects, _git, _fs } = {}) {
|
|
191
161
|
const git = _git || ((args, opts) => shared.shellSafeGit(args, opts));
|
|
192
162
|
const fsm = _fs || fs;
|
|
193
163
|
if (!worktreePath || typeof worktreePath !== 'string') {
|
|
@@ -200,6 +170,14 @@ async function cleanupCreatePrWorktree({ worktreePath, project, _git, _fs } = {}
|
|
|
200
170
|
if (!shared.hasWorktreeOwnerMarker(worktreePath)) {
|
|
201
171
|
return { ok: false, reason: 'not-owned' };
|
|
202
172
|
}
|
|
173
|
+
try {
|
|
174
|
+
shared.assertWorktreeOutsideProjects(
|
|
175
|
+
worktreePath,
|
|
176
|
+
Array.isArray(projects) ? projects : (project ? [project] : shared.getProjects()),
|
|
177
|
+
);
|
|
178
|
+
} catch {
|
|
179
|
+
return { ok: false, reason: 'protected-project-overlap' };
|
|
180
|
+
}
|
|
203
181
|
const fromDir = (project && project.localPath) || worktreePath;
|
|
204
182
|
try {
|
|
205
183
|
await git(['-C', fromDir, 'worktree', 'remove', '--force', worktreePath]);
|
|
@@ -81,6 +81,20 @@ function validateKeepPidsRecord(parsed, opts) {
|
|
|
81
81
|
if (typeof parsed.cwd === 'string' && parsed.cwd.length > 500) {
|
|
82
82
|
return { ok: false, reason: 'cwd-too-long' };
|
|
83
83
|
}
|
|
84
|
+
if (opts.allowedCwdRoot) {
|
|
85
|
+
if (typeof parsed.cwd !== 'string' || parsed.cwd.length === 0) {
|
|
86
|
+
return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-required-for-dispatch-confinement' };
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const cwdReal = shared.realPathForComparison(parsed.cwd);
|
|
90
|
+
const rootReal = shared.realPathForComparison(opts.allowedCwdRoot);
|
|
91
|
+
if (!shared.isPathInsideOrEqual(cwdReal, rootReal)) {
|
|
92
|
+
return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-outside-dispatch-worktree' };
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-containment-check-failed (' + err.message + ')' };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
84
98
|
// W-mp6k7ywi000fa33c — when the engine requires a real git workdir
|
|
85
99
|
// (default true; per-WI override via `meta.keep_processes_skip_workdir_check`),
|
|
86
100
|
// verify the recorded `cwd` looks like a real worktree. Empty/missing
|
|
@@ -110,22 +124,105 @@ function validateKeepPidsRecord(parsed, opts) {
|
|
|
110
124
|
if (parsed.wi_id != null && typeof parsed.wi_id !== 'string') {
|
|
111
125
|
return { ok: false, reason: 'wi_id-not-string' };
|
|
112
126
|
}
|
|
127
|
+
for (const field of ['project', 'work_type', 'checkout_mode', 'dispatch_root']) {
|
|
128
|
+
if (parsed[field] != null && typeof parsed[field] !== 'string') {
|
|
129
|
+
return { ok: false, reason: field + '-not-string' };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let canonicalCwd = typeof parsed.cwd === 'string' ? parsed.cwd : '';
|
|
134
|
+
if (canonicalCwd) {
|
|
135
|
+
try { canonicalCwd = shared.realPathForComparison(canonicalCwd); }
|
|
136
|
+
catch (err) {
|
|
137
|
+
return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'cwd-canonicalization-failed (' + err.message + ')' };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
let canonicalDispatchRoot = typeof parsed.dispatch_root === 'string' ? parsed.dispatch_root : '';
|
|
141
|
+
if (canonicalDispatchRoot) {
|
|
142
|
+
try { canonicalDispatchRoot = shared.realPathForComparison(canonicalDispatchRoot); }
|
|
143
|
+
catch (err) {
|
|
144
|
+
return { ok: false, reason: INVALID_WORKDIR_REASON_PREFIX + 'dispatch-root-canonicalization-failed (' + err.message + ')' };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
113
147
|
|
|
114
148
|
return {
|
|
115
149
|
ok: true,
|
|
116
150
|
value: {
|
|
117
151
|
pids: pids,
|
|
118
152
|
purpose: typeof parsed.purpose === 'string' ? parsed.purpose : '',
|
|
119
|
-
cwd:
|
|
153
|
+
cwd: canonicalCwd,
|
|
120
154
|
ports: Array.isArray(parsed.ports) ? parsed.ports.map(Number) : [],
|
|
121
155
|
expires_at: parsed.expires_at,
|
|
122
156
|
expiresAtMs: expiresAtMs,
|
|
123
157
|
written_by: typeof parsed.written_by === 'string' ? parsed.written_by : '',
|
|
124
158
|
wi_id: typeof parsed.wi_id === 'string' ? parsed.wi_id : '',
|
|
159
|
+
project: typeof parsed.project === 'string' ? parsed.project : '',
|
|
160
|
+
work_type: typeof parsed.work_type === 'string' ? parsed.work_type : '',
|
|
161
|
+
checkout_mode: typeof parsed.checkout_mode === 'string' ? parsed.checkout_mode : '',
|
|
162
|
+
dispatch_root: canonicalDispatchRoot,
|
|
125
163
|
},
|
|
126
164
|
};
|
|
127
165
|
}
|
|
128
166
|
|
|
167
|
+
function validateKeepProcessExecutionContext(record, opts) {
|
|
168
|
+
opts = opts || {};
|
|
169
|
+
if (!record) return { ok: false, reason: 'record-missing' };
|
|
170
|
+
if (!record.cwd) {
|
|
171
|
+
return record.checkout_mode || record.dispatch_root
|
|
172
|
+
? { ok: false, reason: 'cwd-missing' }
|
|
173
|
+
: { ok: true, legacy: true };
|
|
174
|
+
}
|
|
175
|
+
let projects;
|
|
176
|
+
try {
|
|
177
|
+
projects = Array.isArray(opts.projects) ? opts.projects : shared.getProjects(opts.config);
|
|
178
|
+
} catch {
|
|
179
|
+
return { ok: false, reason: 'project-config-unavailable' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
for (const project of projects) {
|
|
183
|
+
if (!project?.localPath) continue;
|
|
184
|
+
const mode = shared.resolveCheckoutMode(
|
|
185
|
+
project,
|
|
186
|
+
record.work_type || shared.WORK_TYPE.IMPLEMENT,
|
|
187
|
+
);
|
|
188
|
+
if (mode !== shared.CHECKOUT_MODES.WORKTREE) continue;
|
|
189
|
+
try {
|
|
190
|
+
if (shared.pathsOverlap(record.cwd, project.localPath)) {
|
|
191
|
+
return { ok: false, reason: 'cwd-overlaps-worktree-mode-operator-checkout' };
|
|
192
|
+
}
|
|
193
|
+
} catch {
|
|
194
|
+
return { ok: false, reason: 'cwd-overlap-check-failed' };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const project = record.project ? shared.findProjectByName(projects, record.project) : null;
|
|
199
|
+
if (project && shared.resolveCheckoutMode(project, record.work_type || shared.WORK_TYPE.IMPLEMENT) === shared.CHECKOUT_MODES.LIVE) {
|
|
200
|
+
try {
|
|
201
|
+
if (!shared.isPathInsideOrEqual(record.cwd, project.localPath)) {
|
|
202
|
+
return { ok: false, reason: 'live-cwd-outside-project' };
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
return { ok: false, reason: 'live-cwd-unavailable' };
|
|
206
|
+
}
|
|
207
|
+
return { ok: true };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (!record.dispatch_root) {
|
|
211
|
+
return record.checkout_mode
|
|
212
|
+
? { ok: false, reason: 'dispatch-root-missing' }
|
|
213
|
+
: { ok: true, legacy: true };
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
shared.assertWorktreeOutsideProjects(record.dispatch_root, projects);
|
|
217
|
+
if (!shared.isPathInsideOrEqual(record.cwd, record.dispatch_root)) {
|
|
218
|
+
return { ok: false, reason: 'cwd-outside-dispatch-root' };
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
return { ok: false, reason: 'dispatch-root-overlaps-operator-checkout' };
|
|
222
|
+
}
|
|
223
|
+
return { ok: true };
|
|
224
|
+
}
|
|
225
|
+
|
|
129
226
|
function readKeepPidsFile(agentId, opts) {
|
|
130
227
|
opts = opts || {};
|
|
131
228
|
const filePath = path.join(_agentsDir(), agentId, KEEP_PIDS_FILENAME);
|
|
@@ -198,6 +295,16 @@ function sweepKeepProcesses(opts) {
|
|
|
198
295
|
const value = rec.value;
|
|
199
296
|
const filePath = rec.filePath;
|
|
200
297
|
const agentId = rec.agentId;
|
|
298
|
+
const executionContext = validateKeepProcessExecutionContext(value, opts);
|
|
299
|
+
if (!executionContext.ok) {
|
|
300
|
+
for (const pid of value.pids) {
|
|
301
|
+
if (killOne(pid)) stats.killedPids++;
|
|
302
|
+
_audit('unsafe-context-kill', { agentId, pid, reason: executionContext.reason, wi_id: value.wi_id });
|
|
303
|
+
}
|
|
304
|
+
try { fs.unlinkSync(filePath); } catch {}
|
|
305
|
+
log('warn', 'keep-processes: removed unsafe execution context for ' + agentId + ' — ' + executionContext.reason);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
201
308
|
if (value.expiresAtMs < now) {
|
|
202
309
|
let killed = 0;
|
|
203
310
|
for (const pid of value.pids) {
|
|
@@ -227,6 +334,7 @@ function getActiveAnchorPids(opts) {
|
|
|
227
334
|
for (const rec of listAllKeepPidsFiles({ now: now })) {
|
|
228
335
|
if (!rec.valid) continue;
|
|
229
336
|
if (rec.value.expiresAtMs < now) continue;
|
|
337
|
+
if (!validateKeepProcessExecutionContext(rec.value, opts).ok) continue;
|
|
230
338
|
for (const pid of rec.value.pids) out.add(pid);
|
|
231
339
|
}
|
|
232
340
|
return out;
|
|
@@ -237,13 +345,7 @@ function getActiveAnchorPidsForAgent(agentId, opts) {
|
|
|
237
345
|
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
238
346
|
const out = new Set();
|
|
239
347
|
if (!agentId) return { pids: out, record: null };
|
|
240
|
-
|
|
241
|
-
// so the per-WI override path can disable workdir validation. Earlier
|
|
242
|
-
// implementation only forwarded `now`, which silently ignored opts.
|
|
243
|
-
const readOpts = { now: now };
|
|
244
|
-
if (Object.prototype.hasOwnProperty.call(opts, 'requireGitWorkdir')) {
|
|
245
|
-
readOpts.requireGitWorkdir = opts.requireGitWorkdir;
|
|
246
|
-
}
|
|
348
|
+
const readOpts = { ...opts, now };
|
|
247
349
|
const rec = readKeepPidsFile(String(agentId), readOpts);
|
|
248
350
|
if (!rec) return { pids: out, record: null };
|
|
249
351
|
if (!rec.valid) {
|
|
@@ -251,10 +353,26 @@ function getActiveAnchorPidsForAgent(agentId, opts) {
|
|
|
251
353
|
return { pids: out, record: null, reason: rec.reason };
|
|
252
354
|
}
|
|
253
355
|
if (rec.value.expiresAtMs < now) return { pids: out, record: rec.value, reason: 'expired' };
|
|
356
|
+
const executionContext = validateKeepProcessExecutionContext(rec.value, opts);
|
|
357
|
+
if (!executionContext.ok) return { pids: out, record: rec.value, reason: executionContext.reason };
|
|
254
358
|
for (const pid of rec.value.pids) out.add(pid);
|
|
255
359
|
return { pids: out, record: rec.value };
|
|
256
360
|
}
|
|
257
361
|
|
|
362
|
+
function getActiveKeepProcessCwds(opts) {
|
|
363
|
+
opts = opts || {};
|
|
364
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
365
|
+
const out = [];
|
|
366
|
+
for (const rec of listAllKeepPidsFiles({ now })) {
|
|
367
|
+
if (!rec.valid || rec.value.expiresAtMs < now || !rec.value.cwd) continue;
|
|
368
|
+
if (!validateKeepProcessExecutionContext(rec.value, opts).ok) continue;
|
|
369
|
+
if (alivePids(rec.value.pids, opts).length === 0) continue;
|
|
370
|
+
try { out.push(shared.realPathForComparison(rec.value.cwd)); }
|
|
371
|
+
catch { /* malformed/unreachable cwd contributes no anchor */ }
|
|
372
|
+
}
|
|
373
|
+
return out;
|
|
374
|
+
}
|
|
375
|
+
|
|
258
376
|
function computeReapPlan(descendants, agentId, opts) {
|
|
259
377
|
opts = opts || {};
|
|
260
378
|
const list = (Array.isArray(descendants) ? descendants : []).map(Number)
|
|
@@ -311,6 +429,7 @@ function evaluateKeepPidsAcceptance(agentId, opts) {
|
|
|
311
429
|
record: rec.value,
|
|
312
430
|
recordedCwd: rec.value.cwd || null,
|
|
313
431
|
filePath: rec.filePath,
|
|
432
|
+
parsedRaw: rec.parsed || null,
|
|
314
433
|
};
|
|
315
434
|
}
|
|
316
435
|
const reason = rec.reason || 'unknown';
|
|
@@ -368,7 +487,7 @@ function buildKeepProcessesHint(opts) {
|
|
|
368
487
|
'{',
|
|
369
488
|
' "pids": [12345, 12346],',
|
|
370
489
|
' "purpose": "bun run dev (Constellation server + dashboard)",',
|
|
371
|
-
' "cwd": "
|
|
490
|
+
' "cwd": "<absolute path at or below MINIONS_AGENT_CWD>",',
|
|
372
491
|
' "ports": [3001, 5173],',
|
|
373
492
|
' "expires_at": "<ISO-8601 timestamp <= ' + ttl + ' minutes from now>",',
|
|
374
493
|
' "written_by": "' + agentId + '",',
|
|
@@ -377,6 +496,7 @@ function buildKeepProcessesHint(opts) {
|
|
|
377
496
|
'```',
|
|
378
497
|
'',
|
|
379
498
|
'Caps the engine enforces: max ' + maxPids + ' PIDs, TTL <= ' + maxTtl + ' minutes, `purpose`/`cwd` <= 500 chars, <= 20 ports. Files outside the cap are dropped (engine reaps as normal) and a warning is logged.',
|
|
499
|
+
'`cwd` MUST resolve at or below the current dispatch root from `MINIONS_AGENT_CWD`. The operator checkout is rejected for worktree-mode dispatches, even when it is a valid Git checkout.',
|
|
380
500
|
'',
|
|
381
501
|
'If you do NOT write the file, the engine will kill ALL of your descendant processes when you exit (today\'s default). Do not write the file unless you are intentionally leaving a process behind for the human or follow-up work.',
|
|
382
502
|
'',
|
|
@@ -452,6 +572,8 @@ module.exports = {
|
|
|
452
572
|
sweepKeepProcesses: sweepKeepProcesses,
|
|
453
573
|
getActiveAnchorPids: getActiveAnchorPids,
|
|
454
574
|
getActiveAnchorPidsForAgent: getActiveAnchorPidsForAgent,
|
|
575
|
+
getActiveKeepProcessCwds: getActiveKeepProcessCwds,
|
|
576
|
+
validateKeepProcessExecutionContext: validateKeepProcessExecutionContext,
|
|
455
577
|
computeReapPlan: computeReapPlan,
|
|
456
578
|
buildKeepProcessesHint: buildKeepProcessesHint,
|
|
457
579
|
evaluateKeepPidsAcceptance: evaluateKeepPidsAcceptance,
|
package/engine/lifecycle.js
CHANGED
|
@@ -3501,7 +3501,7 @@ async function rebaseBranchOntoMain(pr, project, config) {
|
|
|
3501
3501
|
// and produce mirror writes. Misconfigured engine.worktreeRoot is the only
|
|
3502
3502
|
// way this lands inside root; the assert throws so the caller can recover.
|
|
3503
3503
|
try {
|
|
3504
|
-
shared.
|
|
3504
|
+
shared.assertWorktreeOutsideProjects(tmpWt, shared.getProjects(config));
|
|
3505
3505
|
} catch (err) {
|
|
3506
3506
|
log('warn', `Post-merge rebase: refusing nested worktree path — ${err.message}`);
|
|
3507
3507
|
return { success: false, error: err.message };
|
package/engine/live-checkout.js
CHANGED
|
@@ -2034,6 +2034,7 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
|
|
|
2034
2034
|
*
|
|
2035
2035
|
* @param {object} opts
|
|
2036
2036
|
* @param {object} opts.item persisted dispatch record (has id, originalRef, meta.branch, meta.project.{localPath,name})
|
|
2037
|
+
* @param {object} [opts.config] current config; worktree mode makes this helper fail closed
|
|
2037
2038
|
* @param {boolean} [opts.isTerminalFailure] true on error/timeout/crash
|
|
2038
2039
|
* @param {string} [opts.resultLabel] short status word for the alert body
|
|
2039
2040
|
* @param {function} [opts.log] (level, msg) => void
|
|
@@ -2043,8 +2044,9 @@ async function applyLiveCheckoutAutoStash(opts = {}) {
|
|
|
2043
2044
|
* @returns {Promise<object>} restore result (or { skipped:true } for a non-live record)
|
|
2044
2045
|
*/
|
|
2045
2046
|
async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
|
|
2046
|
-
const { item, isTerminalFailure, resultLabel, log, writeInboxAlert, gitOpts, _git } = opts;
|
|
2047
|
-
if (!
|
|
2047
|
+
const { item, config, isTerminalFailure, resultLabel, log, writeInboxAlert, gitOpts, _git } = opts;
|
|
2048
|
+
if (!shared.isLiveCheckoutDispatchRecord(item, config)
|
|
2049
|
+
|| !item.originalRef || !item.meta?.branch || !item.meta?.project?.localPath) {
|
|
2048
2050
|
return { restored: false, reason: 'not-live-record', skipped: true };
|
|
2049
2051
|
}
|
|
2050
2052
|
try {
|