@yemi33/minions 0.1.2355 → 0.1.2357

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.
@@ -561,7 +561,12 @@ async function _refreshPipelineDetail(id) {
561
561
  _pipelinesData = _pipelinesData.map(function(x) { return x.id === id ? fresh : x; });
562
562
  _pipelinePollHash = _computePipelineDetailHash(fresh);
563
563
  renderPipelines(_pipelinesData);
564
- openPipelineDetail(id);
564
+ // Only refresh the detail modal when it's already open for this pipeline —
565
+ // callers triggered from the card list (modal closed / different id) must not
566
+ // pop the modal open. See same guard pattern above at :625, :640, :649.
567
+ if (document.getElementById('modal')?.classList?.contains('open') && _pipelinePollId === id) {
568
+ openPipelineDetail(id);
569
+ }
565
570
  }
566
571
  } catch (e) { /* silent — next poll will catch up */ }
567
572
  }
@@ -569,12 +574,16 @@ async function _refreshPipelineDetail(id) {
569
574
  async function _triggerPipeline(id, btn) {
570
575
  if (btn) { btn.textContent = 'Starting...'; btn.style.pointerEvents = 'none'; btn.style.opacity = '0.6'; }
571
576
  showToast('cmd-toast', 'Pipeline triggered', true);
572
- // Optimistic: inject a running state so the UI updates immediately
577
+ // Optimistic: inject a running state so the card list updates immediately.
578
+ // Do NOT open the modal here — this function is shared by the CARD's Run Now
579
+ // button (modal must stay closed) and the modal's own Run Now button (the
580
+ // modal, if open for this id, is refreshed exactly once below via the
581
+ // guarded _refreshPipelineDetail() call once the trigger POST resolves).
573
582
  var p = _pipelinesData.find(function(x) { return x.id === id; });
574
583
  if (p) {
575
584
  if (!p.runs) p.runs = [];
576
585
  p.runs.push({ status: 'running', startedAt: new Date().toISOString(), stages: {} });
577
- openPipelineDetail(id);
586
+ renderPipelines(_pipelinesData);
578
587
  }
579
588
  try {
580
589
  var res = await fetch('/api/pipelines/trigger', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: id }) });
@@ -225,6 +225,26 @@ function prRow(pr) {
225
225
  + 'onclick="event.stopPropagation();resumeBuildFixIneffective(this)">'
226
226
  + '⏸ infra issue suspected ▶</button>';
227
227
  }
228
+ // Issue #764 — worktree-held-externally chip. pr._worktreeHeldPaused is set
229
+ // when a PR-driven review/fix dispatch hit WORKTREE_PREFLIGHT because this
230
+ // PR's branch is checked out at the project root or in an external
231
+ // worktree the engine can't reach (see engine/lifecycle.js
232
+ // recordWorktreeHeldPause). engine.js auto-clears this pause once it
233
+ // detects the holder no longer holds the branch; this chip lets an
234
+ // operator force-clear it immediately via
235
+ // /api/pull-requests/clear-worktree-held-pause (resumeWorktreeHeldPause
236
+ // below) without waiting for the next PR poll tick.
237
+ var worktreeHeldPaused = pr._worktreeHeldPaused;
238
+ if (worktreeHeldPaused && typeof worktreeHeldPaused === 'object') {
239
+ var whpTitle = (worktreeHeldPaused.reason || 'Branch is held externally; PR automation paused')
240
+ + '. Auto-clears once the holder releases the branch, or click to resume now.';
241
+ pausedChips += ' <button class="pr-badge rejected pr-worktree-held-chip" '
242
+ + 'style="font-size:var(--text-xs);cursor:pointer;border:none" '
243
+ + 'title="' + escapeHtml(whpTitle) + '" '
244
+ + 'data-pr-id="' + escapeHtml(String(pr.id || '')) + '" '
245
+ + 'onclick="event.stopPropagation();resumeWorktreeHeldPause(this)">'
246
+ + '⏸ branch held externally ▶</button>';
247
+ }
228
248
  const titleText = pr.title || 'Untitled';
229
249
  // Polish (W-mqv5ccn7): flatten Markdown in the body so the preview shows
230
250
  // plain text instead of raw `##` / `**…**` / backtick source.
@@ -791,4 +811,38 @@ async function resumeBuildFixIneffective(btn) {
791
811
  }
792
812
  }
793
813
 
