@yemi33/minions 0.1.2306 → 0.1.2308
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/minions.js +1 -0
- package/dashboard/js/settings.js +31 -1
- package/dashboard/slim/body.html +6 -22
- package/dashboard/slim/js/modals-tiles.js +32 -22
- package/dashboard/slim/js/plans.js +24 -516
- package/dashboard/slim/styles.css +10 -7
- package/dashboard.js +63 -8
- package/docs/completion-reports.md +2 -2
- package/docs/harness-transparency.md +18 -0
- package/docs/live-checkout-mode.md +53 -6
- package/engine/ado-comment.js +5 -2
- package/engine/cc-worker-pool.js +16 -4
- package/engine/cli.js +58 -3
- package/engine/comment-format.js +82 -2
- package/engine/discover-project-skills.js +4 -0
- package/engine/dispatch.js +36 -1
- package/engine/gh-comment.js +8 -3
- package/engine/lifecycle.js +92 -25
- package/engine/live-checkout.js +207 -0
- package/engine/pre-dispatch-eval.js +54 -5
- package/engine/shared.js +90 -1
- package/engine/supervisor.js +13 -0
- package/engine/watchdog.js +10 -0
- package/engine.js +208 -3
- package/package.json +1 -1
- package/playbooks/review.md +2 -0
- package/playbooks/shared-rules.md +9 -0
package/engine/shared.js
CHANGED
|
@@ -1946,6 +1946,86 @@ function mutateControl(mutator) {
|
|
|
1946
1946
|
}, { defaultValue: { state: 'stopped', pid: null }, skipWriteIfUnchanged: true });
|
|
1947
1947
|
}
|
|
1948
1948
|
|
|
1949
|
+
// ── Crash-loop detection (W-mr2c46590003e3ee) ──────────────────────────────
|
|
1950
|
+
// Three independent mechanisms can respawn a dead/frozen engine process:
|
|
1951
|
+
// engine/supervisor.js#checkEngine / #checkEngineHung, engine/watchdog.js
|
|
1952
|
+
// (OS-scheduled external recovery), and dashboard.js's in-process 30s
|
|
1953
|
+
// watchdog. None of them counted or throttled repeated crashes, so a genuine
|
|
1954
|
+
// crash-loop (engine dying every few minutes) silently self-healed forever
|
|
1955
|
+
// with no human-visible signal — see the 2026-07-01 00:20-00:30 incident
|
|
1956
|
+
// (4 respawns in ~5 minutes, notes/inbox/dallas-W-mr2azk6i000y06e0-*.md).
|
|
1957
|
+
//
|
|
1958
|
+
// recordEngineRespawn() gives all three mechanisms a single rolling counter
|
|
1959
|
+
// persisted in control.json so it survives process restarts: every call
|
|
1960
|
+
// appends {ts, source}, prunes entries older than CRASH_LOOP_WINDOW_MS, and
|
|
1961
|
+
// once the count within the window reaches CRASH_LOOP_THRESHOLD it flips a
|
|
1962
|
+
// dashboard-visible `control.crashLoopAlert` flag and writes a deduped
|
|
1963
|
+
// (one-per-day) inbox note.
|
|
1964
|
+
//
|
|
1965
|
+
// Callers MUST invoke this only at the point an actual respawn action is
|
|
1966
|
+
// taken — not on every health-check tick. The existing
|
|
1967
|
+
// RESPAWN_IN_PROGRESS_WINDOW_MS / post-spawn-grace guards already in each
|
|
1968
|
+
// mechanism prevent two watchers from double-spawning for the same crash, so
|
|
1969
|
+
// recording only real spawns keeps a single crash from being counted 2-3x
|
|
1970
|
+
// across the three watchers.
|
|
1971
|
+
const CRASH_LOOP_WINDOW_MS = Number(process.env.MINIONS_CRASH_LOOP_WINDOW_MS) || 10 * 60 * 1000; // 10 min
|
|
1972
|
+
const CRASH_LOOP_THRESHOLD = Number(process.env.MINIONS_CRASH_LOOP_THRESHOLD) || 3;
|
|
1973
|
+
|
|
1974
|
+
function recordEngineRespawn(source) {
|
|
1975
|
+
const now = Date.now();
|
|
1976
|
+
let crashLoop = false;
|
|
1977
|
+
let count = 1;
|
|
1978
|
+
let windowEntries = [];
|
|
1979
|
+
try {
|
|
1980
|
+
mutateControl((control) => {
|
|
1981
|
+
const prior = Array.isArray(control.engineRespawns) ? control.engineRespawns : [];
|
|
1982
|
+
const entries = prior
|
|
1983
|
+
.filter(e => e && Number.isFinite(e.ts) && (now - e.ts) <= CRASH_LOOP_WINDOW_MS)
|
|
1984
|
+
.concat([{ ts: now, source: String(source || 'unknown') }]);
|
|
1985
|
+
// Defensive cap — the window filter above already bounds this in
|
|
1986
|
+
// practice, but guards against unbounded growth under clock skew or an
|
|
1987
|
+
// operator-widened window.
|
|
1988
|
+
control.engineRespawns = entries.slice(-50);
|
|
1989
|
+
windowEntries = control.engineRespawns;
|
|
1990
|
+
count = windowEntries.length;
|
|
1991
|
+
if (count >= CRASH_LOOP_THRESHOLD) {
|
|
1992
|
+
crashLoop = true;
|
|
1993
|
+
control.crashLoopAlert = {
|
|
1994
|
+
count,
|
|
1995
|
+
windowMs: CRASH_LOOP_WINDOW_MS,
|
|
1996
|
+
firstAt: new Date(windowEntries[0].ts).toISOString(),
|
|
1997
|
+
lastAt: new Date(now).toISOString(),
|
|
1998
|
+
sources: windowEntries.map(e => e.source),
|
|
1999
|
+
};
|
|
2000
|
+
} else if (control.crashLoopAlert) {
|
|
2001
|
+
// Window rolled off past incidents — clear a stale alert so the
|
|
2002
|
+
// dashboard doesn't keep showing a resolved crash-loop forever.
|
|
2003
|
+
delete control.crashLoopAlert;
|
|
2004
|
+
}
|
|
2005
|
+
return control;
|
|
2006
|
+
});
|
|
2007
|
+
} catch (e) {
|
|
2008
|
+
log('warn', `recordEngineRespawn: control.json update failed: ${e.message}`);
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
if (crashLoop) {
|
|
2012
|
+
try {
|
|
2013
|
+
const lines = windowEntries.map(e => `- ${e.source} @ ${new Date(e.ts).toISOString()}`).join('\n');
|
|
2014
|
+
const body = `# Engine crash-loop detected\n\n` +
|
|
2015
|
+
`${count} engine respawns in the last ${Math.round(CRASH_LOOP_WINDOW_MS / 60000)} minutes ` +
|
|
2016
|
+
`(threshold: ${CRASH_LOOP_THRESHOLD}).\n\n` +
|
|
2017
|
+
`Respawns:\n${lines}\n\n` +
|
|
2018
|
+
`The engine keeps self-healing (each respawn succeeded) but is crashing repeatedly — ` +
|
|
2019
|
+
`investigate \`engine/engine-stdio.log\` around the timestamps above before this becomes ` +
|
|
2020
|
+
`a silent outage.\n`;
|
|
2021
|
+
writeToInbox('engine', 'crash-loop-detected', body, null, { severity: 'critical' });
|
|
2022
|
+
} catch (e) {
|
|
2023
|
+
log('warn', `recordEngineRespawn: inbox alert failed: ${e.message}`);
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
return { count, crashLoop };
|
|
2027
|
+
}
|
|
2028
|
+
|
|
1949
2029
|
// W-mp60tw0u000j3931: Lock-safe read-modify-write for engine/state.json — the
|
|
1950
2030
|
// persistent cross-restart engine-level state file (migration markers,
|
|
1951
2031
|
// one-shot reconciliation versions, etc.). Mirrors mutateControl's shape.
|
|
@@ -2904,6 +2984,14 @@ const ENGINE_DEFAULTS = {
|
|
|
2904
2984
|
// & Review Loop, or set `engine.autoFixPaused: true` in config.json.
|
|
2905
2985
|
autoFixPaused: false, // hard-stop kill-switch / master override (see comment above)
|
|
2906
2986
|
autoConsolidateMemory: false, // opt-in: periodically spawn engine/kb-sweep-runner.js from the tick loop (4h cadence). Inbox→notes consolidation already runs every tick via consolidateInbox; this flag only controls the KB sweep.
|
|
2987
|
+
// W-mqtvnnj1000357fa — fleet-wide fallback for live-checkout auto-stash. When
|
|
2988
|
+
// true, a dirty live-checkout tree is `git stash push --include-untracked`'d
|
|
2989
|
+
// before dispatch instead of failing with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY,
|
|
2990
|
+
// so dispatch proceeds without manual operator intervention. Per-project
|
|
2991
|
+
// `project.liveCheckoutAutoStash` overrides this (resolveLiveCheckoutAutoStash).
|
|
2992
|
+
// The engine never auto-pops the stash — the operator runs `git stash pop`.
|
|
2993
|
+
// Default false so a fresh install behaves identically to before this knob.
|
|
2994
|
+
liveCheckoutAutoStash: false,
|
|
2907
2995
|
prNoOpFixPauseAttempts: 2, // pause one PR automation cause after repeated no-op fixes for unchanged evidence
|
|
2908
2996
|
quarantineAutoRecoveryMax: 2, // #2996 follow-up: cap on auto-flipping WORKTREE_DIRTY/WORKTREE_DIVERGENT failures back to pending (the quarantine is self-healing so the next dispatch starts clean; the cap prevents infinite loops if quarantine itself keeps failing).
|
|
2909
2997
|
// W-mq5n1zx5 — Layer 1a/2b: harden the quarantine rename path against
|
|
@@ -4556,6 +4644,7 @@ const FAILURE_CLASS = {
|
|
|
4556
4644
|
LIVE_CHECKOUT_FAILED: 'live-checkout-failed', // #305 (live-checkout dispatch mode): prepareLiveCheckout THREW before agent spawn (helper guard, ref validation, or a transient `git status`/`rev-parse`/`checkout` failure) — distinct from the confirmed-dirty result (LIVE_CHECKOUT_DIRTY) which the helper returns, not throws. A thrown error is NOT proof the tree is dirty, so it must not be over-classified as dirty. Retryable with bounded backoff (NOT in dispatch.js neverRetry): racy branch-lock handoff, just-finished sibling dispatch, or transient git errors frequently clear on the next attempt; the engine auto-retries up to ENGINE_DEFAULTS.maxRetries before giving up. Genuinely terminal underlying reasons (auth, validation) still short-circuit via the reason-string check in isRetryableFailureReason.
|
|
4557
4645
|
LIVE_CHECKOUT_MID_OPERATION: 'live-checkout-mid-operation', // P-a7f3c1d9 (live-checkout dispatch mode): spawnAgent could not switch/create the target branch in project.localPath because the operator tree is mid-operation — an in-progress merge/rebase/cherry-pick/bisect or a detached HEAD. Distinct from LIVE_CHECKOUT_DIRTY (uncommitted changes): here the tree may be clean but the branch op cannot proceed. Engine refuses to spawn (it never runs `git reset`/`git clean`/`git rebase --abort` against the operator tree). Non-retryable — operator must finish or abort the in-progress operation, or checkout a branch, before re-dispatch.
|
|
4558
4646
|
LIVE_CHECKOUT_BLOB_FETCH: 'live-checkout-blob-fetch', // PL-live-checkout-reliability-hardening (live-checkout dispatch mode): `git checkout <existing-branch>` in project.localPath failed because the tree could not be materialized — on a Scalar/GVFS-managed ADO partial (blobless) clone, switching onto a branch whose tree differs from HEAD hydrates the changed paths' blobs through the GVFS cache server (`*.gvfscache.dev.azure.com`), a different endpoint than the main git remote that receives NO auth in the headless engine shell, so the fetch fails DETERMINISTICALLY. Distinct from LIVE_CHECKOUT_FAILED (transient) because retrying reproduces it identically — surfaced once as an operator-actionable refusal instead of retry-storming to the cap. Non-retryable — the operator hydrates the branch once with their own credentials (`git checkout <branch>` interactively, or `scalar prefetch` / `git -C <repo> fetch`), then re-dispatches. The engine NEVER forces, resets, cleans, or stashes the operator tree, and best-effort switches HEAD back to the original ref so the tree is not stranded half-populated.
|
|
4647
|
+
LIVE_CHECKOUT_WORKTREE_CONFLICT: 'live-checkout-worktree-conflict', // W-mr28h2j2000y0de1 (live-checkout dispatch mode): `git checkout <existing-branch>` in project.localPath failed because that exact branch is ALSO checked out in a SECOND worktree elsewhere (a leftover from a prior isolated-worktree dispatch, a manually-created worktree, or a stale worktree left by a checkoutMode change). git refuses deterministically with `fatal: '<branch>' is already used by worktree at '<path>'`. Distinct from LIVE_CHECKOUT_FAILED (transient) because this is a STRUCTURAL conflict — it does NOT clear on retry, ever, until a human or the engine removes/reassigns the other worktree; retrying just reproduces it identically and burns maxRetries. Non-retryable — the operator either `git worktree remove <path>` (freeing the branch) if that worktree is stale, or finishes/commits/pushes from it directly if it holds real WIP. The engine NEVER forces, resets, cleans, or stashes the operator tree, and best-effort switches HEAD back to the original ref so the tree is not stranded.
|
|
4559
4648
|
INVALID_WORKDIR: 'invalid-workdir', // P-714ef144: dispatch carried a meta.workdir override that failed validation — non-string, absolute path, drive-letter prefix, null byte, ".." segment, or post-resolve containment escape against project.localPath / worktree root. Engine refuses to spawn (the subpath would either be unreachable on disk or point outside the operator's allowed surface). Non-retryable — operator must fix the WI's meta.workdir before re-dispatch. Inbox alert lists the offending value + the resolved-vs-base mismatch.
|
|
4560
4649
|
MODEL_UNAVAILABLE: 'model-unavailable', // W-mpg6isvy000xca4d: requested model returned overloaded_error / 503 / service_unavailable. Retriable — engine swaps in the runtime-appropriate fallback model on next spawn (Claude leans on --fallback-model already plumbed; Copilot overrides --model with engine.copilotFallbackModel).
|
|
4561
4650
|
WORKSPACE_MANIFEST_REPO: 'workspace-manifest-repo-forbidden', // W-mq07avbk000m5543: dispatch routed an agent to a project/repo not present in its workspace_manifest.allowed_repos. Structural — never retryable until the manifest is widened or a different agent is chosen.
|
|
@@ -9061,7 +9150,7 @@ module.exports = {
|
|
|
9061
9150
|
assertStateFileSize,
|
|
9062
9151
|
withFileLock,
|
|
9063
9152
|
mutateJsonFileLocked,
|
|
9064
|
-
mutateControl,
|
|
9153
|
+
mutateControl, recordEngineRespawn,
|
|
9065
9154
|
mutateEngineState, // W-mp60tw0u000j3931
|
|
9066
9155
|
readEngineState, // W-mp60tw0u000j3931
|
|
9067
9156
|
mutateCooldowns,
|
package/engine/supervisor.js
CHANGED
|
@@ -45,6 +45,17 @@ const MINIONS_DIR = path.resolve(__dirname, '..');
|
|
|
45
45
|
function _sharedOrNull() {
|
|
46
46
|
try { return require('./shared'); } catch { return null; }
|
|
47
47
|
}
|
|
48
|
+
// Crash-loop counter (W-mr2c46590003e3ee) — best-effort. shared.js owns the
|
|
49
|
+
// counter/threshold/inbox-alert logic (engine/shared.js#recordEngineRespawn)
|
|
50
|
+
// so supervisor.js, engine/watchdog.js, and dashboard.js's in-process
|
|
51
|
+
// watchdog all feed the same rolling window in control.json instead of each
|
|
52
|
+
// keeping (and silently ignoring) their own. Falls through to a no-op when
|
|
53
|
+
// shared.js can't load, matching this file's existing fail-open posture.
|
|
54
|
+
function _recordCrashLoopRespawn(source) {
|
|
55
|
+
const shared = _sharedOrNull();
|
|
56
|
+
if (!shared || typeof shared.recordEngineRespawn !== 'function') return;
|
|
57
|
+
try { shared.recordEngineRespawn(source); } catch { /* best-effort */ }
|
|
58
|
+
}
|
|
48
59
|
function _engineDir() { return _sharedOrNull()?.ENGINE_DIR || __dirname; }
|
|
49
60
|
const STATIC_ENGINE_DIR = __dirname; // for require-time paths that can't be lazy
|
|
50
61
|
function CONTROL_PATH_FN() { return path.join(_engineDir(), 'control.json'); }
|
|
@@ -458,6 +469,7 @@ function checkEngine(now) {
|
|
|
458
469
|
reapStrayProcesses(path.join(MINIONS_DIR, 'engine.js'), 'engine');
|
|
459
470
|
const newPid = spawnEngine();
|
|
460
471
|
_lastEngineRespawnAt = now;
|
|
472
|
+
_recordCrashLoopRespawn('supervisor:dead-pid');
|
|
461
473
|
console.log(`[supervisor] Engine respawned (new PID: ${newPid})`);
|
|
462
474
|
}
|
|
463
475
|
|
|
@@ -492,6 +504,7 @@ function checkEngineHung(now) {
|
|
|
492
504
|
reapStrayProcesses(path.join(MINIONS_DIR, 'engine.js'), 'engine');
|
|
493
505
|
const newPid = spawnEngine();
|
|
494
506
|
_lastEngineRespawnAt = now;
|
|
507
|
+
_recordCrashLoopRespawn('supervisor:stale-heartbeat');
|
|
495
508
|
console.log(`[supervisor] Engine restarted due to stale heartbeat (new PID: ${newPid})`);
|
|
496
509
|
}
|
|
497
510
|
|
package/engine/watchdog.js
CHANGED
|
@@ -249,6 +249,16 @@ async function tick(opts) {
|
|
|
249
249
|
});
|
|
250
250
|
if (child && typeof child.unref === 'function') child.unref();
|
|
251
251
|
logLine(minionsHome, `spawned minions ${action} pid=${child && child.pid} (detached)`);
|
|
252
|
+
// Crash-loop counter (W-mr2c46590003e3ee) — best-effort, injected so this
|
|
253
|
+
// module stays dependency-free of shared.js (matches isStopIntentSet /
|
|
254
|
+
// confirmPortUp above). Caller (bin/minions.js) wires opts.recordRespawn
|
|
255
|
+
// to shared.recordEngineRespawn so all three engine-respawn mechanisms
|
|
256
|
+
// (supervisor.js, this watchdog, dashboard.js's in-process watchdog)
|
|
257
|
+
// share one rolling window in control.json instead of each silently
|
|
258
|
+
// ignoring repeated crashes.
|
|
259
|
+
if (typeof opts.recordRespawn === 'function') {
|
|
260
|
+
try { opts.recordRespawn(`watchdog:${action}`); } catch { /* best-effort */ }
|
|
261
|
+
}
|
|
252
262
|
return { healthy: false, action, spawnedPid: child && child.pid };
|
|
253
263
|
} catch (err) {
|
|
254
264
|
logLine(minionsHome, `FAILED to spawn minions ${action}: ${err && err.message || err}`);
|
package/engine.js
CHANGED
|
@@ -1974,6 +1974,43 @@ function _applyAgentTempEnv(childEnv, project) {
|
|
|
1974
1974
|
} catch { /* leave inherited TEMP untouched */ }
|
|
1975
1975
|
}
|
|
1976
1976
|
|
|
1977
|
+
// W-mr28h2j2000y0de1 review (calebt_microsoft, 2026-07-01): "classify as
|
|
1978
|
+
// non-retryable" alone leaves the operator to manually clean up every time.
|
|
1979
|
+
// Before refusing, ATTEMPT to actually resolve the conflict — remove the
|
|
1980
|
+
// conflicting worktree and let the caller retry the checkout — but ONLY when
|
|
1981
|
+
// it is safe to do so. Every one of these gates must pass or we leave the
|
|
1982
|
+
// conflicting worktree untouched and fall back to the existing refusal:
|
|
1983
|
+
// 1. conflictPath resolves inside THIS project's configured worktreeRoot —
|
|
1984
|
+
// the standard location for engine-created worktrees. Anything outside
|
|
1985
|
+
// it might be a human's own tree; shared.removeWorktree's containment
|
|
1986
|
+
// check would refuse it anyway, but we check first for a clearer reason.
|
|
1987
|
+
// 2. it carries the engine ownership marker (shared.hasWorktreeOwnerMarker)
|
|
1988
|
+
// — a hand-made worktree (`git worktree add /tmp/foo …`) has no marker.
|
|
1989
|
+
// 3. shared.removeWorktree succeeds. It independently refuses to touch a
|
|
1990
|
+
// worktree with a LIVE dispatch running inside it (isWorktreePathLive,
|
|
1991
|
+
// fails open) and refuses real-repo-root / non-linked-worktree paths.
|
|
1992
|
+
// Returns { removed, reason } for alert/log enrichment either way.
|
|
1993
|
+
function _tryAutoResolveLiveCheckoutWorktreeConflict({ conflictPath, gitRoot, engineConfig, log }) {
|
|
1994
|
+
try {
|
|
1995
|
+
const _wtRoot = path.resolve(gitRoot, (engineConfig && engineConfig.worktreeRoot) || ENGINE_DEFAULTS.worktreeRoot);
|
|
1996
|
+
const _resolvedConflict = path.resolve(conflictPath);
|
|
1997
|
+
if (_resolvedConflict !== _wtRoot && !_resolvedConflict.startsWith(_wtRoot + path.sep)) {
|
|
1998
|
+
return { removed: false, reason: 'outside the configured worktreeRoot — not engine-managed, left untouched' };
|
|
1999
|
+
}
|
|
2000
|
+
if (!shared.hasWorktreeOwnerMarker(_resolvedConflict)) {
|
|
2001
|
+
return { removed: false, reason: 'no engine ownership marker — likely human-created, left untouched' };
|
|
2002
|
+
}
|
|
2003
|
+
const _removed = shared.removeWorktree(_resolvedConflict, gitRoot, _wtRoot);
|
|
2004
|
+
if (!_removed) {
|
|
2005
|
+
return { removed: false, reason: 'shared.removeWorktree declined (live dispatch active inside it, real-repo guard, or removal failure — see engine logs)' };
|
|
2006
|
+
}
|
|
2007
|
+
return { removed: true, reason: 'removed stale engine-owned worktree' };
|
|
2008
|
+
} catch (e) {
|
|
2009
|
+
log('warn', `live-checkout: auto-resolve threw: ${e.message}`);
|
|
2010
|
+
return { removed: false, reason: `auto-resolve threw: ${e.message}` };
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
|
|
1977
2014
|
async function spawnAgent(dispatchItem, config) {
|
|
1978
2015
|
const { id, agent: agentId, type, meta } = dispatchItem;
|
|
1979
2016
|
// Resolve prompt — prefers sidecar file when dispatchItem._promptFile is set
|
|
@@ -2420,6 +2457,56 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2420
2457
|
cleanupTempAgent(agentId);
|
|
2421
2458
|
return null;
|
|
2422
2459
|
}
|
|
2460
|
+
// ── Auto-stash a dirty live-checkout tree (W-mqtvnnj1000357fa) ──────────
|
|
2461
|
+
// When the tree is dirty AND auto-stash is enabled (per-project field wins,
|
|
2462
|
+
// else engine fleet-wide fallback), `git stash push --include-untracked` the
|
|
2463
|
+
// operator's changes so dispatch can proceed instead of failing with
|
|
2464
|
+
// FAILURE_CLASS.LIVE_CHECKOUT_DIRTY. The full flow (stash → re-preflight →
|
|
2465
|
+
// clear retry stamp → operator inbox note) lives in live-checkout.js to keep
|
|
2466
|
+
// spawnAgent lean; here we only handle the engine-specific outcomes. The
|
|
2467
|
+
// engine NEVER pops the stash automatically (operator choice).
|
|
2468
|
+
// NOTE: the helper gates on `liveResult.reason === 'dirty'` internally so the
|
|
2469
|
+
// confirmed-dirty refusal `if` below remains the first textual match for that
|
|
2470
|
+
// dirty-reason check in this file.
|
|
2471
|
+
const _autoStash = await _liveCheckout.applyLiveCheckoutAutoStash({
|
|
2472
|
+
liveResult: _liveResult,
|
|
2473
|
+
project,
|
|
2474
|
+
engine: engineConfig,
|
|
2475
|
+
localPath: cwd,
|
|
2476
|
+
branchName,
|
|
2477
|
+
mainRef: _liveMainRef,
|
|
2478
|
+
gitOpts: _gitOpts,
|
|
2479
|
+
dispatchId: id,
|
|
2480
|
+
wiId: _wiIdForAlert,
|
|
2481
|
+
log: (msg, lvl) => log(lvl || 'info', msg),
|
|
2482
|
+
clearDirtyStamp: () => {
|
|
2483
|
+
const _wiPath = resolveWorkItemPath(dispatchItem.meta);
|
|
2484
|
+
if (_wiPath && dispatchItem.meta?.item?.id) {
|
|
2485
|
+
mutateJsonFileLocked(_wiPath, (data) => {
|
|
2486
|
+
if (!Array.isArray(data)) return data;
|
|
2487
|
+
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
2488
|
+
if (wi && wi._pendingReason === 'live_checkout_dirty') delete wi._pendingReason;
|
|
2489
|
+
return data;
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
},
|
|
2493
|
+
writeStashNote: (key, body) => writeInboxAlert(key, body),
|
|
2494
|
+
});
|
|
2495
|
+
if (_autoStash.outcome === 'threw') {
|
|
2496
|
+
const stashLiveErr = _autoStash.error;
|
|
2497
|
+
log('error', `spawnAgent: live-checkout helper threw after auto-stash for ${id}: ${stashLiveErr.message}`);
|
|
2498
|
+
_cleanupPromptFiles();
|
|
2499
|
+
completeDispatch(
|
|
2500
|
+
id,
|
|
2501
|
+
DISPATCH_RESULT.ERROR,
|
|
2502
|
+
`live-checkout failed: ${stashLiveErr.message}`.slice(0, 800),
|
|
2503
|
+
'Live-checkout helper threw after auto-stash (not proof of a dirty tree). Auto-retried with bounded backoff; transient git/branch-lock state usually clears on the next attempt.',
|
|
2504
|
+
{ failureClass: FAILURE_CLASS.LIVE_CHECKOUT_FAILED },
|
|
2505
|
+
);
|
|
2506
|
+
cleanupTempAgent(agentId);
|
|
2507
|
+
return null;
|
|
2508
|
+
}
|
|
2509
|
+
_liveResult = _autoStash.liveResult;
|
|
2423
2510
|
if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'dirty') {
|
|
2424
2511
|
const _dirtyFiles = Array.isArray(_liveResult.dirtyFiles) ? _liveResult.dirtyFiles : [];
|
|
2425
2512
|
const _branchInfo = typeof _liveResult.branchInfo === 'string' ? _liveResult.branchInfo : '';
|
|
@@ -2637,6 +2724,109 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2637
2724
|
cleanupTempAgent(agentId);
|
|
2638
2725
|
return null;
|
|
2639
2726
|
}
|
|
2727
|
+
// W-mr28h2j2000y0de1: worktree-conflict refusal. The existing-branch
|
|
2728
|
+
// `git checkout` refused because the target branch is ALSO checked out in a
|
|
2729
|
+
// SECOND worktree elsewhere (a leftover from a prior isolated-worktree
|
|
2730
|
+
// dispatch, a manually-created worktree, or a stale worktree left by a
|
|
2731
|
+
// checkoutMode change). This is a STRUCTURAL conflict — it does NOT clear on
|
|
2732
|
+
// retry, ever, until a human or the engine removes/reassigns the other
|
|
2733
|
+
// worktree — so it gets its own non-retryable FAILURE_CLASS instead of
|
|
2734
|
+
// LIVE_CHECKOUT_FAILED's bounded retry-storm. prepareLiveCheckout already
|
|
2735
|
+
// best-effort switched HEAD back to the operator's original ref.
|
|
2736
|
+
if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'worktree-conflict') {
|
|
2737
|
+
// W-mr28h2j2000y0de1 review (calebt_microsoft): try to actually RESOLVE
|
|
2738
|
+
// the conflict before refusing. Only proceeds when the conflicting
|
|
2739
|
+
// worktree is engine-owned and not live-in-use (see
|
|
2740
|
+
// _tryAutoResolveLiveCheckoutWorktreeConflict); on success, retry the
|
|
2741
|
+
// checkout once and fall through to the normal success path below.
|
|
2742
|
+
const _conflictPathForResolve = typeof _liveResult.conflictingWorktreePath === 'string' && _liveResult.conflictingWorktreePath
|
|
2743
|
+
? _liveResult.conflictingWorktreePath
|
|
2744
|
+
: null;
|
|
2745
|
+
if (_conflictPathForResolve) {
|
|
2746
|
+
const _resolveOutcome = _tryAutoResolveLiveCheckoutWorktreeConflict({
|
|
2747
|
+
conflictPath: _conflictPathForResolve, gitRoot: cwd, engineConfig, log,
|
|
2748
|
+
});
|
|
2749
|
+
if (_resolveOutcome.removed) {
|
|
2750
|
+
log('info', `live-checkout: auto-resolved worktree conflict — ${_resolveOutcome.reason} at ${_conflictPathForResolve}; retrying checkout`);
|
|
2751
|
+
try {
|
|
2752
|
+
_liveResult = await _liveCheckout.prepareLiveCheckout({
|
|
2753
|
+
localPath: cwd,
|
|
2754
|
+
branchName,
|
|
2755
|
+
mainRef: _liveMainRef,
|
|
2756
|
+
gitOpts: _gitOpts,
|
|
2757
|
+
dispatchId: id,
|
|
2758
|
+
wiId: _wiIdForAlert,
|
|
2759
|
+
skipDirtyCheck: !!project.skipLiveCheckoutDirtyCheck,
|
|
2760
|
+
log: (msg, lvl) => log(lvl || 'info', msg),
|
|
2761
|
+
});
|
|
2762
|
+
} catch (retryErr) {
|
|
2763
|
+
log('warn', `live-checkout: retry after auto-resolve threw: ${retryErr.message}`);
|
|
2764
|
+
_liveResult = { ok: false, reason: 'worktree-conflict', conflictingWorktreePath: _conflictPathForResolve, message: retryErr.message };
|
|
2765
|
+
}
|
|
2766
|
+
} else {
|
|
2767
|
+
log('info', `live-checkout: worktree conflict at ${_conflictPathForResolve} not auto-resolved (${_resolveOutcome.reason})`);
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'worktree-conflict') {
|
|
2772
|
+
const _conflictMsg = typeof _liveResult.message === 'string' ? _liveResult.message : '';
|
|
2773
|
+
const _conflictPath = typeof _liveResult.conflictingWorktreePath === 'string' && _liveResult.conflictingWorktreePath
|
|
2774
|
+
? _liveResult.conflictingWorktreePath
|
|
2775
|
+
: null;
|
|
2776
|
+
const _pathPhrase = _conflictPath ? `\`${_conflictPath}\`` : 'an unknown location';
|
|
2777
|
+
const _alertBody = [
|
|
2778
|
+
'# Live-checkout blocked: branch already used by another worktree',
|
|
2779
|
+
'',
|
|
2780
|
+
`**Project:** ${project.name || '(unknown)'}`,
|
|
2781
|
+
`**Local path:** ${cwd}`,
|
|
2782
|
+
`**Branch:** ${branchName}`,
|
|
2783
|
+
`**Conflicting worktree:** ${_conflictPath || '(unknown location)'}`,
|
|
2784
|
+
`**Work item:** ${_wiIdForAlert}`,
|
|
2785
|
+
`**Dispatch:** ${id}`,
|
|
2786
|
+
'',
|
|
2787
|
+
`The engine could not switch \`${cwd}\` onto \`${branchName}\` because that branch is already checked out in another worktree at ${_pathPhrase}. This is a structural conflict — **it will NOT clear on retry**. Your tree was left on its original ref (the engine never forces, resets, or cleans it).`,
|
|
2788
|
+
...(_conflictMsg ? ['', '```', _conflictMsg, '```'] : []),
|
|
2789
|
+
'',
|
|
2790
|
+
'## Recovery',
|
|
2791
|
+
'',
|
|
2792
|
+
`Pick ONE, depending on whether the other worktree still holds work you need:`,
|
|
2793
|
+
'',
|
|
2794
|
+
`1. **If that worktree is stale / no longer needed** — remove it to free the branch for the live checkout:`,
|
|
2795
|
+
'',
|
|
2796
|
+
'```',
|
|
2797
|
+
`git worktree remove ${_conflictPath || '<path>'}`,
|
|
2798
|
+
`# or, if it has uncommitted changes you are SURE you can discard (this DISCARDS that WIP):`,
|
|
2799
|
+
`git worktree remove --force ${_conflictPath || '<path>'}`,
|
|
2800
|
+
'```',
|
|
2801
|
+
'',
|
|
2802
|
+
`2. **If that worktree holds real work-in-progress** — do NOT re-dispatch this work item in live mode. Finish, commit, and push directly from ${_pathPhrase} instead, or rename/retarget the branch so the two no longer collide.`,
|
|
2803
|
+
].join('\n');
|
|
2804
|
+
try { writeInboxAlert(`live-checkout-worktree-conflict-${_wiIdForAlert}`, _alertBody); }
|
|
2805
|
+
catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
|
|
2806
|
+
try {
|
|
2807
|
+
const _wiPath = resolveWorkItemPath(dispatchItem.meta);
|
|
2808
|
+
if (_wiPath && dispatchItem.meta?.item?.id) {
|
|
2809
|
+
mutateJsonFileLocked(_wiPath, (data) => {
|
|
2810
|
+
if (!Array.isArray(data)) return data;
|
|
2811
|
+
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
2812
|
+
if (wi) wi._pendingReason = 'live_checkout_worktree_conflict';
|
|
2813
|
+
return data;
|
|
2814
|
+
});
|
|
2815
|
+
}
|
|
2816
|
+
} catch (e) { log('warn', `live-checkout: failed to stamp _pendingReason: ${e.message}`); }
|
|
2817
|
+
const _shortMsg = `live-checkout blocked: ${branchName} already used by worktree at ${_conflictPath || 'unknown location'}`;
|
|
2818
|
+
log('error', `spawnAgent: ${_shortMsg}`);
|
|
2819
|
+
_cleanupPromptFiles();
|
|
2820
|
+
completeDispatch(
|
|
2821
|
+
id,
|
|
2822
|
+
DISPATCH_RESULT.ERROR,
|
|
2823
|
+
_shortMsg.slice(0, 800),
|
|
2824
|
+
'Live-checkout could not switch onto the branch because it is already checked out in another worktree. Structural — operator must remove/reassign the conflicting worktree (or finish its WIP) before re-dispatch. Retrying is provably useless.',
|
|
2825
|
+
{ failureClass: FAILURE_CLASS.LIVE_CHECKOUT_WORKTREE_CONFLICT, agentRetryable: false },
|
|
2826
|
+
);
|
|
2827
|
+
cleanupTempAgent(agentId);
|
|
2828
|
+
return null;
|
|
2829
|
+
}
|
|
2640
2830
|
log('info', `live-checkout: ${_liveResult.created ? 'created' : 'switched to'} branch ${branchName} in ${cwd} (in-place; no worktree)`);
|
|
2641
2831
|
// PL-live-checkout-reliability-hardening: a clean spawn clears the two-strike
|
|
2642
2832
|
// dirty counter so a project that was transiently dirty once isn't treated as
|
|
@@ -8049,6 +8239,20 @@ function discoverFromWorkItems(config, project) {
|
|
|
8049
8239
|
const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
|
|
8050
8240
|
const prBranch = linkedPr?.branch || '';
|
|
8051
8241
|
const isPrTargeted = !!(linkedPr && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
|
|
8242
|
+
// Issue #607 — `linkedPr` above resolves through the LOOSE
|
|
8243
|
+
// extractWorkItemPrRef (title/first-paragraph-of-description text scan),
|
|
8244
|
+
// which is fine for prompt-context enrichment (promptItem, above) that
|
|
8245
|
+
// downgrades gracefully on a miss. But `dispatch.meta.pr` is a hard signal:
|
|
8246
|
+
// `_isPrBackedDispatch`/`getStalePrDispatchReason` (engine/dispatch.js)
|
|
8247
|
+
// treat ANY dispatch carrying it as PR-backed and will cancel the source
|
|
8248
|
+
// work item outright if the tracked PR turns out to be merged/abandoned/
|
|
8249
|
+
// context-only or branch-mismatched. A loose text match against an
|
|
8250
|
+
// unrelated already-tracked PR (e.g. an ordinary implement item whose
|
|
8251
|
+
// description says "see PR #99 for context") must never bind meta.pr —
|
|
8252
|
+
// only a STRUCTURED PR pointer (targetPr/pr/references[]/pr_followup —
|
|
8253
|
+
// see extractStructuredWorkItemPrRef) is strong enough evidence of real
|
|
8254
|
+
// operator intent to make this dispatch cancellable on PR staleness.
|
|
8255
|
+
const dispatchLinkedPr = (linkedPr && getStructuredWorkItemPrRef(item)) ? linkedPr : null;
|
|
8052
8256
|
// W-mq18ec6h000p7b87: gate on the STRUCTURED-only ref. Loose
|
|
8053
8257
|
// getWorkItemPrRef would also pick up description-scan refs (e.g. a
|
|
8054
8258
|
// refactor item whose prose mentioned "PR #3015 for context") and trip
|
|
@@ -8130,7 +8334,7 @@ function discoverFromWorkItems(config, project) {
|
|
|
8130
8334
|
agentRole: config.agents[agentId]?.role || tempAgents.get(agentId)?.role || 'Agent',
|
|
8131
8335
|
task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
|
|
8132
8336
|
prompt,
|
|
8133
|
-
meta: { dispatchKey: key, cooldownKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(isPrTargeted || (item.branchStrategy === 'shared-branch' && item.featureBranch)), item: promptItem, project: { name: project?.name, localPath: project?.localPath }, deferAgentResolution: deferredAgentResolution, ...(
|
|
8337
|
+
meta: { dispatchKey: key, cooldownKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(isPrTargeted || (item.branchStrategy === 'shared-branch' && item.featureBranch)), item: promptItem, project: { name: project?.name, localPath: project?.localPath }, deferAgentResolution: deferredAgentResolution, ...(dispatchLinkedPr ? { pr: dispatchLinkedPr } : {}) }
|
|
8134
8338
|
});
|
|
8135
8339
|
|
|
8136
8340
|
} catch (err) { log('warn', `discoverFromWorkItems: skipping ${item.id}: ${err.message}`); }
|
|
@@ -9914,8 +10118,8 @@ async function tickInner() {
|
|
|
9914
10118
|
// sibling-less stamps are preserved as tracking signals.
|
|
9915
10119
|
try {
|
|
9916
10120
|
const result = pruneScopeMismatchDuplicatePrs(config);
|
|
9917
|
-
if (result?.pruned > 0) {
|
|
9918
|
-
log('info', `[pull-requests] scope-mismatch sweep pruned ${result.pruned} duplicate record(s) across ${result.scanned} scanned`);
|
|
10121
|
+
if (result?.pruned > 0 || result?.relocated > 0) {
|
|
10122
|
+
log('info', `[pull-requests] scope-mismatch sweep pruned ${result.pruned} duplicate record(s), relocated ${result.relocated || 0} record(s) across ${result.scanned} scanned`);
|
|
9919
10123
|
}
|
|
9920
10124
|
} catch (err) {
|
|
9921
10125
|
log('warn', `[pull-requests] scope-mismatch sweep error: ${err?.message || err}`);
|
|
@@ -10602,6 +10806,7 @@ module.exports = {
|
|
|
10602
10806
|
findExistingWorktree, // exported for testing
|
|
10603
10807
|
probeBranchOnRemote, // exported for testing (W-mphnm6a1000281b8)
|
|
10604
10808
|
_maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
|
|
10809
|
+
_tryAutoResolveLiveCheckoutWorktreeConflict, // exported for testing (W-mr28h2j2000y0de1 review — auto-resolve worktree conflicts)
|
|
10605
10810
|
_isOutputTruncated, AGENT_OUTPUT_CAP_BYTES, // P-8e4c2a17: exported for testing (OUTPUT_TRUNCATED detection)
|
|
10606
10811
|
promoteCheckpointSteeringForClose, // exported for testing
|
|
10607
10812
|
normalizePrBranch, resolvePrBranch, prCausePart, getPrCauseHead, getPrCauseBase, getPrAutomationCauseKey, getPrAutomationDispatchKey, // exported for testing
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2308",
|
|
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"
|
package/playbooks/review.md
CHANGED
|
@@ -30,6 +30,8 @@ Use subagents only for genuinely parallel, independent tasks (e.g., reviewing un
|
|
|
30
30
|
{{#project_skills_block}}
|
|
31
31
|
{{project_skills_block}}
|
|
32
32
|
|
|
33
|
+
> **Skipping an in-scope review skill is gated.** Before you record a `meta.review.skillSkipped` (or `meta.skill.skipped`) outcome for any skill listed above, re-read that skill's own SKILL.md checklist and confirm your skip reason does not contradict any explicit "Flag if…" / "Verify that…" / "Requirements" item it documents. If the diff trips ANY such item, do NOT skip — apply the skill (or at least that item). When you do skip, surface the exact same `{name, reason}` in your PR review comment via `minions pr comment … --skill-skipped-json '{"name":"…","reason":"…"}'` so a human sees it alongside your verdict before merge (no drift between the report and the comment). See `playbooks/shared-rules.md` → "Skipped an in-scope project skill?".
|
|
34
|
+
|
|
33
35
|
{{/project_skills_block}}
|
|
34
36
|
2. Think about deploy risk before commenting:
|
|
35
37
|
- What user-visible behavior changed?
|
|
@@ -176,6 +176,15 @@ When you author a **PR review / fix summary comment**, the same harness footprin
|
|
|
176
176
|
|
|
177
177
|
One bullet per affordance, in the order skills → MCP servers → commands → docs; `N` is the bullet count. Whether the CLI folds it in or you hand-render it on a raw fallback, the section is the human-readable mirror of your `harnessUsed` completion field — keep the two consistent.
|
|
178
178
|
|
|
179
|
+
### Skipped an in-scope project skill? Validate the skip, then surface it in the PR comment
|
|
180
|
+
|
|
181
|
+
`meta.review.skillSkipped` / `meta.skill.skipped` (shape `{name, reason}`, see `docs/completion-reports.md`) lets you self-report that you intentionally skipped an available, **in-scope** project (review) skill. Two hard rules apply — this is not aspirational:
|
|
182
|
+
|
|
183
|
+
1. **Validate the skip against the skill's own scope before recording it.** Re-read the skipped skill's SKILL.md review criteria / checklist (the content already injected via the `## Project skills` / `## Project review skills` block). Confirm your skip reason does **not** contradict any explicit "Flag if…" / "Verify that…" / "Requirements" item the skill documents. If the diff trips ANY explicit checklist item the skill covers, skipping is **not** permitted — apply the skill (or at minimum that specific checklist item). A skip reason that waves away a gate the skill explicitly documents (e.g. "no CHANGELOG needed for this diff" when the skill's CHANGELOG Requirements section says otherwise) is invalid.
|
|
184
|
+
2. **Surface the skip in the PR comment, not just the JSON.** When you post a review/fix summary comment, the skip must be visible in the same comment as the verdict so a human sees it **before** merge. Via `minions pr comment` (GitHub and ADO `--host ado`), pass `--skill-skipped-file <path>` (JSON `{name, reason}`) or inline `--skill-skipped-json <json>`; the shared builder (`engine/comment-format.js#buildSkippedSkillSection`, consumed by both `engine/gh-comment.js` and `engine/ado-comment.js`) folds a byte-identical `> ⚠️ Skipped project skill: <name> — <reason>` callout in for you. On raw fallbacks (`gh pr comment` / `az repos pr comment` / ADO REST) render that exact one-line blockquote callout yourself.
|
|
185
|
+
|
|
186
|
+
The **same** `{name, reason}` you record in `meta.review.skillSkipped` / `meta.skill.skipped` must be the one rendered in the PR comment — no drift between the two.
|
|
187
|
+
|
|
179
188
|
## Minions API access
|
|
180
189
|
|
|
181
190
|
The Minions dashboard runs at `http://localhost:7331` whenever the engine is up. Agents may call its HTTP endpoints when (and only when) the playbook explicitly authorizes it for a particular task — most dispatches do not need any API access, and uninvited writes to engine-managed state are still prohibited (see "Do NOT write to `agents/*/status.json`" above).
|