@yemi33/minions 0.1.2344 → 0.1.2346
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 +25 -3
- package/docs/README.md +1 -0
- package/docs/completion-reports.md +23 -0
- package/docs/kb-sweep.md +57 -24
- package/docs/live-checkout-mode.md +27 -5
- package/docs/managed-spawn.md +27 -0
- package/engine/dispatch.js +1 -0
- package/engine/kb-sweep.js +293 -10
- package/engine/lifecycle.js +10 -0
- package/engine/live-checkout.js +157 -24
- package/engine/managed-spawn.js +39 -0
- package/engine/metrics-store.js +0 -0
- package/engine/qa-sessions.js +38 -0
- package/engine/shared.js +28 -2
- package/engine.js +125 -14
- package/package.json +1 -1
- package/playbooks/qa-session-setup.md +8 -0
package/engine/qa-sessions.js
CHANGED
|
@@ -681,6 +681,39 @@ function markDone(id, patch) { return transitionSession(id, QA_SESSION_STATE.DON
|
|
|
681
681
|
function markFailed(id, patch) { return transitionSession(id, QA_SESSION_STATE.FAILED, patch); }
|
|
682
682
|
function markKilled(id, patch) { return transitionSession(id, QA_SESSION_STATE.KILLED, patch); }
|
|
683
683
|
|
|
684
|
+
// Issue #716 — a lifecycle completion hook (handleSetupComplete/
|
|
685
|
+
// handleDraftComplete/handleExecuteComplete) can race a human/engine action
|
|
686
|
+
// that already pushed the session to a terminal state (failed/done/killed) —
|
|
687
|
+
// e.g. a retried SETUP dispatch finally succeeds AFTER the first attempt's
|
|
688
|
+
// timeout already marked the session failed. Before this guard, that late
|
|
689
|
+
// completion fell straight into `transitionSession`, which throws on the
|
|
690
|
+
// illegal `failed -> drafting` (etc.) transition; the throw was swallowed by
|
|
691
|
+
// the caller's try/catch (engine/lifecycle.js) and logged as a bare warning,
|
|
692
|
+
// silently discarding real completed work (e.g. a working managed-spawn
|
|
693
|
+
// setup) with no trace for a human to act on. This guard intercepts BEFORE
|
|
694
|
+
// the throw, logs loudly, and writes an inbox note so the orphaned success
|
|
695
|
+
// is visible instead of vanishing into engine logs.
|
|
696
|
+
//
|
|
697
|
+
// Returns true when the session is already terminal (caller must return
|
|
698
|
+
// early without applying opts); false when the caller should proceed.
|
|
699
|
+
function _guardAlreadyTerminal(sessionId, session, phase, opts) {
|
|
700
|
+
if (!session || !TERMINAL_STATES.has(session.state)) return false;
|
|
701
|
+
const detail = `qa-sessions: ${phase} completion for session ${sessionId} arrived after the ` +
|
|
702
|
+
`session was already terminal (state=${session.state}). opts.success=${!!opts.success}. ` +
|
|
703
|
+
'This completion is NOT applied — the session stays ' + session.state + '.' +
|
|
704
|
+
(opts.success
|
|
705
|
+
? ' The underlying dispatch reported SUCCESS, so real completed work may now be orphaned' +
|
|
706
|
+
' — a human should inspect and manually resume/requeue the session if needed.'
|
|
707
|
+
: '');
|
|
708
|
+
log('warn', detail);
|
|
709
|
+
try {
|
|
710
|
+
// Lazy require (mirrors engine/live-checkout.js#_defaultWriteInboxNote) to
|
|
711
|
+
// sidestep any require-cycle between dispatch.js and qa-sessions.js.
|
|
712
|
+
require('./dispatch').writeInboxAlert(`qa-session-late-completion-${sessionId}`, detail);
|
|
713
|
+
} catch (e) { log('warn', `qa-sessions: writeInboxAlert for late completion ${sessionId} failed: ${e.message}`); }
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
|
|
684
717
|
// ── Work-item builders (pure) ──────────────────────────────────────────────
|
|
685
718
|
//
|
|
686
719
|
// Each builder returns a WI spec ready for mutateWorkItems().push + addToDispatch.
|
|
@@ -1014,6 +1047,7 @@ function queueSetup(sessionId, opts = {}) {
|
|
|
1014
1047
|
function handleSetupComplete(sessionId, opts = {}) {
|
|
1015
1048
|
const session = getSession(sessionId);
|
|
1016
1049
|
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
1050
|
+
if (_guardAlreadyTerminal(sessionId, session, 'SETUP', opts)) return null;
|
|
1017
1051
|
|
|
1018
1052
|
const isMulti = session.setupStatus && typeof session.setupStatus === 'object'
|
|
1019
1053
|
&& Object.keys(session.setupStatus).length > 1;
|
|
@@ -1123,6 +1157,9 @@ function handleSetupComplete(sessionId, opts = {}) {
|
|
|
1123
1157
|
function handleDraftComplete(sessionId, opts = {}) {
|
|
1124
1158
|
const session = getSession(sessionId);
|
|
1125
1159
|
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
1160
|
+
if (_guardAlreadyTerminal(sessionId, session, 'DRAFT', opts)) {
|
|
1161
|
+
return { nextState: session.state, queuedExecuteWi: null };
|
|
1162
|
+
}
|
|
1126
1163
|
if (!opts.success) {
|
|
1127
1164
|
markFailed(sessionId, {
|
|
1128
1165
|
failureClass: 'qa-session-draft-failed',
|
|
@@ -1170,6 +1207,7 @@ function handleDraftComplete(sessionId, opts = {}) {
|
|
|
1170
1207
|
function handleExecuteComplete(sessionId, opts = {}) {
|
|
1171
1208
|
const session = getSession(sessionId);
|
|
1172
1209
|
if (!session) throw new Error('qa-sessions: session not found: ' + sessionId);
|
|
1210
|
+
if (_guardAlreadyTerminal(sessionId, session, 'EXECUTE', opts)) return session.state;
|
|
1173
1211
|
// qa-run terminal status (when known) trumps dispatch-level success — a
|
|
1174
1212
|
// passing assertion run with an exit-1 wrapper still reports a passed
|
|
1175
1213
|
// qa-run; we mark the session done. Conversely, a failed/errored qa-run
|
package/engine/shared.js
CHANGED
|
@@ -3193,6 +3193,24 @@ const ENGINE_DEFAULTS = {
|
|
|
3193
3193
|
'build-reports': 14,
|
|
3194
3194
|
'reviews': 30,
|
|
3195
3195
|
},
|
|
3196
|
+
// W-mr973knu — structural consolidation for the KB sweep (PRIMARY balloon
|
|
3197
|
+
// fix; TTL above is the secondary safety net). The sweep groups boilerplate
|
|
3198
|
+
// transient-category notes by (category, agent, kind, time-bucket) — where
|
|
3199
|
+
// kind is the agent-failure `failureClass`, or a no-op / merge-conflict fix
|
|
3200
|
+
// report — and rolls any group of at least `minGroupSize` notes into a single
|
|
3201
|
+
// recoverable digest (`_digest-<agent>-<kind>-<period>.md`), archiving the
|
|
3202
|
+
// originals to knowledge/_swept/. Unlike TTL expiry this collapses RECENT
|
|
3203
|
+
// duplication too, so the KB shrinks continuously instead of only aging out.
|
|
3204
|
+
// Only `categories` listed here are consolidated; durable categories
|
|
3205
|
+
// (architecture, conventions) and per-agent memory are never touched.
|
|
3206
|
+
// config.engine.kbConsolidation is merged over these defaults; set
|
|
3207
|
+
// `enabled: false` or `categories: []` to disable.
|
|
3208
|
+
kbConsolidation: {
|
|
3209
|
+
enabled: true,
|
|
3210
|
+
categories: ['project-notes', 'build-reports', 'reviews'],
|
|
3211
|
+
minGroupSize: 5, // collapse a group only once it reaches this many near-duplicate notes
|
|
3212
|
+
periodDays: 7, // one digest per (agent, kind) per this many days
|
|
3213
|
+
},
|
|
3196
3214
|
// W-mqtvnnj1000357fa — fleet-wide fallback for live-checkout auto-stash. When
|
|
3197
3215
|
// true, a dirty live-checkout tree is `git stash push --include-untracked`'d
|
|
3198
3216
|
// before dispatch instead of failing with FAILURE_CLASS.LIVE_CHECKOUT_DIRTY,
|
|
@@ -4698,12 +4716,19 @@ function mutateWatches(mutator) {
|
|
|
4698
4716
|
function mutateMetrics(mutator) {
|
|
4699
4717
|
const metricsPath = path.join(MINIONS_DIR, 'engine', 'metrics.json');
|
|
4700
4718
|
const store = require('./metrics-store');
|
|
4719
|
+
// W-mradg74d000rfd45 — the JSON mirror is now written INSIDE
|
|
4720
|
+
// applyMetricsMutation's SQL transaction (before commit), not here
|
|
4721
|
+
// after the fact. Mirroring post-commit left a cross-process window
|
|
4722
|
+
// where a sibling process (dashboard vs. engine — separate Node
|
|
4723
|
+
// processes, each with its own in-memory mirror-hash cache) could
|
|
4724
|
+
// observe the newly committed SQL rows before the JSON file caught up
|
|
4725
|
+
// and read stale/missing metrics off the sidecar. See
|
|
4726
|
+
// metrics-store.js#applyMetricsMutation for the full rationale.
|
|
4701
4727
|
const { wrote, result } = store.applyMetricsMutation((m) => {
|
|
4702
4728
|
if (!m || typeof m !== 'object') m = {};
|
|
4703
4729
|
return mutator(m) || m;
|
|
4704
|
-
});
|
|
4730
|
+
}, { mirrorPath: metricsPath });
|
|
4705
4731
|
if (wrote) {
|
|
4706
|
-
try { store._mirrorJsonFromSql(metricsPath); } catch { /* mirror best-effort */ }
|
|
4707
4732
|
try { require('./db-events').emitStateEvent('metrics'); } catch { /* optional */ }
|
|
4708
4733
|
}
|
|
4709
4734
|
return result;
|
|
@@ -4877,6 +4902,7 @@ const FAILURE_CLASS = {
|
|
|
4877
4902
|
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.
|
|
4878
4903
|
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.
|
|
4879
4904
|
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.
|
|
4905
|
+
LIVE_CHECKOUT_STALE_BASE: 'live-checkout-stale-base', // W-mr98op8w000ma4ad (live-checkout dispatch mode, STALE-BASE GUARD): spawnAgent was about to fork a NEW branch off the operator's current HEAD while HEAD sits on the project base ref (mainRef), but the LOCAL mainRef tip has DIVERGED from origin/<mainRef> with unpushed commit(s) (`git rev-list --count origin/<mainRef>..<mainRef>` > 0). Forking would silently inherit that COMMITTED contamination into the new branch and its PR — the exact scope-contamination class first seen on ADO PR 5411214 and recurring on AB#12016662 / ADO PR 5419146. Survives the dirty check (contamination is committed, not uncommitted) and cannot be caught by a headBranch !== mainRef check (headBranch IS mainRef here). Detected WITHOUT a fetch (issue #226) via the existing origin/<mainRef> remote-tracking ref. Distinct from LIVE_CHECKOUT_FAILED (transient) because the contaminated local base does NOT change on retry — it reproduces identically until a human reconciles the local base branch. Non-retryable — the engine NEVER resets/cleans the operator's local mainRef; the operator must reconcile it (push/reset/rebase their own way), then re-dispatch. The guard SKIPS (proceeds normally) when HEAD is not on mainRef, when no local origin/<mainRef> ref exists (local-only/no-remote repo), or when the divergence count cannot be computed.
|
|
4880
4906
|
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.
|
|
4881
4907
|
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.
|
|
4882
4908
|
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).
|
package/engine.js
CHANGED
|
@@ -2934,6 +2934,83 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2934
2934
|
cleanupTempAgent(agentId);
|
|
2935
2935
|
return null;
|
|
2936
2936
|
}
|
|
2937
|
+
// W-mr98op8w000ma4ad: STALE-BASE GUARD refusal. prepareLiveCheckout was about
|
|
2938
|
+
// to fork a NEW branch off the operator's current HEAD while HEAD sits on the
|
|
2939
|
+
// project base ref (mainRef), but the LOCAL mainRef tip has diverged from
|
|
2940
|
+
// origin/<mainRef> with unpushed commit(s). Forking would silently bake that
|
|
2941
|
+
// committed contamination into the new branch and its PR (the scope-leak class
|
|
2942
|
+
// first seen on ADO PR 5411214, recurring on AB#12016662 / ADO PR 5419146).
|
|
2943
|
+
// Deterministic — a contaminated local base reproduces identically on retry —
|
|
2944
|
+
// so it gets its own non-retryable FAILURE_CLASS instead of LIVE_CHECKOUT_FAILED's
|
|
2945
|
+
// bounded retry-storm. The engine NEVER resets/cleans the operator's local
|
|
2946
|
+
// mainRef; the operator must reconcile it themselves, then re-dispatch.
|
|
2947
|
+
if (_liveResult && _liveResult.ok === false && _liveResult.reason === 'stale-main') {
|
|
2948
|
+
const _staleMainRef = typeof _liveResult.mainRef === 'string' && _liveResult.mainRef ? _liveResult.mainRef : 'main';
|
|
2949
|
+
const _staleAhead = Number.isFinite(_liveResult.ahead) ? _liveResult.ahead : 0;
|
|
2950
|
+
const _localSha = typeof _liveResult.localMainSha === 'string' && _liveResult.localMainSha ? _liveResult.localMainSha : '(unknown)';
|
|
2951
|
+
const _originSha = typeof _liveResult.originMainSha === 'string' && _liveResult.originMainSha ? _liveResult.originMainSha : '(unknown)';
|
|
2952
|
+
const _alertBody = [
|
|
2953
|
+
'# Live-checkout blocked: local base branch has diverged (stale/contaminated base)',
|
|
2954
|
+
'',
|
|
2955
|
+
`**Project:** ${project.name || '(unknown)'}`,
|
|
2956
|
+
`**Local path:** ${cwd}`,
|
|
2957
|
+
`**New branch (refused):** ${branchName}`,
|
|
2958
|
+
`**Base ref:** ${_staleMainRef}`,
|
|
2959
|
+
`**Local ${_staleMainRef}:** ${_localSha}`,
|
|
2960
|
+
`**origin/${_staleMainRef}:** ${_originSha}`,
|
|
2961
|
+
`**Unpushed commits on local ${_staleMainRef}:** ${_staleAhead}`,
|
|
2962
|
+
`**Work item:** ${_wiIdForAlert}`,
|
|
2963
|
+
`**Dispatch:** ${id}`,
|
|
2964
|
+
'',
|
|
2965
|
+
`The engine refused to fork \`${branchName}\` off local \`${_staleMainRef}\` because your local \`${_staleMainRef}\` is **${_staleAhead} commit(s) ahead of \`origin/${_staleMainRef}\`**. Those commits were never pushed — forking a new branch (and its PR) off this base would silently inherit them, contaminating an otherwise-clean change with unrelated commits. The working tree was clean (no uncommitted changes), so the dirty check could not catch this: the contamination is COMMITTED. Your tree was left untouched — the engine never resets, cleans, or rebases your \`${_staleMainRef}\`.`,
|
|
2966
|
+
'',
|
|
2967
|
+
'## Recovery',
|
|
2968
|
+
'',
|
|
2969
|
+
`Reconcile your local \`${_staleMainRef}\` so it no longer carries unpushed commits, then re-dispatch. Pick ONE, depending on whether those commits are real work:`,
|
|
2970
|
+
'',
|
|
2971
|
+
`1. **If the extra commits are real work you want to keep** — move them onto their own branch, then reset \`${_staleMainRef}\` to the remote:`,
|
|
2972
|
+
'',
|
|
2973
|
+
'```',
|
|
2974
|
+
`git -C "${cwd}" branch salvage-${_staleMainRef} ${_staleMainRef}`,
|
|
2975
|
+
`git -C "${cwd}" checkout ${_staleMainRef}`,
|
|
2976
|
+
`git -C "${cwd}" reset --hard origin/${_staleMainRef}`,
|
|
2977
|
+
'```',
|
|
2978
|
+
'',
|
|
2979
|
+
`2. **If the extra commits are leftover junk** (e.g. a prior dispatch's abandoned WIP) — discard them by resetting \`${_staleMainRef}\` to the remote (this DISCARDS those commits):`,
|
|
2980
|
+
'',
|
|
2981
|
+
'```',
|
|
2982
|
+
`git -C "${cwd}" checkout ${_staleMainRef}`,
|
|
2983
|
+
`git -C "${cwd}" reset --hard origin/${_staleMainRef}`,
|
|
2984
|
+
'```',
|
|
2985
|
+
'',
|
|
2986
|
+
`The salvage branch in option 1 keeps the commits recoverable; both leave \`${_staleMainRef}\` matching \`origin/${_staleMainRef}\` so the next dispatch forks off a clean base.`,
|
|
2987
|
+
].join('\n');
|
|
2988
|
+
try { writeInboxAlert(`live-checkout-stale-base-${_wiIdForAlert}`, _alertBody); }
|
|
2989
|
+
catch (e) { log('warn', `live-checkout: writeInboxAlert failed: ${e.message}`); }
|
|
2990
|
+
try {
|
|
2991
|
+
const _wiPath = resolveWorkItemPath(dispatchItem.meta);
|
|
2992
|
+
if (_wiPath && dispatchItem.meta?.item?.id) {
|
|
2993
|
+
mutateJsonFileLocked(_wiPath, (data) => {
|
|
2994
|
+
if (!Array.isArray(data)) return data;
|
|
2995
|
+
const wi = data.find(i => i && i.id === dispatchItem.meta.item.id);
|
|
2996
|
+
if (wi) wi._pendingReason = 'live_checkout_stale_base';
|
|
2997
|
+
return data;
|
|
2998
|
+
});
|
|
2999
|
+
}
|
|
3000
|
+
} catch (e) { log('warn', `live-checkout: failed to stamp _pendingReason: ${e.message}`); }
|
|
3001
|
+
const _shortMsg = `live-checkout blocked: local ${_staleMainRef} is ${_staleAhead} commit(s) ahead of origin/${_staleMainRef} (would contaminate ${branchName})`;
|
|
3002
|
+
log('error', `spawnAgent: ${_shortMsg}`);
|
|
3003
|
+
_cleanupPromptFiles();
|
|
3004
|
+
completeDispatch(
|
|
3005
|
+
id,
|
|
3006
|
+
DISPATCH_RESULT.ERROR,
|
|
3007
|
+
_shortMsg.slice(0, 800),
|
|
3008
|
+
`Live-checkout STALE-BASE GUARD: forking ${branchName} off local ${_staleMainRef} would inherit ${_staleAhead} unpushed commit(s) not on origin/${_staleMainRef}. Deterministic — operator must reconcile the local base branch (the engine never resets it) before re-dispatch. Retrying is provably useless.`,
|
|
3009
|
+
{ failureClass: FAILURE_CLASS.LIVE_CHECKOUT_STALE_BASE, agentRetryable: false },
|
|
3010
|
+
);
|
|
3011
|
+
cleanupTempAgent(agentId);
|
|
3012
|
+
return null;
|
|
3013
|
+
}
|
|
2937
3014
|
log('info', `live-checkout: ${_liveResult.created ? 'created' : 'switched to'} branch ${branchName} in ${cwd} (in-place; no worktree)`);
|
|
2938
3015
|
// PL-live-checkout-reliability-hardening: a clean spawn clears the two-strike
|
|
2939
3016
|
// dirty counter so a project that was transiently dirty once isn't treated as
|
|
@@ -7115,19 +7192,30 @@ async function discoverFromPrs(config, project) {
|
|
|
7115
7192
|
const autoFixBuilds = !autoFixPaused && (config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds);
|
|
7116
7193
|
const autoFixConflicts = !autoFixPaused && (config.engine?.autoFixConflicts ?? ENGINE_DEFAULTS.autoFixConflicts);
|
|
7117
7194
|
|
|
7118
|
-
// Collect active PR dispatches to prevent simultaneous review+fix on same PR
|
|
7195
|
+
// Collect active PR dispatches to prevent simultaneous review+fix on same PR.
|
|
7196
|
+
// Issue #722: `meta.pr.id` alone is not proof the dispatch actually touches
|
|
7197
|
+
// the PR's branch — a tracking-only dispatch (e.g. a follow-up work item
|
|
7198
|
+
// whose `pr_followup.parent_pr_id` merely references the PR for context,
|
|
7199
|
+
// engine.js `dispatchLinkedPr` ~line 8551) carries `meta.pr` while running
|
|
7200
|
+
// on a completely unrelated `meta.branch`. Recording each dispatch's own
|
|
7201
|
+
// branch here lets the loop below only trip the guard on real git-resource
|
|
7202
|
+
// overlap instead of freezing all automation on the PR indefinitely.
|
|
7119
7203
|
const dispatch = getDispatch();
|
|
7120
|
-
const
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
|
|
7129
|
-
|
|
7130
|
-
|
|
7204
|
+
const activePrDispatchBranches = new Map(); // canonicalPrId -> Set<normalized branch | null>
|
|
7205
|
+
for (const d of (dispatch.active || [])) {
|
|
7206
|
+
if (!d.meta?.pr?.id) continue;
|
|
7207
|
+
const dispatchProject = d.meta?.project?.name
|
|
7208
|
+
? (shared.resolveProjectSource(d.meta.project.name, shared.getProjects(config), { allowCentral: false }).project || d.meta.project)
|
|
7209
|
+
: (d.meta?.project || null);
|
|
7210
|
+
const canonicalId = shared.getCanonicalPrId(dispatchProject, d.meta.pr, d.meta.pr?.url || '');
|
|
7211
|
+
if (!canonicalId) continue;
|
|
7212
|
+
if (!activePrDispatchBranches.has(canonicalId)) activePrDispatchBranches.set(canonicalId, new Set());
|
|
7213
|
+
// `null` is a sentinel for "branch unknown" — legacy/defensive dispatches
|
|
7214
|
+
// with no meta.branch fall back to the old conservative (always-skip)
|
|
7215
|
+
// behavior rather than risk a false negative.
|
|
7216
|
+
activePrDispatchBranches.get(canonicalId).add(d.meta?.branch ? normalizePrBranch(d.meta.branch) : null);
|
|
7217
|
+
}
|
|
7218
|
+
const activePrIds = new Set(activePrDispatchBranches.keys());
|
|
7131
7219
|
|
|
7132
7220
|
for (const pr of prs) {
|
|
7133
7221
|
if (pr.status !== PR_STATUS.ACTIVE) continue;
|
|
@@ -7140,8 +7228,21 @@ async function discoverFromPrs(config, project) {
|
|
|
7140
7228
|
if (!shared.isPrCompatibleWithProject(project, pr, pr.url || '')) continue;
|
|
7141
7229
|
const prDisplayId = shared.getPrDisplayId(pr);
|
|
7142
7230
|
const prCanonicalId = shared.getCanonicalPrId(project, pr, pr.url || '');
|
|
7143
|
-
if (activePrIds.has(prCanonicalId)) continue; // Skip PRs with active dispatch (prevent race)
|
|
7144
7231
|
const prBranchForMutex = resolvePrBranch(pr);
|
|
7232
|
+
if (activePrIds.has(prCanonicalId)) {
|
|
7233
|
+
const dispatchBranches = activePrDispatchBranches.get(prCanonicalId);
|
|
7234
|
+
const prOwnBranch = normalizePrBranch(prBranchForMutex);
|
|
7235
|
+
// Guard trips (skip PR) when: any active dispatch's branch is unknown
|
|
7236
|
+
// (conservative fallback), or the PR's own branch is unknown (can't
|
|
7237
|
+
// prove non-overlap), or a dispatch's branch actually matches the PR's
|
|
7238
|
+
// branch (real overlap). Otherwise every active dispatch referencing
|
|
7239
|
+
// this PR id is tracking-only on a different branch — let discovery
|
|
7240
|
+
// proceed.
|
|
7241
|
+
const realOverlap = !prOwnBranch
|
|
7242
|
+
|| [...dispatchBranches].some(b => b === null || b === prOwnBranch);
|
|
7243
|
+
if (realOverlap) continue; // Skip PRs with active dispatch (prevent race)
|
|
7244
|
+
log('info', `Race-guard: PR ${prDisplayId} (${prCanonicalId}) referenced by ${dispatchBranches.size} active dispatch(es) tracking-only on unrelated branch(es) — not blocking automation`);
|
|
7245
|
+
}
|
|
7145
7246
|
// Branch mutex: skip if PR branch is locked by any active dispatch (cross-type collision)
|
|
7146
7247
|
if (prBranchForMutex && isBranchActive(prBranchForMutex)) {
|
|
7147
7248
|
log('info', `Branch mutex: skipping PR ${pr.id} dispatch — branch ${prBranchForMutex} locked by another agent`);
|
|
@@ -8455,7 +8556,17 @@ function discoverFromWorkItems(config, project) {
|
|
|
8455
8556
|
shared.autoEnrollPrFromWorkItem(item, project, MINIONS_DIR);
|
|
8456
8557
|
} catch (e) { log('warn', `auto-enroll PR for ${item.id}: ${e.message}`); }
|
|
8457
8558
|
|
|
8458
|
-
|
|
8559
|
+
// Issue #716 — `item.skipPr` marks work items that never own a PR (QA
|
|
8560
|
+
// Session SETUP/DRAFT/EXECUTE WIs are the canonical example: `skipPr:
|
|
8561
|
+
// true`, `oneShot: true`). Their descriptions legitimately embed the
|
|
8562
|
+
// *target's* PR reference as prose/JSON (e.g. `Target: {"kind":"pr",
|
|
8563
|
+
// "prId":"..."}`), which the LOOSE getWorkItemPrRef text-scan (used by
|
|
8564
|
+
// resolveWorkItemPrRecord) misreads as "this work item is about that PR"
|
|
8565
|
+
// — resolving/attaching an unrelated PR record, corrupting branchName
|
|
8566
|
+
// resolution below, and (via stampWiPrRef/autoDispatchLiveValidationWi)
|
|
8567
|
+
// mislabeling the WI's own `_pr` field. skipPr WIs must never enter PR
|
|
8568
|
+
// context inference.
|
|
8569
|
+
const linkedPr = item.skipPr ? null : resolveWorkItemPrRecord(item, project);
|
|
8459
8570
|
const promptItem = linkedPr ? withWorkItemPrContext(item, linkedPr) : item;
|
|
8460
8571
|
const prBranch = linkedPr?.branch || '';
|
|
8461
8572
|
const isPrTargeted = !!(linkedPr && (workType === WORK_TYPE.FIX || workType === WORK_TYPE.REVIEW || workType === WORK_TYPE.TEST));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2346",
|
|
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"
|
|
@@ -77,6 +77,14 @@ live instance:
|
|
|
77
77
|
project README "Run locally / Getting Started" section, a `Makefile` `dev`
|
|
78
78
|
target, or a docker-compose service that exposes an HTTP port. Pick the
|
|
79
79
|
smallest command that brings the app up and binds to a TCP port.
|
|
80
|
+
- **Native app / emulator-backed target (no HTTP surface)?** — e.g. an
|
|
81
|
+
Android Gradle/Kotlin app, an iOS simulator target. Do not force an HTTP
|
|
82
|
+
healthcheck onto it. Spawn only the long-lived environment dependency
|
|
83
|
+
(emulator/simulator/device boot) here and defer the one-shot
|
|
84
|
+
build+install+launch to DRAFT/EXECUTE; use a `command` healthcheck (e.g.
|
|
85
|
+
`adb shell getprop sys.boot_completed` polled for `^1$`) as the readiness
|
|
86
|
+
signal. See the "Native app / emulator-backed targets" section of the
|
|
87
|
+
`managed_spawn` block below for the full worked example.
|
|
80
88
|
3. **Write the managed-spawn sidecar** to
|
|
81
89
|
`agents/{{agent_id}}/managed-spawn.json` (relative to the Minions root)
|
|
82
90
|
with **exactly one** spec named **`{{managed_spawn_name}}`**. Use the JSON
|