@yemi33/minions 0.1.2305 → 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/refresh.js +17 -13
- package/dashboard/js/render-other.js +251 -3
- package/dashboard/js/render-plans.js +1 -1
- package/dashboard/js/render-prs.js +4 -1
- package/dashboard/js/render-utils.js +13 -0
- package/dashboard/js/render-work-items.js +11 -13
- package/dashboard/js/settings.js +30 -3
- 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/styles.css +58 -0
- package/dashboard.js +66 -9
- package/docs/completion-reports.md +27 -2
- package/docs/copilot-cli-schema.md +31 -1
- package/docs/design-state-storage.md +1 -1
- package/docs/harness-transparency.md +18 -0
- package/docs/live-checkout-mode.md +57 -8
- package/engine/ado-comment.js +5 -2
- package/engine/ado.js +37 -0
- package/engine/cli.js +58 -3
- package/engine/comment-format.js +82 -2
- package/engine/consolidation.js +59 -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 +241 -25
- package/engine/live-checkout.js +335 -7
- package/engine/playbook.js +2 -2
- package/engine/pre-dispatch-eval.js +54 -5
- package/engine/queries.js +11 -7
- package/engine/shared.js +96 -6
- package/engine/supervisor.js +144 -19
- package/engine/watchdog.js +10 -0
- package/engine.js +210 -5
- package/package.json +1 -1
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +9 -0
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.
|
|
@@ -188,6 +220,65 @@ function _collectAutoCleanablePaths(dirtyFiles) {
|
|
|
188
220
|
return cleanable.length > 0 ? cleanable : null;
|
|
189
221
|
}
|
|
190
222
|
|
|
223
|
+
// issue #608 — detects a branch name that the ENGINE created for a live-mode
|
|
224
|
+
// dispatch (never the operator's own branch). Matches ONLY the literal
|
|
225
|
+
// `work/<wi-id>` convention produced by `shared.deriveWorkItemBranchName` —
|
|
226
|
+
// deliberately narrow so an operator's own `user/...`/`feature/...` branch is
|
|
227
|
+
// never mistaken for agent litter. Used by the orphaned-agent-branch self-heal
|
|
228
|
+
// below (prepareLiveCheckout) to recognize a stray branch left behind by an
|
|
229
|
+
// earlier dispatch whose dispatch-end restore did not complete.
|
|
230
|
+
function _looksLikeAgentBranch(name) {
|
|
231
|
+
return typeof name === 'string' && /^work\//.test(name.trim());
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// issue #608 — pull the current local branch name out of the `## <branch>...`
|
|
235
|
+
// header line that `git status --porcelain=v1 -b` already returns, so the
|
|
236
|
+
// orphaned-agent-branch self-heal below needs ZERO extra git calls on the
|
|
237
|
+
// common (non-stray) dirty path — preserving the existing "no extra git calls
|
|
238
|
+
// when the tree isn't all-artifact-cleanable" contract asserted by
|
|
239
|
+
// test/unit/prepare-live-checkout.test.js. Returns '' for a detached HEAD
|
|
240
|
+
// (`## HEAD (no branch)`) or an unparsable header.
|
|
241
|
+
function _extractBranchFromPorcelainHeader(branchInfo) {
|
|
242
|
+
if (!branchInfo || typeof branchInfo !== 'string') return '';
|
|
243
|
+
const body = branchInfo.replace(/^##\s*/, '');
|
|
244
|
+
const firstToken = body.split(' ')[0] || '';
|
|
245
|
+
const localBranch = firstToken.split('...')[0];
|
|
246
|
+
return (!localBranch || localBranch === 'HEAD') ? '' : localBranch;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// issue #608 — commit whatever is staged/unstaged onto the CURRENT branch
|
|
250
|
+
// with bounded retry+backoff, specifically to survive a transient
|
|
251
|
+
// `.git/index.lock` collision against a sibling git process (the root cause
|
|
252
|
+
// of the 2026-06-30 incident: two live-checkout dispatches raced `git add -A`
|
|
253
|
+
// / `git commit` against the same working tree, the commit lost the lock race
|
|
254
|
+
// once, and the prior code gave up on the first failure with no retry). Only
|
|
255
|
+
// RETRIES when the failure looks like transient lock contention; any other
|
|
256
|
+
// failure (e.g. "nothing to commit") returns immediately so callers fall
|
|
257
|
+
// through to their existing best-effort handling. Never throws — callers get
|
|
258
|
+
// `{ ok:false, error }` on exhaustion.
|
|
259
|
+
async function _commitAgentWipWithRetry(git, baseOpts, message, options = {}) {
|
|
260
|
+
const attempts = Number.isFinite(options.attempts) ? options.attempts : 3;
|
|
261
|
+
const baseDelayMs = Number.isFinite(options.baseDelayMs) ? options.baseDelayMs : 300;
|
|
262
|
+
const sleep = typeof options.sleep === 'function'
|
|
263
|
+
? options.sleep
|
|
264
|
+
: (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
265
|
+
let lastErr = null;
|
|
266
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
267
|
+
try {
|
|
268
|
+
await git(['add', '-A'], baseOpts);
|
|
269
|
+
await git(['commit', '--no-verify', '-m', message], baseOpts);
|
|
270
|
+
return { ok: true, attempts: attempt };
|
|
271
|
+
} catch (e) {
|
|
272
|
+
lastErr = e;
|
|
273
|
+
const msg = String((e && e.message) || '');
|
|
274
|
+
const transientLock = /index\.lock/i.test(msg) || /another git process/i.test(msg);
|
|
275
|
+
if (!transientLock || attempt === attempts) break;
|
|
276
|
+
await sleep(baseDelayMs * attempt);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return { ok: false, error: lastErr, attempts };
|
|
280
|
+
}
|
|
281
|
+
|
|
191
282
|
// W-mqvejug6000eeb20 — production default for resolving the live-checkout
|
|
192
283
|
// auto-reset decision when the caller (engine.js spawnAgent) does not pass an
|
|
193
284
|
// explicit `autoReset` boolean. engine.js is intentionally not threading the
|
|
@@ -237,6 +328,7 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
237
328
|
_rm, // private injection for testing — defaults to fs.rmSync (recursive, force)
|
|
238
329
|
_resolveAutoReset, // private injection for testing — defaults to config-based resolver
|
|
239
330
|
_writeInboxNote, // private injection for testing — defaults to dispatch.writeInboxAlert
|
|
331
|
+
_sleep, // private injection for testing — defaults to a real setTimeout-based sleep
|
|
240
332
|
} = opts;
|
|
241
333
|
|
|
242
334
|
// ── Required-arg guards. Throw rather than return {ok:false} so a
|
|
@@ -319,6 +411,52 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
319
411
|
const reRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
|
|
320
412
|
({ branchInfo, dirtyFiles } = parsePorcelain(reRaw));
|
|
321
413
|
}
|
|
414
|
+
// issue #608 — orphaned-agent-branch self-heal. If the tree is STILL dirty
|
|
415
|
+
// and HEAD is sitting on a DIFFERENT engine-created branch than the one
|
|
416
|
+
// THIS dispatch is about to use, the dirt is provably leftover from an
|
|
417
|
+
// earlier dispatch whose dispatch-end restore never completed (e.g. an
|
|
418
|
+
// `.git/index.lock` race against a sibling dispatch — see
|
|
419
|
+
// restoreLiveCheckoutAtDispatchEnd). Left alone, the ORIGINAL-REF CAPTURE
|
|
420
|
+
// below (Step 3) would adopt that stray branch as `originalRef`, and every
|
|
421
|
+
// future dispatch would inherit it in turn — the tree never stops
|
|
422
|
+
// orbiting agent branches and every dispatch fails LIVE_CHECKOUT_DIRTY
|
|
423
|
+
// forever (321 incidents / 12 days). Since the branch was created BY the
|
|
424
|
+
// engine (matched narrowly via `_looksLikeAgentBranch` — the literal
|
|
425
|
+
// `work/<id>` convention from shared.deriveWorkItemBranchName, never the
|
|
426
|
+
// operator's own branch), committing the leftover WIP there is exactly as
|
|
427
|
+
// safe as the existing dispatch-end self-heal: additive, fully
|
|
428
|
+
// recoverable from that branch's own history, and never touches the
|
|
429
|
+
// operator's real branch. Unconditional (not opt-in) because it is
|
|
430
|
+
// non-destructive, unlike the auto-reset path below. After committing,
|
|
431
|
+
// switch back to `mainRef` — the one ref that is always a real,
|
|
432
|
+
// non-agent baseline — so the tree stops drifting onto agent branches.
|
|
433
|
+
if (dirtyFiles.length > 0) {
|
|
434
|
+
const strayBranch = _extractBranchFromPorcelainHeader(branchInfo);
|
|
435
|
+
if (strayBranch && strayBranch !== branchName && _looksLikeAgentBranch(strayBranch)) {
|
|
436
|
+
const commitRes = await _commitAgentWipWithRetry(
|
|
437
|
+
git, baseOpts,
|
|
438
|
+
`minions: auto-save orphaned WIP from ${strayBranch} (dispatch ${dispatchId || '(unknown)'})`,
|
|
439
|
+
{ sleep: _sleep },
|
|
440
|
+
);
|
|
441
|
+
if (commitRes.ok) {
|
|
442
|
+
logFn(`[live-checkout] self-healed orphaned agent branch '${strayBranch}' — committed WIP` +
|
|
443
|
+
(commitRes.attempts > 1 ? ` after ${commitRes.attempts} attempts` : '') +
|
|
444
|
+
`, switching back to '${mainRef}'`, 'info');
|
|
445
|
+
try {
|
|
446
|
+
await git(['checkout', mainRef], baseOpts);
|
|
447
|
+
} catch (backErr) {
|
|
448
|
+
logFn(`[live-checkout] could not switch back to '${mainRef}' after self-healing '${strayBranch}': ${backErr && backErr.message}`, 'warn');
|
|
449
|
+
}
|
|
450
|
+
// Re-run the dirty check. Either the tree is now clean (self-heal
|
|
451
|
+
// succeeded end-to-end) or something new appeared (rare) — either
|
|
452
|
+
// way the normal auto-reset / dirty-bail logic below sees fresh state.
|
|
453
|
+
const healRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
|
|
454
|
+
({ branchInfo, dirtyFiles } = parsePorcelain(healRaw));
|
|
455
|
+
} else {
|
|
456
|
+
logFn(`[live-checkout] could not self-heal orphaned agent branch '${strayBranch}' (commit failed): ${commitRes.error && commitRes.error.message}`, 'warn');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
322
460
|
if (dirtyFiles.length > 0) {
|
|
323
461
|
// W-mqvejug6000eeb20 — opt-in auto-reset. Default behavior is to bail with
|
|
324
462
|
// reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
|
|
@@ -558,6 +696,18 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
558
696
|
originalRefType,
|
|
559
697
|
};
|
|
560
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
|
+
}
|
|
561
711
|
throw checkoutErr; // transient → caller retries (LIVE_CHECKOUT_FAILED)
|
|
562
712
|
}
|
|
563
713
|
};
|
|
@@ -641,6 +791,7 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
|
|
|
641
791
|
log,
|
|
642
792
|
writeInboxAlert,
|
|
643
793
|
_git, // private injection for testing — defaults to shared.shellSafeGit
|
|
794
|
+
_sleep, // private injection for testing — defaults to a real setTimeout-based sleep
|
|
644
795
|
} = opts;
|
|
645
796
|
|
|
646
797
|
const logFn = (typeof log === 'function') ? log : () => {};
|
|
@@ -750,16 +901,27 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
|
|
|
750
901
|
onAgentBranch = cur && cur === branchName;
|
|
751
902
|
} catch { /* best-effort */ }
|
|
752
903
|
if (onAgentBranch) {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
904
|
+
// issue #608 — bounded retry+backoff on the commit. The 2026-06-30
|
|
905
|
+
// incident was caused by exactly this commit losing a transient
|
|
906
|
+
// `.git/index.lock` race against a sibling dispatch's git process;
|
|
907
|
+
// the previous single-attempt code gave up immediately and left the
|
|
908
|
+
// stage half-committed, which then perpetuated as a stray agent
|
|
909
|
+
// branch for every subsequent dispatch (see the orphaned-agent-branch
|
|
910
|
+
// self-heal in prepareLiveCheckout). `--no-verify` skips operator
|
|
911
|
+
// commit hooks that may fail headless.
|
|
912
|
+
const commitRes = await _commitAgentWipWithRetry(
|
|
913
|
+
git, baseOpts,
|
|
914
|
+
`minions: auto-save agent WIP (dispatch ${did})`,
|
|
915
|
+
{ sleep: _sleep },
|
|
916
|
+
);
|
|
917
|
+
if (commitRes.ok) {
|
|
757
918
|
result.autoCommitted = true;
|
|
758
|
-
logFn('info', `live-checkout: auto-committed agent WIP onto ${branchName} before restore (dispatch ${did})`
|
|
759
|
-
|
|
919
|
+
logFn('info', `live-checkout: auto-committed agent WIP onto ${branchName} before restore (dispatch ${did})` +
|
|
920
|
+
(commitRes.attempts > 1 ? ` after ${commitRes.attempts} attempts` : ''));
|
|
921
|
+
} else {
|
|
760
922
|
// Leave the tree as-is; the plain checkout below will refuse and the
|
|
761
923
|
// fallback alert fires. Never force, never discard.
|
|
762
|
-
logFn('warn', `live-checkout: could not auto-save agent WIP onto ${branchName} for ${did}: ${
|
|
924
|
+
logFn('warn', `live-checkout: could not auto-save agent WIP onto ${branchName} for ${did}: ${commitRes.error && commitRes.error.message}`);
|
|
763
925
|
}
|
|
764
926
|
}
|
|
765
927
|
}
|
|
@@ -836,6 +998,164 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
|
|
|
836
998
|
}
|
|
837
999
|
}
|
|
838
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
|
+
|
|
839
1159
|
/**
|
|
840
1160
|
* maybeRestoreLiveCheckoutFromRecord — PL-live-checkout-reliability-hardening
|
|
841
1161
|
*
|
|
@@ -902,9 +1222,17 @@ async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
|
|
|
902
1222
|
module.exports = {
|
|
903
1223
|
prepareLiveCheckout,
|
|
904
1224
|
restoreLiveCheckoutAtDispatchEnd,
|
|
1225
|
+
resolveLiveCheckoutAutoStash,
|
|
1226
|
+
performLiveCheckoutAutoStash,
|
|
1227
|
+
applyLiveCheckoutAutoStash,
|
|
905
1228
|
maybeRestoreLiveCheckoutFromRecord,
|
|
906
1229
|
_isPartialCloneBlobError,
|
|
1230
|
+
_isWorktreeConflictError,
|
|
1231
|
+
_extractConflictingWorktreePath,
|
|
907
1232
|
_isAutoCleanableArtifact,
|
|
908
1233
|
_collectAutoCleanablePaths,
|
|
1234
|
+
_looksLikeAgentBranch,
|
|
1235
|
+
_extractBranchFromPorcelainHeader,
|
|
1236
|
+
_commitAgentWipWithRetry,
|
|
909
1237
|
LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,
|
|
910
1238
|
};
|
package/engine/playbook.js
CHANGED
|
@@ -13,7 +13,7 @@ const { wrapUntrusted, buildSource } = require('./untrusted-fence');
|
|
|
13
13
|
const { MINIONS_BRAND_URL } = require('./comment-format');
|
|
14
14
|
|
|
15
15
|
const { safeJson, safeRead, getProjects, log, ts, dateStamp, truncateTextBytes, ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT, getProjectOrg } = shared;
|
|
16
|
-
const { getConfig, getDispatch, getNotes,
|
|
16
|
+
const { getConfig, getDispatch, getNotes, getPrs, getKnowledgeBaseIndex, AGENTS_DIR } = queries;
|
|
17
17
|
|
|
18
18
|
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
19
19
|
const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');
|
|
@@ -1052,7 +1052,7 @@ function _checkPlaybookSections(content, context = '') {
|
|
|
1052
1052
|
// Lean system prompt: agent identity + rules only (~2-4KB, never grows)
|
|
1053
1053
|
function buildSystemPrompt(agentId, config, project) {
|
|
1054
1054
|
const agent = config.agents[agentId] || tempAgents.get(agentId) || { name: agentId, role: 'Temporary Agent', expertise: [] };
|
|
1055
|
-
const charter =
|
|
1055
|
+
const charter = safeRead(path.join(AGENTS_DIR, agentId, 'charter.md')); // returns '' for temp agents (no charter file)
|
|
1056
1056
|
project = project || getProjects(config)[0] || {};
|
|
1057
1057
|
|
|
1058
1058
|
let prompt = '';
|
|
@@ -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/queries.js
CHANGED
|
@@ -722,10 +722,6 @@ function getAgentStatus(agentId) {
|
|
|
722
722
|
// setAgentStatus removed — agent status is derived from dispatch.json.
|
|
723
723
|
// Status.json files no longer exist.
|
|
724
724
|
|
|
725
|
-
function getAgentCharter(agentId) {
|
|
726
|
-
return safeRead(path.join(AGENTS_DIR, agentId, 'charter.md'));
|
|
727
|
-
}
|
|
728
|
-
|
|
729
725
|
function getAgents(config) {
|
|
730
726
|
config = config || getConfig();
|
|
731
727
|
// Fall back to DEFAULT_AGENTS if config has no agents (uninitialized repo)
|
|
@@ -2063,13 +2059,21 @@ function getWorkItems(config, opts) {
|
|
|
2063
2059
|
});
|
|
2064
2060
|
|
|
2065
2061
|
const cacheMtimes = _snapshotWorkItemsMtimes(config);
|
|
2062
|
+
// Use Date.now() here (not the `now` captured at function entry) so the
|
|
2063
|
+
// 1 s TTL is measured from when the cache was BUILT, not from when the
|
|
2064
|
+
// call started. On a slow or loaded machine the first cold call can take
|
|
2065
|
+
// > 1 s while DB migrations run; if we used the entry-time `now`, the
|
|
2066
|
+
// very next call would see (entry_time_of_call_2 − entry_time_of_call_1)
|
|
2067
|
+
// >= 1 000 ms and miss the cache, defeating the cache entirely.
|
|
2068
|
+
// (W-mr00aooc000i1d6b — queries-work-items-cache CI failure on slow runners)
|
|
2069
|
+
const cacheSetAt = Date.now();
|
|
2066
2070
|
if (enrich) {
|
|
2067
2071
|
_workItemsCache = allItems;
|
|
2068
|
-
_workItemsCacheAt =
|
|
2072
|
+
_workItemsCacheAt = cacheSetAt;
|
|
2069
2073
|
_workItemsCacheMtimes = cacheMtimes;
|
|
2070
2074
|
} else {
|
|
2071
2075
|
_workItemsLeanCache = allItems;
|
|
2072
|
-
_workItemsLeanCacheAt =
|
|
2076
|
+
_workItemsLeanCacheAt = cacheSetAt;
|
|
2073
2077
|
_workItemsLeanCacheMtimes = cacheMtimes;
|
|
2074
2078
|
}
|
|
2075
2079
|
return allItems;
|
|
@@ -3320,7 +3324,7 @@ module.exports = {
|
|
|
3320
3324
|
_buildNotesByWiMap, // exported for testing
|
|
3321
3325
|
|
|
3322
3326
|
// Agents
|
|
3323
|
-
getAgentStatus,
|
|
3327
|
+
getAgentStatus, getAgents, getAgentDetail,
|
|
3324
3328
|
|
|
3325
3329
|
// Pull requests
|
|
3326
3330
|
getPrs, getPullRequests,
|