@yemi33/minions 0.1.2289 → 0.1.2290

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.
@@ -172,6 +172,7 @@ async function prepareLiveCheckout(opts = {}) {
172
172
  gitOpts,
173
173
  dispatchId, // accepted for caller bookkeeping; not used by the helper
174
174
  wiId, // accepted for caller bookkeeping; used in the auto-reset note slug
175
+ skipDirtyCheck, // #522: opt-out for GVFS repos where git status output exceeds maxBuffer
175
176
  log,
176
177
  autoReset, // W-mqvejug6000eeb20: tri-state. `true`/`false` short-circuits the
177
178
  // config-based resolution; `undefined` (the engine.js call shape)
@@ -212,6 +213,14 @@ async function prepareLiveCheckout(opts = {}) {
212
213
  const baseOpts = { cwd: localPath, maxBuffer: LIVE_CHECKOUT_GIT_MAX_BUFFER, ...(gitOpts || {}) };
213
214
 
214
215
  // ── Step 1: git status --porcelain=v1 -b. Bail early on dirty tree. ─────
216
+ // Skipped when `skipDirtyCheck:true` (issue #522): GVFS/VFS-for-Git repos
217
+ // report all un-hydrated virtual files as modified — this is normal GVFS
218
+ // behavior, not actual WIP. On repos with ~120k virtual dirty files the
219
+ // buffered `git status` output exceeds Node's default maxBuffer (~1MB) and
220
+ // throws before any agent spawns. Setting `skipDirtyCheck:true` via the
221
+ // project-level `skipLiveCheckoutDirtyCheck` config key bypasses the probe
222
+ // entirely so agents can dispatch normally on GVFS repos.
223
+ //
215
224
  // Porcelain v1 -b adds a `## <branch>` header line as the first output line
216
225
  // so callers get branch diagnostics for free alongside the file-status lines.
217
226
  // The file-status lines still use the two-char XY status code (e.g.
@@ -220,92 +229,94 @@ async function prepareLiveCheckout(opts = {}) {
220
229
  // trailing whitespace/CR per line — outer-trimming the whole blob would eat
221
230
  // the leading XY space on the first line. The `## ` header is separated out
222
231
  // before the dirtyFiles check so callers receive it as `branchInfo`.
223
- const statusRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
224
- const statusStr = typeof statusRaw === 'string' ? statusRaw : '';
225
- const statusLines = statusStr
226
- .split(/\r?\n/)
227
- .map((line) => line.replace(/\s+$/, ''))
228
- .filter((line) => line.length > 0);
229
- const branchInfo = statusLines.find((line) => line.startsWith('## ')) || '';
230
- const dirtyFiles = statusLines.filter((line) => !line.startsWith('## '));
231
- if (dirtyFiles.length > 0) {
232
- // W-mqvejug6000eeb20 — opt-in auto-reset. Default behavior is to bail with
233
- // reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
234
- // and alerts the operator). When auto-reset is enabled — either the caller
235
- // passed an explicit `autoReset` boolean, or the config-based resolver says
236
- // so we DISCARD the dirty state via `git fetch origin` + `git reset --hard
237
- // origin/<branch>` and continue. This is destructive (the operator's
238
- // uncommitted work is gone), which is why it is strictly opt-in.
239
- let wantAutoReset = false;
240
- if (typeof autoReset === 'boolean') {
241
- wantAutoReset = autoReset;
242
- } else {
243
- const resolver = (typeof _resolveAutoReset === 'function') ? _resolveAutoReset : _defaultResolveAutoReset;
244
- try { wantAutoReset = !!resolver(localPath); } catch { wantAutoReset = false; }
245
- }
232
+ if (!skipDirtyCheck) {
233
+ const statusRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
234
+ const statusStr = typeof statusRaw === 'string' ? statusRaw : '';
235
+ const statusLines = statusStr
236
+ .split(/\r?\n/)
237
+ .map((line) => line.replace(/\s+$/, ''))
238
+ .filter((line) => line.length > 0);
239
+ const branchInfo = statusLines.find((line) => line.startsWith('## ')) || '';
240
+ const dirtyFiles = statusLines.filter((line) => !line.startsWith('## '));
241
+ if (dirtyFiles.length > 0) {
242
+ // W-mqvejug6000eeb20 opt-in auto-reset. Default behavior is to bail with
243
+ // reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
244
+ // and alerts the operator). When auto-reset is enabled either the caller
245
+ // passed an explicit `autoReset` boolean, or the config-based resolver says
246
+ // so we DISCARD the dirty state via `git fetch origin` + `git reset --hard
247
+ // origin/<branch>` and continue. This is destructive (the operator's
248
+ // uncommitted work is gone), which is why it is strictly opt-in.
249
+ let wantAutoReset = false;
250
+ if (typeof autoReset === 'boolean') {
251
+ wantAutoReset = autoReset;
252
+ } else {
253
+ const resolver = (typeof _resolveAutoReset === 'function') ? _resolveAutoReset : _defaultResolveAutoReset;
254
+ try { wantAutoReset = !!resolver(localPath); } catch { wantAutoReset = false; }
255
+ }
246
256
 
247
- if (!wantAutoReset) {
248
- return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
249
- }
257
+ if (!wantAutoReset) {
258
+ return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
259
+ }
250
260
 
251
- if (typeof log === 'function') {
252
- log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
253
- }
254
- let resetOk = true;
255
- try {
256
- await git(['fetch', 'origin'], baseOpts);
257
- await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
258
- } catch (e) {
259
- resetOk = false;
260
261
  if (typeof log === 'function') {
261
- log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
262
+ log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
262
263
  }
263
- }
264
-
265
- // Re-run the porcelain preflight once. If the tree is now clean we proceed;
266
- // otherwise (reset failed, or something is still dirty) we fall back to the
267
- // safe dirty refusal so we never dispatch onto an unexpected tree.
268
- let stillDirty = dirtyFiles;
269
- if (resetOk) {
264
+ let resetOk = true;
270
265
  try {
271
- const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
272
- const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
273
- stillDirty = recheckStr
274
- .split(/\r?\n/)
275
- .map((line) => line.replace(/\s+$/, ''))
276
- .filter((line) => line.length > 0 && !line.startsWith('## '));
266
+ await git(['fetch', 'origin'], baseOpts);
267
+ await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
277
268
  } catch (e) {
278
269
  resetOk = false;
279
270
  if (typeof log === 'function') {
280
- log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
271
+ log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
281
272
  }
282
273
  }
283
- }
284
274
 
285
- if (!resetOk || stillDirty.length > 0) {
286
- return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
287
- }
275
+ // Re-run the porcelain preflight once. If the tree is now clean we proceed;
276
+ // otherwise (reset failed, or something is still dirty) we fall back to the
277
+ // safe dirty refusal so we never dispatch onto an unexpected tree.
278
+ let stillDirty = dirtyFiles;
279
+ if (resetOk) {
280
+ try {
281
+ const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
282
+ const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
283
+ stillDirty = recheckStr
284
+ .split(/\r?\n/)
285
+ .map((line) => line.replace(/\s+$/, ''))
286
+ .filter((line) => line.length > 0 && !line.startsWith('## '));
287
+ } catch (e) {
288
+ resetOk = false;
289
+ if (typeof log === 'function') {
290
+ log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
291
+ }
292
+ }
293
+ }
288
294
 
289
- // Audit trail: record the discarded paths so the operator can recover from
290
- // reflog / understand why their tree changed. Best-effort, never throws.
291
- try {
292
- const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
293
- const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
294
- const body = [
295
- `# Live-checkout auto-reset on '${branchName}'`,
296
- '',
297
- `⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
298
- '`liveCheckoutAutoReset` is enabled, so it was force-reset to',
299
- `\`origin/${branchName}\`. **The following uncommitted changes were DISCARDED**`,
300
- '(recover from `git reflog` / `git fsck --lost-found` if needed):',
301
- '',
302
- '```',
303
- ...dirtyFiles,
304
- '```',
305
- ].join('\n');
306
- writeNote(slug, body);
307
- } catch { /* best-effort audit note */ }
308
- // Fall through — tree is now clean, continue with normal preparation.
295
+ if (!resetOk || stillDirty.length > 0) {
296
+ return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
297
+ }
298
+
299
+ // Audit trail: record the discarded paths so the operator can recover from
300
+ // reflog / understand why their tree changed. Best-effort, never throws.
301
+ try {
302
+ const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
303
+ const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
304
+ const body = [
305
+ `# Live-checkout auto-reset on '${branchName}'`,
306
+ '',
307
+ `⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
308
+ '`liveCheckoutAutoReset` is enabled, so it was force-reset to',
309
+ `\`origin/${branchName}\`. **The following uncommitted changes were DISCARDED**`,
310
+ '(recover from `git reflog` / `git fsck --lost-found` if needed):',
311
+ '',
312
+ '```',
313
+ ...dirtyFiles,
314
+ '```',
315
+ ].join('\n');
316
+ writeNote(slug, body);
317
+ } catch { /* best-effort audit note */ }
318
+ // Fall through — tree is now clean, continue with normal preparation.
319
+ }
309
320
  }
310
321
 
311
322
  // ── Step 2: mid-operation / detached-HEAD preflight (P-b2e8d4a6). ──────
@@ -528,6 +539,7 @@ async function prepareLiveCheckout(opts = {}) {
528
539
  * @param {boolean} [opts.isTerminalFailure] true on error/timeout/crash
529
540
  * @param {string} [opts.resultLabel] short status word for the alert body
530
541
  * @param {object} [opts.gitOpts] extra execFile opts merged into cwd
542
+ * @param {boolean} [opts.skipDirtyCheck] #522: skip the self-healing dirty probe (GVFS repos)
531
543
  * @param {function} [opts.log] (level, msg) => void
532
544
  * @param {function} opts.writeInboxAlert (slug, body) => any (false = deduped)
533
545
  * @param {function} [opts._git] test seam; defaults to shellSafeGit
@@ -544,6 +556,7 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
544
556
  isTerminalFailure,
545
557
  resultLabel,
546
558
  gitOpts,
559
+ skipDirtyCheck, // #522: opt-out for GVFS repos — same flag as prepareLiveCheckout
547
560
  log,
548
561
  writeInboxAlert,
549
562
  _git, // private injection for testing — defaults to shared.shellSafeGit
@@ -633,7 +646,16 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
633
646
  // never reaches here (git status is empty → not dirty). Best-effort: a
634
647
  // failed commit falls through to the plain checkout, which refuses and
635
648
  // writes the existing manual-recovery alert — no worse than before.
636
- if (branchName && branchName !== originalRef) {
649
+ //
650
+ // Skipped when `skipDirtyCheck:true` (#522 / GVFS repos): on GVFS/VFS-for-Git
651
+ // trees, `git status` reports ~120k un-hydrated virtual files as modified even
652
+ // when the operator's tree is effectively clean. Since prepareLiveCheckout
653
+ // also skips the dirty probe on GVFS repos, the precondition "tree was verified
654
+ // clean before the agent ran" doesn't hold — we cannot distinguish real
655
+ // agent-authored dirt from GVFS virtual noise. Skipping the self-heal here
656
+ // is safe: the plain `git checkout <originalRef>` below will succeed because
657
+ // GVFS's virtual dirty state does NOT block git checkout.
658
+ if (!skipDirtyCheck && branchName && branchName !== originalRef) {
637
659
  let dirty = false;
638
660
  try {
639
661
  const statusRaw = await git(['status', '--porcelain'], baseOpts);
package/engine.js CHANGED
@@ -2380,6 +2380,7 @@ async function spawnAgent(dispatchItem, config) {
2380
2380
  gitOpts: _gitOpts,
2381
2381
  dispatchId: id,
2382
2382
  wiId: _wiIdForAlert,
2383
+ skipDirtyCheck: !!project.skipLiveCheckoutDirtyCheck,
2383
2384
  log: (msg, lvl) => log(lvl || 'info', msg),
2384
2385
  });
2385
2386
  } catch (liveErr) {
@@ -5283,6 +5284,7 @@ async function spawnAgent(dispatchItem, config) {
5283
5284
  isTerminalFailure: effectiveResult !== DISPATCH_RESULT.SUCCESS,
5284
5285
  resultLabel: errorReason || effectiveResult,
5285
5286
  gitOpts: _gitOpts,
5287
+ skipDirtyCheck: !!project?.skipLiveCheckoutDirtyCheck,
5286
5288
  log,
5287
5289
  writeInboxAlert,
5288
5290
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2289",
3
+ "version": "0.1.2290",
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"