@yemi33/minions 0.1.2306 → 0.1.2307
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/settings.js +27 -0
- package/dashboard/slim/body.html +6 -22
- package/dashboard/slim/js/modals-tiles.js +32 -22
- package/dashboard/slim/js/plans.js +24 -516
- package/dashboard/slim/styles.css +10 -7
- package/dashboard.js +34 -0
- package/docs/completion-reports.md +2 -2
- package/docs/harness-transparency.md +18 -0
- package/docs/live-checkout-mode.md +53 -6
- package/engine/ado-comment.js +5 -2
- package/engine/cli.js +58 -3
- package/engine/comment-format.js +82 -2
- package/engine/discover-project-skills.js +4 -0
- package/engine/dispatch.js +36 -1
- package/engine/gh-comment.js +8 -3
- package/engine/lifecycle.js +92 -25
- package/engine/live-checkout.js +207 -0
- package/engine/pre-dispatch-eval.js +54 -5
- package/engine/shared.js +90 -1
- package/engine/supervisor.js +13 -0
- package/engine/watchdog.js +10 -0
- package/engine.js +208 -3
- package/package.json +1 -1
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +9 -0
package/engine/lifecycle.js
CHANGED
|
@@ -6308,18 +6308,34 @@ function diagnoseEmptyOutput(failureClass, code, elapsedMs) {
|
|
|
6308
6308
|
return `[empty-output: process exited in ${elapsedMs}ms \u2014 possible causes: machine sleep, network unavailability, auth failure]`;
|
|
6309
6309
|
}
|
|
6310
6310
|
|
|
6311
|
-
// W-mqba5ulq000nd255 — Reconciliation sweep
|
|
6312
|
-
// `_invalidProjectScope: { reason: "pr_scope_mismatch" }`
|
|
6313
|
-
//
|
|
6314
|
-
// matches the PR URL (i.e. the correctly-scoped project owns the canonical
|
|
6315
|
-
// record). Sibling-less mismatches are preserved as tracking. Runs once per
|
|
6316
|
-
// tick from engine.js after the ADO/GitHub reconcile polls finish.
|
|
6311
|
+
// W-mqba5ulq000nd255 / W-mr2cqq12000b04f8 — Reconciliation sweep with two
|
|
6312
|
+
// defenses against mis-scoped `_invalidProjectScope: { reason: "pr_scope_mismatch" }`
|
|
6313
|
+
// records:
|
|
6317
6314
|
//
|
|
6318
|
-
//
|
|
6315
|
+
// 1. PRUNE — a sibling record for the same canonical pr.id already exists in
|
|
6316
|
+
// the correctly-scoped project (e.g. a fresh dispatch there re-synced it).
|
|
6317
|
+
// The mis-scoped duplicate is deleted; the sibling is the surviving record.
|
|
6318
|
+
//
|
|
6319
|
+
// 2. RELOCATE — no sibling exists yet, but a project IS configured whose
|
|
6320
|
+
// scope matches the PR's real repo (e.g. work dispatched under project
|
|
6321
|
+
// "minions-opg" opened a PR against `yemi33/minions`, and a "minions"
|
|
6322
|
+
// project mapping to that repo is configured). Without this, the record
|
|
6323
|
+
// would sit mis-scoped forever — the correct project's tracker (read by
|
|
6324
|
+
// repo-scoped schedules such as daily-merge-approved-minions-prs) would
|
|
6325
|
+
// never see it. The record is moved: upserted into the correct project's
|
|
6326
|
+
// file (which clears `_invalidProjectScope` since it's now correctly
|
|
6327
|
+
// scoped) and removed from the mis-scoped project's file.
|
|
6328
|
+
//
|
|
6329
|
+
// When no configured project matches the PR's real scope at all, the record
|
|
6330
|
+
// is left in place with its `_invalidProjectScope` stamp so operators can
|
|
6331
|
+
// still find it (still surfaced via GET /api/pull-requests aggregation).
|
|
6332
|
+
// Runs once per tick from engine.js after the ADO/GitHub reconcile polls finish.
|
|
6333
|
+
//
|
|
6334
|
+
// Returns { pruned, relocated, scanned } so the engine tick can log a summary line.
|
|
6319
6335
|
function pruneScopeMismatchDuplicatePrs(config) {
|
|
6320
6336
|
config = config || getConfig();
|
|
6321
6337
|
const projects = shared.getProjects(config) || [];
|
|
6322
|
-
if (projects.length === 0) return { pruned: 0, scanned: 0 };
|
|
6338
|
+
if (projects.length === 0) return { pruned: 0, relocated: 0, scanned: 0 };
|
|
6323
6339
|
|
|
6324
6340
|
// Map project name -> canonical scope so we can find which project IS the
|
|
6325
6341
|
// correctly-scoped owner for a given mismatch record.
|
|
@@ -6344,13 +6360,17 @@ function pruneScopeMismatchDuplicatePrs(config) {
|
|
|
6344
6360
|
}
|
|
6345
6361
|
|
|
6346
6362
|
// Build per-project delete sets keyed by id, so we batch one mutation per
|
|
6347
|
-
// affected project file.
|
|
6348
|
-
|
|
6363
|
+
// affected project file. Track prune-deletes and relocate-deletes in
|
|
6364
|
+
// separate id sets (per project) so `pruned` vs `relocated` counts stay
|
|
6365
|
+
// accurate even though both end up removing rows from the same file.
|
|
6366
|
+
const pruneDeletesByProject = new Map(); // projectName -> Set(prId)
|
|
6367
|
+
const relocateDeletesByProject = new Map(); // projectName -> Set(prId)
|
|
6368
|
+
// Records to insert into their correctly-scoped project's file.
|
|
6369
|
+
const relocationsByTargetProject = new Map(); // projectName -> [{ rec, owningScope }]
|
|
6349
6370
|
let scanned = 0;
|
|
6350
6371
|
let pruned = 0;
|
|
6351
6372
|
|
|
6352
6373
|
for (const records of byId.values()) {
|
|
6353
|
-
if (records.length < 2) continue;
|
|
6354
6374
|
for (const rec of records) {
|
|
6355
6375
|
scanned++;
|
|
6356
6376
|
if (!rec._invalidProjectScope || rec._invalidProjectScope.reason !== 'pr_scope_mismatch') continue;
|
|
@@ -6358,32 +6378,79 @@ function pruneScopeMismatchDuplicatePrs(config) {
|
|
|
6358
6378
|
if (!correctScope) continue;
|
|
6359
6379
|
const correctProject = projectByScope.get(correctScope);
|
|
6360
6380
|
if (!correctProject) continue; // no configured project owns the URL — keep as tracking
|
|
6361
|
-
// Sibling check: is there a record under the correct scope for the same id?
|
|
6362
|
-
const sibling = records.find(r => r !== rec && r._scope === correctProject.name);
|
|
6363
|
-
if (!sibling) continue;
|
|
6364
|
-
// Safe to prune.
|
|
6365
6381
|
const owningScope = rec._scope;
|
|
6366
6382
|
if (!owningScope || owningScope === 'central') continue;
|
|
6367
|
-
if (
|
|
6368
|
-
|
|
6383
|
+
if (owningScope === correctProject.name) continue; // already correctly scoped
|
|
6384
|
+
// Sibling check: is there a record under the correct scope for the same id?
|
|
6385
|
+
const sibling = records.find(r => r !== rec && r._scope === correctProject.name);
|
|
6386
|
+
if (sibling) {
|
|
6387
|
+
// Safe to prune — the sibling already owns the canonical record.
|
|
6388
|
+
if (!pruneDeletesByProject.has(owningScope)) pruneDeletesByProject.set(owningScope, new Set());
|
|
6389
|
+
pruneDeletesByProject.get(owningScope).add(rec.id);
|
|
6390
|
+
} else {
|
|
6391
|
+
// No sibling anywhere: relocate the record into the correctly-scoped
|
|
6392
|
+
// project instead of leaving it stuck mis-scoped indefinitely.
|
|
6393
|
+
if (!relocationsByTargetProject.has(correctProject.name)) relocationsByTargetProject.set(correctProject.name, []);
|
|
6394
|
+
relocationsByTargetProject.get(correctProject.name).push({ rec, owningScope });
|
|
6395
|
+
}
|
|
6396
|
+
}
|
|
6397
|
+
}
|
|
6398
|
+
|
|
6399
|
+
// Relocate first (insert into target), only marking the source record for
|
|
6400
|
+
// deletion once the insert into the correct project succeeds — this avoids
|
|
6401
|
+
// losing a record if the insert throws mid-way.
|
|
6402
|
+
let relocated = 0;
|
|
6403
|
+
for (const [targetProjectName, entries] of relocationsByTargetProject) {
|
|
6404
|
+
const targetProject = projects.find(p => p.name === targetProjectName);
|
|
6405
|
+
if (!targetProject) continue;
|
|
6406
|
+
const targetPrPath = projectPrPath(targetProject);
|
|
6407
|
+
for (const { rec, owningScope } of entries) {
|
|
6408
|
+
const entry = { ...rec };
|
|
6409
|
+
delete entry._scope;
|
|
6410
|
+
delete entry._invalidProjectScope;
|
|
6411
|
+
delete entry.project; // let upsertPullRequestRecord backfill from targetProject
|
|
6412
|
+
try {
|
|
6413
|
+
const result = shared.upsertPullRequestRecord(targetPrPath, entry, {
|
|
6414
|
+
project: targetProject,
|
|
6415
|
+
itemIds: Array.isArray(rec.prdItems) ? rec.prdItems : null,
|
|
6416
|
+
});
|
|
6417
|
+
if (result.created || result.linked) {
|
|
6418
|
+
relocated++;
|
|
6419
|
+
if (!relocateDeletesByProject.has(owningScope)) relocateDeletesByProject.set(owningScope, new Set());
|
|
6420
|
+
relocateDeletesByProject.get(owningScope).add(rec.id);
|
|
6421
|
+
log('info', `[pull-requests] relocated mis-scoped PR ${rec.id} from project=${owningScope} to correctly-scoped project=${targetProjectName}`);
|
|
6422
|
+
}
|
|
6423
|
+
} catch (err) {
|
|
6424
|
+
log('warn', `pruneScopeMismatchDuplicatePrs: failed to relocate ${rec.id} to ${targetProjectName}: ${err?.message || err}`);
|
|
6425
|
+
}
|
|
6369
6426
|
}
|
|
6370
6427
|
}
|
|
6371
6428
|
|
|
6372
|
-
|
|
6429
|
+
// Merge prune + relocate delete sets per project so each file is mutated once.
|
|
6430
|
+
const deletesByProject = new Map();
|
|
6431
|
+
for (const [projectName, ids] of pruneDeletesByProject) {
|
|
6432
|
+
deletesByProject.set(projectName, new Set(ids));
|
|
6433
|
+
}
|
|
6434
|
+
for (const [projectName, ids] of relocateDeletesByProject) {
|
|
6435
|
+
if (!deletesByProject.has(projectName)) deletesByProject.set(projectName, new Set());
|
|
6436
|
+
for (const id of ids) deletesByProject.get(projectName).add(id);
|
|
6437
|
+
}
|
|
6438
|
+
|
|
6439
|
+
if (deletesByProject.size === 0) return { pruned: 0, relocated, scanned };
|
|
6373
6440
|
|
|
6374
6441
|
for (const [projectName, idsToDelete] of deletesByProject) {
|
|
6375
6442
|
const project = projects.find(p => p.name === projectName);
|
|
6376
6443
|
if (!project) continue;
|
|
6377
6444
|
const prPath = projectPrPath(project);
|
|
6445
|
+
const pruneIds = pruneDeletesByProject.get(projectName) || new Set();
|
|
6378
6446
|
try {
|
|
6379
6447
|
shared.mutatePullRequests(prPath, (prs) => {
|
|
6380
|
-
const
|
|
6448
|
+
const deletedIds = prs.filter(p => idsToDelete.has(p?.id)).map(p => p.id);
|
|
6381
6449
|
const next = prs.filter(p => !idsToDelete.has(p?.id));
|
|
6382
|
-
const
|
|
6383
|
-
|
|
6384
|
-
|
|
6385
|
-
|
|
6386
|
-
log('info', `[pull-requests] pruned scope-mismatch duplicate ${id} from project=${projectName} (sibling exists in correctly-scoped project)`);
|
|
6450
|
+
for (const id of deletedIds) {
|
|
6451
|
+
if (pruneIds.has(id)) {
|
|
6452
|
+
pruned++;
|
|
6453
|
+
log('info', `[pull-requests] removed mis-scoped duplicate ${id} from project=${projectName}`);
|
|
6387
6454
|
}
|
|
6388
6455
|
}
|
|
6389
6456
|
return next;
|
|
@@ -6393,7 +6460,7 @@ function pruneScopeMismatchDuplicatePrs(config) {
|
|
|
6393
6460
|
}
|
|
6394
6461
|
}
|
|
6395
6462
|
|
|
6396
|
-
return { pruned, scanned };
|
|
6463
|
+
return { pruned, relocated, scanned };
|
|
6397
6464
|
}
|
|
6398
6465
|
|
|
6399
6466
|
// Repair helper: collapse prNumber duplicates across all project-scoped and
|
package/engine/live-checkout.js
CHANGED
|
@@ -53,6 +53,11 @@
|
|
|
53
53
|
* { ok:false, reason:'dirty', dirtyFiles:[…] }
|
|
54
54
|
* { ok:false, reason:'mid-operation', op:'merge'|'rebase'|'cherry-pick'|'revert', details }
|
|
55
55
|
* { ok:false, reason:'detached-head', sha }
|
|
56
|
+
* { ok:false, reason:'blob-fetch', op, branch, message, originalRef, originalRefType }
|
|
57
|
+
* { ok:false, reason:'worktree-conflict', op, branch, conflictingWorktreePath, message, originalRef, originalRefType }
|
|
58
|
+
* (W-mr28h2j2000y0de1) — the target branch is already checked out in another
|
|
59
|
+
* worktree; `git checkout <branch>` refuses deterministically. Non-retryable
|
|
60
|
+
* at the caller — a human/engine must remove or reassign the other worktree.
|
|
56
61
|
*
|
|
57
62
|
* NOTE: `mainRef` is still accepted (and validated) for caller-contract
|
|
58
63
|
* stability, but it is NOT used to seed the new branch in live mode — HEAD
|
|
@@ -132,6 +137,33 @@ function _isPartialCloneBlobError(message = '') {
|
|
|
132
137
|
);
|
|
133
138
|
}
|
|
134
139
|
|
|
140
|
+
// W-mr28h2j2000y0de1 — worktree-conflict signature match. When the target
|
|
141
|
+
// branch is ALSO checked out in a SECOND worktree elsewhere (a leftover from a
|
|
142
|
+
// prior isolated-worktree dispatch, a manually-created worktree, or a stale
|
|
143
|
+
// worktree left behind by a checkoutMode change), a plain `git checkout
|
|
144
|
+
// <branch>` inside the operator checkout (project.localPath) refuses
|
|
145
|
+
// DETERMINISTICALLY with git's own literal phrasing:
|
|
146
|
+
// fatal: '<branch>' is already used by worktree at '<path>'
|
|
147
|
+
// This is a STRUCTURAL conflict — it does NOT clear on retry, ever, until a
|
|
148
|
+
// human or the engine removes/reassigns the other worktree. So (like the
|
|
149
|
+
// blob-fetch case) it must be surfaced ONCE as an operator-actionable refusal
|
|
150
|
+
// instead of retry-storming an identical LIVE_CHECKOUT_FAILED to the cap. The
|
|
151
|
+
// matcher keys on git's stable literal wording (case-insensitive substring)
|
|
152
|
+
// and, when present, captures the conflicting worktree path so the caller's
|
|
153
|
+
// inbox alert can name the exact tree to remove or finish work in.
|
|
154
|
+
function _isWorktreeConflictError(message = '') {
|
|
155
|
+
return /is already used by worktree at/i.test(String(message || ''));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Extract the conflicting worktree path from git's "is already used by worktree
|
|
159
|
+
// at '<path>'" message. Returns the captured path, or null when the phrasing
|
|
160
|
+
// didn't include an extractable path (defensive — the caller degrades to an
|
|
161
|
+
// "unknown location" alert).
|
|
162
|
+
function _extractConflictingWorktreePath(message = '') {
|
|
163
|
+
const m = String(message || '').match(/is already used by worktree at '([^']+)'/i);
|
|
164
|
+
return m ? m[1] : null;
|
|
165
|
+
}
|
|
166
|
+
|
|
135
167
|
// W-mqva907q — auto-clean safe untracked build artifacts before a live-checkout
|
|
136
168
|
// dirty bail. A `git status --porcelain` line for a stray `__pycache__/` (or
|
|
137
169
|
// other regenerable cache) should NOT block dispatch the way a real edit does.
|
|
@@ -664,6 +696,18 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
664
696
|
originalRefType,
|
|
665
697
|
};
|
|
666
698
|
}
|
|
699
|
+
if (_isWorktreeConflictError(msg)) {
|
|
700
|
+
return {
|
|
701
|
+
ok: false,
|
|
702
|
+
reason: 'worktree-conflict',
|
|
703
|
+
op: creating ? 'create' : 'checkout',
|
|
704
|
+
branch: branchName,
|
|
705
|
+
conflictingWorktreePath: _extractConflictingWorktreePath(msg),
|
|
706
|
+
message: msg.slice(0, 500),
|
|
707
|
+
originalRef,
|
|
708
|
+
originalRefType,
|
|
709
|
+
};
|
|
710
|
+
}
|
|
667
711
|
throw checkoutErr; // transient → caller retries (LIVE_CHECKOUT_FAILED)
|
|
668
712
|
}
|
|
669
713
|
};
|
|
@@ -954,6 +998,164 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
|
|
|
954
998
|
}
|
|
955
999
|
}
|
|
956
1000
|
|
|
1001
|
+
/**
|
|
1002
|
+
* resolveLiveCheckoutAutoStash — W-mqtvnnj1000357fa
|
|
1003
|
+
*
|
|
1004
|
+
* Decides whether a dirty live-checkout tree should be auto-stashed before
|
|
1005
|
+
* dispatch instead of failing with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY.
|
|
1006
|
+
*
|
|
1007
|
+
* Resolution order (per-project overrides fleet, matching resolveCheckoutMode
|
|
1008
|
+
* and the per-agent cli/model override pattern):
|
|
1009
|
+
* 1. If `project.liveCheckoutAutoStash` is an explicit boolean, use it.
|
|
1010
|
+
* 2. Else if `engine.liveCheckoutAutoStash` is an explicit boolean, use it.
|
|
1011
|
+
* 3. Else default false.
|
|
1012
|
+
*
|
|
1013
|
+
* Pure — no I/O. Tested in test/unit/live-checkout-auto-stash.test.js.
|
|
1014
|
+
*
|
|
1015
|
+
* @param {{ project?:object|null, engine?:object|null }} opts
|
|
1016
|
+
* @returns {boolean}
|
|
1017
|
+
*/
|
|
1018
|
+
function resolveLiveCheckoutAutoStash(opts = {}) {
|
|
1019
|
+
const { project, engine } = opts;
|
|
1020
|
+
if (project && typeof project.liveCheckoutAutoStash === 'boolean') {
|
|
1021
|
+
return project.liveCheckoutAutoStash;
|
|
1022
|
+
}
|
|
1023
|
+
if (engine && typeof engine.liveCheckoutAutoStash === 'boolean') {
|
|
1024
|
+
return engine.liveCheckoutAutoStash;
|
|
1025
|
+
}
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* performLiveCheckoutAutoStash — W-mqtvnnj1000357fa
|
|
1031
|
+
*
|
|
1032
|
+
* Runs `git stash push --include-untracked -m "<message>"` in the operator's
|
|
1033
|
+
* live checkout so a dirty tree can be parked and dispatch can proceed. The
|
|
1034
|
+
* engine NEVER pops the stash automatically — that is the operator's choice
|
|
1035
|
+
* (step 5 of the WI). The stash name is logged + surfaced in an inbox note so
|
|
1036
|
+
* the operator can `git stash pop` manually.
|
|
1037
|
+
*
|
|
1038
|
+
* `--include-untracked` is intentional (the WI text shows the bare form): the
|
|
1039
|
+
* dirty preflight uses `git status --porcelain`, which counts untracked `??`
|
|
1040
|
+
* files as dirty, so a tracked-only stash would leave the tree dirty and the
|
|
1041
|
+
* re-run preflight would still bail. Including untracked makes the tree clean.
|
|
1042
|
+
*
|
|
1043
|
+
* Never throws on a stash failure and never silently swallows it — returns
|
|
1044
|
+
* `{ ok:false, error }` so the caller can fall through to the existing
|
|
1045
|
+
* retry-once-then-fail behavior (step 4 of the WI).
|
|
1046
|
+
*
|
|
1047
|
+
* MUST be called OUTSIDE any file lock — it shells out to git and the lock
|
|
1048
|
+
* callbacks must stay synchronous/fast.
|
|
1049
|
+
*
|
|
1050
|
+
* @param {{ localPath:string, dispatchId?:string, gitOpts?:object,
|
|
1051
|
+
* log?:(msg:string, lvl?:string)=>void, _git?:Function }} opts
|
|
1052
|
+
* @returns {Promise<{ ok:boolean, stashMessage:string, error?:string }>}
|
|
1053
|
+
*/
|
|
1054
|
+
async function performLiveCheckoutAutoStash(opts = {}) {
|
|
1055
|
+
const { localPath, dispatchId, gitOpts, log, _git } = opts;
|
|
1056
|
+
if (!localPath || typeof localPath !== 'string') {
|
|
1057
|
+
throw new Error('performLiveCheckoutAutoStash: localPath is required (got ' + JSON.stringify(localPath) + ')');
|
|
1058
|
+
}
|
|
1059
|
+
const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
|
|
1060
|
+
const logFn = (typeof log === 'function') ? log : () => {};
|
|
1061
|
+
const baseOpts = { cwd: localPath, ...(gitOpts || {}) };
|
|
1062
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1063
|
+
const stashMessage = `minions-auto-stash-${dispatchId || 'nodispatch'}-${stamp}`;
|
|
1064
|
+
try {
|
|
1065
|
+
await git(['stash', 'push', '--include-untracked', '-m', stashMessage], baseOpts);
|
|
1066
|
+
logFn(
|
|
1067
|
+
`live-checkout: auto-stashed dirty tree as "${stashMessage}" in ${localPath} — ` +
|
|
1068
|
+
`the engine will NOT pop it automatically; run \`git stash pop\` in ${localPath} to restore.`,
|
|
1069
|
+
'info',
|
|
1070
|
+
);
|
|
1071
|
+
return { ok: true, stashMessage };
|
|
1072
|
+
} catch (e) {
|
|
1073
|
+
const error = (e && e.message) ? e.message : String(e);
|
|
1074
|
+
logFn(`live-checkout: auto-stash failed in ${localPath}: ${error}`, 'warn');
|
|
1075
|
+
return { ok: false, stashMessage, error };
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* applyLiveCheckoutAutoStash — W-mqtvnnj1000357fa
|
|
1081
|
+
*
|
|
1082
|
+
* Orchestrates the dirty-tree auto-stash flow so spawnAgent stays lean. When the
|
|
1083
|
+
* preflight `liveResult` is a confirmed-dirty refusal AND auto-stash is enabled
|
|
1084
|
+
* (per-project field wins, else engine fleet-wide fallback), this:
|
|
1085
|
+
* 1. `git stash push --include-untracked` the operator's changes
|
|
1086
|
+
* (performLiveCheckoutAutoStash — runs OUTSIDE any file lock).
|
|
1087
|
+
* 2. On stash success, re-runs `prepareLiveCheckout` (the tree is now clean).
|
|
1088
|
+
* 3. Clears the WI's `_pendingReason === 'live_checkout_dirty'` stamp via the
|
|
1089
|
+
* injected `clearDirtyStamp` callback so a future dirty tree is not treated
|
|
1090
|
+
* as "already failed" (resets the retry-once state).
|
|
1091
|
+
* 4. Writes an operator inbox note via the injected `writeStashNote(key, body)`
|
|
1092
|
+
* callback (the engine NEVER pops the stash automatically).
|
|
1093
|
+
*
|
|
1094
|
+
* Returns a discriminated outcome the caller acts on:
|
|
1095
|
+
* - `{ outcome:'unchanged', liveResult }` — not dirty, disabled, or stash failed
|
|
1096
|
+
* (caller falls through to the existing retry-once-then-fail dirty handling).
|
|
1097
|
+
* - `{ outcome:'stashed', liveResult }` — stashed + re-preflighted; proceed.
|
|
1098
|
+
* - `{ outcome:'threw', error }` — the re-preflight threw; caller must
|
|
1099
|
+
* fail the dispatch as LIVE_CHECKOUT_FAILED (engine-specific completion).
|
|
1100
|
+
*
|
|
1101
|
+
* @param {object} opts
|
|
1102
|
+
* @returns {Promise<{outcome:'unchanged'|'stashed'|'threw', liveResult?:object, error?:Error}>}
|
|
1103
|
+
*/
|
|
1104
|
+
async function applyLiveCheckoutAutoStash(opts = {}) {
|
|
1105
|
+
const {
|
|
1106
|
+
liveResult, project, engine, localPath, branchName, mainRef,
|
|
1107
|
+
gitOpts, dispatchId, wiId, log,
|
|
1108
|
+
clearDirtyStamp, writeStashNote,
|
|
1109
|
+
} = opts;
|
|
1110
|
+
const logFn = (typeof log === 'function') ? log : () => {};
|
|
1111
|
+
const reason = (liveResult && liveResult.ok === false) ? liveResult.reason : null;
|
|
1112
|
+
if (reason !== 'dirty' || !resolveLiveCheckoutAutoStash({ project, engine })) {
|
|
1113
|
+
return { outcome: 'unchanged', liveResult };
|
|
1114
|
+
}
|
|
1115
|
+
let stashResult;
|
|
1116
|
+
try {
|
|
1117
|
+
stashResult = await performLiveCheckoutAutoStash({ localPath, dispatchId, gitOpts, log });
|
|
1118
|
+
} catch (stashErr) {
|
|
1119
|
+
stashResult = { ok: false, error: stashErr && stashErr.message };
|
|
1120
|
+
}
|
|
1121
|
+
if (!stashResult || !stashResult.ok) {
|
|
1122
|
+
logFn(`spawnAgent: live-checkout auto-stash failed for ${dispatchId} (${(stashResult && stashResult.error) || 'unknown'}); falling through to dirty-fail handling`, 'warn');
|
|
1123
|
+
return { outcome: 'unchanged', liveResult };
|
|
1124
|
+
}
|
|
1125
|
+
let newLive;
|
|
1126
|
+
try {
|
|
1127
|
+
newLive = await prepareLiveCheckout({ localPath, branchName, mainRef, gitOpts, dispatchId, wiId, log });
|
|
1128
|
+
} catch (stashLiveErr) {
|
|
1129
|
+
return { outcome: 'threw', error: stashLiveErr };
|
|
1130
|
+
}
|
|
1131
|
+
if (typeof clearDirtyStamp === 'function') {
|
|
1132
|
+
try { clearDirtyStamp(); } catch (e) { logFn(`live-checkout: failed to clear _pendingReason after auto-stash: ${e.message}`, 'warn'); }
|
|
1133
|
+
}
|
|
1134
|
+
if (typeof writeStashNote === 'function') {
|
|
1135
|
+
try {
|
|
1136
|
+
const body = [
|
|
1137
|
+
'# Live-checkout dirty tree auto-stashed',
|
|
1138
|
+
'',
|
|
1139
|
+
`**Project:** ${(project && project.name) || '(unknown)'}`,
|
|
1140
|
+
`**Local path:** ${localPath}`,
|
|
1141
|
+
`**Branch:** ${branchName}`,
|
|
1142
|
+
`**Work item:** ${wiId}`,
|
|
1143
|
+
`**Dispatch:** ${dispatchId}`,
|
|
1144
|
+
`**Stash:** \`${stashResult.stashMessage}\``,
|
|
1145
|
+
'',
|
|
1146
|
+
`The live checkout at \`${localPath}\` had uncommitted changes. Because live-checkout auto-stash is enabled, the engine ran \`git stash push --include-untracked\` so this dispatch could proceed. The engine never \`git reset\` or \`git clean\` your tree.`,
|
|
1147
|
+
'',
|
|
1148
|
+
'## Recovery',
|
|
1149
|
+
'',
|
|
1150
|
+
`The engine does NOT pop the stash automatically. To restore your changes, run \`git stash pop\` (or \`git stash apply\`) in \`${localPath}\`. Locate it with \`git stash list\` — look for \`${stashResult.stashMessage}\`.`,
|
|
1151
|
+
].join('\n');
|
|
1152
|
+
writeStashNote(`live-checkout-autostash-${wiId}`, body);
|
|
1153
|
+
} catch (e) { logFn(`live-checkout: writeInboxAlert (auto-stash) failed: ${e.message}`, 'warn'); }
|
|
1154
|
+
}
|
|
1155
|
+
logFn(`spawnAgent: live-checkout auto-stashed dirty tree for ${dispatchId} (${stashResult.stashMessage}); proceeding with dispatch`, 'info');
|
|
1156
|
+
return { outcome: 'stashed', liveResult: newLive };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
957
1159
|
/**
|
|
958
1160
|
* maybeRestoreLiveCheckoutFromRecord — PL-live-checkout-reliability-hardening
|
|
959
1161
|
*
|
|
@@ -1020,8 +1222,13 @@ async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
|
|
|
1020
1222
|
module.exports = {
|
|
1021
1223
|
prepareLiveCheckout,
|
|
1022
1224
|
restoreLiveCheckoutAtDispatchEnd,
|
|
1225
|
+
resolveLiveCheckoutAutoStash,
|
|
1226
|
+
performLiveCheckoutAutoStash,
|
|
1227
|
+
applyLiveCheckoutAutoStash,
|
|
1023
1228
|
maybeRestoreLiveCheckoutFromRecord,
|
|
1024
1229
|
_isPartialCloneBlobError,
|
|
1230
|
+
_isWorktreeConflictError,
|
|
1231
|
+
_extractConflictingWorktreePath,
|
|
1025
1232
|
_isAutoCleanableArtifact,
|
|
1026
1233
|
_collectAutoCleanablePaths,
|
|
1027
1234
|
_looksLikeAgentBranch,
|
|
@@ -45,6 +45,44 @@ const DEFAULT_TIMEOUT_MS = 60000;
|
|
|
45
45
|
// descriptions don't carry enough signal for the gate to add value.
|
|
46
46
|
const DESCRIPTION_MIN_CHARS = 80;
|
|
47
47
|
|
|
48
|
+
// Guardrail appended to every prompt. The validator runs a pure text-reasoning
|
|
49
|
+
// LLM call (direct: true, cwd hardcoded to MINIONS_DIR per engine/llm.js
|
|
50
|
+
// _spawnProcess) with NO filesystem tools, so the model cannot see the target
|
|
51
|
+
// project's code. P-b5e2a481 removed the read tools that were making the model
|
|
52
|
+
// search MINIONS_DIR, but the model kept *asserting* unverified file-existence
|
|
53
|
+
// claims in its reasoning text (e.g. "this repo contains no TypeScript files")
|
|
54
|
+
// while actually describing the Minions engine checkout it runs inside rather
|
|
55
|
+
// than the WI's real target repo (W-mr25eg2400078d4b, repro WI
|
|
56
|
+
// W-mr2401ga0007830d targeting constellation). Forbidding empirical repo claims
|
|
57
|
+
// in the prompt makes an invalid verdict that hinges on a fabricated
|
|
58
|
+
// file-existence claim structurally impossible.
|
|
59
|
+
const NO_FS_GUARDRAIL = [
|
|
60
|
+
'IMPORTANT — you have NO filesystem or repository access and CANNOT see the',
|
|
61
|
+
"target project's code, files, or directory structure. Judge ONLY whether the",
|
|
62
|
+
'work item is well-specified: clear, internally consistent, actionable, and',
|
|
63
|
+
'testable in the abstract as a software task. Do NOT assert or assume whether',
|
|
64
|
+
'any specific file, directory, path, module, package, or code symbol exists or',
|
|
65
|
+
'does not exist, and do NOT cite file paths or file contents as evidence. A',
|
|
66
|
+
'verdict of "valid": false must NEVER rest on a claim about what the repository',
|
|
67
|
+
'does or does not contain — you have not seen it. Only mark invalid when the',
|
|
68
|
+
'description itself is too vague, contradictory, or underspecified to act on.',
|
|
69
|
+
].join('\n');
|
|
70
|
+
|
|
71
|
+
function _describeProject(project) {
|
|
72
|
+
if (!project || typeof project !== 'object') return '';
|
|
73
|
+
const name = project.name ? String(project.name).trim() : '';
|
|
74
|
+
const slug = project.slug ? String(project.slug).trim() : '';
|
|
75
|
+
const localPath = project.localPath ? String(project.localPath).trim() : '';
|
|
76
|
+
const parts = [];
|
|
77
|
+
if (name) parts.push(`name "${name}"`);
|
|
78
|
+
if (slug) parts.push(`repo ${slug}`);
|
|
79
|
+
if (localPath) parts.push(`local checkout at ${localPath}`);
|
|
80
|
+
if (parts.length === 0) return '';
|
|
81
|
+
return `Target project: ${parts.join(', ')}. This work item targets that `
|
|
82
|
+
+ 'project, which is a DIFFERENT repository from the Minions engine codebase '
|
|
83
|
+
+ 'you are running inside.';
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
function _extractCriteria(workItem) {
|
|
49
87
|
if (!workItem || typeof workItem !== 'object') return [];
|
|
50
88
|
const candidates = [workItem.acceptance_criteria, workItem.acceptanceCriteria];
|
|
@@ -59,17 +97,21 @@ function _extractDescription(workItem) {
|
|
|
59
97
|
return (workItem.description || '').trim();
|
|
60
98
|
}
|
|
61
99
|
|
|
62
|
-
function _buildPrompt(workItem, criteria) {
|
|
100
|
+
function _buildPrompt(workItem, criteria, project) {
|
|
63
101
|
const title = workItem.title || workItem.name || workItem.id || 'untitled';
|
|
64
102
|
const description = _extractDescription(workItem);
|
|
65
|
-
const lines = [
|
|
66
|
-
|
|
67
|
-
|
|
103
|
+
const lines = [];
|
|
104
|
+
const projectLine = _describeProject(project);
|
|
105
|
+
if (projectLine) lines.push(projectLine, '');
|
|
106
|
+
lines.push(`Work item: ${title}`);
|
|
68
107
|
const hasCriteria = Array.isArray(criteria) && criteria.length > 0;
|
|
69
108
|
if (description) lines.push('', 'Description:', description);
|
|
70
109
|
if (hasCriteria) {
|
|
71
110
|
lines.push('', 'Acceptance criteria:');
|
|
72
111
|
for (const c of criteria) lines.push(`- ${c}`);
|
|
112
|
+
}
|
|
113
|
+
lines.push('', NO_FS_GUARDRAIL);
|
|
114
|
+
if (hasCriteria) {
|
|
73
115
|
lines.push('',
|
|
74
116
|
'Are these acceptance criteria clear, actionable, and testable?',
|
|
75
117
|
'Reply with JSON: {"valid": true|false, "reason": "..."}.');
|
|
@@ -146,6 +188,12 @@ function _resolveModel(opts) {
|
|
|
146
188
|
* @param {string} [opts.model] - explicit model override; otherwise the
|
|
147
189
|
* engine's CC model resolution applies, falling back to `undefined`
|
|
148
190
|
* (adapter default) when nothing is configured.
|
|
191
|
+
* @param {object} [opts.project] - target-project identity for prompt context
|
|
192
|
+
* (`{ name, slug, localPath }`). Threaded by
|
|
193
|
+
* `engine/dispatch.js addToDispatchWithValidation` so the model knows which
|
|
194
|
+
* repo the work item targets (it CANNOT see any repo) and does not default to
|
|
195
|
+
* describing the Minions engine checkout it runs inside
|
|
196
|
+
* (W-mr25eg2400078d4b).
|
|
149
197
|
* @param {number} [opts.timeout] - LLM timeout in ms.
|
|
150
198
|
* @returns {Promise<{valid: boolean, reason: string}>}
|
|
151
199
|
*/
|
|
@@ -159,7 +207,7 @@ async function validateAcceptanceCriteria(workItem, opts = {}) {
|
|
|
159
207
|
return { valid: true, reason: 'no acceptance criteria to validate' };
|
|
160
208
|
}
|
|
161
209
|
|
|
162
|
-
const prompt = _buildPrompt(workItem, criteria);
|
|
210
|
+
const prompt = _buildPrompt(workItem, criteria, opts.project);
|
|
163
211
|
let result;
|
|
164
212
|
try {
|
|
165
213
|
result = await callLLM(prompt, SYSTEM_PROMPT, {
|
|
@@ -219,6 +267,7 @@ module.exports = {
|
|
|
219
267
|
// Exposed for unit testing — engine code MUST go through validateAcceptanceCriteria.
|
|
220
268
|
_extractCriteria,
|
|
221
269
|
_extractDescription,
|
|
270
|
+
_describeProject,
|
|
222
271
|
_buildPrompt,
|
|
223
272
|
_parseResponse,
|
|
224
273
|
_resolveModel,
|
package/engine/shared.js
CHANGED
|
@@ -1946,6 +1946,86 @@ function mutateControl(mutator) {
|
|
|
1946
1946
|
}, { defaultValue: { state: 'stopped', pid: null }, skipWriteIfUnchanged: true });
|
|
1947
1947
|
}
|
|
1948
1948
|
|
|
1949
|
+
// ── Crash-loop detection (W-mr2c46590003e3ee) ──────────────────────────────
|
|
1950
|
+
// Three independent mechanisms can respawn a dead/frozen engine process:
|
|
1951
|
+
// engine/supervisor.js#checkEngine / #checkEngineHung, engine/watchdog.js
|
|
1952
|
+
// (OS-scheduled external recovery), and dashboard.js's in-process 30s
|
|
1953
|
+
// watchdog. None of them counted or throttled repeated crashes, so a genuine
|
|
1954
|
+
// crash-loop (engine dying every few minutes) silently self-healed forever
|
|
1955
|
+
// with no human-visible signal — see the 2026-07-01 00:20-00:30 incident
|
|
1956
|
+
// (4 respawns in ~5 minutes, notes/inbox/dallas-W-mr2azk6i000y06e0-*.md).
|
|
1957
|
+
//
|
|
1958
|
+
// recordEngineRespawn() gives all three mechanisms a single rolling counter
|
|
1959
|
+
// persisted in control.json so it survives process restarts: every call
|
|
1960
|
+
// appends {ts, source}, prunes entries older than CRASH_LOOP_WINDOW_MS, and
|
|
1961
|
+
// once the count within the window reaches CRASH_LOOP_THRESHOLD it flips a
|
|
1962
|
+
// dashboard-visible `control.crashLoopAlert` flag and writes a deduped
|
|
1963
|
+
// (one-per-day) inbox note.
|
|
1964
|
+
//
|
|
1965
|
+
// Callers MUST invoke this only at the point an actual respawn action is
|
|
1966
|
+
// taken — not on every health-check tick. The existing
|
|
1967
|
+
// RESPAWN_IN_PROGRESS_WINDOW_MS / post-spawn-grace guards already in each
|
|
1968
|
+
// mechanism prevent two watchers from double-spawning for the same crash, so
|
|
1969
|
+
// recording only real spawns keeps a single crash from being counted 2-3x
|
|
1970
|
+
// across the three watchers.
|
|
1971
|
+
const CRASH_LOOP_WINDOW_MS = Number(process.env.MINIONS_CRASH_LOOP_WINDOW_MS) || 10 * 60 * 1000; // 10 min
|
|
1972
|
+
const CRASH_LOOP_THRESHOLD = Number(process.env.MINIONS_CRASH_LOOP_THRESHOLD) || 3;
|
|
1973
|
+
|
|
1974
|
+
function recordEngineRespawn(source) {
|
|
1975
|
+
const now = Date.now();
|
|
1976
|
+
let crashLoop = false;
|
|
1977
|
+
let count = 1;
|
|
1978
|
+
let windowEntries = [];
|
|
1979
|
+
try {
|
|
1980
|
+
mutateControl((control) => {
|
|
1981
|
+
const prior = Array.isArray(control.engineRespawns) ? control.engineRespawns : [];
|
|
1982
|
+
const entries = prior
|
|
1983
|
+
.filter(e => e && Number.isFinite(e.ts) && (now - e.ts) <= CRASH_LOOP_WINDOW_MS)
|
|
1984
|
+
.concat([{ ts: now, source: String(source || 'unknown') }]);
|
|
1985
|
+
// Defensive cap — the window filter above already bounds this in
|
|
1986
|
+
// practice, but guards against unbounded growth under clock skew or an
|
|
1987
|
+
// operator-widened window.
|
|
1988
|
+
control.engineRespawns = entries.slice(-50);
|
|
1989
|
+
windowEntries = control.engineRespawns;
|
|
1990
|
+
count = windowEntries.length;
|
|
1991
|
+
if (count >= CRASH_LOOP_THRESHOLD) {
|
|
1992
|
+
crashLoop = true;
|
|
1993
|
+
control.crashLoopAlert = {
|
|
1994
|
+
count,
|
|
1995
|
+
windowMs: CRASH_LOOP_WINDOW_MS,
|
|
1996
|
+
firstAt: new Date(windowEntries[0].ts).toISOString(),
|
|
1997
|
+
lastAt: new Date(now).toISOString(),
|
|
1998
|
+
sources: windowEntries.map(e => e.source),
|
|
1999
|
+
};
|
|
2000
|
+
} else if (control.crashLoopAlert) {
|
|
2001
|
+
// Window rolled off past incidents — clear a stale alert so the
|
|
2002
|
+
// dashboard doesn't keep showing a resolved crash-loop forever.
|
|
2003
|
+
delete control.crashLoopAlert;
|
|
2004
|
+
}
|
|
2005
|
+
return control;
|
|
2006
|
+
});
|
|
2007
|
+
} catch (e) {
|
|
2008
|
+
log('warn', `recordEngineRespawn: control.json update failed: ${e.message}`);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if (crashLoop) {
|
|
2012
|
+
try {
|
|
2013
|
+
const lines = windowEntries.map(e => `- ${e.source} @ ${new Date(e.ts).toISOString()}`).join('\n');
|
|
2014
|
+
const body = `# Engine crash-loop detected\n\n` +
|
|
2015
|
+
`${count} engine respawns in the last ${Math.round(CRASH_LOOP_WINDOW_MS / 60000)} minutes ` +
|
|
2016
|
+
`(threshold: ${CRASH_LOOP_THRESHOLD}).\n\n` +
|
|
2017
|
+
`Respawns:\n${lines}\n\n` +
|
|
2018
|
+
`The engine keeps self-healing (each respawn succeeded) but is crashing repeatedly — ` +
|
|
2019
|
+
`investigate \`engine/engine-stdio.log\` around the timestamps above before this becomes ` +
|
|
2020
|
+
`a silent outage.\n`;
|
|
2021
|
+
writeToInbox('engine', 'crash-loop-detected', body, null, { severity: 'critical' });
|
|
2022
|
+
} catch (e) {
|
|
2023
|
+
log('warn', `recordEngineRespawn: inbox alert failed: ${e.message}`);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return { count, crashLoop };
|
|
2027
|
+
}
|
|
2028
|
+
|
|
1949
2029
|
// W-mp60tw0u000j3931: Lock-safe read-modify-write for engine/state.json — the
|
|
1950
2030
|
// persistent cross-restart engine-level state file (migration markers,
|
|
1951
2031
|
// one-shot reconciliation versions, etc.). Mirrors mutateControl's shape.
|
|
@@ -2904,6 +2984,14 @@ const ENGINE_DEFAULTS = {
|
|
|
2904
2984
|
// & Review Loop, or set `engine.autoFixPaused: true` in config.json.
|
|
2905
2985
|
autoFixPaused: false, // hard-stop kill-switch / master override (see comment above)
|
|
2906
2986
|
autoConsolidateMemory: false, // opt-in: periodically spawn engine/kb-sweep-runner.js from the tick loop (4h cadence). Inbox→notes consolidation already runs every tick via consolidateInbox; this flag only controls the KB sweep.
|
|
2987
|
+
// W-mqtvnnj1000357fa — fleet-wide fallback for live-checkout auto-stash. When
|
|
2988
|
+
// true, a dirty live-checkout tree is `git stash push --include-untracked`'d
|
|
2989
|
+
// before dispatch instead of failing with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY,
|
|
2990
|
+
// so dispatch proceeds without manual operator intervention. Per-project
|
|
2991
|
+
// `project.liveCheckoutAutoStash` overrides this (resolveLiveCheckoutAutoStash).
|
|
2992
|
+
// The engine never auto-pops the stash — the operator runs `git stash pop`.
|
|
2993
|
+
// Default false so a fresh install behaves identically to before this knob.
|
|
2994
|
+
liveCheckoutAutoStash: false,
|
|
2907
2995
|
prNoOpFixPauseAttempts: 2, // pause one PR automation cause after repeated no-op fixes for unchanged evidence
|
|
2908
2996
|
quarantineAutoRecoveryMax: 2, // #2996 follow-up: cap on auto-flipping WORKTREE_DIRTY/WORKTREE_DIVERGENT failures back to pending (the quarantine is self-healing so the next dispatch starts clean; the cap prevents infinite loops if quarantine itself keeps failing).
|
|
2909
2997
|
// W-mq5n1zx5 — Layer 1a/2b: harden the quarantine rename path against
|
|
@@ -4556,6 +4644,7 @@ const FAILURE_CLASS = {
|
|
|
4556
4644
|
LIVE_CHECKOUT_FAILED: 'live-checkout-failed', // #305 (live-checkout dispatch mode): prepareLiveCheckout THREW before agent spawn (helper guard, ref validation, or a transient `git status`/`rev-parse`/`checkout` failure) — distinct from the confirmed-dirty result (LIVE_CHECKOUT_DIRTY) which the helper returns, not throws. A thrown error is NOT proof the tree is dirty, so it must not be over-classified as dirty. Retryable with bounded backoff (NOT in dispatch.js neverRetry): racy branch-lock handoff, just-finished sibling dispatch, or transient git errors frequently clear on the next attempt; the engine auto-retries up to ENGINE_DEFAULTS.maxRetries before giving up. Genuinely terminal underlying reasons (auth, validation) still short-circuit via the reason-string check in isRetryableFailureReason.
|
|
4557
4645
|
LIVE_CHECKOUT_MID_OPERATION: 'live-checkout-mid-operation', // P-a7f3c1d9 (live-checkout dispatch mode): spawnAgent could not switch/create the target branch in project.localPath because the operator tree is mid-operation — an in-progress merge/rebase/cherry-pick/bisect or a detached HEAD. Distinct from LIVE_CHECKOUT_DIRTY (uncommitted changes): here the tree may be clean but the branch op cannot proceed. Engine refuses to spawn (it never runs `git reset`/`git clean`/`git rebase --abort` against the operator tree). Non-retryable — operator must finish or abort the in-progress operation, or checkout a branch, before re-dispatch.
|
|
4558
4646
|
LIVE_CHECKOUT_BLOB_FETCH: 'live-checkout-blob-fetch', // PL-live-checkout-reliability-hardening (live-checkout dispatch mode): `git checkout <existing-branch>` in project.localPath failed because the tree could not be materialized — on a Scalar/GVFS-managed ADO partial (blobless) clone, switching onto a branch whose tree differs from HEAD hydrates the changed paths' blobs through the GVFS cache server (`*.gvfscache.dev.azure.com`), a different endpoint than the main git remote that receives NO auth in the headless engine shell, so the fetch fails DETERMINISTICALLY. Distinct from LIVE_CHECKOUT_FAILED (transient) because retrying reproduces it identically — surfaced once as an operator-actionable refusal instead of retry-storming to the cap. Non-retryable — the operator hydrates the branch once with their own credentials (`git checkout <branch>` interactively, or `scalar prefetch` / `git -C <repo> fetch`), then re-dispatches. The engine NEVER forces, resets, cleans, or stashes the operator tree, and best-effort switches HEAD back to the original ref so the tree is not stranded half-populated.
|
|
4647
|
+
LIVE_CHECKOUT_WORKTREE_CONFLICT: 'live-checkout-worktree-conflict', // W-mr28h2j2000y0de1 (live-checkout dispatch mode): `git checkout <existing-branch>` in project.localPath failed because that exact branch is ALSO checked out in a SECOND worktree elsewhere (a leftover from a prior isolated-worktree dispatch, a manually-created worktree, or a stale worktree left by a checkoutMode change). git refuses deterministically with `fatal: '<branch>' is already used by worktree at '<path>'`. Distinct from LIVE_CHECKOUT_FAILED (transient) because this is a STRUCTURAL conflict — it does NOT clear on retry, ever, until a human or the engine removes/reassigns the other worktree; retrying just reproduces it identically and burns maxRetries. Non-retryable — the operator either `git worktree remove <path>` (freeing the branch) if that worktree is stale, or finishes/commits/pushes from it directly if it holds real WIP. The engine NEVER forces, resets, cleans, or stashes the operator tree, and best-effort switches HEAD back to the original ref so the tree is not stranded.
|
|
4559
4648
|
INVALID_WORKDIR: 'invalid-workdir', // P-714ef144: dispatch carried a meta.workdir override that failed validation — non-string, absolute path, drive-letter prefix, null byte, ".." segment, or post-resolve containment escape against project.localPath / worktree root. Engine refuses to spawn (the subpath would either be unreachable on disk or point outside the operator's allowed surface). Non-retryable — operator must fix the WI's meta.workdir before re-dispatch. Inbox alert lists the offending value + the resolved-vs-base mismatch.
|
|
4560
4649
|
MODEL_UNAVAILABLE: 'model-unavailable', // W-mpg6isvy000xca4d: requested model returned overloaded_error / 503 / service_unavailable. Retriable — engine swaps in the runtime-appropriate fallback model on next spawn (Claude leans on --fallback-model already plumbed; Copilot overrides --model with engine.copilotFallbackModel).
|
|
4561
4650
|
WORKSPACE_MANIFEST_REPO: 'workspace-manifest-repo-forbidden', // W-mq07avbk000m5543: dispatch routed an agent to a project/repo not present in its workspace_manifest.allowed_repos. Structural — never retryable until the manifest is widened or a different agent is chosen.
|
|
@@ -9061,7 +9150,7 @@ module.exports = {
|
|
|
9061
9150
|
assertStateFileSize,
|
|
9062
9151
|
withFileLock,
|
|
9063
9152
|
mutateJsonFileLocked,
|
|
9064
|
-
mutateControl,
|
|
9153
|
+
mutateControl, recordEngineRespawn,
|
|
9065
9154
|
mutateEngineState, // W-mp60tw0u000j3931
|
|
9066
9155
|
readEngineState, // W-mp60tw0u000j3931
|
|
9067
9156
|
mutateCooldowns,
|