@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.
@@ -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 activePrIds = new Set(
7121
- (dispatch.active || [])
7122
- .filter(d => d.meta?.pr?.id)
7123
- .map(d => {
7124
- const dispatchProject = d.meta?.project?.name
7125
- ? (shared.resolveProjectSource(d.meta.project.name, shared.getProjects(config), { allowCentral: false }).project || d.meta.project)
7126
- : (d.meta?.project || null);
7127
- return shared.getCanonicalPrId(dispatchProject, d.meta.pr, d.meta.pr?.url || '');
7128
- })
7129
- .filter(Boolean)
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
- const linkedPr = resolveWorkItemPrRecord(item, project);
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.2344",
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