@yemi33/minions 0.1.2382 → 0.1.2384
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/minions.js +1 -0
- package/dashboard/js/refresh.js +2 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard.js +37 -19
- package/docs/completion-reports.md +20 -1
- package/docs/keep-processes.md +7 -2
- package/docs/live-checkout-mode.md +7 -7
- package/docs/managed-spawn.md +14 -1
- package/docs/runtime-adapters.md +61 -26
- package/docs/skills.md +2 -0
- package/docs/workspace-manifests.md +1 -1
- package/docs/worktree-lifecycle.md +36 -0
- package/engine/acp-transport.js +273 -58
- package/engine/ado-comment.js +3 -1
- package/engine/agent-worker-pool.js +278 -54
- package/engine/cc-worker-pool.js +12 -3
- package/engine/cleanup.js +15 -11
- package/engine/cli.js +56 -28
- package/engine/comment-format.js +51 -14
- package/engine/create-pr-worktree.js +57 -79
- package/engine/dispatch.js +7 -2
- package/engine/execution-model.js +68 -0
- package/engine/gh-comment.js +6 -3
- package/engine/github.js +6 -7
- package/engine/keep-process-sweep.js +131 -9
- package/engine/lifecycle.js +126 -45
- package/engine/live-checkout.js +4 -2
- package/engine/llm.js +59 -83
- package/engine/managed-spawn.js +112 -5
- package/engine/memory-store.js +3 -1
- package/engine/playbook.js +4 -6
- package/engine/pooled-agent-process.js +512 -45
- package/engine/pr-action.js +6 -8
- package/engine/process-utils.js +154 -18
- package/engine/runtimes/claude.js +94 -25
- package/engine/runtimes/codex.js +1 -0
- package/engine/runtimes/contract.js +239 -0
- package/engine/runtimes/copilot.js +97 -9
- package/engine/runtimes/index.js +37 -9
- package/engine/shared.js +349 -82
- package/engine/spawn-agent.js +85 -116
- package/engine/spawn-phase-watchdog.js +8 -37
- package/engine/timeout.js +14 -10
- package/engine/worktree-gc.js +55 -46
- package/engine.js +418 -241
- package/package.json +1 -1
- package/playbooks/fix.md +20 -0
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +2 -2
- package/prompts/cc-system.md +11 -11
- package/skills/check-self-authored-review-comment/SKILL.md +34 -0
package/engine/cli.js
CHANGED
|
@@ -9,6 +9,7 @@ const shared = require('./shared');
|
|
|
9
9
|
const { safeRead, safeJson, safeJsonArr, safeWrite, readWorkItems, readPullRequests, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, REVIEW_STATUS, DISPATCH_RESULT, FAILURE_CLASS } = shared;
|
|
10
10
|
const queries = require('./queries');
|
|
11
11
|
const agentWorkerPool = require('./agent-worker-pool');
|
|
12
|
+
const { resolveCommentExecutionModel } = require('./execution-model');
|
|
12
13
|
const { getConfig, getControl, getDispatch, getAgentStatus,
|
|
13
14
|
MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH } = queries;
|
|
14
15
|
|
|
@@ -652,6 +653,12 @@ const commands = {
|
|
|
652
653
|
|
|
653
654
|
const hadPid = agentPid && agentPid > 0; // track before liveness check
|
|
654
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
|
+
}
|
|
655
662
|
|
|
656
663
|
// PID was found but confirmed dead — exempt from restart grace period (#869)
|
|
657
664
|
if (hadPid && !agentPid) {
|
|
@@ -811,18 +818,14 @@ const commands = {
|
|
|
811
818
|
);
|
|
812
819
|
} catch (err) { e.log('warn', `Orphan dispatch complete: ${err.message}`); }
|
|
813
820
|
|
|
814
|
-
//
|
|
815
|
-
//
|
|
816
|
-
//
|
|
817
|
-
|
|
818
|
-
// carries no checkoutMode. Mirrors the in-process onAgentClose wiring
|
|
819
|
-
// (engine.js). Fire-and-forget + best-effort: the helper swallows its
|
|
820
|
-
// own errors and only ever issues a plain `git checkout <originalRef>`
|
|
821
|
-
// (never --force/reset/clean/stash) against the operator tree.
|
|
822
|
-
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)) {
|
|
823
825
|
try {
|
|
824
826
|
require('./live-checkout').maybeRestoreLiveCheckoutFromRecord({
|
|
825
827
|
item,
|
|
828
|
+
config,
|
|
826
829
|
isTerminalFailure: !isSuccess,
|
|
827
830
|
resultLabel: result,
|
|
828
831
|
log: (lvl, msg) => e.log(lvl, msg),
|
|
@@ -1200,6 +1203,38 @@ const commands = {
|
|
|
1200
1203
|
const timeout = config.engine?.shutdownTimeout || shared.ENGINE_DEFAULTS.shutdownTimeout;
|
|
1201
1204
|
const deadline = Date.now() + timeout;
|
|
1202
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
|
+
|
|
1203
1238
|
const poll = setInterval(() => {
|
|
1204
1239
|
pooledIds = getOutstandingPooledIds();
|
|
1205
1240
|
if (pooledIds.length === 0 && agentWorkerPool.isDrained()) {
|
|
@@ -1209,24 +1244,10 @@ const commands = {
|
|
|
1209
1244
|
}
|
|
1210
1245
|
if (Date.now() >= deadline) {
|
|
1211
1246
|
clearInterval(poll);
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
e.log('warn', `Failed to cancel pooled dispatch ${id} during shutdown: ${err.message}`);
|
|
1217
|
-
}
|
|
1218
|
-
e.activeProcesses.delete(id);
|
|
1219
|
-
e.realActivityMap.delete(id);
|
|
1220
|
-
}
|
|
1221
|
-
e.completeDispatch(
|
|
1222
|
-
id,
|
|
1223
|
-
DISPATCH_RESULT.ERROR,
|
|
1224
|
-
'Pooled dispatch cancelled after graceful shutdown timeout',
|
|
1225
|
-
'',
|
|
1226
|
-
{ failureClass: FAILURE_CLASS.TIMEOUT, retryable: true },
|
|
1227
|
-
);
|
|
1228
|
-
}
|
|
1229
|
-
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
|
+
});
|
|
1230
1251
|
}
|
|
1231
1252
|
}, 2000);
|
|
1232
1253
|
}
|
|
@@ -2076,6 +2097,11 @@ const commands = {
|
|
|
2076
2097
|
console.error('error: must supply either --body-file <path> or --body <text>');
|
|
2077
2098
|
process.exit(2);
|
|
2078
2099
|
}
|
|
2100
|
+
const executionModel = resolveCommentExecutionModel({
|
|
2101
|
+
dispatch: getDispatch(),
|
|
2102
|
+
agentId,
|
|
2103
|
+
workItemId,
|
|
2104
|
+
});
|
|
2079
2105
|
|
|
2080
2106
|
// ── Azure DevOps: <prNumber> --host ado --ado-org --ado-project --repo-id ──
|
|
2081
2107
|
if (isAdo) {
|
|
@@ -2097,6 +2123,7 @@ const commands = {
|
|
|
2097
2123
|
const adoComment = require('./ado-comment');
|
|
2098
2124
|
adoComment.postAdoPrComment({
|
|
2099
2125
|
orgBase, project, repositoryId, prNumber, body, agentId, kind, workItemId,
|
|
2126
|
+
model: executionModel.model,
|
|
2100
2127
|
resolved: flags.resolved === true,
|
|
2101
2128
|
}).then((result) => {
|
|
2102
2129
|
if (result && result.threadId) {
|
|
@@ -2135,7 +2162,7 @@ const commands = {
|
|
|
2135
2162
|
|
|
2136
2163
|
try {
|
|
2137
2164
|
const result = ghComment.postPrComment({
|
|
2138
|
-
repo, prNumber, body, agentId, kind, workItemId,
|
|
2165
|
+
repo, prNumber, body, agentId, kind, workItemId, model: executionModel.model,
|
|
2139
2166
|
});
|
|
2140
2167
|
if (result.output) console.log(result.output);
|
|
2141
2168
|
} catch (e) {
|
|
@@ -2274,6 +2301,7 @@ module.exports = {
|
|
|
2274
2301
|
_readDispatchPid: readDispatchPid,
|
|
2275
2302
|
_normalizeSessionBranch: normalizeSessionBranch,
|
|
2276
2303
|
_dispatchSessionBranch: dispatchSessionBranch,
|
|
2304
|
+
_resolveCommentExecutionModel: resolveCommentExecutionModel, // exported for testing
|
|
2277
2305
|
// W-mpcyvff6000pf828 (#2653) — heartbeat writer + factory exported for tests
|
|
2278
2306
|
_writeHeartbeatNow: writeHeartbeatNow,
|
|
2279
2307
|
_createHeartbeatInterval: createHeartbeatInterval,
|
package/engine/comment-format.js
CHANGED
|
@@ -48,14 +48,17 @@ function linkifyBrandTrailer(text) {
|
|
|
48
48
|
// org/project/repoId).
|
|
49
49
|
//
|
|
50
50
|
// Marker format (single line, ASCII):
|
|
51
|
-
// <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> -->
|
|
51
|
+
// <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> model=<modelId> -->
|
|
52
52
|
//
|
|
53
53
|
// Validation (enforced before the marker is built):
|
|
54
54
|
// - agentId /^[a-z][a-z0-9-]{0,30}$/
|
|
55
55
|
// - kind /^[a-z][a-z0-9-]{0,30}$/
|
|
56
56
|
// - workItemId /^[A-Z]-[a-z0-9]+$/ (optional)
|
|
57
|
+
// - model concrete runtime execution model (optional)
|
|
57
58
|
// - no field may contain `--` (would close the HTML comment early)
|
|
58
59
|
|
|
60
|
+
const { normalizeExecutionModel } = require('./execution-model');
|
|
61
|
+
|
|
59
62
|
const AGENT_ID_RE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
60
63
|
const KIND_RE = /^[a-z][a-z0-9-]{0,30}$/;
|
|
61
64
|
const WORK_ITEM_ID_RE = /^[A-Z]-[a-z0-9]+$/;
|
|
@@ -63,7 +66,7 @@ const WORK_ITEM_ID_RE = /^[A-Z]-[a-z0-9]+$/;
|
|
|
63
66
|
// Marker line (single-line HTML comment). Multiline flag so it matches at the
|
|
64
67
|
// start of any line — required for round-trip detection of the builder output.
|
|
65
68
|
const MINIONS_COMMENT_MARKER_RE =
|
|
66
|
-
/^<!--\s*minions:agent=([^\s]+)\s+kind=([^\s]+)(?:\s+wi=([^\s]+))?\s*-->/m;
|
|
69
|
+
/^<!--\s*minions:agent=([^\s]+)\s+kind=([^\s]+)(?:\s+wi=([^\s]+))?(?:\s+model=([^\s]+))?\s*-->/m;
|
|
67
70
|
|
|
68
71
|
// Canonical-format sample marker for tests/fixtures that need a marked body but
|
|
69
72
|
// don't care about the specific fields.
|
|
@@ -85,18 +88,44 @@ function _validateField(name, value, re) {
|
|
|
85
88
|
}
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
function _validateMarkerInputs({ agentId, kind, workItemId }) {
|
|
91
|
+
function _validateMarkerInputs({ agentId, kind, workItemId, model }) {
|
|
89
92
|
_validateField('agentId', agentId, AGENT_ID_RE);
|
|
90
93
|
_validateField('kind', kind, KIND_RE);
|
|
91
94
|
if (workItemId !== undefined && workItemId !== null) {
|
|
92
95
|
_validateField('workItemId', workItemId, WORK_ITEM_ID_RE);
|
|
93
96
|
}
|
|
97
|
+
if (model !== undefined && model !== null && !normalizeExecutionModel(model)) {
|
|
98
|
+
throw new Error(`invalid model: ${JSON.stringify(model)}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _encodeMarkerModel(model) {
|
|
103
|
+
const encoded = encodeURIComponent(model);
|
|
104
|
+
return encoded.includes('--') ? encoded.replace(/-/g, '%2D') : encoded;
|
|
94
105
|
}
|
|
95
106
|
|
|
96
|
-
function _buildMarker({ agentId, kind, workItemId }) {
|
|
97
|
-
_validateMarkerInputs({ agentId, kind, workItemId });
|
|
107
|
+
function _buildMarker({ agentId, kind, workItemId, model }) {
|
|
108
|
+
_validateMarkerInputs({ agentId, kind, workItemId, model });
|
|
98
109
|
const wi = (workItemId !== undefined && workItemId !== null) ? ` wi=${workItemId}` : '';
|
|
99
|
-
|
|
110
|
+
const modelPart = model ? ` model=${_encodeMarkerModel(model)}` : '';
|
|
111
|
+
return `<!-- minions:agent=${agentId} kind=${kind}${wi}${modelPart} -->`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const MODEL_SIGNOFF_RE =
|
|
115
|
+
/(\b(?:Review|Reviewed|Fixed|Verified|Rebased) by (?:Minions|\[Minions\]\([^)]+\)) \([^\r\n)]*? · )[^)\r\n]+(\))/g;
|
|
116
|
+
|
|
117
|
+
function stampExecutionModel(text, model) {
|
|
118
|
+
const normalized = normalizeExecutionModel(model);
|
|
119
|
+
if (!normalized || typeof text !== 'string') return text;
|
|
120
|
+
return text.replace(MODEL_SIGNOFF_RE, (_match, prefix, suffix) => `${prefix}${normalized}${suffix}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _stampLeadingMarkerModel(text, model) {
|
|
124
|
+
if (!model || typeof text !== 'string') return text;
|
|
125
|
+
return text.replace(MINIONS_COMMENT_MARKER_RE, (marker, agentId, kind, workItemId) => {
|
|
126
|
+
if (marker !== text.slice(0, marker.length)) return marker;
|
|
127
|
+
return _buildMarker({ agentId, kind, workItemId, model });
|
|
128
|
+
});
|
|
100
129
|
}
|
|
101
130
|
|
|
102
131
|
/**
|
|
@@ -106,41 +135,49 @@ function _buildMarker({ agentId, kind, workItemId }) {
|
|
|
106
135
|
* Idempotency: if `body` already starts with a minions marker it is returned
|
|
107
136
|
* with the brand transform applied but no second marker prepended.
|
|
108
137
|
*
|
|
109
|
-
* @param {{agentId:string, kind:string, workItemId?:string, body?:string}} args
|
|
138
|
+
* @param {{agentId:string, kind:string, workItemId?:string, model?:string, body?:string}} args
|
|
110
139
|
* @returns {string} the final comment body
|
|
111
140
|
*/
|
|
112
|
-
function buildMinionsCommentBody({ agentId, kind, workItemId, body }) {
|
|
141
|
+
function buildMinionsCommentBody({ agentId, kind, workItemId, model, body }) {
|
|
113
142
|
// Validate the inputs even when the body is pre-marked, so callers can't
|
|
114
143
|
// silently bypass validation by pre-marking their body.
|
|
115
|
-
_validateMarkerInputs({ agentId, kind, workItemId });
|
|
144
|
+
_validateMarkerInputs({ agentId, kind, workItemId, model });
|
|
116
145
|
let safeBody = body == null ? '' : String(body);
|
|
117
146
|
|
|
147
|
+
safeBody = stampExecutionModel(safeBody, model);
|
|
148
|
+
|
|
118
149
|
// Brand-link safety net: deterministically hyperlink the first bare
|
|
119
150
|
// "… by Minions" signature trailer (no-op when the agent already linked it).
|
|
120
151
|
safeBody = linkifyBrandTrailer(safeBody);
|
|
121
152
|
|
|
122
|
-
if (_LEADING_MARKER_RE.test(safeBody)) return safeBody;
|
|
123
|
-
const marker = _buildMarker({ agentId, kind, workItemId });
|
|
153
|
+
if (_LEADING_MARKER_RE.test(safeBody)) return _stampLeadingMarkerModel(safeBody, model);
|
|
154
|
+
const marker = _buildMarker({ agentId, kind, workItemId, model });
|
|
124
155
|
return `${marker}\n\n${safeBody}`;
|
|
125
156
|
}
|
|
126
157
|
|
|
127
158
|
/**
|
|
128
|
-
* Inverse of the marker builder: parse `{agentId, kind, workItemId}`
|
|
129
|
-
* marked body, or null when no marker is present.
|
|
159
|
+
* Inverse of the marker builder: parse `{agentId, kind, workItemId, model?}`
|
|
160
|
+
* out of a marked body, or null when no marker is present.
|
|
130
161
|
*/
|
|
131
162
|
function parseMinionsMarker(body) {
|
|
132
163
|
if (typeof body !== 'string' || body.length === 0) return null;
|
|
133
164
|
const m = body.match(MINIONS_COMMENT_MARKER_RE);
|
|
134
165
|
if (!m) return null;
|
|
135
|
-
|
|
166
|
+
const parsed = {
|
|
136
167
|
agentId: m[1],
|
|
137
168
|
kind: m[2],
|
|
138
169
|
workItemId: m[3] === undefined ? undefined : m[3],
|
|
139
170
|
};
|
|
171
|
+
if (m[4] !== undefined) {
|
|
172
|
+
try { parsed.model = decodeURIComponent(m[4]); }
|
|
173
|
+
catch { return null; }
|
|
174
|
+
}
|
|
175
|
+
return parsed;
|
|
140
176
|
}
|
|
141
177
|
|
|
142
178
|
module.exports = {
|
|
143
179
|
linkifyBrandTrailer,
|
|
180
|
+
stampExecutionModel,
|
|
144
181
|
MINIONS_BRAND_URL,
|
|
145
182
|
// Neutral marker + body builder (consumed by gh-comment.js and ado-comment.js).
|
|
146
183
|
buildMinionsCommentBody,
|
|
@@ -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]);
|
package/engine/dispatch.js
CHANGED
|
@@ -978,7 +978,12 @@ function markCompletedSourceWorkItem(item) {
|
|
|
978
978
|
// ─── Complete Dispatch ───────────────────────────────────────────────────────
|
|
979
979
|
|
|
980
980
|
function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', resultSummary = '', opts = {}) {
|
|
981
|
-
const {
|
|
981
|
+
const {
|
|
982
|
+
processWorkItemFailure = true,
|
|
983
|
+
processWorkItemSuccess = false,
|
|
984
|
+
countAgentRetryFailure = true,
|
|
985
|
+
failureClass,
|
|
986
|
+
} = opts;
|
|
982
987
|
const agentRetryable = normalizeRetryableDecision(opts.agentRetryable ?? opts.retryable);
|
|
983
988
|
let item = null;
|
|
984
989
|
let completedSourceWorkItem = false;
|
|
@@ -1185,7 +1190,7 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
1185
1190
|
// agent hits the threshold. Skip when no agent is resolvable
|
|
1186
1191
|
// (anonymous failures shouldn't corrupt the map shape).
|
|
1187
1192
|
const failedAgent = item.agent || wi.dispatched_to;
|
|
1188
|
-
if (failedAgent) shared.bumpAgentRetryCount(wi, failedAgent);
|
|
1193
|
+
if (failedAgent && countAgentRetryFailure) shared.bumpAgentRetryCount(wi, failedAgent);
|
|
1189
1194
|
delete wi.failReason;
|
|
1190
1195
|
delete wi.failedAt;
|
|
1191
1196
|
delete wi.dispatched_at;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-model evidence shared by dispatch, PR-comment, and review persistence.
|
|
3
|
+
*
|
|
4
|
+
* Priority is deliberate: runtime completion output is authoritative, an
|
|
5
|
+
* earlier stream/session capture is next, and the requested model is only a
|
|
6
|
+
* fallback when Minions explicitly sent one to the runtime. No catalog-based
|
|
7
|
+
* default inference belongs here.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const UNKNOWN_MODEL = 'unknown';
|
|
11
|
+
const MODEL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
|
|
12
|
+
|
|
13
|
+
function normalizeExecutionModel(value) {
|
|
14
|
+
if (typeof value !== 'string') return null;
|
|
15
|
+
const model = value.trim();
|
|
16
|
+
return MODEL_ID_RE.test(model) ? model : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function resolveExecutionModel({
|
|
20
|
+
reportedModel,
|
|
21
|
+
capturedModel,
|
|
22
|
+
requestedModel,
|
|
23
|
+
} = {}) {
|
|
24
|
+
for (const [source, value] of [
|
|
25
|
+
['reported', reportedModel],
|
|
26
|
+
['captured', capturedModel],
|
|
27
|
+
['requested', requestedModel],
|
|
28
|
+
]) {
|
|
29
|
+
const model = normalizeExecutionModel(value);
|
|
30
|
+
if (model) return { model, source };
|
|
31
|
+
}
|
|
32
|
+
return { model: UNKNOWN_MODEL, source: 'unknown' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function _dispatchWorkItemId(item) {
|
|
36
|
+
return item?.meta?.item?.id
|
|
37
|
+
|| item?.meta?.workItemId
|
|
38
|
+
|| item?.workItemId
|
|
39
|
+
|| null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveCommentExecutionModel({
|
|
43
|
+
dispatch,
|
|
44
|
+
agentId,
|
|
45
|
+
workItemId,
|
|
46
|
+
} = {}) {
|
|
47
|
+
const active = Array.isArray(dispatch?.active) ? dispatch.active : [];
|
|
48
|
+
const candidates = active.filter((item) => {
|
|
49
|
+
if (String(item?.agent || '').toLowerCase() !== String(agentId || '').toLowerCase()) return false;
|
|
50
|
+
if (!workItemId) return true;
|
|
51
|
+
return item.id === workItemId || _dispatchWorkItemId(item) === workItemId;
|
|
52
|
+
});
|
|
53
|
+
candidates.sort((a, b) => String(b.started_at || '').localeCompare(String(a.started_at || '')));
|
|
54
|
+
const item = candidates[0] || null;
|
|
55
|
+
if (!item) return { model: null, source: 'none' };
|
|
56
|
+
return resolveExecutionModel({
|
|
57
|
+
capturedModel: item?.executionModel,
|
|
58
|
+
requestedModel: item?.requestedModel,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
UNKNOWN_MODEL,
|
|
64
|
+
MODEL_ID_RE,
|
|
65
|
+
normalizeExecutionModel,
|
|
66
|
+
resolveExecutionModel,
|
|
67
|
+
resolveCommentExecutionModel,
|
|
68
|
+
};
|
package/engine/gh-comment.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* minions-authored PR comments by structure rather than by body shape.
|
|
5
5
|
*
|
|
6
6
|
* Marker format (single line, ASCII):
|
|
7
|
-
* <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> -->
|
|
7
|
+
* <!-- minions:agent=<agentId> kind=<kind> wi=<workItemId> model=<modelId> -->
|
|
8
8
|
*
|
|
9
9
|
* Followed by `\n\n` and then the caller-provided body. `wi=` is omitted when
|
|
10
10
|
* no workItemId is supplied. The marker is intentionally minimal so it round-
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* - agentId /^[a-z][a-z0-9-]{0,30}$/ (lowercase, hyphenated, ≤31 chars)
|
|
15
15
|
* - kind /^[a-z][a-z0-9-]{0,30}$/ (same shape — categorical tag)
|
|
16
16
|
* - workItemId /^[A-Z]-[a-z0-9]+$/ (e.g. W-mp3bp0ha000997ab, P-d5a8c9b6)
|
|
17
|
+
* - model concrete runtime model, when active-dispatch evidence exists
|
|
17
18
|
* - no field may contain `--` (would close the HTML comment early)
|
|
18
19
|
* - no field may contain `=` `<` `>` `\n` `"` `'` `` ` `` ` ` (whitespace)
|
|
19
20
|
*
|
|
@@ -153,13 +154,14 @@ function postPrComment({
|
|
|
153
154
|
agentId,
|
|
154
155
|
kind,
|
|
155
156
|
workItemId,
|
|
157
|
+
model,
|
|
156
158
|
timeoutMs = 30000,
|
|
157
159
|
execFileSync = _execFileSync,
|
|
158
160
|
resolveTokenForSlug,
|
|
159
161
|
} = {}) {
|
|
160
162
|
_validateRepo(repo);
|
|
161
163
|
_validatePrNumber(prNumber);
|
|
162
|
-
const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
|
|
164
|
+
const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, model, body });
|
|
163
165
|
const file = _writeTempBodyFile(finalBody);
|
|
164
166
|
const env = _resolveTokenEnvForRepo(repo, resolveTokenForSlug);
|
|
165
167
|
try {
|
|
@@ -182,13 +184,14 @@ function postPrReviewComment({
|
|
|
182
184
|
agentId,
|
|
183
185
|
kind,
|
|
184
186
|
workItemId,
|
|
187
|
+
model,
|
|
185
188
|
timeoutMs = 30000,
|
|
186
189
|
execFileSync = _execFileSync,
|
|
187
190
|
resolveTokenForSlug,
|
|
188
191
|
} = {}) {
|
|
189
192
|
_validateRepo(repo);
|
|
190
193
|
_validatePrNumber(prNumber);
|
|
191
|
-
const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
|
|
194
|
+
const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, model, body });
|
|
192
195
|
const file = _writeTempBodyFile(finalBody);
|
|
193
196
|
const env = _resolveTokenEnvForRepo(repo, resolveTokenForSlug);
|
|
194
197
|
try {
|