@yemi33/minions 0.1.2252 → 0.1.2254
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 +45 -2
- package/engine/cleanup.js +50 -0
- package/engine/create-pr-worktree.js +179 -0
- package/engine/lifecycle.js +95 -0
- package/engine/pr-action.js +43 -17
- package/engine/shared.js +18 -0
- package/engine/worktree-gc.js +2 -0
- package/package.json +1 -1
- package/prompts/cc-system.md +5 -1
package/dashboard.js
CHANGED
|
@@ -53,6 +53,7 @@ const steering = require('./engine/steering');
|
|
|
53
53
|
const steeringStore = require('./engine/steering-store');
|
|
54
54
|
const projectDiscovery = require('./engine/project-discovery');
|
|
55
55
|
const prAction = require('./engine/pr-action');
|
|
56
|
+
const createPrWorktree = require('./engine/create-pr-worktree');
|
|
56
57
|
const prResolve = require('./engine/pr-resolve');
|
|
57
58
|
const prFixTarget = require('./engine/pr-fix-target');
|
|
58
59
|
const prTrack = require('./engine/pr-track');
|
|
@@ -13325,11 +13326,53 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
13325
13326
|
const changedFileCount = porcelain.split('\n').map(s => s.trim()).filter(Boolean).length;
|
|
13326
13327
|
const hasChanges = changedFileCount > 0;
|
|
13327
13328
|
const branch = (typeof body?.branch === 'string' && body.branch.trim()) || currentBranch || '';
|
|
13329
|
+
// The Create-PR action follows the SAME checkout pattern as a dispatch
|
|
13330
|
+
// for this project: 'live' commits in the live checkout; 'worktree'
|
|
13331
|
+
// routes the changes through an isolated worktree (so nothing is ever
|
|
13332
|
+
// committed in the operator's live main). See engine/create-pr-worktree.js.
|
|
13333
|
+
const checkoutMode = shared.resolveCheckoutMode(project);
|
|
13328
13334
|
const followups = hasChanges
|
|
13329
|
-
? prAction.buildCreatePrFollowups({ project: projectName, branch, contextOnly: !!body?.contextOnly })
|
|
13335
|
+
? prAction.buildCreatePrFollowups({ project: projectName, branch, contextOnly: !!body?.contextOnly, checkoutMode })
|
|
13330
13336
|
: [];
|
|
13331
13337
|
recordCcTurnIfPresent(req, { kind: 'pr-action', id: '', title: `offer create-pr (${projectName})`, project: projectName, followups });
|
|
13332
|
-
return jsonReply(res, 200, { ok: true, hasChanges, branch, changedFileCount, followups }, req);
|
|
13338
|
+
return jsonReply(res, 200, { ok: true, hasChanges, branch, changedFileCount, checkoutMode, followups }, req);
|
|
13339
|
+
} catch (e) {
|
|
13340
|
+
return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
|
|
13341
|
+
}
|
|
13342
|
+
}},
|
|
13343
|
+
|
|
13344
|
+
{ method: 'POST', path: '/api/pr-action/prepare-create-pr-worktree', desc: 'Stage a worktree-mode project\'s uncommitted live-checkout changes into an ISOLATED git worktree and restore the live checkout clean (P-cc-createpr-checkout-mode). The Create-PR action calls this for projects whose checkoutMode is "worktree" so the subsequent commit/push/PR happen off the operator tree — never committing to the live main. Safe ordering: the worktree is populated and verified BEFORE the live checkout is touched; on apply failure the worktree is removed and the live checkout is left untouched. Returns {ok, worktreePath, branch, baseBranch, baseSha, trackedChanged, untrackedCount}; {ok:false, reason:"no-changes"} when the tree is clean.', params: 'project (configured project name), branch (optional preferred branch name)', handler: async (req, res) => {
|
|
13345
|
+
const body = await readBody(req);
|
|
13346
|
+
try {
|
|
13347
|
+
reloadConfig();
|
|
13348
|
+
const projectName = typeof body?.project === 'string' ? body.project.trim() : '';
|
|
13349
|
+
if (!projectName) return jsonReply(res, 400, { error: 'project is required' }, req);
|
|
13350
|
+
const project = shared.getProjects(CONFIG).find(p => p && p.name === projectName);
|
|
13351
|
+
if (!project) return jsonReply(res, 404, { error: `unknown project: ${projectName}` }, req);
|
|
13352
|
+
if (!project.localPath) return jsonReply(res, 400, { error: `project ${projectName} has no localPath` }, req);
|
|
13353
|
+
const result = await createPrWorktree.prepareCreatePrWorktree({
|
|
13354
|
+
project,
|
|
13355
|
+
branch: typeof body?.branch === 'string' ? body.branch : undefined,
|
|
13356
|
+
minionsDir: MINIONS_DIR,
|
|
13357
|
+
engine: CONFIG.engine,
|
|
13358
|
+
log: (lvl, msg) => console.log(msg),
|
|
13359
|
+
});
|
|
13360
|
+
return jsonReply(res, 200, result, req);
|
|
13361
|
+
} catch (e) {
|
|
13362
|
+
return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
|
|
13363
|
+
}
|
|
13364
|
+
}},
|
|
13365
|
+
|
|
13366
|
+
{ method: 'POST', path: '/api/pr-action/cleanup-create-pr-worktree', desc: 'Remove an isolated worktree created by /api/pr-action/prepare-create-pr-worktree once its PR has been pushed/opened (P-cc-createpr-checkout-mode). Refuses any path missing the minions ownership marker, so it can never delete an arbitrary directory.', params: 'project (configured project name), worktreePath (the path returned by prepare-create-pr-worktree)', handler: async (req, res) => {
|
|
13367
|
+
const body = await readBody(req);
|
|
13368
|
+
try {
|
|
13369
|
+
reloadConfig();
|
|
13370
|
+
const projectName = typeof body?.project === 'string' ? body.project.trim() : '';
|
|
13371
|
+
const project = projectName ? shared.getProjects(CONFIG).find(p => p && p.name === projectName) : null;
|
|
13372
|
+
const worktreePath = typeof body?.worktreePath === 'string' ? body.worktreePath.trim() : '';
|
|
13373
|
+
if (!worktreePath) return jsonReply(res, 400, { error: 'worktreePath is required' }, req);
|
|
13374
|
+
const result = await createPrWorktree.cleanupCreatePrWorktree({ worktreePath, project });
|
|
13375
|
+
return jsonReply(res, 200, result, req);
|
|
13333
13376
|
} catch (e) {
|
|
13334
13377
|
return jsonReply(res, e.statusCode || 400, { error: e.message }, req);
|
|
13335
13378
|
}
|
package/engine/cleanup.js
CHANGED
|
@@ -425,6 +425,39 @@ function _killProcessInWorktree(dir, activeProcesses, activeIds) {
|
|
|
425
425
|
} catch {} // tmp dir may not exist
|
|
426
426
|
}
|
|
427
427
|
|
|
428
|
+
// Agent scratch (`.agent-temp` under the worktree root) holds throwaway git
|
|
429
|
+
// repos written by dispatched agents + the test-suite-run-by-agents. Nothing
|
|
430
|
+
// owns its lifecycle, so it accumulates unboundedly (observed: 26k+ dirs). The
|
|
431
|
+
// worktree sweeps now SKIP it (shared.isWorktreeRootInfraEntry), so the pile no
|
|
432
|
+
// longer stalls the tick loop — but it still needs bounded hygiene. This reaps
|
|
433
|
+
// entries whose mtime is older than the TTL via plain fs deletion (NOT
|
|
434
|
+
// removeWorktree — these are standalone repos, not worktrees). Bounded per pass
|
|
435
|
+
// so the reaper itself can never stall the tick on a huge backlog; it drains
|
|
436
|
+
// across cleanup cycles.
|
|
437
|
+
const AGENT_SCRATCH_TTL_MS = 6 * 60 * 60 * 1000; // 6h — comfortably > agentTimeout (5h), so only dead-dispatch scratch is reaped
|
|
438
|
+
const AGENT_SCRATCH_MAX_SCAN_PER_CLEANUP = 2000;
|
|
439
|
+
|
|
440
|
+
function reapAgentScratch(worktreeRoot) {
|
|
441
|
+
const scratchDir = path.join(worktreeRoot, shared.WORKTREE_SCRATCH_DIR_NAME);
|
|
442
|
+
let names;
|
|
443
|
+
try { names = fs.readdirSync(scratchDir); } catch { return 0; } // no scratch dir → nothing to do
|
|
444
|
+
const cutoff = Date.now() - AGENT_SCRATCH_TTL_MS;
|
|
445
|
+
let reaped = 0;
|
|
446
|
+
const limit = Math.min(names.length, AGENT_SCRATCH_MAX_SCAN_PER_CLEANUP);
|
|
447
|
+
for (let i = 0; i < limit; i++) {
|
|
448
|
+
const full = path.join(scratchDir, names[i]);
|
|
449
|
+
let stat;
|
|
450
|
+
try { stat = fs.statSync(full); } catch { continue; }
|
|
451
|
+
if (stat.mtimeMs >= cutoff) continue; // recent — may belong to a live dispatch
|
|
452
|
+
try { fs.rmSync(full, { recursive: true, force: true }); reaped++; }
|
|
453
|
+
catch { /* locked / in use — leave it for a later pass */ }
|
|
454
|
+
}
|
|
455
|
+
if (reaped > 0) {
|
|
456
|
+
log('info', `Reaped ${reaped} stale agent-scratch entries (>${Math.round(AGENT_SCRATCH_TTL_MS / 3600000)}h) from ${scratchDir}`);
|
|
457
|
+
}
|
|
458
|
+
return reaped;
|
|
459
|
+
}
|
|
460
|
+
|
|
428
461
|
// ─── Cleanup Orchestrator ────────────────────────────────────────────────────
|
|
429
462
|
|
|
430
463
|
async function runCleanup(config, verbose = false) {
|
|
@@ -685,6 +718,8 @@ async function runCleanup(config, verbose = false) {
|
|
|
685
718
|
|
|
686
719
|
// 3. Clean git worktrees for merged/abandoned PRs
|
|
687
720
|
const _attemptedWorktreePaths = new Set(); // dedup across projects sharing a worktreeRoot
|
|
721
|
+
const _reapedScratchRoots = new Set(); // reap each worktree root's .agent-temp once per cleanup
|
|
722
|
+
cleaned.agentScratch = 0;
|
|
688
723
|
for (const project of projects) {
|
|
689
724
|
const root = project.localPath ? path.resolve(project.localPath) : null;
|
|
690
725
|
if (!root || !fs.existsSync(root)) continue;
|
|
@@ -698,6 +733,14 @@ async function runCleanup(config, verbose = false) {
|
|
|
698
733
|
|
|
699
734
|
for (const worktreeRoot of worktreeRoots) {
|
|
700
735
|
|
|
736
|
+
// Bounded TTL reaper for the agent-scratch dir under this worktree root
|
|
737
|
+
// (once per unique root). Plain fs deletion — these are standalone repos,
|
|
738
|
+
// not worktrees, so removeWorktree (correctly) refuses them.
|
|
739
|
+
if (!_reapedScratchRoots.has(worktreeRoot)) {
|
|
740
|
+
_reapedScratchRoots.add(worktreeRoot);
|
|
741
|
+
try { cleaned.agentScratch += reapAgentScratch(worktreeRoot); } catch (_e) { /* best-effort */ }
|
|
742
|
+
}
|
|
743
|
+
|
|
701
744
|
// Get PRs for this project
|
|
702
745
|
const prs = safeJson(projectPrPath(project)) || [];
|
|
703
746
|
const mergedBranches = new Set();
|
|
@@ -714,6 +757,12 @@ async function runCleanup(config, verbose = false) {
|
|
|
714
757
|
const allDirs = [];
|
|
715
758
|
const topDirs = fs.readdirSync(worktreeRoot);
|
|
716
759
|
for (const dir of topDirs) {
|
|
760
|
+
// Skip infra/scratch dirs (`.agent-temp`, `.git`, …). They are never
|
|
761
|
+
// managed worktrees, and recursing into them (the else branch below)
|
|
762
|
+
// would readdir + stat their entire contents — `.agent-temp` routinely
|
|
763
|
+
// holds thousands of throwaway test/agent git repos, and walking that
|
|
764
|
+
// pile every cleanup cycle stalls the engine tick loop ("engine stale").
|
|
765
|
+
if (shared.isWorktreeRootInfraEntry(dir)) continue;
|
|
717
766
|
const dirPath = path.join(worktreeRoot, dir);
|
|
718
767
|
try { if (!fs.statSync(dirPath).isDirectory()) continue; } catch { continue; }
|
|
719
768
|
// Check if this is a git worktree (has .git file) or a parent directory
|
|
@@ -1672,4 +1721,5 @@ module.exports = {
|
|
|
1672
1721
|
getWorktreeBranch, // exported for lifecycle cleanup
|
|
1673
1722
|
cleanupMergedPrLocalBranch, // exported for lifecycle cleanup and testing
|
|
1674
1723
|
collectPhantomBranchesForProject, // P-e0b4f7a5 — exported for testing
|
|
1724
|
+
reapAgentScratch, // exported for testing
|
|
1675
1725
|
};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/create-pr-worktree.js — checkout-mode-aware Create-PR staging.
|
|
3
|
+
*
|
|
4
|
+
* Background (the bug this fixes): the CC "Create PR" flow has the LLM commit /
|
|
5
|
+
* branch / push directly in the project's LIVE operator checkout
|
|
6
|
+
* (`project.localPath`). For a `checkoutMode: "worktree"` project that is wrong —
|
|
7
|
+
* a misstep by the LLM (committing before branching, freelancing `git pull`)
|
|
8
|
+
* leaks commits / merges onto the operator's `main`. The Create-PR action must
|
|
9
|
+
* follow the SAME checkout pattern as a normal dispatch for that project:
|
|
10
|
+
* - `live` → operate in the live checkout (handled by the existing flow);
|
|
11
|
+
* - `worktree` → operate in an isolated git worktree, never the live checkout.
|
|
12
|
+
*
|
|
13
|
+
* This module implements the `worktree` path. `prepareCreatePrWorktree` moves the
|
|
14
|
+
* uncommitted edits CC made in the live checkout into a fresh worktree (created
|
|
15
|
+
* off the same base commit, on a new branch) and then restores the live checkout
|
|
16
|
+
* clean — DETERMINISTICALLY, server-side, so nothing relies on the LLM running
|
|
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.
|
|
19
|
+
*
|
|
20
|
+
* Safety ordering: the worktree is populated and verified BEFORE the live
|
|
21
|
+
* checkout is touched. If applying the diff fails, the worktree is removed and
|
|
22
|
+
* the live checkout is left exactly as it was — the operator never loses work.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
const shared = require('./shared');
|
|
28
|
+
|
|
29
|
+
const PATCH_APPLY_TIMEOUT_MS = 120000;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Move a worktree-mode project's uncommitted live-checkout changes into an
|
|
33
|
+
* isolated worktree and restore the live checkout clean.
|
|
34
|
+
*
|
|
35
|
+
* @returns {Promise<{ ok:true, worktreePath, branch, baseBranch, baseSha,
|
|
36
|
+
* trackedChanged:boolean, untrackedCount:number } | { ok:false, reason:string }>}
|
|
37
|
+
* @throws on a git/fs failure AFTER which the live checkout is guaranteed
|
|
38
|
+
* untouched (the operator keeps their work).
|
|
39
|
+
*/
|
|
40
|
+
async function prepareCreatePrWorktree({
|
|
41
|
+
project, branch, minionsDir, engine, log = () => {}, _git, _fs,
|
|
42
|
+
} = {}) {
|
|
43
|
+
const git = _git || ((args, opts) => shared.shellSafeGit(args, opts));
|
|
44
|
+
const fsm = _fs || fs;
|
|
45
|
+
if (!project || typeof project !== 'object') {
|
|
46
|
+
throw new Error('prepareCreatePrWorktree: project object required');
|
|
47
|
+
}
|
|
48
|
+
const localPath = project.localPath;
|
|
49
|
+
if (!localPath) {
|
|
50
|
+
throw new Error('prepareCreatePrWorktree: project.localPath required');
|
|
51
|
+
}
|
|
52
|
+
const mainBranch = (project.mainBranch && String(project.mainBranch).trim()) || 'main';
|
|
53
|
+
|
|
54
|
+
// 1. Capture the uncommitted state from the live checkout.
|
|
55
|
+
const baseSha = (await git(['-C', localPath, 'rev-parse', 'HEAD'])).trim();
|
|
56
|
+
if (!/^[0-9a-f]{7,40}$/i.test(baseSha)) {
|
|
57
|
+
throw new Error(`prepareCreatePrWorktree: could not resolve HEAD in ${localPath} (got ${JSON.stringify(baseSha)})`);
|
|
58
|
+
}
|
|
59
|
+
// `git diff HEAD --binary` captures staged + unstaged tracked changes
|
|
60
|
+
// (including deletions and binary deltas) as a single patch.
|
|
61
|
+
const trackedDiff = await git(['-C', localPath, 'diff', '--binary', 'HEAD']);
|
|
62
|
+
const porcelain = await git(['-C', localPath, 'status', '--porcelain']);
|
|
63
|
+
const untracked = porcelain
|
|
64
|
+
.split('\n')
|
|
65
|
+
.filter(l => l.startsWith('?? '))
|
|
66
|
+
.map(l => l.slice(3).trim())
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
const hasTracked = !!trackedDiff.trim();
|
|
69
|
+
if (!hasTracked && untracked.length === 0) {
|
|
70
|
+
return { ok: false, reason: 'no-changes' };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// 2. Create an isolated worktree off the SAME base commit the edits were made
|
|
74
|
+
// against, on a NEW branch (so it never collides with `main` being checked
|
|
75
|
+
// out in the live tree, and never commits onto a shared branch).
|
|
76
|
+
const uid = shared.uid();
|
|
77
|
+
const sanitized = String(project.name || 'project').replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 40) || 'project';
|
|
78
|
+
const requested = branch && String(branch).trim();
|
|
79
|
+
const branchName = (requested && /^[\w./-]{1,80}$/.test(requested) && requested !== mainBranch)
|
|
80
|
+
? requested
|
|
81
|
+
: `cc-pr/${sanitized}-${uid}`;
|
|
82
|
+
|
|
83
|
+
let projectRoot;
|
|
84
|
+
try { projectRoot = shared.resolveProjectRootDir(localPath, minionsDir); }
|
|
85
|
+
catch (e) { throw new Error(`prepareCreatePrWorktree: cannot resolve project root for ${localPath} — ${e.message}`); }
|
|
86
|
+
const wtRel = (engine && engine.worktreeRoot) || shared.ENGINE_DEFAULTS.worktreeRoot;
|
|
87
|
+
const worktreesBase = path.resolve(projectRoot, wtRel);
|
|
88
|
+
const wtPath = path.join(worktreesBase, `cc-createpr-${sanitized}-${uid}`);
|
|
89
|
+
try { fsm.mkdirSync(worktreesBase, { recursive: true }); } catch { /* best-effort; worktree add will surface a real failure */ }
|
|
90
|
+
|
|
91
|
+
await git(
|
|
92
|
+
['-C', localPath, 'worktree', 'add', '-b', branchName, wtPath, baseSha],
|
|
93
|
+
{ timeout: shared.ENGINE_DEFAULTS.worktreeCreateTimeout },
|
|
94
|
+
);
|
|
95
|
+
try { shared.writeWorktreeOwnerMarker(wtPath, { source: 'cc-create-pr', project: project.name }); } catch { /* marker is best-effort */ }
|
|
96
|
+
|
|
97
|
+
// 3. Reproduce the live-checkout changes inside the worktree. If anything here
|
|
98
|
+
// fails, tear the worktree down and leave the live checkout untouched.
|
|
99
|
+
try {
|
|
100
|
+
if (hasTracked) {
|
|
101
|
+
const patchFile = path.join(wtPath, `.cc-createpr-${uid}.patch`);
|
|
102
|
+
fsm.writeFileSync(patchFile, trackedDiff);
|
|
103
|
+
await git(['-C', wtPath, 'apply', '--whitespace=nowarn', patchFile], { timeout: PATCH_APPLY_TIMEOUT_MS });
|
|
104
|
+
try { fsm.unlinkSync(patchFile); } catch { /* ignore */ }
|
|
105
|
+
}
|
|
106
|
+
for (const rel of untracked) {
|
|
107
|
+
// `?? dir/` entries from porcelain are directories — copy their tree.
|
|
108
|
+
const src = path.join(localPath, rel);
|
|
109
|
+
const dst = path.join(wtPath, rel);
|
|
110
|
+
_copyRecursive(fsm, src, dst);
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
try { await git(['-C', localPath, 'worktree', 'remove', '--force', wtPath]); } catch { /* leak rather than throw a second error */ }
|
|
114
|
+
throw new Error(
|
|
115
|
+
`prepareCreatePrWorktree: failed to stage changes into the worktree — ${e.message}. ` +
|
|
116
|
+
'The live checkout was left untouched.',
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// 4. The worktree now holds the changes — restore the live checkout clean.
|
|
121
|
+
await git(['-C', localPath, 'checkout', '--', '.']);
|
|
122
|
+
for (const rel of untracked) {
|
|
123
|
+
try { fsm.rmSync(path.join(localPath, rel.replace(/\/$/, '')), { recursive: true, force: true }); } catch { /* ignore */ }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
log('info', `[cc-create-pr] staged ${project.name} changes into isolated worktree ${wtPath} on branch ${branchName} (live checkout restored)`);
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
worktreePath: wtPath,
|
|
130
|
+
branch: branchName,
|
|
131
|
+
baseBranch: mainBranch,
|
|
132
|
+
baseSha,
|
|
133
|
+
trackedChanged: hasTracked,
|
|
134
|
+
untrackedCount: untracked.length,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Remove a worktree previously created by prepareCreatePrWorktree. Refuses to
|
|
140
|
+
* touch a path that doesn't carry the minions ownership marker (so a bad/forged
|
|
141
|
+
* path can never delete an arbitrary directory).
|
|
142
|
+
*/
|
|
143
|
+
async function cleanupCreatePrWorktree({ worktreePath, project, _git, _fs } = {}) {
|
|
144
|
+
const git = _git || ((args, opts) => shared.shellSafeGit(args, opts));
|
|
145
|
+
const fsm = _fs || fs;
|
|
146
|
+
if (!worktreePath || typeof worktreePath !== 'string') {
|
|
147
|
+
throw new Error('cleanupCreatePrWorktree: worktreePath required');
|
|
148
|
+
}
|
|
149
|
+
if (!fsm.existsSync(worktreePath)) {
|
|
150
|
+
return { ok: true, alreadyGone: true };
|
|
151
|
+
}
|
|
152
|
+
// Guard: only ever remove a directory the engine stamped as its own worktree.
|
|
153
|
+
if (!shared.hasWorktreeOwnerMarker(worktreePath)) {
|
|
154
|
+
return { ok: false, reason: 'not-owned' };
|
|
155
|
+
}
|
|
156
|
+
const fromDir = (project && project.localPath) || worktreePath;
|
|
157
|
+
try {
|
|
158
|
+
await git(['-C', fromDir, 'worktree', 'remove', '--force', worktreePath]);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
return { ok: false, reason: 'remove-failed', error: e.message };
|
|
161
|
+
}
|
|
162
|
+
return { ok: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Copy a file or directory tree (untracked entries may be either).
|
|
166
|
+
function _copyRecursive(fsm, src, dst) {
|
|
167
|
+
const st = fsm.statSync(src);
|
|
168
|
+
if (st.isDirectory()) {
|
|
169
|
+
fsm.mkdirSync(dst, { recursive: true });
|
|
170
|
+
for (const entry of fsm.readdirSync(src)) {
|
|
171
|
+
_copyRecursive(fsm, path.join(src, entry), path.join(dst, entry));
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
fsm.mkdirSync(path.dirname(dst), { recursive: true });
|
|
175
|
+
fsm.copyFileSync(src, dst);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
module.exports = { prepareCreatePrWorktree, cleanupCreatePrWorktree };
|
package/engine/lifecycle.js
CHANGED
|
@@ -1966,6 +1966,89 @@ async function enforcePrAttachmentContract(type, meta, agentId, config, resultSu
|
|
|
1966
1966
|
return { reason, itemId: meta.item.id, severity, phantom: isPhantom };
|
|
1967
1967
|
}
|
|
1968
1968
|
|
|
1969
|
+
// W-mqsk1ip00006cbae — After a verify WI transitions to done, assert that a PR
|
|
1970
|
+
// exists: either a direct reference on the item (_pr/_prUrl) or a matching
|
|
1971
|
+
// entry in pull-requests.json for the plan (by sourcePlan). If no PR is found,
|
|
1972
|
+
// flip the WI to failed with failure_class: verify-missing-pr and write an
|
|
1973
|
+
// inbox alert so operators can investigate.
|
|
1974
|
+
//
|
|
1975
|
+
// Returns null on the happy path (PR found or type is not verify).
|
|
1976
|
+
// Returns { reason, severity: 'hard', failureClass: 'verify-missing-pr' } when
|
|
1977
|
+
// the guard fires so the caller can set skipDoneStatus = true.
|
|
1978
|
+
function enforceVerifyPrContract(type, meta, agentId, config, resultSummary) {
|
|
1979
|
+
if (type !== WORK_TYPE.VERIFY) return null;
|
|
1980
|
+
if (!meta?.item?.id) return null;
|
|
1981
|
+
|
|
1982
|
+
const item = meta.item;
|
|
1983
|
+
|
|
1984
|
+
// Fast path: direct PR reference stamped on the WI.
|
|
1985
|
+
if (item._pr || item._prUrl) return null;
|
|
1986
|
+
|
|
1987
|
+
// Canonical tracker check: does any PR record list this WI in prdItems?
|
|
1988
|
+
try {
|
|
1989
|
+
if (hasCanonicalPrAttachment(item.id, config)) return null;
|
|
1990
|
+
} catch (err) {
|
|
1991
|
+
// If the check throws (e.g. unreadable PR file) we cannot confirm a PR
|
|
1992
|
+
// exists, so fall through to the failure path.
|
|
1993
|
+
log('warn', `enforceVerifyPrContract: canonical PR check failed for ${item.id}: ${err.message}`);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// Plan-level check: any PR with matching sourcePlan in project PR files.
|
|
1997
|
+
if (item.sourcePlan) {
|
|
1998
|
+
try {
|
|
1999
|
+
const projects = shared.getProjects(config);
|
|
2000
|
+
for (const p of projects) {
|
|
2001
|
+
const prs = readOptionalJsonStrict(shared.projectPrPath(p), 'project pull-requests', Array.isArray) || [];
|
|
2002
|
+
if (prs.some(pr => pr.sourcePlan === item.sourcePlan)) return null;
|
|
2003
|
+
}
|
|
2004
|
+
const centralPrs = readOptionalJsonStrict(path.join(MINIONS_DIR, 'pull-requests.json'), 'central pull-requests', Array.isArray) || [];
|
|
2005
|
+
if (centralPrs.some(pr => pr.sourcePlan === item.sourcePlan)) return null;
|
|
2006
|
+
} catch (err) {
|
|
2007
|
+
log('warn', `enforceVerifyPrContract: sourcePlan PR check failed for ${item.id}: ${err.message}`);
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
// No PR found — flip WI to failed and surface an alert.
|
|
2012
|
+
const failureClass = FAILURE_CLASS.VERIFY_MISSING_PR;
|
|
2013
|
+
const reason = `Verify WI ${item.id} completed done but no PR was found ` +
|
|
2014
|
+
`(no _prUrl/_pr on item, no canonical attachment, and no pull-requests.json entry for plan ` +
|
|
2015
|
+
`${item.sourcePlan || '(none)'}). Flipped to failed so the plan does not silently advance. ` +
|
|
2016
|
+
`(Branch: ${meta.branch || '(none)'}, agent: ${agentId})`;
|
|
2017
|
+
|
|
2018
|
+
const wiPath = resolveWorkItemPath(meta);
|
|
2019
|
+
if (wiPath) {
|
|
2020
|
+
mutateJsonFileLocked(wiPath, data => {
|
|
2021
|
+
if (!Array.isArray(data)) return data;
|
|
2022
|
+
const w = data.find(i => i.id === item.id);
|
|
2023
|
+
if (!w) return data;
|
|
2024
|
+
w.status = WI_STATUS.FAILED;
|
|
2025
|
+
w._failureClass = failureClass;
|
|
2026
|
+
w.failReason = reason;
|
|
2027
|
+
w.failedAt = ts();
|
|
2028
|
+
delete w.completedAt;
|
|
2029
|
+
return data;
|
|
2030
|
+
}, { skipWriteIfUnchanged: true });
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
if (item.sourcePlan) {
|
|
2034
|
+
try { syncPrdItemStatus(item.id, WI_STATUS.FAILED, item.sourcePlan); } catch (e) { log('warn', `verify-missing-pr PRD sync: ${e.message}`); }
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
shared.writeToInbox('engine', `verify-missing-pr-${item.id}`,
|
|
2038
|
+
`# Verify WI completed without a PR: ${item.id}\n\n` +
|
|
2039
|
+
`**Agent:** ${agentId}\n` +
|
|
2040
|
+
`**Work item:** \`${item.id}\` — ${item.title || ''}\n` +
|
|
2041
|
+
`**Plan:** ${item.sourcePlan || '(none)'}\n` +
|
|
2042
|
+
`**Branch:** ${meta.branch || '(none)'}\n\n` +
|
|
2043
|
+
`${reason}\n` +
|
|
2044
|
+
(resultSummary ? `\n## Agent summary\n${resultSummary}\n` : ''),
|
|
2045
|
+
null,
|
|
2046
|
+
{ sourceItem: item.id, reason: 'verify-missing-pr' });
|
|
2047
|
+
|
|
2048
|
+
log('warn', reason);
|
|
2049
|
+
return { reason, itemId: item.id, severity: 'hard', failureClass };
|
|
2050
|
+
}
|
|
2051
|
+
|
|
1969
2052
|
// ─── Post-Completion Hooks ──────────────────────────────────────────────────
|
|
1970
2053
|
|
|
1971
2054
|
/**
|
|
@@ -5141,6 +5224,17 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
5141
5224
|
}
|
|
5142
5225
|
}
|
|
5143
5226
|
|
|
5227
|
+
// W-mqsk1ip00006cbae — verify WI PR guard. After enforcePrAttachmentContract
|
|
5228
|
+
// (which skips verify types), assert that a verify WI has a PR before
|
|
5229
|
+
// allowing the done transition.
|
|
5230
|
+
if (effectiveSuccess && meta?.item?.id && !skipDoneStatus && !noopRationale) {
|
|
5231
|
+
const verifyContractFailure = enforceVerifyPrContract(type, meta, agentId, config, resultSummary);
|
|
5232
|
+
if (verifyContractFailure?.severity === 'hard') {
|
|
5233
|
+
completionContractFailure = completionContractFailure || verifyContractFailure;
|
|
5234
|
+
skipDoneStatus = true;
|
|
5235
|
+
}
|
|
5236
|
+
}
|
|
5237
|
+
|
|
5144
5238
|
if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
|
|
5145
5239
|
meta._agentId = agentId;
|
|
5146
5240
|
if (noopRationale) {
|
|
@@ -5946,6 +6040,7 @@ module.exports = {
|
|
|
5946
6040
|
deferNonTerminalCompletion,
|
|
5947
6041
|
deferPhantomCompletion,
|
|
5948
6042
|
enforcePrAttachmentContract,
|
|
6043
|
+
enforceVerifyPrContract,
|
|
5949
6044
|
markMissingPrAttachment,
|
|
5950
6045
|
parseCompletionReportFile,
|
|
5951
6046
|
resolveHarnessPropagated,
|
package/engine/pr-action.js
CHANGED
|
@@ -146,36 +146,62 @@ const CREATE_PR_FOLLOWUP = Object.freeze({ kind: 'create-pr', label: 'Create PR'
|
|
|
146
146
|
* well-named branch" wording when absent.
|
|
147
147
|
* `contextOnly` — when true, the chip links the new PR as context-only instead
|
|
148
148
|
* of auto-managed (the default is auto-managed / contextOnly:false).
|
|
149
|
+
* `checkoutMode` — the project's resolved checkout pattern
|
|
150
|
+
* (`shared.resolveCheckoutMode(project)`). The Create-PR action
|
|
151
|
+
* MUST follow the same pattern as a normal dispatch for that
|
|
152
|
+
* project: `'live'` operates in the live operator checkout
|
|
153
|
+
* (legacy behavior); `'worktree'` (the default) routes the
|
|
154
|
+
* changes through an isolated git worktree so nothing is ever
|
|
155
|
+
* committed in the operator's live `main`. Defaults to `'live'`
|
|
156
|
+
* for back-compat when a caller doesn't resolve it.
|
|
149
157
|
*
|
|
150
158
|
* Each chip: `{ kind:'create-pr', label, project, branch, message }` where
|
|
151
159
|
* `message` is the Command Center turn the chip click should send.
|
|
152
160
|
*/
|
|
153
|
-
function buildCreatePrFollowups({ project, branch, contextOnly = false } = {}) {
|
|
161
|
+
function buildCreatePrFollowups({ project, branch, contextOnly = false, checkoutMode = 'live' } = {}) {
|
|
154
162
|
const proj = (project && String(project).trim()) || '';
|
|
155
163
|
if (!proj) return [];
|
|
156
164
|
const br = branch && String(branch).trim() ? String(branch).trim() : '';
|
|
157
|
-
const branchClause = br
|
|
158
|
-
? `(the working tree is on branch ${br}; if ${br} is the project's default/main branch, create a new well-named branch off it and commit there rather than committing to ${br} directly)`
|
|
159
|
-
: 'on a new well-named branch off the project main branch';
|
|
160
165
|
const linkBody = contextOnly
|
|
161
166
|
? `{"url":"<new PR url>","project":"${proj}","contextOnly":true}`
|
|
162
167
|
: `{"url":"<new PR url>","project":"${proj}","contextOnly":false}`;
|
|
163
168
|
const trackNote = contextOnly
|
|
164
169
|
? 'so it is tracked but not auto-reviewed'
|
|
165
170
|
: 'so the engine auto-manages it (review -> fix -> re-review -> auto-merge)';
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
171
|
+
|
|
172
|
+
let message;
|
|
173
|
+
if (checkoutMode === 'worktree') {
|
|
174
|
+
// Worktree-mode projects: the server moves the live-checkout changes into an
|
|
175
|
+
// isolated worktree and restores the live checkout clean, so the commit /
|
|
176
|
+
// push / PR all happen OFF the operator's tree. CC never runs git in the
|
|
177
|
+
// live checkout — that is what kept leaking commits onto `main`.
|
|
178
|
+
message =
|
|
179
|
+
`Create a PR from the local changes you just made in the ${proj} project. ` +
|
|
180
|
+
`This project uses isolated worktrees, so do NOT commit, branch, or push in its live checkout. ` +
|
|
181
|
+
`Steps: (1) call POST /api/pr-action/prepare-create-pr-worktree with {"project":"${proj}"} — the server moves your uncommitted changes into a fresh isolated worktree, restores the live checkout clean, and returns {worktreePath, branch, baseBranch}; ` +
|
|
182
|
+
`(2) run all git from inside worktreePath (use \`git -C <worktreePath> ...\`): stage and commit the changes on the returned branch with a clear conventional-commit message; ` +
|
|
183
|
+
`(3) push that branch; (4) open a PR against baseBranch using the right CLI for the repo host (gh for GitHub, az repos for ADO); ` +
|
|
184
|
+
`(5) link it by calling POST /api/pull-requests/link with ${linkBody} ${trackNote}; ` +
|
|
185
|
+
`(6) finally call POST /api/pr-action/cleanup-create-pr-worktree with {"project":"${proj}","worktreePath":"<worktreePath>"} to remove the worktree. ` +
|
|
186
|
+
`Never git-commit/-push in the live checkout. Then show me the PR URL.`;
|
|
187
|
+
} else {
|
|
188
|
+
// Live-checkout projects: operate in place, but branch off main and restore
|
|
189
|
+
// the original branch so the PR branch is never left checked out.
|
|
190
|
+
const branchClause = br
|
|
191
|
+
? `(the working tree is on branch ${br}; if ${br} is the project's default/main branch, create a new well-named branch off it and commit there rather than committing to ${br} directly)`
|
|
192
|
+
: 'on a new well-named branch off the project main branch';
|
|
193
|
+
const restoreClause = br
|
|
194
|
+
? `switch the working tree back to the ORIGINAL branch (${br})`
|
|
195
|
+
: 'switch the working tree back to the original branch it started on';
|
|
196
|
+
message =
|
|
197
|
+
`Create a PR from the local changes you just made in the ${proj} project. ` +
|
|
198
|
+
(br ? `Remember the working tree's current branch (${br}) as the ORIGINAL branch to return to when done. ` : '') +
|
|
199
|
+
`Steps: (1) in that project's working tree, stage and commit the modified files with a clear conventional-commit message ${branchClause}; ` +
|
|
200
|
+
`(2) push the branch; (3) open a PR against the project's main branch using the right CLI for the repo host (gh for GitHub, az repos for ADO); ` +
|
|
201
|
+
`(4) link it to the tracker by calling POST /api/pull-requests/link with ${linkBody} ${trackNote}; ` +
|
|
202
|
+
`(5) finally, ${restoreClause} — do not leave the new PR branch checked out. ` +
|
|
203
|
+
`Then show me the PR URL.`;
|
|
204
|
+
}
|
|
179
205
|
return [{ kind: CREATE_PR_FOLLOWUP.kind, label: CREATE_PR_FOLLOWUP.label, project: proj, branch: br || null, message }];
|
|
180
206
|
}
|
|
181
207
|
|
package/engine/shared.js
CHANGED
|
@@ -4359,6 +4359,7 @@ const FAILURE_CLASS = {
|
|
|
4359
4359
|
WORKSPACE_MANIFEST_URL: 'workspace-manifest-url-forbidden', // W-mq07avbk000m5543: out-of-scope external URL fetch. Non-retryable as-is.
|
|
4360
4360
|
SPAWN_PHASE_STALL: 'spawn-phase-stall', // W-mq0e2dae000a003d: process spawned and ran startup-only events (MCP init / hooks) but never emitted real task progress; CPU usage stayed below threshold past the grace window. Engine kills the wedged child and treats this as retryable (fresh-session) so a re-spawn can clear a transient MCP wedge.
|
|
4361
4361
|
OUTPUT_TRUNCATED: 'output-truncated', // P-8e4c2a17: the agent streamed more stdout than the engine's hard capture cap (engine.js AGENT_OUTPUT_CAP_BYTES, 1MB) BEFORE the terminal `result` event arrived. The result (session id, completion block, final text) lives at the END of the stream, so it fell outside the captured window and parseOutput found nothing — the dispatch would otherwise fail as an opaque UNKNOWN and retry to death. Surfaced loudly with an actionable message; non-retryable (mechanical retry just reproduces the overflow — the agent must reduce output volume or the task must be split).
|
|
4362
|
+
VERIFY_MISSING_PR: 'verify-missing-pr', // W-mqsk1ip00006cbae: a verify WI exited done but no PR was attached (neither _prUrl/_pr on the item nor a matching pull-requests.json entry for the plan). Flipped to failed so the plan doesn't silently advance without an E2E PR. Retryable — agent may have phantom-crashed before pushing the branch.
|
|
4362
4363
|
UNKNOWN: 'unknown', // Unclassified failure
|
|
4363
4364
|
};
|
|
4364
4365
|
const ESCALATION_POLICY = {
|
|
@@ -5552,6 +5553,21 @@ function buildWorktreeDirName({
|
|
|
5552
5553
|
return `${projectSlug}-${branchSlug}-${suffix}`;
|
|
5553
5554
|
}
|
|
5554
5555
|
|
|
5556
|
+
// The worktree root (`<localPath>/../worktrees`) is a SHARED namespace: it holds
|
|
5557
|
+
// managed git worktrees AND infra/scratch dirs — most importantly `.agent-temp`,
|
|
5558
|
+
// the agent scratch base (`resolveAgentTempBaseDir`) where dispatched agents and
|
|
5559
|
+
// the test-suite-run-by-agents write thousands of throwaway git repos. Worktree
|
|
5560
|
+
// names from `buildWorktreeDirName` are ALWAYS `W-…` / `<project>-<branch>-<hash>`
|
|
5561
|
+
// and never dot-prefixed, so any dot-prefixed entry in the worktree root is infra
|
|
5562
|
+
// or scratch, never a managed worktree. The worktree sweeps MUST skip these — a
|
|
5563
|
+
// single `.agent-temp` accumulating thousands of entries otherwise makes the
|
|
5564
|
+
// sweep (which recurses one level into non-worktree dirs) readdir + stat the
|
|
5565
|
+
// whole pile every cleanup cycle, stalling the engine tick loop (engine "stale").
|
|
5566
|
+
const WORKTREE_SCRATCH_DIR_NAME = '.agent-temp';
|
|
5567
|
+
function isWorktreeRootInfraEntry(name) {
|
|
5568
|
+
return typeof name === 'string' && name.length > 0 && name.charCodeAt(0) === 46; // '.'
|
|
5569
|
+
}
|
|
5570
|
+
|
|
5555
5571
|
/**
|
|
5556
5572
|
* True when `childPath` is strictly nested within `parentPath` (descendant,
|
|
5557
5573
|
* NOT the same path). Cross-platform via `path.relative`; resilient to mixed
|
|
@@ -8903,6 +8919,8 @@ module.exports = {
|
|
|
8903
8919
|
deriveWorkItemBranchName,
|
|
8904
8920
|
safeSlugComponent,
|
|
8905
8921
|
buildWorktreeDirName, // exported for testing
|
|
8922
|
+
isWorktreeRootInfraEntry,
|
|
8923
|
+
WORKTREE_SCRATCH_DIR_NAME,
|
|
8906
8924
|
isPathInside,
|
|
8907
8925
|
isPathInsideOrEqual,
|
|
8908
8926
|
parseWorktreePorcelain,
|
package/engine/worktree-gc.js
CHANGED
|
@@ -819,6 +819,8 @@ function pruneOrphanWorktrees(opts) {
|
|
|
819
819
|
if (!ent || (typeof ent.isDirectory === 'function' && !ent.isDirectory())) continue;
|
|
820
820
|
const name = ent.name || ent;
|
|
821
821
|
if (typeof name !== 'string' || name.length === 0) continue;
|
|
822
|
+
// Skip infra/scratch dirs (`.agent-temp`, `.git`, …) — never worktrees.
|
|
823
|
+
if (shared.isWorktreeRootInfraEntry(name)) continue;
|
|
822
824
|
projStats.scanned++;
|
|
823
825
|
result.scanned++;
|
|
824
826
|
const wtPath = path.join(wtParent, name);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2254",
|
|
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
|
@@ -96,7 +96,11 @@ curl -s -X POST http://localhost:{{dashboard_port}}/api/pr-action/offer-create-p
|
|
|
96
96
|
-H 'Content-Type: application/json' -H 'X-CC-Turn-Id: {{cc_turn_id}}' \
|
|
97
97
|
-d '{"project":"<project name>"}'
|
|
98
98
|
```
|
|
99
|
-
This checks the project's working tree (`git status --porcelain`) and, when there are uncommitted changes, returns a **`[Create PR]`** follow-up chip to the user (same chip mechanism as the `pr-action` `[Comment]`/`[Fix once]`/`[Track for auto-fix]` chips). When the user clicks it, you'll receive a turn
|
|
99
|
+
This checks the project's working tree (`git status --porcelain`) and, when there are uncommitted changes, returns a **`[Create PR]`** follow-up chip to the user (same chip mechanism as the `pr-action` `[Comment]`/`[Fix once]`/`[Track for auto-fix]` chips). When the user clicks it, you'll receive a turn with **explicit step-by-step instructions — follow them exactly**. The instructions adapt to the project's checkout pattern, so just do what the turn says:
|
|
100
|
+
- **Worktree-mode projects** (the default): the turn tells you to first `POST /api/pr-action/prepare-create-pr-worktree {"project":"…"}`. The server moves your uncommitted changes into an **isolated worktree** and restores the live checkout clean, returning `{worktreePath, branch, baseBranch}`. You then run **all git from inside `worktreePath`** (`git -C <worktreePath> …`) to commit/push/open the PR, then `POST /api/pr-action/cleanup-create-pr-worktree` to remove it. **Never commit, branch, or push in the live checkout** — that leaks commits onto the operator's `main`.
|
|
101
|
+
- **Live-mode projects**: the turn tells you to commit on a new branch off main in the live checkout and switch back to the original branch when done.
|
|
102
|
+
|
|
103
|
+
Pass `"contextOnly":true` if the PR should be tracked-but-not-auto-reviewed; omit it to have the engine auto-manage the PR (review → fix → re-review → auto-merge). If `hasChanges` is `false`, there's nothing to PR — skip the offer. Don't commit/push on your own initiative; surface the chip and let the user decide.
|
|
100
104
|
|
|
101
105
|
## When to dispatch vs answer inline
|
|
102
106
|
|