794
- window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, openPrDetail, unlinkPr, togglePrObserve, resumePausedCause, resumeBuildFixIneffective };
814
+ // Issue #764 resume a paused worktree-held-externally pause. Mirrors
815
+ // resumeBuildFixIneffective above but targets the holder-path-keyed
816
+ // _worktreeHeldPaused record via its own endpoint. engine.js already
817
+ // auto-clears this pause the next time it detects the holder released the
818
+ // branch; this handler exists for operators who don't want to wait for the
819
+ // next PR poll tick.
820
+ async function resumeWorktreeHeldPause(btn) {
821
+ if (!btn || btn.disabled) return;
822
+ const prId = btn.dataset.prId;
823
+ if (!prId) return;
824
+ if (!confirm('Resume PR automation on ' + prId + '?\n\nThis clears the "branch held externally" pause so the engine can re-dispatch review/fix work on the next discovery pass.')) return;
825
+ btn.disabled = true;
826
+ const prevDisplay = btn.style.display;
827
+ btn.style.display = 'none';
828
+ showToast('pr-toast', 'Resumed PR automation on ' + prId, true);
829
+ try {
830
+ const res = await fetch('/api/pull-requests/clear-worktree-held-pause', {
831
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
832
+ body: JSON.stringify({ prId })
833
+ });
834
+ if (!res.ok) {
835
+ const d = await res.json().catch(() => ({}));
836
+ btn.style.display = prevDisplay;
837
+ btn.disabled = false;
838
+ showToast('pr-toast', 'Failed: ' + (d.error || 'unknown'), false);
839
+ return;
840
+ }
841
+ } catch (e) {
842
+ btn.style.display = prevDisplay;
843
+ btn.disabled = false;
844
+ showToast('pr-toast', 'Error: ' + e.message, false);
845
+ }
846
+ }
847
+
848
+ window.MinionsPrs = { prRow, prTableHtml, renderPrs, prPrev, prNext, openAllPrs, openModal, openAddPrModal, openPrDetail, unlinkPr, togglePrObserve, resumePausedCause, resumeBuildFixIneffective, resumeWorktreeHeldPause };
@@ -342,17 +342,31 @@ async function openSettings() {
342
342
  '<option value="off"' + (autoStashRaw === 'off' ? ' selected' : '') + '>Off — fail on dirty tree</option>' +
343
343
  '</select>' +
344
344
  '<div style="font-size:var(--text-xs);color:var(--muted);margin-top:2px;line-height:1.4">Live mode only. On = `git stash push --include-untracked` a dirty tree before dispatch instead of failing; the engine never pops the stash (run `git stash pop` manually). Overrides the fleet-wide setting under Worktrees.</div>' +
345
- '</div>';
345
+ '</div>';
346
+ // W-mrchrfe100067d3a — per-project explicit allowlist of secondary repo
347
+ // scopes this project's checkout is also allowed to track PRs for (e.g.
348
+ // minions-opg's checkout has both an `origin` remote → opg-microsoft/minions
349
+ // and a `yemi33` remote → yemi33/minions for backport work — see
350
+ // CLAUDE.md "Remotes"). Comma-separated canonical scope strings
351
+ // (`github:owner/repo` or `ado:org/project/repo`); parsed + validated
352
+ // server-side by shared.validateAdditionalRepoScopes on save.
353
+ var additionalScopesSearch = 'additional repo scopes multi-scope dual-remote backport yemi33';
354
+ var additionalScopesBlock = '<div data-search="' + escHtml(additionalScopesSearch) + '" style="margin-bottom:6px">' +
355
+ settingsField('Additional repo scopes (secondary remotes)', 'set-additionalRepoScopes-' + p.name,
356
+ (Array.isArray(p.additionalRepoScopes) ? p.additionalRepoScopes : []).join(', '), '',
357
+ 'Comma-separated canonical scopes this project\'s PRs may ALSO belong to (e.g. "github:yemi33/minions"). Explicit opt-in allowlist only — PRs matching one of these scopes are treated as compatible for auto-review/auto-fix without being flagged pr_scope_mismatch. Leave empty for single-scope projects.') +
358
+ '</div>';
346
359
  return '<div data-settings-project="' + escHtml(p.name) + '" data-search="project ' + escHtml(p.name.toLowerCase()) + '" style="border:1px solid var(--border);border-radius:6px;padding:10px 12px;margin-bottom:12px">' +
347
- '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
348
- '<div style="font-size:var(--text-md);font-weight:600">' + escHtml(p.name) + '</div>' +
349
- '<button onclick="MinionsSettings.removeProject(\'' + escHtml(p.name) + '\')" style="font-size:var(--text-xs);padding:2px 8px;background:transparent;color:var(--red);border:1px solid var(--red);border-radius:3px;cursor:pointer">Remove</button>' +
350
- '</div>' +
351
- pathRow +
352
- branchGrid +
353
- worktreeModeBlock +
354
- autoStashBlock +
355
- driftNote +
360
+ '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">' +
361
+ '<div style="font-size:var(--text-md);font-weight:600">' + escHtml(p.name) + '</div>' +
362
+ '<button onclick="MinionsSettings.removeProject(\'' + escHtml(p.name) + '\')" style="font-size:var(--text-xs);padding:2px 8px;background:transparent;color:var(--red);border:1px solid var(--red);border-radius:3px;cursor:pointer">Remove</button>' +
363
+ '</div>' +
364
+ pathRow +
365
+ branchGrid +
366
+ worktreeModeBlock +
367
+ autoStashBlock +
368
+ additionalScopesBlock +
369
+ driftNote +
356
370
  '<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">' +
357
371
  settingsToggle('Discover from PRs', 'set-ws-prs-' + p.name, p.workSources.pullRequests.enabled, 'Discovery gate: scan repo for open PRs and surface them as review tasks. Independent of ADO/GitHub polling — does not affect already-tracked PRs.') +
358
372
  settingsToggle('Discover from Work Items', 'set-ws-wi-' + p.name, p.workSources.workItems.enabled, 'Auto-discover work from ADO/GitHub work items') +
@@ -1257,12 +1271,18 @@ async function saveSettings() {
1257
1271
  const autoStashInput = document.getElementById('set-liveCheckoutAutoStash-' + p.name);
1258
1272
  const autoStashRaw = autoStashInput ? autoStashInput.value : '';
1259
1273
  const autoStashValue = autoStashRaw === 'on' ? true : autoStashRaw === 'off' ? false : null;
1274
+ // W-mrchrfe100067d3a — parse the comma-separated scope list back into an
1275
+ // array; empty string → null (clears the override server-side).
1276
+ const additionalScopesInput = document.getElementById('set-additionalRepoScopes-' + p.name);
1277
+ const additionalScopesRaw = additionalScopesInput ? additionalScopesInput.value : '';
1278
+ const additionalRepoScopes = additionalScopesRaw.split(',').map(function(s) { return s.trim(); }).filter(Boolean);
1260
1279
  return {
1261
1280
  name: p.name,
1262
1281
  mainBranch: mainBranchValue || null,
1263
1282
  checkoutMode: checkoutMode,
1264
1283
  liveValidation: liveValidation,
1265
1284
  liveCheckoutAutoStash: autoStashValue,
1285
+ additionalRepoScopes: additionalRepoScopes.length > 0 ? additionalRepoScopes : null,
1266
1286
  workSources: {
1267
1287
  pullRequests: { enabled: document.getElementById('set-ws-prs-' + p.name)?.checked ?? true },
1268
1288
  workItems: { enabled: document.getElementById('set-ws-wi-' + p.name)?.checked ?? true }
package/dashboard.js CHANGED
@@ -6434,6 +6434,14 @@ const server = http.createServer(async (req, res) => {
6434
6434
  found = true;
6435
6435
  item.status = WI_STATUS.PENDING;
6436
6436
  item._retryCount = 0; // Reset retry counter on manual retry
6437
+ // W-mrcqzuxy000w676e — quarantineRecoveryCount is a lifetime cumulative
6438
+ // counter (engine.js discoverWork auto-recovery loop), so unrelated prior
6439
+ // quarantine noise can permanently give up on a WI even when a later
6440
+ // attempt would recover cleanly. A manual retry is an explicit human
6441
+ // signal to try again — reset the counter/give-up flag so it isn't
6442
+ // punished by history.
6443
+ delete item._quarantineRecoveryCount;
6444
+ delete item._quarantineRecoveryGaveUp;
6437
6445
  delete item.dispatched_at;
6438
6446
  delete item.dispatched_to;
6439
6447
  delete item.failReason;
@@ -7097,6 +7105,16 @@ const server = http.createServer(async (req, res) => {
7097
7105
  // committed. Compensate by removing the item from the target
7098
7106
  // store again, so the item ends up in exactly one store (its
7099
7107
  // original) instead of duplicated across two.
7108
+ //
7109
+ // W-mrciooy1000e2ac3 — the compensating rollback write can ITSELF
7110
+ // fail (double-failure: destination insert succeeded, source
7111
+ // splice failed, and the compensating removal from destination
7112
+ // also failed). Swallowing that second error and unconditionally
7113
+ // reporting "move was rolled back" is a lie: the item is still
7114
+ // duplicated in both stores. Track the rollback outcome and
7115
+ // surface the double-failure distinctly so the caller/UI never
7116
+ // believes a rollback happened when it didn't.
7117
+ let rollbackErr = null;
7100
7118
  try {
7101
7119
  mutateJsonFileLocked(projectMove.wiPath, (targetItems) => {
7102
7120
  if (!Array.isArray(targetItems)) targetItems = [];
@@ -7104,7 +7122,30 @@ const server = http.createServer(async (req, res) => {
7104
7122
  if (ti !== -1) targetItems.splice(ti, 1);
7105
7123
  return targetItems;
7106
7124
  });
7107
- } catch { /* best-effort rollback; surfaced via the 500 below regardless */ }
7125
+ } catch (rbErr) { rollbackErr = rbErr; }
7126
+
7127
+ if (rollbackErr) {
7128
+ console.error(
7129
+ `[work-items/update] DOUBLE FAILURE on cross-project move of ${id}: ` +
7130
+ `source-splice failed (${spliceErr.message}) AND compensating rollback ` +
7131
+ `from target store also failed (${rollbackErr.message}). Item may now be ` +
7132
+ `duplicated across source (${wiPath}) and target (${projectMove.wiPath}) ` +
7133
+ `— manual reconciliation required.`
7134
+ );
7135
+ return jsonReply(res, 500, {
7136
+ error: `project move failed while removing from source store, and the compensating rollback also failed: ${rollbackErr.message}. ` +
7137
+ `Item may be duplicated across both stores — manual reconciliation required.`,
7138
+ reconciliation: {
7139
+ required: true,
7140
+ id,
7141
+ sourceStore: wiPath,
7142
+ targetStore: projectMove.wiPath,
7143
+ spliceError: spliceErr.message,
7144
+ rollbackError: rollbackErr.message,
7145
+ },
7146
+ });
7147
+ }
7148
+
7108
7149
  return jsonReply(res, 500, {
7109
7150
  error: `project move failed while removing from source store; move was rolled back: ${spliceErr.message}`,
7110
7151
  });
@@ -11184,6 +11225,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11184
11225
  // tri-state dropdown. Undefined means "use fleet default"
11185
11226
  // (engine.liveCheckoutAutoStash); only an explicit boolean overrides.
11186
11227
  liveCheckoutAutoStash: (typeof p.liveCheckoutAutoStash === 'boolean') ? p.liveCheckoutAutoStash : undefined,
11228
+ // W-mrchrfe100067d3a — surface the per-project secondary-repo
11229
+ // allowlist so the Settings → Projects UI can pre-fill / edit it.
11230
+ // Empty array when unset (never null) so the UI can render a
11231
+ // simple add/remove list without an extra null-check.
11232
+ additionalRepoScopes: shared.getProjectAdditionalPrScopes(p),
11187
11233
  workSources: {
11188
11234
  pullRequests: { enabled: p.workSources?.pullRequests?.enabled !== false, cooldownMinutes: p.workSources?.pullRequests?.cooldownMinutes ?? 30 },
11189
11235
  workItems: { enabled: p.workSources?.workItems?.enabled !== false, cooldownMinutes: p.workSources?.workItems?.cooldownMinutes ?? 0 }
@@ -11668,6 +11714,24 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11668
11714
  proj.liveCheckoutAutoStash = !!rawStash;
11669
11715
  }
11670
11716
  }
11717
+ // W-mrchrfe100067d3a — per-project explicit allowlist of secondary
11718
+ // repo scopes (e.g. minions-opg's checkout also legitimately opens
11719
+ // PRs against yemi33/minions for backport work — see CLAUDE.md
11720
+ // "Remotes"). Empty array / null / '' clears the override (falls
11721
+ // back to single-scope matching); any other value flows through
11722
+ // shared.validateAdditionalRepoScopes, which throws HTTP 400 on a
11723
+ // non-array or malformed scope string (errors propagate to the
11724
+ // outer catch below via e.statusCode/e.message).
11725
+ if (Object.prototype.hasOwnProperty.call(update, 'additionalRepoScopes')) {
11726
+ const rawScopes = update.additionalRepoScopes;
11727
+ if (rawScopes === '' || rawScopes === null || (Array.isArray(rawScopes) && rawScopes.length === 0)) {
11728
+ delete proj.additionalRepoScopes;
11729
+ } else {
11730
+ const validatedScopes = shared.validateAdditionalRepoScopes(rawScopes);
11731
+ if (validatedScopes === undefined) delete proj.additionalRepoScopes;
11732
+ else proj.additionalRepoScopes = validatedScopes;
11733
+ }
11734
+ }
11671
11735
  }
11672
11736
  }
11673
11737
 
@@ -14144,6 +14208,42 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14144
14208
  return jsonReply(res, 200, { ok: true, cleared: 'build-fix-ineffective', prId });
14145
14209
  }},
14146
14210
 
14211
+ // Issue #764 — sibling to the two clear endpoints above, for the
14212
+ // holder-path-keyed `_worktreeHeldPaused` circuit breaker (see
14213
+ // engine/lifecycle.js#recordWorktreeHeldPause / engine.js's
14214
+ // isWorktreeHeldPauseSuppressed). engine.js already auto-clears this the
14215
+ // next time it detects the holder no longer holds the branch, but this
14216
+ // endpoint lets an operator force a clear immediately (e.g. they just
14217
+ // released the branch and don't want to wait for the next PR poll tick).
14218
+ { method: 'POST', path: '/api/pull-requests/clear-worktree-held-pause', desc: 'Resume a paused worktree-held-externally pause (issue #764) — clears _worktreeHeldPaused so PR-driven review/fix auto-dispatch can resume', params: 'prId (canonical host:slug#number)', handler: async (req, res) => {
14219
+ const body = await readBody(req);
14220
+ const prId = typeof body?.prId === 'string' ? body.prId.trim() : '';
14221
+ if (!prId) return jsonReply(res, 400, { error: 'prId required' });
14222
+ reloadConfig();
14223
+ const lifecycle = require('./engine/lifecycle');
14224
+ const prPaths = [
14225
+ ...shared.getProjects(CONFIG).map(p => shared.projectPrPath(p)),
14226
+ shared.centralPullRequestsPath(MINIONS_DIR),
14227
+ ];
14228
+ let prFound = false;
14229
+ let recordCleared = false;
14230
+ for (const prPath of prPaths) {
14231
+ if (recordCleared) break;
14232
+ shared.mutatePullRequests(prPath, (prs) => {
14233
+ if (!Array.isArray(prs)) return prs;
14234
+ const target = prs.find(p => p && p.id === prId);
14235
+ if (!target) return prs;
14236
+ prFound = true;
14237
+ recordCleared = lifecycle.clearWorktreeHeldPause(target);
14238
+ return prs;
14239
+ });
14240
+ }
14241
+ if (!prFound) return jsonReply(res, 404, { error: `PR ${prId} not found` });
14242
+ if (!recordCleared) return jsonReply(res, 400, { error: `PR ${prId} has no tracked worktree-held pause` });
14243
+ invalidateStatusCache();
14244
+ return jsonReply(res, 200, { ok: true, cleared: 'worktree-held-pause', prId });
14245
+ }},
14246
+
14147
14247
  // ─── P-f3c9d0e7: convenience pause/resume endpoints ─────────────────────
14148
14248
  // Single-call wrappers over `POST /api/settings { engine: { <flag>: bool } }`
14149
14249
  // for the two operator kill-switches:
@@ -14707,7 +14807,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
14707
14807
 
14708
14808
  // Settings
14709
14809
  { method: 'GET', path: '/api/settings', desc: 'Return current engine + claude + routing config', handler: handleSettingsRead },
14710
- { method: 'POST', path: '/api/settings', desc: 'Update engine + claude + agent + projects config', params: 'engine?, claude?, agents?, projects? (per-project: checkoutMode, liveValidation, mainBranch, workSources)', handler: handleSettingsUpdate },
14810
+ { method: 'POST', path: '/api/settings', desc: 'Update engine + claude + agent + projects config', params: 'engine?, claude?, agents?, projects? (per-project: checkoutMode, liveValidation, mainBranch, workSources, additionalRepoScopes)', handler: handleSettingsUpdate },
14711
14811
  { method: 'POST', path: '/api/settings/routing', desc: 'Update routing.md', params: 'content', handler: handleSettingsRouting },
14712
14812
  { method: 'POST', path: '/api/settings/reset', desc: 'Reset engine + claude + agent settings to defaults', handler: handleSettingsReset },
14713
14813
 
package/docs/README.md CHANGED
@@ -31,6 +31,7 @@ Architecture, design proposals, and lifecycle references for people working on t
31
31
  - [harness-propagation.md](harness-propagation.md) — How user-level and project-local harness assets (skills, slash-commands, MCP config, `CLAUDE.md` / `AGENTS.md`) propagate into an agent's worktree via `--add-dir`, the `harnessPropagateProjectLocal` flag, and the project-local-on-main worktree-visibility footgun.
32
32
  - [harness-transparency.md](harness-transparency.md) — The `harnessUsed` self-report contract: capture (agent reports the skills / MCPs / commands / docs it used) → ground (engine cross-checks against `_harnessPropagated` and annotates `grounded:true\|false`, never dropping) → surface (PR comment, final agent note, work-item modal).
33
33
  - [kb-dedup-duplicate-pair-investigation.md](kb-dedup-duplicate-pair-investigation.md) — Investigation-only pass diffing suspected duplicate KB filename groups (content-hash dedup for agent-authored reviews/build-reports) to confirm true full-body duplication before any fix is proposed.
34
+ - [kb-pr3223-cascade-archiving.md](kb-pr3223-cascade-archiving.md) — Findings from PR #3223: cascade-archiving PRDs when their linked markdown plan is archived, including the per-concern try/catch pattern and `source_plan` basename-matching invariant in `handlePlansArchive` (`dashboard.js`).
34
35
  - [kb-sweep.md](kb-sweep.md) — Knowledge-base consolidation sweep (hash dedup → LLM batch dedup/reclassify → per-entry compress) and the detached runner that keeps it alive across `minions restart`.
35
36
  - [keep-processes.md](keep-processes.md) — `meta.keep_processes` sidecar contract: when to use it vs managed-spawn, sidecar schema, caps, and the [`engine/keep-process-sweep.js`](../engine/keep-process-sweep.js) lifecycle.
36
37
  - [live-checkout-mode.md](live-checkout-mode.md) — Per-project opt-in `checkoutMode: 'live'`: skips `git worktree add` and dispatches in-place inside `project.localPath` for `repo`-managed trees, submodule-heavy repos, deep Windows paths, and native build state. Includes the refuse-on-dirty contract and the per-project mutating-concurrency cap of 1.
@@ -30,6 +30,10 @@ Implementation: `engine.js` builds a `liveProjectsInUse` set from the active dis
30
30
 
31
31
  ### 2. Refuse-on-dirty (engine never resets the operator tree)
32
32
 
33
+ **W-mrcifup2000c218a — detected at ALLOCATION time first.** Before a live-mode work item is even dispatched, `engine.js`'s dispatch-existing-pending loop calls `engine/live-checkout.js#checkLiveCheckoutDirty` (a cheap, read-only, non-mutating `git status --porcelain=v1 -b` probe — same command, same auto-cleanable-artifact allowance as the in-spawn check below) in the same pass that already gates on `live_checkout_busy`. A dirty hit stamps `_pendingReason: 'live_checkout_dirty'` on the pending item and `continue`s — **no dispatch attempt, no retry consumed, no failure recorded** — exactly like the busy gate. The item is re-checked every tick and dispatches normally the moment the tree is clean. A `live-checkout-dirty-<wi-id>` inbox alert is written (deduped per day, same as the in-spawn alert below) so a persistent dirty condition is still surfaced to a human even though the WI itself stays pending.
34
+
35
+ The in-spawn check below remains as a **defense-in-depth fallback** for the race window between the allocation-time probe and the actual git operations inside `spawnAgent` (e.g. the tree goes dirty in the few seconds between the pre-check and the spawn) — it is now the *exception* path, not the common path.
36
+
33
37
  Before spawning, `engine/live-checkout.js#prepareLiveCheckout` runs `git status --porcelain` from `project.localPath`. Any output (staged, unstaged, untracked) **returns** an explicit `{ ok:false, reason:'dirty', dirtyFiles:[…] }` result (it does NOT throw), which refuses the dispatch immediately:
34
38
 
35
39
  - Non-retryable `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (added to `engine/dispatch.js`'s `neverRetry` set so the dispatcher never re-spawns mechanically). **Reserved for this confirmed-dirty result only** — a thrown helper/git error is `LIVE_CHECKOUT_FAILED`, see below (#305). **Auto-cleanup exception (W-mqzmkoqt000hbca2, #582):** when `liveCheckoutAutoReset` or `liveCheckoutAutoStash` is enabled (per-project or fleet-wide), `spawnAgent` treats the dirty refusal as retryable instead — the engine re-cleans the tree on every attempt, so the two-strike cap below is skipped and the dispatch keeps retrying up to the normal `maxRetries` cap. The `dispatch.js` `neverRetry` entry is kept only as a defensive fallback for callers that omit the explicit `agentRetryable` override.
@@ -129,6 +133,8 @@ Either condition fails the dispatch non-retryably with `FAILURE_CLASS.LIVE_CHECK
129
133
 
130
134
  ### 6. STALE-BASE GUARD — refuse to fork off a diverged local base (W-mr98op8w000ma4ad)
131
135
 
136
+ **W-mrcifup2000c218a — detected at ALLOCATION time first.** Same as Guarantee 2, the stale-base condition is now checked BEFORE dispatch by `engine/live-checkout.js#checkLiveCheckoutStaleBase` (a cheap, read-only, fetch-free `git rev-list --count origin/<mainRef>..<mainRef>` probe, mirroring the in-spawn check below) in the dispatch-existing-pending loop. A stale hit stamps `_pendingReason: 'live_checkout_stale_base'` and leaves the item pending — no retry consumed, no terminal failure — re-checked every tick, instead of the pre-fix behavior of failing non-retryably on the very first hit. A `live-checkout-stale-base-<wi-id>` inbox alert is written (deduped per day) so the condition is still surfaced to a human. The in-spawn refusal documented below is now a defense-in-depth fallback for the race window between the pre-check and the actual `git checkout -b`, not the common path — it still fails non-retryably when it fires, because a stale base found there is still deterministic and un-recoverable by a bare retry.
137
+
132
138
  A **clean** tree can still hide **committed** contamination. When `prepareLiveCheckout` is about to create a **new** branch (`git checkout -b <branch>`) off the operator's current HEAD, and HEAD is sitting on the project base ref (`mainRef`, the common case), the local `mainRef` branch may carry commits that were never pushed to `origin/<mainRef>` — e.g. leftover in-progress work from a prior dispatch on a live-mode project (capped to one mutating dispatch, but shared across many sequential dispatches), an interrupted process, or a bad rebase. Forking a new branch off that base silently inherits those commits into the branch **and its PR**. This is exactly the scope-contamination class first seen on ADO PR 5411214 and recurring on AB#12016662 / ADO PR 5419146 (~20 unrelated OCM files baked into an otherwise-clean 2-file a11y fix).
133
139
 
134
140
  It survives the dirty check (Guarantee 2) because the contamination is **committed**, not uncommitted, and it cannot be caught by a "HEAD is on the wrong branch" check because here HEAD **is** on `mainRef`.
@@ -84,6 +84,47 @@ recreate a clean worktree. Six layered defenses cover the Windows EBUSY
84
84
  race when git status descendants still hold packfile handles
85
85
  (W-mq5n1zx5000hcfb5; PR #3156).
86
86
 
87
+ ### Uncommitted-work preservation (W-mrcqzuxy000w676e)
88
+
89
+ The committed-HEAD backup ref (`refs/minions/quarantine/<sanitized-branch>/<ts>`,
90
+ written near the end of `_quarantineDirtyWorktreeCore`) only ever captured
91
+ `git rev-parse HEAD` — it never covered **uncommitted** modified/untracked
92
+ files. A prior incident (W-mrciooy1000e2ac3) lost a real, on-task fix + a new
93
+ regression test that a previous agent had written but not committed: the
94
+ worktree was quarantined cleanly, but the uncommitted edits were gone the
95
+ moment the dir was renamed away.
96
+
97
+ `_quarantineDirtyWorktreeCore` now runs a **separate, best-effort WIP-snapshot
98
+ step** immediately after the live-dispatch guard and before any destructive
99
+ work (holder-reap / rename / force-remove):
100
+
101
+ 1. Probe `git status --porcelain` in the worktree.
102
+ 2. If it reports any entries, run `git add -A && git commit --no-verify -m
103
+ "quarantine: uncommitted WIP snapshot (<dispatchId>)"` in the worktree
104
+ (captures both modified tracked files and new untracked files), then
105
+ `git update-ref refs/minions/quarantine-wip/<sanitized-branch>/<ts> <sha>`
106
+ in the shared repo to pin the WIP commit under its own ref — independent of
107
+ the plain `refs/minions/quarantine/...` backup ref.
108
+ 3. Never blocks the quarantine: any failure at any point (status probe, add,
109
+ commit, update-ref) is caught, logged, and the quarantine proceeds
110
+ regardless.
111
+
112
+ The result object carries this as `result.wipRef` (the ref path, or `null`)
113
+ and `result.wipOutcome`, one of exactly three values so a "did we lose
114
+ anything?" question always has an unambiguous, logged answer:
115
+
116
+ - `'clean'` — no uncommitted changes existed; nothing to preserve.
117
+ - `'preserved'` — uncommitted changes were committed and backed up to `wipRef`.
118
+ - `'failed'` — uncommitted changes existed (or status was undeterminable) but
119
+ the snapshot attempt itself failed; check the engine log for the specific
120
+ git error.
121
+
122
+ The quarantine inbox note (`notes/inbox/engine-worktree-quarantine-*.md`) always
123
+ includes a `wipOutcome`-specific recovery line — the `wipRef` cherry-pick/show
124
+ command when `preserved`, a `⚠️` warning when `failed`, or an explicit "nothing
125
+ to preserve" note when `clean` — so a human never has to guess which bucket a
126
+ given quarantine landed in.
127
+
87
128
  ### Layer 1b — `--no-optional-locks` on every status probe (load-bearing)
88
129
 
89
130
  `_statusPorcelainCmd()` emits `git --no-optional-locks status …`. Skips
@@ -105,6 +146,33 @@ budget explicitly. Toggles:
105
146
  `ENGINE_DEFAULTS.quarantineRenameRetryAttempts` (6),
106
147
  `quarantineRenameRetryBaseMs` (250).
107
148
 
149
+ ### Layer 1a′ — concurrent-quarantine race guard (W-mrcllbrx000oec11)
150
+
151
+ Two overlapping recovery attempts against the SAME dirty worktree path can
152
+ both reach the rename step (observed ~96ms apart in production — see
153
+ W-mrciooy1000e2ac3, 12 recovery cycles burned before the WI was wrongly
154
+ marked terminally failed even though the tree was already clean). ENOENT is
155
+ non-retryable in `_retryFsOp`, so the loser's rename throws immediately once
156
+ the winner has already moved the source dir aside. Two layers address this:
157
+
158
+ 1. **In-process serialization.** `engine.js#_quarantineDirtyWorktree` is now a
159
+ thin wrapper that runs the real implementation
160
+ (`_quarantineDirtyWorktreeCore`) through `_withPathLock`, keyed by
161
+ `worktreePath`. Overlapping calls for the SAME path queue and run their
162
+ destructive steps (holder-reap, rename, force-remove) strictly one at a
163
+ time; calls for different paths are unaffected.
164
+ 2. **ENOENT idempotency check.** If the rename still fails with `ENOENT` AND
165
+ `worktreePath` no longer exists on disk, the source was already renamed
166
+ away by a concurrent/prior attempt — this is quarantine having *already
167
+ succeeded*, not an environment block. `_quarantineDirtyWorktreeCore` best-
168
+ effort locates the sibling `<worktreePath>-quarantine-*` dir the winning
169
+ attempt created (used for `result.quarantinedPath`), sets
170
+ `result.alreadyQuarantinedConcurrently = true`, clears the rename error,
171
+ and proceeds through the normal success path (backup-ref, `git worktree
172
+ prune`, inbox note) instead of falling into the force-remove fallback or
173
+ the `WORKTREE_QUARANTINE_ENV_BLOCKED` total-failure branch. Counter:
174
+ `metrics._engine.worktreeQuarantineOutcomes.alreadyQuarantinedConcurrent`.
175
+
108
176
  ### Layer 2a — `_killGitDescendantsForWorktree` (Windows-only)
109
177
 
110
178
  PowerShell sweep, 2 s timeout. Shell-out built via single-quoted literal
@@ -128,10 +196,18 @@ the new class via both enum check and regex on legacy `failReason`
128
196
  strings, then re-queues under the existing
129
197
  `ENGINE_DEFAULTS.quarantineAutoRecoveryMax` (default 2) cap.
130
198
 
199
+ `_quarantineRecoveryCount` is a **lifetime cumulative** counter — it is never
200
+ reset by a clean recovery, only by a manual retry. `POST /api/work-items/retry`
201
+ (`dashboard.js#handleWorkItemsRetry`) deletes both `_quarantineRecoveryCount`
202
+ and `_quarantineRecoveryGaveUp` on every manual retry (W-mrcqzuxy000w676e), so
203
+ an explicit human "try again" isn't punished by unrelated prior quarantine
204
+ noise that would otherwise leave `_quarantineRecoveryGaveUp: true` permanently
205
+ set even after a later attempt recovers cleanly.
206
+
131
207
  ### Layer 3a/3b — metrics + inbox alert
132
208
 
133
209
  Counters at
134
- `metrics._engine.worktreeQuarantineOutcomes.{attempts, success, successAfterRetry, fallbackForceRemove, totalFailure}`.
210
+ `metrics._engine.worktreeQuarantineOutcomes.{attempts, success, successAfterRetry, fallbackForceRemove, totalFailure, alreadyQuarantinedConcurrent}`.
135
211
  Total-failure path writes a structured inbox alert with the exact
136
212
  PowerShell/POSIX recovery commands and `git worktree prune` instructions.
137
213
 
package/engine/ado.js CHANGED
@@ -279,7 +279,7 @@ function repairAdoProjectConfig(project, purpose, prs = null) {
279
279
  const missingBefore = getMissingAdoProjectConfigFields(project);
280
280
  if (missingBefore.length === 0) return false;
281
281
 
282
- const trackedPrs = prs || shared.safeJson(shared.projectPrPath(project)) || [];
282
+ const trackedPrs = prs || shared.safeJsonArr(shared.projectPrPath(project));
283
283
  for (const candidate of collectAdoRepairCandidates(project, trackedPrs)) {
284
284
  const repairs = {};
285
285
  if (!project.adoOrg && candidate.adoOrg) repairs.adoOrg = candidate.adoOrg;
package/engine/cleanup.js CHANGED
@@ -9,7 +9,7 @@ const shared = require('./shared');
9
9
  const queries = require('./queries');
10
10
 
11
11
  const { exec, execAsync, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
12
- const { safeJson, safeJsonNoRestore, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
12
+ const { safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
13
13
  sanitizeBranch, KB_CATEGORIES } = shared;
14
14
  const { getDispatch, getAgentStatus } = queries;
15
15
 
@@ -98,7 +98,7 @@ function localBranchWorktreeInUse(root, branch) {
98
98
  function collectPhantomBranchesForProject(project) {
99
99
  const branches = new Set();
100
100
  try {
101
- const items = safeJson(projectWorkItemsPath(project)) || [];
101
+ const items = safeJsonArr(projectWorkItemsPath(project));
102
102
  if (!Array.isArray(items)) return branches;
103
103
  for (const w of items) {
104
104
  if (w && w._phantomCompletion === true && w._phantomBranch) {
@@ -1427,11 +1427,11 @@ async function runCleanup(config, verbose = false) {
1427
1427
  try {
1428
1428
  const wiIds = new Set();
1429
1429
  for (const project of projects) {
1430
- const items = safeJson(projectWorkItemsPath(project)) || [];
1430
+ const items = safeJsonArr(projectWorkItemsPath(project));
1431
1431
  for (const wi of items) { if (wi?.id) wiIds.add(wi.id); }
1432
1432
  }
1433
1433
  try {
1434
- const centralWi = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
1434
+ const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
1435
1435
  for (const wi of centralWi) { if (wi?.id) wiIds.add(wi.id); }
1436
1436
  } catch { /* optional */ }
1437
1437
 
@@ -0,0 +1,31 @@
1
+ // engine/db/migrations/016-pr-mirror-hash.js
2
+ //
3
+ // Fix for opg-microsoft/minions#759: pull-requests-store.js tracked its
4
+ // JSON-mirror "last known hash" per scope in an in-memory-only Map
5
+ // (`_lastMirrorHashByScope`). That state resets to empty on every engine
6
+ // restart, so the very first touch of a scope after a restart has no way to
7
+ // tell "SQL and JSON already agree" from "JSON just changed out from under
8
+ // us" — it falls back to a lastHash==null heuristic that only partially
9
+ // covers the case. Combined with a restart landing mid-flight of another
10
+ // process's mirror write, a stale JSON snapshot could get treated as
11
+ // canonical and used to rebuild (blindly DELETE + reinsert) the SQL table
12
+ // for that scope, permanently dropping any rows not present in the stale
13
+ // file.
14
+ //
15
+ // This table makes the mirror-hash durable across restarts: it is now the
16
+ // single, persisted cross-process source of truth for "what mirror state
17
+ // did the store last agree with", instead of a per-process cache that goes
18
+ // blank on every restart.
19
+ module.exports = {
20
+ version: 16,
21
+ description: 'pr_mirror_hashes — persist pull-requests.json mirror-hash across restarts (#759)',
22
+ up(db) {
23
+ db.exec(`
24
+ CREATE TABLE pr_mirror_hashes (
25
+ scope TEXT PRIMARY KEY,
26
+ hash TEXT NOT NULL,
27
+ updated_at INTEGER NOT NULL
28
+ );
29
+ `);
30
+ },
31
+ };