@yemi33/minions 0.1.2341 → 0.1.2343
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/js/settings.js +1 -0
- package/docs/live-checkout-mode.md +30 -0
- package/engine/ado.js +6 -1
- package/engine/dispatch.js +1 -0
- package/engine/lifecycle.js +103 -10
- package/engine/live-checkout.js +205 -11
- package/engine/playbook.js +17 -4
- package/engine/shared.js +23 -1
- package/engine.js +78 -0
- package/package.json +1 -1
package/dashboard/js/settings.js
CHANGED
|
@@ -1112,6 +1112,7 @@ async function saveSettings() {
|
|
|
1112
1112
|
autoReapOrphanWorktreeHolders: !!document.getElementById('set-autoReapOrphanWorktreeHolders')?.checked,
|
|
1113
1113
|
liveCheckoutAutoStash: !!document.getElementById('set-liveCheckoutAutoStash')?.checked,
|
|
1114
1114
|
liveCheckoutAutoReset: !!document.getElementById('set-liveCheckoutAutoReset')?.checked,
|
|
1115
|
+
liveCheckoutAutoBaseRepair: !!document.getElementById('set-liveCheckoutAutoBaseRepair')?.checked,
|
|
1115
1116
|
orphanHolderScanTimeoutMs: document.getElementById('set-orphanHolderScanTimeoutMs')?.value,
|
|
1116
1117
|
statusProbeKillTimeoutMs: document.getElementById('set-statusProbeKillTimeoutMs')?.value,
|
|
1117
1118
|
adoPollEnabled: document.getElementById('set-adoPollEnabled').checked,
|
|
@@ -125,6 +125,36 @@ Pool short-circuits live in `engine.js:1368` and `engine/cleanup.js`; both gate
|
|
|
125
125
|
|
|
126
126
|
Either condition fails the dispatch non-retryably with `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` (`'live-checkout-mid-operation'`; added to `engine/dispatch.js`'s neverRetry set alongside `LIVE_CHECKOUT_DIRTY`). `spawnAgent` writes a `live-checkout-blocked-<wi-id>` inbox alert and stamps the work item `_pendingReason: 'live_checkout_mid_operation'` (or `'live_checkout_detached_head'` for the detached case). The recovery guidance tells the operator to finish or abort the in-progress op with their own commands (`git <op> --continue` / `git <op> --abort`), or checkout a branch, then re-dispatch. The engine never runs `git reset`, `git clean`, `git stash`, `git rebase --abort`, or moves HEAD on the operator's behalf.
|
|
127
127
|
|
|
128
|
+
### 6. Refuse on stale non-main base when forking a new branch (W-mr3lunnq000o9f41)
|
|
129
|
+
|
|
130
|
+
When a live-checkout dispatch needs a **new** branch, `git checkout -b <branch>` seeds it from the operator's **current HEAD** (issue #226 — live mode never fetches `origin/<mainRef>` to avoid auth-less GVFS blob fetches). That is safe only when HEAD is already on the project's base branch. If a human — or a prior dispatch — left the operator checkout sitting on some unrelated topic/feature branch, the new branch (and therefore its PR) would silently inherit **every commit already on that topic branch**. This is scope contamination baked into git history, not a stray uncommitted diff, so it survives the dirty-tree (Guarantee 2) and mid-operation (Guarantee 5) preflights, which only check for uncommitted changes / in-progress ops, never *"is HEAD on the expected base?"*. The motivating incident is ADO PR `!5411214`, which silently carried ~22 unrelated OCM files the PR author disavowed.
|
|
131
|
+
|
|
132
|
+
Immediately before the new-branch `git checkout -b`, `prepareLiveCheckout` compares HEAD's branch against the intended base:
|
|
133
|
+
|
|
134
|
+
- **The base is per-project, NOT a hardcoded `'main'`.** `spawnAgent` resolves `mainRef` via `shared.resolveMainBranch(cwd, project.mainBranch)` (`engine.js`), which honors the project's configured `mainBranch`, else auto-detects `refs/remotes/origin/HEAD`, else falls back to `main`. Repos whose default branch is **not** `main` — e.g. Office Android development, whose harness (`claude.md`) documents a `lkg/main/android` base — are handled by setting `project.mainBranch: "lkg/main/android"` (or by having `origin/HEAD` point at it); the guard then keys on that branch. The word "main" in this guarantee is shorthand for *"the project's resolved base branch,"* never the literal string.
|
|
135
|
+
- **HEAD already on `mainRef`** → forks normally; the guard never probes the base ref.
|
|
136
|
+
- **HEAD off-base, local `refs/heads/<mainRef>` exists** → PLAIN `git checkout <mainRef>` (no fetch, no reset, no `--force` — the tree was verified clean at Guarantee 2), then forks off it. No auth-less GVFS blob fetch is forced in the common case.
|
|
137
|
+
- **HEAD off-base, no usable local `mainRef`** (fresh-clone edge case) — or the local checkout itself fails — → **fail closed** with `{ ok:false, reason:'wrong-base' }` **unless auto-base-repair is enabled (see §6a).** The caller refuses **non-retryably** with `FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE` (`'live-checkout-wrong-base'`; added to `engine/dispatch.js`'s neverRetry set), writes an inbox alert naming the off-base HEAD, the expected base, and the exact `git checkout <mainRef>` recovery command, and stamps the work item `_pendingReason`. This mirrors the dirty / mid-operation philosophy of refusing on ambiguous operator state rather than silently forking off the wrong base.
|
|
138
|
+
|
|
139
|
+
#### 6a. Opt-in auto-base-repair (`liveCheckoutAutoBaseRepair`, W-mr3lunnq000o9f41)
|
|
140
|
+
|
|
141
|
+
The fail-closed refusal above is safe but forces a manual `git checkout <mainRef>` before every re-dispatch. Enabling **`liveCheckoutAutoBaseRepair`** (per-project `project.liveCheckoutAutoBaseRepair` > fleet-wide `engine.liveCheckoutAutoBaseRepair` > **`false`**, resolved by `shared.resolveLiveCheckoutAutoBaseRepair`) lets the engine self-heal the *missing-local-base* case:
|
|
142
|
+
|
|
143
|
+
- When HEAD is off-base **and** there is no local `refs/heads/<mainRef>` **but** a `refs/remotes/origin/<mainRef>` remote-tracking ref exists, the guard materializes a local base branch from it — `git branch --no-track <mainRef> refs/remotes/origin/<mainRef>` — then proceeds exactly like the local-base path (plain `git checkout <mainRef>`, then fork).
|
|
144
|
+
- **Issue #226 is preserved:** `origin/<mainRef>` is consumed as a **local ref** — there is **no `git fetch`**. `origin/<mainRef>` is the *correct* project base, so there is **no scope-contamination risk** (the entire point of Guarantee 6 survives — it never forks off the stale topic HEAD).
|
|
145
|
+
- **Bounded, degrades safely:** the subsequent `git checkout <mainRef>` may hydrate blobs on a partial clone (the one operation issue #226 flagged as hang-prone in headless sessions). It is bounded by the git-wrapper timeout; on failure/hang it falls through to the **same** fail-closed `wrong-base` refusal — strictly no worse than the non-repair path.
|
|
146
|
+
- **Still fail-closed** when neither a local base nor an `origin/<mainRef>` tracking ref exists, or when the base checkout itself fails. Default **OFF**.
|
|
147
|
+
|
|
148
|
+
Enable it fleet-wide via **Settings → "Live-checkout auto-base-repair (fleet-wide)"**, or in `config.json`:
|
|
149
|
+
|
|
150
|
+
```jsonc
|
|
151
|
+
{ "engine": { "liveCheckoutAutoBaseRepair": true } } // fleet-wide
|
|
152
|
+
// or per-project (wins over fleet-wide):
|
|
153
|
+
{ "projects": [ { "name": "office", "checkoutMode": "live", "liveCheckoutAutoBaseRepair": true } ] }
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
**Legitimate continuations are exempt.** PR-targeted fixes and shared-branch plans intentionally continue a non-base branch. `spawnAgent` passes `allowNonMainBase` (derived from `meta.useExistingBranch || meta.branchStrategy === 'shared-branch'` — the same signal it already uses to distinguish fresh forks from continuations), which bypasses the guard entirely. The existing-branch path is also untouched: continuing existing work is still a plain checkout with no rebase/pull.
|
|
157
|
+
|
|
128
158
|
## Dispatch-end auto-restore
|
|
129
159
|
|
|
130
160
|
Live-mode agents run **in-place** in the operator's checkout, so when a dispatch ends the engine switches the tree back to the ref it was on before the agent ran. This runs on **every** terminal result — success, failure, timeout, crash — and also on **every restart-recovery reaping path**: the engine-restart re-attach orphan-completion path (`engine/cli.js`) AND the `engine/timeout.js` paths that reap a dispatch the engine lost the process handle for (`completeFromOutput` + the orphan sweep). All three route through the shared `maybeRestoreLiveCheckoutFromRecord(item, …)` helper (which fires from the *persisted* dispatch record — `originalRef`'s presence is the live-mode signal), so a live agent that spans an engine restart and finishes afterward never strands the checkout on the agent's branch. (Previously the timeout.js reaping paths had **no** restore wiring, which is exactly how a restart-spanning live dispatch left the operator stuck on `work/W-…`.)
|
package/engine/ado.js
CHANGED
|
@@ -1584,7 +1584,12 @@ async function pollPrStatus(config) {
|
|
|
1584
1584
|
newReviewStatus = REVIEW_STATUS.APPROVED;
|
|
1585
1585
|
// Re-approve: ADO resets votes when target branch (master) advances, even though
|
|
1586
1586
|
// the source branch is unchanged. Re-apply the approval vote via API.
|
|
1587
|
-
|
|
1587
|
+
// Gated by autoApplyReviewVote (default OFF): re-casting a +10 vote is a
|
|
1588
|
+
// POSITIVE platform mutation, so when the operator has "Auto-apply review
|
|
1589
|
+
// vote to PR" disabled the engine must NOT touch the platform vote — the
|
|
1590
|
+
// local reviewStatus stays 'approved' (informational) but no +10 is PUT.
|
|
1591
|
+
const autoApplyReviewVote = config?.engine?.autoApplyReviewVote ?? shared.ENGINE_DEFAULTS.autoApplyReviewVote;
|
|
1592
|
+
if (autoApplyReviewVote && !votes.some(v => v >= 5) && sourceCommit && pr._adoSourceCommit === sourceCommit) {
|
|
1588
1593
|
try {
|
|
1589
1594
|
const identityData = await adoFetch(`${orgBase}/_apis/connectionData?api-version=7.1`, token).catch(() => null);
|
|
1590
1595
|
const myId = identityData?.authenticatedUser?.id;
|
package/engine/dispatch.js
CHANGED
|
@@ -749,6 +749,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
|
|
|
749
749
|
FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION, // P-a7f3c1d9 — live-checkout refused to spawn because the operator tree is mid-operation (in-progress merge/rebase/cherry-pick/bisect or detached HEAD); mechanical retry won't fix it (operator must finish/abort the op or checkout a branch)
|
|
750
750
|
FAILURE_CLASS.LIVE_CHECKOUT_BLOB_FETCH, // PL-live-checkout-reliability-hardening — live-checkout `git checkout <existing-branch>` failed hydrating the tree through the auth-less GVFS cache server (blobless partial clone, headless); deterministic, so mechanical retry just reproduces it (operator must hydrate the branch with their own creds, then re-dispatch)
|
|
751
751
|
FAILURE_CLASS.LIVE_CHECKOUT_WORKTREE_CONFLICT, // W-mr28h2j2000y0de1 — live-checkout `git checkout <existing-branch>` failed because that branch is already checked out in another worktree; structural conflict, so mechanical retry just reproduces it (operator must `git worktree remove` the other tree or finish its WIP, then re-dispatch)
|
|
752
|
+
FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE, // W-mr3lunnq000o9f41 — live-checkout refused to fork a new branch because the operator HEAD is on an unrelated branch (not the project base) and no local base ref was usable; forking off it would bake that branch's commits into the new PR (scope contamination). Deterministic — mechanical retry reproduces it (operator must `git checkout <mainRef>`, then re-dispatch)
|
|
752
753
|
FAILURE_CLASS.OUTPUT_TRUNCATED, // P-8e4c2a17 — agent stdout exceeded the hard capture cap before the terminal result event; mechanical retry just reproduces the overflow (agent must reduce output volume or the task must be split)
|
|
753
754
|
]);
|
|
754
755
|
if (neverRetry.has(failureClass)) return false;
|
package/engine/lifecycle.js
CHANGED
|
@@ -2410,19 +2410,20 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
2410
2410
|
// never claim approved if some OTHER reviewer (human, different minion
|
|
2411
2411
|
// account) still has a negative vote/review on the PR.
|
|
2412
2412
|
//
|
|
2413
|
-
// P-f7splitgate
|
|
2414
|
-
//
|
|
2415
|
-
//
|
|
2416
|
-
//
|
|
2417
|
-
//
|
|
2418
|
-
//
|
|
2419
|
-
//
|
|
2420
|
-
//
|
|
2413
|
+
// P-f7splitgate / W-mpea9fyb0010febf — autoApplyReviewVote gates ALL engine
|
|
2414
|
+
// platform vote mutations. When the operator has "Auto-apply review vote to
|
|
2415
|
+
// PR" disabled (default), the engine records the verdict in local reviewStatus
|
|
2416
|
+
// (informational) but MUST NOT touch the platform vote/review in any way —
|
|
2417
|
+
// neither the POSITIVE mirror below (promoting local status from live votes)
|
|
2418
|
+
// NOR the negative-correction reconcile here (dismissing the engine's own
|
|
2419
|
+
// prior REQUEST_CHANGES / re-casting an ADO +10 on a verdict flip to APPROVE).
|
|
2420
|
+
// Re-casting/clearing a platform vote is exactly the "casting votes
|
|
2421
|
+
// unexpectedly" behavior the gate exists to suppress, so it is now gated too.
|
|
2421
2422
|
const prevReviewStatus = reviewPr?.reviewStatus || '';
|
|
2422
2423
|
const wasNegative = prevReviewStatus === REVIEW_STATUS.CHANGES_REQUESTED || prevReviewStatus === REVIEW_STATUS.WAITING
|
|
2423
2424
|
|| liveStatus === REVIEW_STATUS.CHANGES_REQUESTED || liveStatus === REVIEW_STATUS.WAITING;
|
|
2424
2425
|
const autoApplyVote = config?.engine?.autoApplyReviewVote ?? ENGINE_DEFAULTS.autoApplyReviewVote;
|
|
2425
|
-
const canDismissOwnPriorNegative = verdictRaw === REVIEW_STATUS.APPROVED && !isSelfReview && wasNegative && projectObjForChecks;
|
|
2426
|
+
const canDismissOwnPriorNegative = autoApplyVote && verdictRaw === REVIEW_STATUS.APPROVED && !isSelfReview && wasNegative && projectObjForChecks;
|
|
2426
2427
|
if (canDismissOwnPriorNegative) {
|
|
2427
2428
|
try {
|
|
2428
2429
|
const reconcileFn = hostForChecks === 'github'
|
|
@@ -2471,8 +2472,69 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
2471
2472
|
}
|
|
2472
2473
|
}
|
|
2473
2474
|
|
|
2475
|
+
// W-mr9sy3j700076967 — a review dispatch can report success (type:"result",
|
|
2476
|
+
// no crash) yet never actually post a VERDICT: comment on the PR (silent
|
|
2477
|
+
// early-exit, budget exhaustion, model refusal, etc.). Before this guard,
|
|
2478
|
+
// the code below unconditionally stamped `lastReviewedAt` + `minionsReview`
|
|
2479
|
+
// as if the review were resolved. `lastReviewedAt` feeds the `alreadyReviewed`
|
|
2480
|
+
// gate in engine.js (`pr.lastPushedAt <= pr.lastReviewedAt`), so that phantom
|
|
2481
|
+
// stamp permanently blocked the engine from ever re-dispatching a review —
|
|
2482
|
+
// reviewStatus stayed "pending" forever with no path back to a real review.
|
|
2483
|
+
// Detect that case and (a) do NOT set lastReviewedAt so the existing
|
|
2484
|
+
// needsReview gate naturally re-fires a fresh review next tick (bounded by
|
|
2485
|
+
// maxReviewVerdictMissingRetries below), and (b) flag the PR + fire an inbox
|
|
2486
|
+
// alert instead of silently recording minionsReview as if it succeeded.
|
|
2487
|
+
const bailoutOutput = isReviewBailout(resultSummary);
|
|
2488
|
+
const verdictMissing = !postReviewStatus && !verdictRaw && !bailoutOutput;
|
|
2489
|
+
|
|
2474
2490
|
let updatedTarget = null;
|
|
2475
2491
|
const reviewNote = String(resultSummary || '').trim();
|
|
2492
|
+
|
|
2493
|
+
if (verdictMissing) {
|
|
2494
|
+
const maxMissingRetries = config?.engine?.maxReviewVerdictMissingRetries ?? ENGINE_DEFAULTS.maxReviewVerdictMissingRetries;
|
|
2495
|
+
let missingCount = 0;
|
|
2496
|
+
let retriesExhausted = false;
|
|
2497
|
+
shared.mutateJsonFileLocked(prPath, (prs) => {
|
|
2498
|
+
if (!Array.isArray(prs)) return prs;
|
|
2499
|
+
const target = shared.findPrRecord(prs, reviewPr, reviewProject);
|
|
2500
|
+
if (!target) return prs;
|
|
2501
|
+
missingCount = (target._reviewVerdictMissingCount || 0) + 1;
|
|
2502
|
+
retriesExhausted = missingCount >= maxMissingRetries;
|
|
2503
|
+
target._reviewVerdictMissingCount = missingCount;
|
|
2504
|
+
target._reviewVerdictMissing = true;
|
|
2505
|
+
target._reviewVerdictMissingAt = ts();
|
|
2506
|
+
target._lastReviewAttempt = {
|
|
2507
|
+
reviewer: reviewerName,
|
|
2508
|
+
attemptedAt: ts(),
|
|
2509
|
+
dispatchId: dispatchItem?.id || structuredCompletion?.dispatchId || null,
|
|
2510
|
+
note: reviewNote,
|
|
2511
|
+
};
|
|
2512
|
+
// Only lock the PR out of re-review (via lastReviewedAt) once the bounded
|
|
2513
|
+
// retry budget is exhausted — otherwise leave it unset so the engine's
|
|
2514
|
+
// normal needsReview gate re-dispatches a fresh review next tick.
|
|
2515
|
+
if (retriesExhausted) target.lastReviewedAt = ts();
|
|
2516
|
+
return prs;
|
|
2517
|
+
}, { defaultValue: [] });
|
|
2518
|
+
|
|
2519
|
+
const reason = `Review dispatch for ${reviewPr.id} completed without posting a VERDICT: comment ` +
|
|
2520
|
+
`(reviewer: ${reviewerName}, dispatch: ${dispatchItem?.id || structuredCompletion?.dispatchId || '(unknown)'}). ` +
|
|
2521
|
+
`reviewStatus left at "pending" instead of being silently recorded as resolved. ` +
|
|
2522
|
+
`Attempt ${missingCount}/${maxMissingRetries}` +
|
|
2523
|
+
(retriesExhausted
|
|
2524
|
+
? ' — retry budget exhausted; PR flagged (_reviewVerdictMissing) for human follow-up.'
|
|
2525
|
+
: ' — the engine will re-dispatch a fresh review on the next eligible tick.');
|
|
2526
|
+
log('warn', reason);
|
|
2527
|
+
shared.writeToInbox('engine', `review-verdict-missing-${reviewPr.id}`,
|
|
2528
|
+
`# Review completed without a verdict: ${reviewPr.id}\n\n` +
|
|
2529
|
+
`**Reviewer:** ${reviewerName}\n` +
|
|
2530
|
+
`**PR:** ${reviewPr.id}\n\n` +
|
|
2531
|
+
`${reason}\n` +
|
|
2532
|
+
(reviewNote ? `\n## Agent summary\n${reviewNote}\n` : ''),
|
|
2533
|
+
null,
|
|
2534
|
+
{ sourceItem: reviewPr.id, reason: 'review-verdict-missing' });
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
|
|
2476
2538
|
shared.mutateJsonFileLocked(prPath, (prs) => {
|
|
2477
2539
|
if (!Array.isArray(prs)) return prs;
|
|
2478
2540
|
const target = shared.findPrRecord(prs, reviewPr, reviewProject);
|
|
@@ -2486,6 +2548,11 @@ async function updatePrAfterReview(agentId, pr, project, config, resultSummary,
|
|
|
2486
2548
|
}
|
|
2487
2549
|
}
|
|
2488
2550
|
target.lastReviewedAt = ts();
|
|
2551
|
+
// A verdict landed cleanly this time — clear any stale missing-verdict flag.
|
|
2552
|
+
delete target._reviewVerdictMissing;
|
|
2553
|
+
delete target._reviewVerdictMissingCount;
|
|
2554
|
+
delete target._reviewVerdictMissingAt;
|
|
2555
|
+
delete target._lastReviewAttempt;
|
|
2489
2556
|
// W-mpg58wv3 — best-effort capture of thread ids the review cited. Used
|
|
2490
2557
|
// downstream by buildPrDispatch when spawning the review-feedback fix WI
|
|
2491
2558
|
// (meta.addresses_threads) so the closure-loop reaper can correlate. Accept
|
|
@@ -6691,10 +6758,26 @@ function collapseAllDuplicatePrRecords(config) {
|
|
|
6691
6758
|
// meta.liveValidationFor === codingWiId AND meta.liveValidationType === type
|
|
6692
6759
|
// blocks a second dispatch of that specific type, so multiple configured
|
|
6693
6760
|
// types don't collide or skip each other.
|
|
6761
|
+
//
|
|
6762
|
+
// W-mr9u39az000db5c2 — guard against a self-referential recursive cascade.
|
|
6763
|
+
// When liveValidation.type overlaps with a coding WI's own type set (e.g.
|
|
6764
|
+
// ["test","verify","setup"] configured on a project that ALSO dispatches
|
|
6765
|
+
// "setup"/"test"/"fix" coding work), a completed validation WI (type
|
|
6766
|
+
// "setup") is itself a WI whose type is in liveValidation.type. The old
|
|
6767
|
+
// item.type-only filter let that validation WI's own completion re-trigger
|
|
6768
|
+
// this hook and spawn ANOTHER validation WI (title-prefixed "Validate:
|
|
6769
|
+
// Validate: ..."), repeating indefinitely. Any WI that was itself created
|
|
6770
|
+
// as a liveValidation follow-up (tagged via meta.liveValidationFor at
|
|
6771
|
+
// creation, below) must never re-trigger this hook, regardless of its type.
|
|
6694
6772
|
function autoDispatchLiveValidationWi(meta, config) {
|
|
6695
6773
|
const item = meta?.item;
|
|
6696
6774
|
if (!item?.id || !item?.type) return;
|
|
6697
6775
|
|
|
6776
|
+
// Never re-trigger off the completion of a WI that was itself auto-created
|
|
6777
|
+
// as a live-validation follow-up — that's what causes the infinite
|
|
6778
|
+
// "Validate: Validate: ..." cascade (W-mr9u39az000db5c2 / issue #708).
|
|
6779
|
+
if (item.meta && item.meta.liveValidationFor) return;
|
|
6780
|
+
|
|
6698
6781
|
const projectName = meta?.project?.name;
|
|
6699
6782
|
if (!projectName) return;
|
|
6700
6783
|
|
|
@@ -6752,9 +6835,19 @@ function autoDispatchLiveValidationWi(meta, config) {
|
|
|
6752
6835
|
continue;
|
|
6753
6836
|
}
|
|
6754
6837
|
|
|
6838
|
+
const proposedTitle = 'Validate: ' + (item.title || codingWiId);
|
|
6839
|
+
// Defense-in-depth (belt-and-suspenders alongside the meta.liveValidationFor
|
|
6840
|
+
// guard above): never create a doubly-nested "Validate: Validate: ..."
|
|
6841
|
+
// title even if some future caller manages to invoke this function
|
|
6842
|
+
// directly on an already-validation-tagged item.
|
|
6843
|
+
if (/^Validate:\s*Validate:/.test(proposedTitle)) {
|
|
6844
|
+
log('warn', `liveValidation: refusing to create nested "Validate: Validate:" WI for ${codingWiId} (type=${validationType})`);
|
|
6845
|
+
continue;
|
|
6846
|
+
}
|
|
6847
|
+
|
|
6755
6848
|
const validationWi = {
|
|
6756
6849
|
id: 'W-' + shared.uid(),
|
|
6757
|
-
title:
|
|
6850
|
+
title: proposedTitle,
|
|
6758
6851
|
type: validationType,
|
|
6759
6852
|
status: WI_STATUS.PENDING,
|
|
6760
6853
|
depends_on: [codingWiId],
|
package/engine/live-checkout.js
CHANGED
|
@@ -39,13 +39,25 @@
|
|
|
39
39
|
* NO fast-forward, NO reset). The operator owns the checkout
|
|
40
40
|
* state per the live-checkout contract; the engine never
|
|
41
41
|
* overwrites local commits.
|
|
42
|
-
* b. rev-parse fails → branch is new →
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
42
|
+
* b. rev-parse fails → branch is new → fork it. STALE-BASE GUARD
|
|
43
|
+
* (W-mr3lunnq000o9f41) runs first, UNLESS `allowNonMainBase` is set
|
|
44
|
+
* (PR-targeted fixes / shared-branch continuations opt out — they
|
|
45
|
+
* intend to keep building on a non-main branch): if HEAD's branch is
|
|
46
|
+
* NOT the project base (`mainRef`), forking off it would silently bake
|
|
47
|
+
* every commit already on that branch into the new branch and its PR
|
|
48
|
+
* (the ADO PR 5411214 scope-contamination incident — survives the
|
|
49
|
+
* dirty/mid-operation preflights because it is committed history, not
|
|
50
|
+
* an uncommitted diff). Recovery is LOCAL-ONLY (preserves issue #226's
|
|
51
|
+
* no-forced-fetch guarantee): if `refs/heads/<mainRef>` exists locally,
|
|
52
|
+
* PLAIN-checkout it (no fetch/reset/--force) and fork off it; else
|
|
53
|
+
* FAIL CLOSED with { ok:false, reason:'wrong-base' }. When HEAD is
|
|
54
|
+
* already on `mainRef` (or the guard is bypassed) →
|
|
55
|
+
* `git checkout -b <branchName>` from the CURRENT HEAD (NOT
|
|
56
|
+
* origin/<mainRef>). In live mode the operator's checked-out HEAD IS
|
|
57
|
+
* the baseline; branching off origin/<mainRef> forces on-demand blob
|
|
58
|
+
* fetching on blobless partial clones (Scalar/GVFS-managed ADO repos),
|
|
59
|
+
* which fails in headless mode because the GVFS cache server receives
|
|
60
|
+
* no auth and credential.helper is disabled. See issue #226.
|
|
49
61
|
* 5. Returns { ok:true, branch, created:boolean, originalRef, originalRefType }.
|
|
50
62
|
*
|
|
51
63
|
* RETURN SHAPES:
|
|
@@ -53,6 +65,16 @@
|
|
|
53
65
|
* { ok:false, reason:'dirty', dirtyFiles:[…] }
|
|
54
66
|
* { ok:false, reason:'mid-operation', op:'merge'|'rebase'|'cherry-pick'|'revert', details }
|
|
55
67
|
* { ok:false, reason:'detached-head', sha }
|
|
68
|
+
* { ok:false, reason:'wrong-base', headBranch, expectedBase, localMainExists, [switchError], originalRef, originalRefType }
|
|
69
|
+
* (W-mr3lunnq000o9f41) — a NEW-branch fork was requested but the operator's
|
|
70
|
+
* HEAD is on `headBranch`, not the project base `expectedBase` (=mainRef),
|
|
71
|
+
* and no local base ref could be switched to (localMainExists:false → no
|
|
72
|
+
* `refs/heads/<mainRef>`; or localMainExists:true + `switchError` → the local
|
|
73
|
+
* checkout itself failed). Non-retryable at the caller — a human must
|
|
74
|
+
* `git checkout <expectedBase>` before re-dispatch so the new branch forks
|
|
75
|
+
* off the intended baseline instead of inheriting `headBranch`'s commits.
|
|
76
|
+
* Suppressed entirely when the caller passes `allowNonMainBase:true`
|
|
77
|
+
* (PR-targeted / shared-branch continuations).
|
|
56
78
|
* { ok:false, reason:'blob-fetch', op, branch, message, originalRef, originalRefType }
|
|
57
79
|
* { ok:false, reason:'worktree-conflict', op, branch, conflictingWorktreePath, message, originalRef, originalRefType }
|
|
58
80
|
* (W-mr28h2j2000y0de1) — the target branch is already checked out in another
|
|
@@ -320,6 +342,26 @@ function _defaultResolveAutoReset(localPath) {
|
|
|
320
342
|
}
|
|
321
343
|
}
|
|
322
344
|
|
|
345
|
+
// W-mr3lunnq000o9f41 — production default for resolving the live-checkout
|
|
346
|
+
// auto-base-repair decision when the caller (engine.js spawnAgent) does not pass
|
|
347
|
+
// an explicit `autoBaseRepair` boolean. Mirrors `_defaultResolveAutoReset`:
|
|
348
|
+
// self-resolve from config.json by matching the project on `localPath`, defer to
|
|
349
|
+
// `shared.resolveLiveCheckoutAutoBaseRepair`. Runs only on the wrong-base recovery
|
|
350
|
+
// path (cold, not hot). Fails CLOSED — any config read error returns false so a
|
|
351
|
+
// glitch can never silently widen the base-recovery surface.
|
|
352
|
+
function _defaultResolveAutoBaseRepair(localPath) {
|
|
353
|
+
try {
|
|
354
|
+
const config = shared.safeJson(path.join(shared.MINIONS_DIR, 'config.json')) || {};
|
|
355
|
+
const projects = shared.getProjects(config);
|
|
356
|
+
const norm = (p) => path.resolve(String(p || '')).replace(/\\/g, '/').toLowerCase();
|
|
357
|
+
const target = norm(localPath);
|
|
358
|
+
const project = projects.find((p) => p && p.localPath && norm(p.localPath) === target) || null;
|
|
359
|
+
return shared.resolveLiveCheckoutAutoBaseRepair(project, config.engine);
|
|
360
|
+
} catch {
|
|
361
|
+
return false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
323
365
|
// W-mqvejug6000eeb20 — production default inbox-note writer for the auto-reset
|
|
324
366
|
// audit trail. Lazily required to avoid a load-order cycle (dispatch.js does not
|
|
325
367
|
// require live-checkout.js, but the lazy require keeps this module importable in
|
|
@@ -338,14 +380,25 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
338
380
|
dispatchId, // accepted for caller bookkeeping; not used by the helper
|
|
339
381
|
wiId, // accepted for caller bookkeeping; used in the auto-reset note slug
|
|
340
382
|
skipDirtyCheck, // #522: opt-out for GVFS repos where git status output exceeds maxBuffer
|
|
383
|
+
allowNonMainBase, // W-mr3lunnq000o9f41: opt a dispatch OUT of the stale-base
|
|
384
|
+
// guard below. PR-targeted fixes and shared-branch
|
|
385
|
+
// continuations INTEND to keep building on a non-main
|
|
386
|
+
// branch; the caller (spawnAgent) sets this true for them.
|
|
387
|
+
// Undefined/false → the fresh-branch base guard is active.
|
|
341
388
|
log,
|
|
342
389
|
autoReset, // W-mqvejug6000eeb20: tri-state. `true`/`false` short-circuits the
|
|
343
390
|
// config-based resolution; `undefined` (the engine.js call shape)
|
|
344
391
|
// defers to `_resolveAutoReset`.
|
|
392
|
+
autoBaseRepair, // W-mr3lunnq000o9f41: tri-state. `true`/`false` short-circuits
|
|
393
|
+
// config resolution; `undefined` (the engine.js call shape)
|
|
394
|
+
// defers to `_resolveAutoBaseRepair`. When enabled, the
|
|
395
|
+
// wrong-base recovery may materialize a local base branch from
|
|
396
|
+
// `refs/remotes/origin/<mainRef>` (no fetch) instead of failing.
|
|
345
397
|
_git, // private injection for testing — defaults to shared.shellSafeGit
|
|
346
398
|
_exists, // private injection for testing — defaults to fs.existsSync
|
|
347
399
|
_rm, // private injection for testing — defaults to fs.rmSync (recursive, force)
|
|
348
400
|
_resolveAutoReset, // private injection for testing — defaults to config-based resolver
|
|
401
|
+
_resolveAutoBaseRepair, // private injection for testing — defaults to config-based resolver
|
|
349
402
|
_writeInboxNote, // private injection for testing — defaults to dispatch.writeInboxAlert
|
|
350
403
|
_sleep, // private injection for testing — defaults to a real setTimeout-based sleep
|
|
351
404
|
} = opts;
|
|
@@ -755,10 +808,151 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
755
808
|
return { ok: true, branch: branchName, created: false, originalRef, originalRefType };
|
|
756
809
|
}
|
|
757
810
|
|
|
758
|
-
// New branch —
|
|
759
|
-
//
|
|
760
|
-
//
|
|
761
|
-
//
|
|
811
|
+
// New branch — but FIRST guard against forking off a STALE non-main HEAD.
|
|
812
|
+
//
|
|
813
|
+
// ── (4d) STALE-BASE GUARD (W-mr3lunnq000o9f41). ───────────────────────
|
|
814
|
+
// The branch does not exist locally, so we are about to FORK A NEW branch.
|
|
815
|
+
// `git checkout -b <branch>` seeds it from the CURRENT HEAD (issue #226 — we
|
|
816
|
+
// never fetch origin/<mainRef> in live mode). That is safe ONLY when HEAD is
|
|
817
|
+
// already on the project's base branch. If a human (or a prior dispatch) left
|
|
818
|
+
// the operator checkout sitting on some unrelated topic/feature branch, the
|
|
819
|
+
// new branch — and therefore its PR — would SILENTLY inherit every commit
|
|
820
|
+
// already on that topic branch. That is scope contamination baked into git
|
|
821
|
+
// history, not a stray uncommitted diff, so it survives the dirty-tree and
|
|
822
|
+
// mid-operation preflights above (which only check for uncommitted changes and
|
|
823
|
+
// in-progress operations, never "is HEAD on the expected base?"). This is a
|
|
824
|
+
// real, observed incident: ADO PR 5411214 silently carried ~22 unrelated OCM
|
|
825
|
+
// files that the PR author disavowed.
|
|
826
|
+
//
|
|
827
|
+
// `allowNonMainBase` opts a dispatch OUT of the guard: PR-targeted fixes and
|
|
828
|
+
// shared-branch continuations INTEND to keep building on a non-main branch, so
|
|
829
|
+
// the caller passes it for those legitimate continuations. For a plain
|
|
830
|
+
// fresh-branch dispatch the guard is active.
|
|
831
|
+
//
|
|
832
|
+
// Recovery is LOCAL-ONLY to preserve issue #226's no-forced-fetch guarantee:
|
|
833
|
+
// if a local `refs/heads/<mainRef>` already exists we PLAIN-checkout it (no
|
|
834
|
+
// fetch, no reset, no --force — the tree was verified clean at Step 1) and
|
|
835
|
+
// fork off it. If no local mainRef ref is resolvable we normally FAIL CLOSED
|
|
836
|
+
// with { ok:false, reason:'wrong-base' } — mirroring the dirty/mid-operation
|
|
837
|
+
// philosophy of refusing on ambiguous operator state rather than silently
|
|
838
|
+
// forking off the wrong base.
|
|
839
|
+
//
|
|
840
|
+
// ── (4d-repair) OPT-IN AUTO-BASE-REPAIR (W-mr3lunnq000o9f41). ──────────
|
|
841
|
+
// When `autoBaseRepair` is enabled AND no local base branch exists, we fall
|
|
842
|
+
// back to the `refs/remotes/origin/<mainRef>` remote-tracking ref — a purely
|
|
843
|
+
// LOCAL ref (NO `git fetch`, so issue #226 is preserved) that points at the
|
|
844
|
+
// CORRECT project base. We materialize a local `<mainRef>` from it (a plain
|
|
845
|
+
// ref op) and proceed exactly like the local-main path. origin/<mainRef> IS
|
|
846
|
+
// the base, so there is NO scope contamination (the whole point of this guard
|
|
847
|
+
// survives). The subsequent `git checkout <mainRef>` may hydrate blobs on a
|
|
848
|
+
// partial clone; that is bounded by the git wrapper timeout and, on failure,
|
|
849
|
+
// falls through to the SAME fail-closed wrong-base refusal (strictly no worse
|
|
850
|
+
// than the non-repair path). Opt-in because the checkout hydration is the one
|
|
851
|
+
// operation issue #226 flagged as hang-prone in headless sessions.
|
|
852
|
+
if (!allowNonMainBase) {
|
|
853
|
+
// HEAD's branch — captured at Step 3 as originalRef (symbolic-ref --short
|
|
854
|
+
// HEAD) when originalRefType is 'branch'; fall back to the abbrev-ref read
|
|
855
|
+
// at Step 4a. Detached HEAD was already refused at Step 2, so on this path
|
|
856
|
+
// originalRefType is normally 'branch'.
|
|
857
|
+
const headBranch = (originalRefType === 'branch' && originalRef)
|
|
858
|
+
? originalRef
|
|
859
|
+
: (curBranch || '');
|
|
860
|
+
if (headBranch !== mainRef) {
|
|
861
|
+
let localMainExists = false;
|
|
862
|
+
try {
|
|
863
|
+
await git(['rev-parse', '--verify', '--quiet', `refs/heads/${mainRef}`], baseOpts);
|
|
864
|
+
localMainExists = true;
|
|
865
|
+
} catch {
|
|
866
|
+
localMainExists = false;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (!localMainExists) {
|
|
870
|
+
// Resolve the opt-in auto-base-repair decision (explicit boolean wins;
|
|
871
|
+
// else config-based resolver; else fail closed as before).
|
|
872
|
+
let wantBaseRepair = false;
|
|
873
|
+
if (typeof autoBaseRepair === 'boolean') {
|
|
874
|
+
wantBaseRepair = autoBaseRepair;
|
|
875
|
+
} else {
|
|
876
|
+
const resolver = (typeof _resolveAutoBaseRepair === 'function') ? _resolveAutoBaseRepair : _defaultResolveAutoBaseRepair;
|
|
877
|
+
try { wantBaseRepair = !!resolver(localPath); } catch { wantBaseRepair = false; }
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
let originMainExists = false;
|
|
881
|
+
if (wantBaseRepair) {
|
|
882
|
+
try {
|
|
883
|
+
await git(['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${mainRef}`], baseOpts);
|
|
884
|
+
originMainExists = true;
|
|
885
|
+
} catch {
|
|
886
|
+
originMainExists = false;
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
if (!wantBaseRepair || !originMainExists) {
|
|
891
|
+
return {
|
|
892
|
+
ok: false,
|
|
893
|
+
reason: 'wrong-base',
|
|
894
|
+
headBranch,
|
|
895
|
+
expectedBase: mainRef,
|
|
896
|
+
localMainExists: false,
|
|
897
|
+
autoBaseRepair: !!wantBaseRepair,
|
|
898
|
+
originMainExists,
|
|
899
|
+
originalRef,
|
|
900
|
+
originalRefType,
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Materialize a local <mainRef> from the remote-tracking ref. This is a
|
|
905
|
+
// pure ref op (NO fetch, NO checkout, NO hydration). --no-track keeps it
|
|
906
|
+
// a plain local branch. localMainExists then flows into the shared
|
|
907
|
+
// checkout below, so the hydration cost is identical to the ordinary
|
|
908
|
+
// local-main path.
|
|
909
|
+
try {
|
|
910
|
+
await git(['branch', '--no-track', mainRef, `refs/remotes/origin/${mainRef}`], baseOpts);
|
|
911
|
+
localMainExists = true;
|
|
912
|
+
logFn('info',
|
|
913
|
+
`live-checkout auto-base-repair: no local '${mainRef}' — created it from 'origin/${mainRef}' (no fetch, issue #226 preserved) to recover base for forking '${branchName}'`);
|
|
914
|
+
} catch (repairErr) {
|
|
915
|
+
return {
|
|
916
|
+
ok: false,
|
|
917
|
+
reason: 'wrong-base',
|
|
918
|
+
headBranch,
|
|
919
|
+
expectedBase: mainRef,
|
|
920
|
+
localMainExists: false,
|
|
921
|
+
autoBaseRepair: true,
|
|
922
|
+
originMainExists: true,
|
|
923
|
+
baseRepairError: String((repairErr && repairErr.message) || '').slice(0, 500),
|
|
924
|
+
originalRef,
|
|
925
|
+
originalRefType,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// Local mainRef ref now exists (either originally, or just materialized
|
|
931
|
+
// from origin/<mainRef> by auto-base-repair) — switch to it using purely
|
|
932
|
+
// LOCAL refs (NO fetch, NO --force), then fork the new branch off it below.
|
|
933
|
+
try {
|
|
934
|
+
await git(['checkout', mainRef], baseOpts);
|
|
935
|
+
} catch (switchErr) {
|
|
936
|
+
return {
|
|
937
|
+
ok: false,
|
|
938
|
+
reason: 'wrong-base',
|
|
939
|
+
headBranch,
|
|
940
|
+
expectedBase: mainRef,
|
|
941
|
+
localMainExists: true,
|
|
942
|
+
switchError: String((switchErr && switchErr.message) || '').slice(0, 500),
|
|
943
|
+
originalRef,
|
|
944
|
+
originalRefType,
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
logFn('info',
|
|
948
|
+
`live-checkout: HEAD was on '${headBranch}' (not base '${mainRef}') — switched to local '${mainRef}' before forking '${branchName}' (no fetch, issue #226 preserved)`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// Create from HEAD — now guaranteed to be the project base branch unless
|
|
953
|
+
// allowNonMainBase bypassed the guard. NOT origin/<mainRef>: in live mode the
|
|
954
|
+
// operator's HEAD is the baseline, and seeding from a remote ref forces
|
|
955
|
+
// auth-less GVFS blob fetches that fail on partial clones in headless mode.
|
|
762
956
|
const failedCreate = await runCheckout(['checkout', '-b', branchName], { creating: true });
|
|
763
957
|
if (failedCreate) return failedCreate;
|
|
764
958
|
return { ok: true, branch: branchName, created: true, originalRef, originalRefType };
|
package/engine/playbook.js
CHANGED
|
@@ -110,7 +110,7 @@ function getPrFetchInstructions(project) {
|
|
|
110
110
|
`If \`az\` is unavailable, fall back to the ADO REST API: get a token with \`az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken -o tsv\` and \`GET .../_apis/git/repositories/<repoId>/pullrequests/<number>?api-version=7.1\` (Bearer auth). Do not use \`gh\` for Azure DevOps repositories.`;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
function getPrVoteInstructions(project) {
|
|
113
|
+
function getPrVoteInstructions(project, config) {
|
|
114
114
|
const host = getRepoHost(project);
|
|
115
115
|
const repoId = project?.repositoryId || '';
|
|
116
116
|
if (host === 'github') {
|
|
@@ -131,7 +131,20 @@ function getPrVoteInstructions(project) {
|
|
|
131
131
|
`When you author the summary body, append the collapsible **"Harnesses used"** \`<details>\` section described in the shared rules (mirror of your \`harnessUsed\` completion field) so reviewers see which skills / MCPs / docs informed the change; omit it when you used none.\n\n` +
|
|
132
132
|
`**Brand link:** whenever the body names "Minions" (e.g. the \`Reviewed by Minions (…)\` sign-off), render it as \`[Minions](${MINIONS_BRAND_URL})\` per the shared rules. On the GitHub path the engine also auto-links the first bare \`… by Minions\` trailer as a backstop (\`engine/comment-format.js#linkifyBrandTrailer\`), but write the link yourself so the first occurrence is correct.`;
|
|
133
133
|
}
|
|
134
|
-
// Azure DevOps — prefer `az` CLI first, ADO REST API as fallback
|
|
134
|
+
// Azure DevOps — prefer `az` CLI first, ADO REST API as fallback.
|
|
135
|
+
// The platform reviewer vote is a POSITIVE mutation gated by autoApplyReviewVote
|
|
136
|
+
// ("Auto-apply review vote to PR", default OFF). When disabled, agents must NOT
|
|
137
|
+
// cast a platform vote — they post the verdict as a comment only (parity with
|
|
138
|
+
// GitHub, where self-approval is blocked). This is what keeps the toggle in
|
|
139
|
+
// full control of whether Minions ever cast votes on PRs.
|
|
140
|
+
const autoApplyReviewVote = config?.engine?.autoApplyReviewVote ?? ENGINE_DEFAULTS.autoApplyReviewVote;
|
|
141
|
+
if (!autoApplyReviewVote) {
|
|
142
|
+
return `**Do NOT cast a platform reviewer vote.** The operator has "Auto-apply review vote to PR" disabled, so Minions record verdicts as comments only — the human casts the final vote. Do NOT run \`az repos pr set-vote\` or PUT a reviewer vote via the REST API.\n\n` +
|
|
143
|
+
`Post your verdict as a thread comment. **Preferred:** \`minions pr comment <number> --host ado --ado-org ${getProjectOrg(project)} --ado-project ${project?.adoProject || '<ado-project>'} --repo-id ${repoId || '<repo-id>'} --agent <your-agent-id> --kind review-decision [--wi <work-item-id>] --body-file <verdict.md>\` (applies the marker + harness section + brand link for you). Fallback: \`az repos pr comment create --pull-request-id <number> --content @<verdict.md>\`\n\n` +
|
|
144
|
+
`The verdict body file's first non-marker line MUST be \`VERDICT: APPROVE\` or \`VERDICT: REQUEST_CHANGES\` — the engine parses this to record your verdict in local state.\n\n` +
|
|
145
|
+
`When you author the comment body, append the collapsible **"Harnesses used"** \`<details>\` section described in the shared rules (mirror of your \`harnessUsed\` completion field); omit it when you used none.\n\n` +
|
|
146
|
+
`**Brand link:** whenever the body names "Minions" (e.g. the \`Reviewed by Minions (…)\` sign-off), render it as \`Reviewed by [Minions](${MINIONS_BRAND_URL}) (…)\` per the shared rules. \`minions pr comment --host ado\` auto-links the first bare \`… by Minions\` trailer for you; the raw \`az repos pr comment\` fallback does NOT, so if you use it you MUST link it yourself.`;
|
|
147
|
+
}
|
|
135
148
|
return `For Azure DevOps, use the \`az\` CLI first to set your reviewer vote:\n` +
|
|
136
149
|
`- \`az repos pr set-vote --id <number> --vote {approve | approve-with-suggestions | reject | reset | wait-for-author}\`\n` +
|
|
137
150
|
`- Pair the vote with a thread comment. **Preferred:** \`minions pr comment <number> --host ado --ado-org ${getProjectOrg(project)} --ado-project ${project?.adoProject || '<ado-project>'} --repo-id ${repoId || '<repo-id>'} --agent <your-agent-id> --kind review-decision [--wi <work-item-id>] --body-file <verdict.md>\` (applies the marker + harness section + brand link for you). Fallback: \`az repos pr comment create --pull-request-id <number> --content @<verdict.md>\`\n\n` +
|
|
@@ -149,7 +162,7 @@ function getRepoHostLabel(project) {
|
|
|
149
162
|
function getRepoHostToolRule(project) {
|
|
150
163
|
const host = getRepoHost(project);
|
|
151
164
|
if (host === 'github') return 'Use GitHub MCP tools or `gh` CLI for PR operations';
|
|
152
|
-
return 'For Azure DevOps, use the `az` CLI for PR operations (e.g. `az repos pr create`, `az repos pr show`, `az repos pr comment
|
|
165
|
+
return 'For Azure DevOps, use the `az` CLI for PR operations (e.g. `az repos pr create`, `az repos pr show`, `az repos pr comment`); if `az` is unavailable or insufficient, fall back to the ADO REST API (`_apis/git/...`) with a token from `az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798`. Do not use `gh` for Azure DevOps repositories. Whether to cast a reviewer vote (`az repos pr set-vote`) is governed separately by the PR vote instructions — do not vote unless they tell you to.';
|
|
153
166
|
}
|
|
154
167
|
|
|
155
168
|
// ─── Task Context Resolution ────────────────────────────────────────────────
|
|
@@ -967,7 +980,7 @@ function renderPlaybook(type, vars) {
|
|
|
967
980
|
pr_create_instructions: getPrCreateInstructions(renderProject),
|
|
968
981
|
pr_comment_instructions: getPrCommentInstructions(renderProject),
|
|
969
982
|
pr_fetch_instructions: getPrFetchInstructions(renderProject),
|
|
970
|
-
pr_vote_instructions: getPrVoteInstructions(renderProject),
|
|
983
|
+
pr_vote_instructions: getPrVoteInstructions(renderProject, config),
|
|
971
984
|
repo_host_label: getRepoHostLabel(renderProject),
|
|
972
985
|
};
|
|
973
986
|
const allVars = { ...projectVars, ...vars };
|
package/engine/shared.js
CHANGED
|
@@ -2963,6 +2963,25 @@ function resolveLiveCheckoutAutoReset(project, engine) {
|
|
|
2963
2963
|
return false;
|
|
2964
2964
|
}
|
|
2965
2965
|
|
|
2966
|
+
// W-mr3lunnq000o9f41 (companion to the stale-base guard) — resolve the
|
|
2967
|
+
// live-checkout auto-base-repair decision. Per-project `liveCheckoutAutoBaseRepair`
|
|
2968
|
+
// wins, else fleet-wide `engine.liveCheckoutAutoBaseRepair`, else false (opt-in).
|
|
2969
|
+
// When ON, prepareLiveCheckout's wrong-base recovery may materialize a local base
|
|
2970
|
+
// branch from the `refs/remotes/origin/<mainRef>` remote-tracking ref (a LOCAL ref
|
|
2971
|
+
// — no `git fetch`, so issue #226's no-forced-fetch guarantee is preserved) when
|
|
2972
|
+
// no local `refs/heads/<mainRef>` exists, instead of failing closed with
|
|
2973
|
+
// LIVE_CHECKOUT_WRONG_BASE. origin/<mainRef> IS the project base, so there is no
|
|
2974
|
+
// scope-contamination risk (unlike forking off the stale topic HEAD). Default OFF.
|
|
2975
|
+
function resolveLiveCheckoutAutoBaseRepair(project, engine) {
|
|
2976
|
+
if (project && typeof project === 'object' && typeof project.liveCheckoutAutoBaseRepair === 'boolean') {
|
|
2977
|
+
return project.liveCheckoutAutoBaseRepair;
|
|
2978
|
+
}
|
|
2979
|
+
if (engine && typeof engine === 'object' && typeof engine.liveCheckoutAutoBaseRepair === 'boolean') {
|
|
2980
|
+
return engine.liveCheckoutAutoBaseRepair;
|
|
2981
|
+
}
|
|
2982
|
+
return false;
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2966
2985
|
function validateCheckoutMode(value) {
|
|
2967
2986
|
if (value === undefined || value === null || value === '') return undefined;
|
|
2968
2987
|
if (typeof value !== 'string') {
|
|
@@ -3262,6 +3281,7 @@ const ENGINE_DEFAULTS = {
|
|
|
3262
3281
|
maxRetries: 3, // max dispatch retries before marking work item as failed
|
|
3263
3282
|
maxRetriesPerAgent: 2, // W-mpmwxn1j — per-agent retry cap. When the SAME agent fails the same WI this many times, the next retry MUST reassign to a different eligible agent (consults routing.md + agent availability). Falls back to the same agent only when no alternate is available. Counted separately from `maxRetries` (which caps total retries across all agents) and tracked on the WI as `_retriesByAgent: { agentId: count }`. Hard-pinned agents bypass reassignment (operator intent wins).
|
|
3264
3283
|
maxPhantomRetries: 3, // max retries for "phantom completion" (runtime crashed before emitting type:"result"); tracked separately from _retryCount so phantom retries don't pollute the normal PR-attachment retry budget. See engine/lifecycle.markMissingPrAttachment + detectNonTerminalResultSummary.
|
|
3284
|
+
maxReviewVerdictMissingRetries: 2, // W-mr9sy3j700076967 — bounded retry budget for a review dispatch that completes ("success") without posting a VERDICT: comment. Below the budget, engine/lifecycle.updatePrAfterReview leaves `lastReviewedAt` unset so the normal needsReview gate re-dispatches a fresh review; at/above budget it stamps `lastReviewedAt` (stops auto-retry) but keeps `_reviewVerdictMissing: true` so the PR is dashboard-flagged for a human instead of looking like an ordinary still-pending review.
|
|
3265
3285
|
minRetryGapMs: 120000, // 2min — minimum gap between retry dispatches for the same work item; prevents tight retry loops when an idempotent agent (e.g. review bailing out on a duplicate) cannot produce the expected output (#1770)
|
|
3266
3286
|
pipelineApiRetries: 2, // max attempts for pipeline API calls
|
|
3267
3287
|
pipelineApiRetryDelay: 2000, // ms delay between pipeline API retries
|
|
@@ -3421,7 +3441,7 @@ const ENGINE_DEFAULTS = {
|
|
|
3421
3441
|
// stale PRD reopen can't smuggle in unvetted items.
|
|
3422
3442
|
preDispatchEvalSkipPrdSourced: true,
|
|
3423
3443
|
completionNonceRequired: false, // P-d2a8f6c1 (agent trust boundary F8): when true, a missing `nonce` field in the completion JSON hard-fails the dispatch with failure_class:'completion-nonce-mismatch'. Default false for one release so older agents/runtime caches that haven't picked up the prompt change degrade with a warning instead of breaking. Mismatched nonces hard-fail regardless of this flag. See docs/completion-reports.md → "Trust boundary".
|
|
3424
|
-
autoApplyReviewVote: false, // W-mpea9fyb0010febf / P-f7splitgate:
|
|
3444
|
+
autoApplyReviewVote: false, // W-mpea9fyb0010febf / P-f7splitgate: master gate for ALL engine platform vote mutations. When true, the engine (a) mirrors live platform vote state into local pull-requests.json reviewStatus, (b) re-applies its own ADO +10 approval when ADO resets votes on target-branch advance (pollPrStatus), (c) reconciles its own prior negative on a verdict flip to APPROVE (ADO resetReviewerNegativeVote / GitHub dismissPriorViewerChangesRequestedReviews), and (d) instructs ADO review agents to cast `az repos pr set-vote`. When false (default), the verdict is recorded in pull-requests.json reviewStatus from the agent's completion ONLY — informational — and Minions never cast, re-cast, or clear a platform vote/review (ADO agents post the verdict as a comment only, parity with GitHub self-approval block).
|
|
3425
3445
|
|
|
3426
3446
|
// ── Runtime fleet (P-3b8e5f1d) ──────────────────────────────────────────────
|
|
3427
3447
|
// Single source of truth for which CLI runtime + model every spawn uses.
|
|
@@ -4854,6 +4874,7 @@ const FAILURE_CLASS = {
|
|
|
4854
4874
|
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.
|
|
4855
4875
|
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.
|
|
4856
4876
|
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.
|
|
4877
|
+
LIVE_CHECKOUT_WRONG_BASE: 'live-checkout-wrong-base', // W-mr3lunnq000o9f41 (live-checkout dispatch mode): a NEW-branch fork was requested but the operator's checkout HEAD is on an unrelated topic/feature branch, NOT the project base (mainRef), and prepareLiveCheckout could not switch to a LOCAL base ref (no `refs/heads/<mainRef>`, or the local checkout itself failed). Forking off the stale HEAD would silently bake every commit already on that branch into the new branch AND its PR — committed-history scope contamination that survives the dirty/mid-operation preflights (the ADO PR 5411214 incident: ~22 unrelated OCM files, author-disavowed). The engine never fetches origin/<mainRef> in live mode (issue #226) so it cannot self-heal a missing local base. Non-retryable — the operator must `git checkout <mainRef>` in the operator checkout before re-dispatch. Does NOT fire for PR-targeted fixes or shared-branch continuations (those pass allowNonMainBase, intentionally continuing a non-main branch), nor when HEAD is already on mainRef, nor when a local mainRef ref exists (the guard switches to it and forks cleanly). OPT-IN AUTO-RECOVERY (`liveCheckoutAutoBaseRepair`, per-project > fleet-wide, default OFF): when enabled AND a `refs/remotes/origin/<mainRef>` remote-tracking ref exists, the guard materializes a local `<mainRef>` branch from it (a LOCAL ref op — still no `git fetch`, issue #226 preserved; origin/<mainRef> IS the base so no contamination) and forks cleanly instead of failing — eliminating the manual `git checkout <mainRef>` step. Still fails closed when neither a local base nor an origin-tracking base ref exists, or when the base checkout itself fails/hangs.
|
|
4857
4878
|
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.
|
|
4858
4879
|
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).
|
|
4859
4880
|
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.
|
|
@@ -9726,6 +9747,7 @@ module.exports = {
|
|
|
9726
9747
|
resolveCheckoutMode,
|
|
9727
9748
|
isLiveCheckoutProject,
|
|
9728
9749
|
resolveLiveCheckoutAutoReset,
|
|
9750
|
+
resolveLiveCheckoutAutoBaseRepair,
|
|
9729
9751
|
validatePid,
|
|
9730
9752
|
PR_FIX_CAUSE,
|
|
9731
9753
|
getPrFixAutomationCause,
|
package/engine.js
CHANGED
|
@@ -2451,6 +2451,13 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2451
2451
|
}
|
|
2452
2452
|
if (liveMode && branchName && !READ_ONLY_ROOT_TASK_TYPES.has(type)) {
|
|
2453
2453
|
const _liveMainRef = sanitizeBranch(shared.resolveMainBranch(cwd, project.mainBranch));
|
|
2454
|
+
// W-mr3lunnq000o9f41: PR-targeted fixes and shared-branch continuations
|
|
2455
|
+
// intentionally keep building on a non-main branch, so they opt OUT of the
|
|
2456
|
+
// stale-base guard in prepareLiveCheckout. `meta.useExistingBranch` is set
|
|
2457
|
+
// true for every PR-targeted WI and for shared-branch WIs (engine.js
|
|
2458
|
+
// dispatch meta), so it is the exact "this is a continuation, not a fresh
|
|
2459
|
+
// fork off baseline" signal.
|
|
2460
|
+
const _liveAllowNonMainBase = !!(meta?.useExistingBranch || meta?.branchStrategy === 'shared-branch');
|
|
2454
2461
|
const _wiIdForAlert = meta?.item?.id || id;
|
|
2455
2462
|
const _liveCheckout = require('./engine/live-checkout');
|
|
2456
2463
|
let _liveResult;
|
|
@@ -2463,6 +2470,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2463
2470
|
dispatchId: id,
|
|
2464
2471
|
wiId: _wiIdForAlert,
|
|
2465
2472
|
skipDirtyCheck: !!project.skipLiveCheckoutDirtyCheck,
|
|
2473
|
+
allowNonMainBase: _liveAllowNonMainBase,
|
|
2466
2474
|
log: (msg, lvl) => log(lvl || 'info', msg),
|
|
2467
2475
|
});
|
|
2468
2476
|
} catch (liveErr) {
|
|
@@ -2694,6 +2702,75 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2694
2702
|
cleanupTempAgent(agentId);
|
|
2695
2703
|
return null;
|
|
2696
2704
|
}
|
|
2705
|
+
// W-mr3lunnq000o9f41: stale non-main base refusal. A NEW-branch fork was
|
|
2706
|
+
// requested but the operator's checkout HEAD is on an unrelated branch, not
|
|
2707
|
+
// the project base (mainRef), and prepareLiveCheckout could not switch to a
|
|
2708
|
+
// LOCAL base ref (issue #226 forbids fetching origin/<mainRef> in live mode,
|
|
2709
|
+
// so the engine cannot self-heal a missing local base). Forking off the
|
|
2710
|
+
// stale HEAD would silently inherit every commit already on that branch into
|
|
2711
|
+
// the new branch and its PR (committed-history scope contamination — the ADO
|
|
2712
|
+
// PR 5411214 incident, which survived the dirty/mid-operation preflights).
|
|
2713
|
+
// Fail closed with a distinct non-retryable FAILURE_CLASS and operator-
|
|
2714
|
+
// actionable guidance; the engine never moves the operator's HEAD for them
|
|
2715
|
+
// beyond the local-only base switch the helper already attempted.
|
|
2716
|
+
if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'wrong-base') {
|
|
2717
|
+
const _headBranch = typeof _liveResult.headBranch === 'string' && _liveResult.headBranch
|
|
2718
|
+
? _liveResult.headBranch
|
|
2719
|
+
: '(unknown)';
|
|
2720
|
+
const _expectedBase = typeof _liveResult.expectedBase === 'string' && _liveResult.expectedBase
|
|
2721
|
+
? _liveResult.expectedBase
|
|
2722
|
+
: _liveMainRef;
|
|
2723
|
+
const _switchErr = typeof _liveResult.switchError === 'string' ? _liveResult.switchError : '';
|
|
2724
|
+
const _alertBody = [
|
|
2725
|
+
'# Live-checkout blocked: HEAD is on the wrong base branch',
|
|
2726
|
+
'',
|
|
2727
|
+
`**Project:** ${project.name || '(unknown)'}`,
|
|
2728
|
+
`**Local path:** ${cwd}`,
|
|
2729
|
+
`**New branch (requested):** ${branchName}`,
|
|
2730
|
+
`**HEAD is on:** ${_headBranch}`,
|
|
2731
|
+
`**Expected base:** ${_expectedBase}`,
|
|
2732
|
+
`**Work item:** ${_wiIdForAlert}`,
|
|
2733
|
+
`**Dispatch:** ${id}`,
|
|
2734
|
+
'',
|
|
2735
|
+
`The engine refused to spawn the agent because \`${cwd}\` is checked out on \`${_headBranch}\`, not the project base \`${_expectedBase}\`. Forking the new branch \`${branchName}\` off \`${_headBranch}\` would silently inherit **every commit already on that branch** into the new branch — and into its PR — as committed history (this is exactly how ADO PR 5411214 picked up ~22 unrelated files). Live-checkout never fetches \`origin/${_expectedBase}\` for you (issue #226), and no local \`${_expectedBase}\` ref was usable, so the engine cannot safely establish the base itself.`,
|
|
2736
|
+
...(_switchErr ? ['', 'The local base checkout failed with:', '', '```', _switchErr, '```'] : []),
|
|
2737
|
+
'',
|
|
2738
|
+
'## Recovery',
|
|
2739
|
+
'',
|
|
2740
|
+
`Switch the operator checkout back to the base branch, then re-dispatch the work item:`,
|
|
2741
|
+
'',
|
|
2742
|
+
'```',
|
|
2743
|
+
`git -C "${cwd}" checkout ${_expectedBase}`,
|
|
2744
|
+
'```',
|
|
2745
|
+
'',
|
|
2746
|
+
`If you deliberately want to stack this work on \`${_headBranch}\`, dispatch it as a PR-targeted or shared-branch work item instead — those intentionally continue a non-main branch and are exempt from this guard.`,
|
|
2747
|
+
].join('\n');
|
|
2748
|
+
try { writeInboxAlert(`live-checkout-blocked-${_wiIdForAlert}`, _alertBody); }
|
|
2749
|
+
catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
|
|
2750
|
+
try {
|
|
2751
|
+
const _wiPath = resolveWorkItemPath(dispatchItem.meta);
|
|
2752
|
+
if (_wiPath && dispatchItem.meta?.item?.id) {
|
|
2753
|
+
mutateJsonFileLocked(_wiPath, (data) => {
|
|
2754
|
+
if (!Array.isArray(data)) return data;
|
|
2755
|
+
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
2756
|
+
if (wi) wi._pendingReason = 'live_checkout_wrong_base';
|
|
2757
|
+
return data;
|
|
2758
|
+
});
|
|
2759
|
+
}
|
|
2760
|
+
} catch (e) { log('warn', `live-checkout: failed to stamp _pendingReason: ${e.message}`); }
|
|
2761
|
+
const _shortMsg = `live-checkout blocked: HEAD on '${_headBranch}' (not base '${_expectedBase}') in ${cwd} — refusing to fork ${branchName} off a stale base`;
|
|
2762
|
+
log('error', `spawnAgent: ${_shortMsg}`);
|
|
2763
|
+
_cleanupPromptFiles();
|
|
2764
|
+
completeDispatch(
|
|
2765
|
+
id,
|
|
2766
|
+
DISPATCH_RESULT.ERROR,
|
|
2767
|
+
_shortMsg.slice(0, 800),
|
|
2768
|
+
`Live-checkout refused to fork a new branch off '${_headBranch}' because it is not the project base '${_expectedBase}' — doing so would bake that branch's commits into the new PR (scope contamination). The engine never fetches origin in live mode (issue #226) and found no usable local base ref. Operator must \`git checkout ${_expectedBase}\` before re-dispatch. Retrying without moving HEAD is provably useless.`,
|
|
2769
|
+
{ failureClass: FAILURE_CLASS.LIVE_CHECKOUT_WRONG_BASE, agentRetryable: false },
|
|
2770
|
+
);
|
|
2771
|
+
cleanupTempAgent(agentId);
|
|
2772
|
+
return null;
|
|
2773
|
+
}
|
|
2697
2774
|
// PL-live-checkout-reliability-hardening: partial-clone / GVFS blob-fetch
|
|
2698
2775
|
// refusal. The existing-branch `git checkout` could not materialize the tree
|
|
2699
2776
|
// because the operator's checkout is a Scalar/GVFS blobless partial clone and
|
|
@@ -2786,6 +2863,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2786
2863
|
dispatchId: id,
|
|
2787
2864
|
wiId: _wiIdForAlert,
|
|
2788
2865
|
skipDirtyCheck: !!project.skipLiveCheckoutDirtyCheck,
|
|
2866
|
+
allowNonMainBase: _liveAllowNonMainBase,
|
|
2789
2867
|
log: (msg, lvl) => log(lvl || 'info', msg),
|
|
2790
2868
|
});
|
|
2791
2869
|
} catch (retryErr) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2343",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|