@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/github.js
CHANGED
|
@@ -282,10 +282,10 @@ async function _resolveViewerLogin(slug) {
|
|
|
282
282
|
// `_isMinionsAuthoredComment` cannot tell minions posts apart from human
|
|
283
283
|
// posts (the `viewerDidAuthor` gate stays false for everything), so the
|
|
284
284
|
// comment classifier reclassifies every minions comment as human and
|
|
285
|
-
// queues a spurious fix dispatch on every poll cycle.
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
// data is worth tracking so future audits can prioritize a deeper fix
|
|
285
|
+
// queues a spurious fix dispatch on every poll cycle. Review-fix completion
|
|
286
|
+
// now rejects shared-identity-only no-ops, so failing closed here prevents
|
|
287
|
+
// both duplicate traffic and false completion retries. The frequency
|
|
288
|
+
// data is still worth tracking so future audits can prioritize a deeper fix
|
|
289
289
|
// (e.g. a credentialed re-probe of the gh CLI). Global counter under
|
|
290
290
|
// `_engine.viewerLoginResolutionFailures`; surfaced through the existing
|
|
291
291
|
// engine metrics aggregation (`getMetrics()` in `engine/queries.js`).
|
|
@@ -1241,9 +1241,8 @@ async function pollPrHumanComments(config) {
|
|
|
1241
1241
|
// `c.viewerDidAuthor === true`, and `_backfillViewerDidAuthor` is a
|
|
1242
1242
|
// no-op when viewerLogin is null. Running the classifier without a
|
|
1243
1243
|
// login mass-misclassifies every minions comment as human and queues
|
|
1244
|
-
// a redundant fix dispatch on every poll cycle
|
|
1245
|
-
//
|
|
1246
|
-
// a slot/token tax — see audit OQ-Y, HIGH not CRITICAL). Skip the
|
|
1244
|
+
// a redundant fix dispatch on every poll cycle. Shared identity is not
|
|
1245
|
+
// valid review-fix no-op evidence, so skip the
|
|
1247
1246
|
// entire round instead. One log + one counter increment per affected
|
|
1248
1247
|
// PR per cycle (no per-comment fanout).
|
|
1249
1248
|
if (!viewerLogin) {
|
|
@@ -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
|
@@ -17,6 +17,7 @@ const harness = require('./harness');
|
|
|
17
17
|
const smallStateStore = require('./small-state-store');
|
|
18
18
|
const prdStore = require('./prd-store');
|
|
19
19
|
const { isBranchActive } = require('./cooldown');
|
|
20
|
+
const { resolveExecutionModel } = require('./execution-model');
|
|
20
21
|
const { worktreeMatchesBranch, getWorktreeBranch, cleanupMergedPrLocalBranch } = require('./cleanup');
|
|
21
22
|
const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
|
|
22
23
|
MINIONS_DIR, ENGINE_DIR, PLANS_DIR, INBOX_DIR, AGENTS_DIR } = queries;
|
|
@@ -2319,7 +2320,7 @@ function resolveReviewPrContext(pr, project, config, structuredCompletion = null
|
|
|
2319
2320
|
: null;
|
|
2320
2321
|
}
|
|
2321
2322
|
|
|
2322
|
-
async function updatePrAfterReview(agentId, pr, project, config, resultSummary, structuredCompletion = null, dispatchItem = null) {
|
|
2323
|
+
async function updatePrAfterReview(agentId, pr, project, config, resultSummary, structuredCompletion = null, dispatchItem = null, executionMetadata = null) {
|
|
2323
2324
|
|
|
2324
2325
|
if (!config) config = getConfig();
|
|
2325
2326
|
const completionStatus = normalizeCompletionStatus(structuredCompletion?.status);
|
|
@@ -2338,6 +2339,11 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
2338
2339
|
const reviewProject = reviewContext.project;
|
|
2339
2340
|
const prScope = reviewContext.scope;
|
|
2340
2341
|
const reviewerName = config.agents?.[agentId]?.name || agentId;
|
|
2342
|
+
const reviewExecutionModel = resolveExecutionModel({
|
|
2343
|
+
reportedModel: executionMetadata?.reportedModel,
|
|
2344
|
+
capturedModel: dispatchItem?.executionModel,
|
|
2345
|
+
requestedModel: dispatchItem?.requestedModel,
|
|
2346
|
+
});
|
|
2341
2347
|
|
|
2342
2348
|
// Check actual review status from the platform (agent may have approved or requested changes)
|
|
2343
2349
|
// If platform hasn't propagated the vote yet (returns 'pending'), keep current status unchanged.
|
|
@@ -2549,6 +2555,7 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
2549
2555
|
reviewer: reviewerName,
|
|
2550
2556
|
reviewedAt: ts(),
|
|
2551
2557
|
note: reviewNote,
|
|
2558
|
+
model: reviewExecutionModel.model,
|
|
2552
2559
|
dispatchId: dispatchItem?.id || structuredCompletion?.dispatchId || null,
|
|
2553
2560
|
sourceItem: dispatchItem?.meta?.item?.id || null,
|
|
2554
2561
|
...(reviewThreads.length > 0 ? { threads: reviewThreads } : {}),
|
|
@@ -3134,7 +3141,6 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
|
|
|
3134
3141
|
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
3135
3142
|
options = { automationCauseKey: options, dispatchId: legacyDispatchId };
|
|
3136
3143
|
}
|
|
3137
|
-
const explicitlyChangedBranch = options.branchChanged !== false;
|
|
3138
3144
|
const prScope = project || 'central';
|
|
3139
3145
|
const automationCauseKey = options.automationCauseKey || options.dispatchItem?.meta?.automationCauseKey || '';
|
|
3140
3146
|
const fixDispatchId = options.dispatchItem?.id || options.dispatchId || legacyDispatchId || '';
|
|
@@ -3143,6 +3149,15 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
|
|
|
3143
3149
|
source,
|
|
3144
3150
|
task: options.dispatchItem?.task,
|
|
3145
3151
|
});
|
|
3152
|
+
const evidenceOnlyReviewResolution = cause === shared.PR_FIX_CAUSE.REVIEW_FEEDBACK
|
|
3153
|
+
&& options.reviewFindingResolution;
|
|
3154
|
+
// Review-fix completion is governed by the live branch probe. Preserve the
|
|
3155
|
+
// legacy files_changed behavior for unrelated fix causes.
|
|
3156
|
+
let explicitlyChangedBranch = options.branchChanged !== false;
|
|
3157
|
+
if (cause === shared.PR_FIX_CAUSE.REVIEW_FEEDBACK && options.branchChange) {
|
|
3158
|
+
explicitlyChangedBranch = true;
|
|
3159
|
+
}
|
|
3160
|
+
if (evidenceOnlyReviewResolution) explicitlyChangedBranch = false;
|
|
3146
3161
|
let result = null;
|
|
3147
3162
|
shared.mutatePullRequests(prScope, (prs) => {
|
|
3148
3163
|
if (!Array.isArray(prs)) return prs;
|
|
@@ -3486,7 +3501,7 @@ async function rebaseBranchOntoMain(pr, project, config) {
|
|
|
3486
3501
|
// and produce mirror writes. Misconfigured engine.worktreeRoot is the only
|
|
3487
3502
|
// way this lands inside root; the assert throws so the caller can recover.
|
|
3488
3503
|
try {
|
|
3489
|
-
shared.
|
|
3504
|
+
shared.assertWorktreeOutsideProjects(tmpWt, shared.getProjects(config));
|
|
3490
3505
|
} catch (err) {
|
|
3491
3506
|
log('warn', `Post-merge rebase: refusing nested worktree path — ${err.message}`);
|
|
3492
3507
|
return { success: false, error: err.message };
|
|
@@ -4569,8 +4584,8 @@ function parseCompletionBoolean(value) {
|
|
|
4569
4584
|
}
|
|
4570
4585
|
|
|
4571
4586
|
// Detect a deliberate no-op completion — the agent correctly declined to make
|
|
4572
|
-
// changes (work was already shipped, dispatch premise was wrong,
|
|
4573
|
-
//
|
|
4587
|
+
// changes (work was already shipped, dispatch premise was wrong, or a finding
|
|
4588
|
+
// was proven resolved/invalid with current-code evidence) and should NOT be flagged as a
|
|
4574
4589
|
// silent failure for missing a PR. Honored signals:
|
|
4575
4590
|
// - completion.noop === true (canonical, primary)
|
|
4576
4591
|
// - completion.result === 'noop' OR completion.result.type === 'noop'
|
|
@@ -4651,6 +4666,39 @@ function classifyBenignZeroTargetOutcome(dispatchItem, completion, resultSummary
|
|
|
4651
4666
|
return noMutation ? evidence : null;
|
|
4652
4667
|
}
|
|
4653
4668
|
|
|
4669
|
+
function validateReviewFixCompletion({ type, meta, branchChange, structuredCompletion } = {}) {
|
|
4670
|
+
if (!shared.isFixLikeWorkType(type)) return { valid: true };
|
|
4671
|
+
const cause = shared.getPrFixAutomationCause({
|
|
4672
|
+
dispatchKey: meta?.dispatchKey,
|
|
4673
|
+
source: meta?.source,
|
|
4674
|
+
task: meta?.item?.title,
|
|
4675
|
+
});
|
|
4676
|
+
if (cause !== shared.PR_FIX_CAUSE.REVIEW_FEEDBACK
|
|
4677
|
+
|| (branchChange?.changed === true && branchChange.evidence === 'remote-head')) {
|
|
4678
|
+
return { valid: true };
|
|
4679
|
+
}
|
|
4680
|
+
|
|
4681
|
+
const findingId = shared.prReviewFindingIdentity(meta?.pr);
|
|
4682
|
+
const resolution = structuredCompletion?.reviewFindingResolution;
|
|
4683
|
+
const disposition = String(resolution?.disposition || '').trim().toLowerCase();
|
|
4684
|
+
const reportedFindingId = String(resolution?.findingId || '').trim();
|
|
4685
|
+
const currentCodeEvidence = String(resolution?.currentCodeEvidence || '').trim();
|
|
4686
|
+
const hasCurrentCodeReference = /(?:[a-z]:[\\/])?[\w./\\-]+\.[a-z0-9]+:\d+\b/i.test(currentCodeEvidence)
|
|
4687
|
+
|| /\bcommit\s+[0-9a-f]{7,40}\b/i.test(currentCodeEvidence);
|
|
4688
|
+
const validDisposition = disposition === 'already-resolved' || disposition === 'invalid';
|
|
4689
|
+
const explicitNoop = !!parseCompletionNoop(structuredCompletion);
|
|
4690
|
+
|
|
4691
|
+
if (explicitNoop && validDisposition && reportedFindingId === findingId && hasCurrentCodeReference) {
|
|
4692
|
+
return { valid: true, findingId, resolution };
|
|
4693
|
+
}
|
|
4694
|
+
|
|
4695
|
+
return {
|
|
4696
|
+
valid: false,
|
|
4697
|
+
findingId,
|
|
4698
|
+
reason: `Review-fix completion did not advance the PR branch and must explicitly prove the exact finding is already resolved or invalid with current-code evidence. Expected reviewFindingResolution.findingId="${findingId}", disposition "already-resolved" or "invalid", and a file:line or commit reference. Shared platform ownership, viewerDidAuthor, or reviewer/fixer agent equality is not evidence.`,
|
|
4699
|
+
};
|
|
4700
|
+
}
|
|
4701
|
+
|
|
4654
4702
|
function normalizeReviewVerdict(verdict) {
|
|
4655
4703
|
const value = String(verdict || '').trim().toLowerCase().replace(/[\s-]+/g, '_');
|
|
4656
4704
|
if (value === 'approve' || value === 'approved') return REVIEW_STATUS.APPROVED;
|
|
@@ -5807,13 +5855,70 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
5807
5855
|
}
|
|
5808
5856
|
|
|
5809
5857
|
// No-op signal: agent declared the work was correctly NOT done (already
|
|
5810
|
-
// shipped, dispatch premise wrong,
|
|
5858
|
+
// shipped, dispatch premise wrong, evidence-backed resolved finding, etc.). Skip the PR
|
|
5811
5859
|
// attachment contract — a missing PR is intentional, not a silent failure.
|
|
5812
|
-
|
|
5860
|
+
let prFixBranchChange = null;
|
|
5861
|
+
let prFixBuildStillFailing = null;
|
|
5862
|
+
if (shared.isFixLikeWorkType(type) && effectiveSuccess && meta?.pr?.id) {
|
|
5863
|
+
try {
|
|
5864
|
+
prFixBranchChange = await detectPrFixBranchChange(meta, config);
|
|
5865
|
+
} catch (err) {
|
|
5866
|
+
log('warn', `PR fix no-op detection for ${meta.pr.id}: ${err.message}`);
|
|
5867
|
+
prFixBranchChange = { changed: null, reason: err.message };
|
|
5868
|
+
}
|
|
5869
|
+
if (prFixBranchChange?.changed === false) {
|
|
5870
|
+
const fixCause = shared.getPrFixAutomationCause({
|
|
5871
|
+
dispatchKey: dispatchItem?.meta?.dispatchKey,
|
|
5872
|
+
source: meta?.source,
|
|
5873
|
+
task: dispatchItem?.task,
|
|
5874
|
+
});
|
|
5875
|
+
if (fixCause === shared.PR_FIX_CAUSE.BUILD_FAILURE) {
|
|
5876
|
+
try {
|
|
5877
|
+
const liveStatus = await checkBuildStillFailingLive(meta.pr, meta.project);
|
|
5878
|
+
if (liveStatus === shared.BUILD_STATUS.FAILING) {
|
|
5879
|
+
prFixBuildStillFailing = liveStatus;
|
|
5880
|
+
}
|
|
5881
|
+
} catch (err) {
|
|
5882
|
+
log('warn', `Live build re-check for ${meta.pr.id}: ${err.message}`);
|
|
5883
|
+
}
|
|
5884
|
+
}
|
|
5885
|
+
}
|
|
5886
|
+
}
|
|
5887
|
+
let noopRationale = (effectiveSuccess && !skipDoneStatus)
|
|
5813
5888
|
? (parseCompletionNoop(structuredCompletion) || benignNoop)
|
|
5814
5889
|
: null;
|
|
5890
|
+
let reviewFixResolution = null;
|
|
5891
|
+
if (effectiveSuccess && !skipDoneStatus) {
|
|
5892
|
+
const reviewFixValidation = validateReviewFixCompletion({
|
|
5893
|
+
type,
|
|
5894
|
+
meta,
|
|
5895
|
+
branchChange: prFixBranchChange,
|
|
5896
|
+
structuredCompletion,
|
|
5897
|
+
});
|
|
5898
|
+
if (!reviewFixValidation.valid) {
|
|
5899
|
+
skipDoneStatus = true;
|
|
5900
|
+
meta._agentId = agentId;
|
|
5901
|
+
const detection = {
|
|
5902
|
+
phrase: 'review-fix-resolution-evidence-missing',
|
|
5903
|
+
reason: reviewFixValidation.reason,
|
|
5904
|
+
};
|
|
5905
|
+
const reason = meta?.item?.id
|
|
5906
|
+
? deferNonTerminalCompletion(meta, detection)
|
|
5907
|
+
: detection.reason;
|
|
5908
|
+
completionContractFailure = {
|
|
5909
|
+
reason,
|
|
5910
|
+
itemId: meta?.item?.id || dispatchItem.id,
|
|
5911
|
+
nonTerminal: true,
|
|
5912
|
+
processWorkItemFailure: false,
|
|
5913
|
+
};
|
|
5914
|
+
noopRationale = null;
|
|
5915
|
+
log('warn', `Review-fix completion rejected for ${meta?.item?.id || dispatchItem.id}: ${reviewFixValidation.reason}`);
|
|
5916
|
+
} else {
|
|
5917
|
+
reviewFixResolution = reviewFixValidation.resolution || null;
|
|
5918
|
+
}
|
|
5919
|
+
}
|
|
5815
5920
|
if (noopRationale) {
|
|
5816
|
-
log('info', `No-op completion for ${meta
|
|
5921
|
+
log('info', `No-op completion for ${meta?.item?.id || dispatchItem.id}: ${noopRationale.slice(0, 200)}`);
|
|
5817
5922
|
}
|
|
5818
5923
|
|
|
5819
5924
|
if (effectiveSuccess && meta?.item?.id && !skipDoneStatus && !noopRationale) {
|
|
@@ -6051,41 +6156,6 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
6051
6156
|
|
|
6052
6157
|
// Archive is manual — user archives plans from the dashboard when ready
|
|
6053
6158
|
|
|
6054
|
-
let prFixBranchChange = null;
|
|
6055
|
-
let prFixBuildStillFailing = null;
|
|
6056
|
-
if (shared.isFixLikeWorkType(type) && effectiveSuccess && meta?.pr?.id) {
|
|
6057
|
-
try {
|
|
6058
|
-
prFixBranchChange = await detectPrFixBranchChange(meta, config);
|
|
6059
|
-
} catch (err) {
|
|
6060
|
-
log('warn', `PR fix no-op detection for ${meta.pr.id}: ${err.message}`);
|
|
6061
|
-
prFixBranchChange = { changed: null, reason: err.message };
|
|
6062
|
-
}
|
|
6063
|
-
// #639 — a BUILD_FAILURE fix that didn't change the branch is not
|
|
6064
|
-
// necessarily a genuine no-op: the agent may have found its own (or a
|
|
6065
|
-
// prior dispatch's) fix commit already present and concluded "nothing to
|
|
6066
|
-
// change" without checking whether CI actually turned green on that
|
|
6067
|
-
// commit. Re-poll live CI for the head before letting updatePrAfterFix
|
|
6068
|
-
// accept the no-op — if it's still failing, flag it so the no-op path
|
|
6069
|
-
// records a distinct fixIneffective signal instead of a clean no-op.
|
|
6070
|
-
if (prFixBranchChange?.changed === false) {
|
|
6071
|
-
const fixCause = shared.getPrFixAutomationCause({
|
|
6072
|
-
dispatchKey: dispatchItem?.meta?.dispatchKey,
|
|
6073
|
-
source: meta?.source,
|
|
6074
|
-
task: dispatchItem?.task,
|
|
6075
|
-
});
|
|
6076
|
-
if (fixCause === shared.PR_FIX_CAUSE.BUILD_FAILURE) {
|
|
6077
|
-
try {
|
|
6078
|
-
const liveStatus = await checkBuildStillFailingLive(meta.pr, meta.project);
|
|
6079
|
-
if (liveStatus === shared.BUILD_STATUS.FAILING) {
|
|
6080
|
-
prFixBuildStillFailing = liveStatus;
|
|
6081
|
-
}
|
|
6082
|
-
} catch (err) {
|
|
6083
|
-
log('warn', `Live build re-check for ${meta.pr.id}: ${err.message}`);
|
|
6084
|
-
}
|
|
6085
|
-
}
|
|
6086
|
-
}
|
|
6087
|
-
}
|
|
6088
|
-
|
|
6089
6159
|
// Scheduled task back-reference: update SQL state and write a linked inbox note.
|
|
6090
6160
|
if (meta?.item?._scheduleId) {
|
|
6091
6161
|
try {
|
|
@@ -6191,11 +6261,20 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
6191
6261
|
|| completionContractFailure?.nonTerminal === true;
|
|
6192
6262
|
const finalResult = hardContractFail ? DISPATCH_RESULT.ERROR : (effectiveSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR);
|
|
6193
6263
|
if (type === WORK_TYPE.REVIEW && finalResult === DISPATCH_RESULT.SUCCESS && !skipDoneStatus && !benignNoop) {
|
|
6194
|
-
await updatePrAfterReview(
|
|
6264
|
+
await updatePrAfterReview(
|
|
6265
|
+
agentId,
|
|
6266
|
+
meta?.pr,
|
|
6267
|
+
meta?.project,
|
|
6268
|
+
config,
|
|
6269
|
+
resultSummary,
|
|
6270
|
+
structuredCompletion,
|
|
6271
|
+
dispatchItem,
|
|
6272
|
+
{ reportedModel: model },
|
|
6273
|
+
);
|
|
6195
6274
|
} else if (type === WORK_TYPE.REVIEW) {
|
|
6196
6275
|
log('warn', `Skipping PR review metadata update for ${meta?.pr?.id || meta?.pr?.url || '(unknown PR)'} because review dispatch ${dispatchItem.id} did not complete cleanly`);
|
|
6197
6276
|
}
|
|
6198
|
-
if (shared.isFixLikeWorkType(type) && effectiveSuccess) {
|
|
6277
|
+
if (shared.isFixLikeWorkType(type) && effectiveSuccess && !skipDoneStatus) {
|
|
6199
6278
|
updatePrAfterFix(meta?.pr, meta?.project, meta?.source, {
|
|
6200
6279
|
branchChanged: fixCompletionChangedBranch(structuredCompletion),
|
|
6201
6280
|
automationCauseKey: meta?.automationCauseKey,
|
|
@@ -6204,6 +6283,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
6204
6283
|
buildStillFailing: prFixBuildStillFailing,
|
|
6205
6284
|
config,
|
|
6206
6285
|
noopReason: noopRationale || meta?._noopReason || '',
|
|
6286
|
+
reviewFindingResolution: reviewFixResolution,
|
|
6207
6287
|
});
|
|
6208
6288
|
// W-mpg58wv3 — closure-loop dispatch. When the completed fix WI was spawned
|
|
6209
6289
|
// to address a minion REQUEST_CHANGES (its meta carries
|
|
@@ -7044,6 +7124,7 @@ module.exports = {
|
|
|
7044
7124
|
isReviewBailout,
|
|
7045
7125
|
parseCompletionNoop,
|
|
7046
7126
|
classifyBenignZeroTargetOutcome, // exported for testing
|
|
7127
|
+
validateReviewFixCompletion,
|
|
7047
7128
|
detectNonTerminalResultSummary,
|
|
7048
7129
|
deferNonTerminalCompletion,
|
|
7049
7130
|
deferPhantomCompletion,
|
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 {
|