@yemi33/minions 0.1.2147 → 0.1.2149

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.
@@ -79,18 +79,60 @@ function _markStuckSuccess(resolvedPath, opts = {}) {
79
79
  try {
80
80
  const writer = typeof opts.writeToInbox === 'function' ? opts.writeToInbox : shared.writeToInbox;
81
81
  const basename = path.basename(resolvedPath);
82
- const body = [
83
- `# Worktree recovered: ${basename}`,
84
- '',
85
- `Previously stuck worktree dir **${resolvedPath}** has been successfully removed`,
86
- `after escalation. Suppression cleared.`,
87
- ].join('\n');
88
- writer('engine', `worktree-recovered-${basename}`, body);
82
+ // W-mq6f2fe0000557fa when the recovery is the direct result of an
83
+ // auto-reap kill, write a distinct slug + body so operators can see
84
+ // which orphan-sweep escalations were resolved by the engine itself
85
+ // vs. naturally by an external holder release.
86
+ const viaReap = opts.recoveryReason === 'holder-reap';
87
+ const reapedPids = Array.isArray(opts.reapedPids) ? opts.reapedPids : [];
88
+ const slug = viaReap
89
+ ? `worktree-recovered-${basename}-via-holder-reap`
90
+ : `worktree-recovered-${basename}`;
91
+ const bodyLines = viaReap
92
+ ? [
93
+ `# Worktree recovered via holder-reap: ${basename}`,
94
+ '',
95
+ `Previously stuck worktree dir **${resolvedPath}** was successfully removed`,
96
+ `after the engine auto-killed the orphan spawn-agent holder(s)`,
97
+ `${reapedPids.length > 0 ? `(PID${reapedPids.length > 1 ? 's' : ''} ${reapedPids.join(', ')})` : ''}`.trim(),
98
+ `under the \`engine.autoReapOrphanWorktreeHolders\` flag (W-mq6f2fe0000557fa).`,
99
+ `Suppression cleared.`,
100
+ ]
101
+ : [
102
+ `# Worktree recovered: ${basename}`,
103
+ '',
104
+ `Previously stuck worktree dir **${resolvedPath}** has been successfully removed`,
105
+ `after escalation. Suppression cleared.`,
106
+ ];
107
+ writer('engine', slug, bodyLines.join('\n'));
89
108
  } catch { /* note best-effort */ }
90
109
  }
91
110
  return wasEscalated;
92
111
  }
93
112
 
113
+ // W-mq6f2fe0000557fa — gate: is this holder an unambiguous orphan spawn-agent
114
+ // process we can safely kill? Requires ALL of:
115
+ // (a) cmdline contains 'spawn-agent.js' (only minions agent wrappers qualify)
116
+ // (b) cmdline references the worktree basename (extra confirmation it's
117
+ // OUR spawn-agent and not an unrelated process that happens to mention
118
+ // the path)
119
+ // (c) ageMs > agentTimeoutMs * 2 (the worktree was definitely abandoned by
120
+ // its dispatch; agentTimeout default is 5h, so 10h)
121
+ //
122
+ // The orphan-sweep code path already guarantees no live dispatch row claims
123
+ // the path — we'd never be here otherwise — so the "no live dispatch" check
124
+ // is implicit, not duplicated here.
125
+ function _isSafeReapHolder(holder, basename, minAgeMs) {
126
+ if (!holder || !holder.pid || !holder.cmdline) return false;
127
+ const cmd = String(holder.cmdline);
128
+ if (!/spawn-agent\.js/i.test(cmd)) return false;
129
+ if (basename && !cmd.includes(basename)) return false;
130
+ const age = Number(holder.ageMs) || 0;
131
+ if (age <= 0) return false; // unknown age — refuse to kill
132
+ if (age < minAgeMs) return false;
133
+ return true;
134
+ }
135
+
94
136
  function _maybeEscalateStuck(resolvedPath, errorMsg, opts = {}) {
95
137
  const cfgEng = (opts.config && opts.config.engine) || {};
96
138
  const threshold = cfgEng.worktreeStuckThreshold || shared.ENGINE_DEFAULTS.worktreeStuckThreshold || 10;
@@ -100,7 +142,7 @@ function _maybeEscalateStuck(resolvedPath, errorMsg, opts = {}) {
100
142
  rec.failures++;
101
143
  rec.lastAttempt = now;
102
144
  rec.lastError = errorMsg || rec.lastError || 'unknown';
103
- if (rec.failures < threshold) return { escalated: false, alreadyEscalated: false };
145
+ if (rec.failures < threshold) return { escalated: false, alreadyEscalated: false, holders: [], reapedPids: [] };
104
146
 
105
147
  const alreadyEscalated = rec.escalatedAt > 0;
106
148
  if (!alreadyEscalated) {
@@ -112,15 +154,57 @@ function _maybeEscalateStuck(resolvedPath, errorMsg, opts = {}) {
112
154
  rec.suppressedUntil = now + suppressMs;
113
155
  }
114
156
 
157
+ // W-mq6f2fe0000557fa — orphan-sweep enrichment + opt-in auto-reap. Scans
158
+ // and reap-attempts run on EVERY escalated tick (no per-day dedup) so that
159
+ // a fresh holder appearing after the first escalation still gets killed.
160
+ // The inbox NOTE is still dedup'd per UTC day below.
161
+ let holders = [];
162
+ let reapedPids = [];
163
+ const isOrphanSweep = opts.reason === 'orphan-sweep';
164
+ if (isOrphanSweep) {
165
+ try {
166
+ const findHoldersFn = typeof opts.findProcessesWithCwdInside === 'function'
167
+ ? opts.findProcessesWithCwdInside
168
+ : shared.findProcessesWithCwdInside;
169
+ const scanTimeoutMs = cfgEng.orphanHolderScanTimeoutMs
170
+ ?? shared.ENGINE_DEFAULTS.orphanHolderScanTimeoutMs
171
+ ?? 5000;
172
+ holders = findHoldersFn(resolvedPath, { timeoutMs: scanTimeoutMs }) || [];
173
+ } catch { holders = []; }
174
+
175
+ if (cfgEng.autoReapOrphanWorktreeHolders === true && holders.length > 0) {
176
+ const basename = path.basename(resolvedPath);
177
+ const agentTimeoutMs = Number(cfgEng.agentTimeout)
178
+ || Number(shared.ENGINE_DEFAULTS.agentTimeout)
179
+ || (5 * 60 * 60 * 1000);
180
+ const minAgeMs = agentTimeoutMs * 2;
181
+ const killFn = typeof opts.killImmediate === 'function'
182
+ ? opts.killImmediate
183
+ : shared.killImmediate;
184
+ for (const h of holders) {
185
+ if (!_isSafeReapHolder(h, basename, minAgeMs)) continue;
186
+ try {
187
+ killFn({ pid: h.pid });
188
+ reapedPids.push(h.pid);
189
+ } catch { /* fail-open — keep trying other holders */ }
190
+ }
191
+ if (reapedPids.length > 0) {
192
+ try { shared.bumpWorktreeGcMetric('orphanHoldersReaped'); } catch { /* optional */ }
193
+ }
194
+ }
195
+ }
196
+
115
197
  // Dedup inbox note per UTC day.
116
198
  const today = new Date(now).toISOString().slice(0, 10);
117
- if (rec.lastNoteDate === today) return { escalated: !alreadyEscalated, alreadyEscalated };
199
+ if (rec.lastNoteDate === today) {
200
+ return { escalated: !alreadyEscalated, alreadyEscalated, holders, reapedPids };
201
+ }
118
202
  rec.lastNoteDate = today;
119
203
 
120
204
  try {
121
205
  const writer = typeof opts.writeToInbox === 'function' ? opts.writeToInbox : shared.writeToInbox;
122
206
  const basename = path.basename(resolvedPath);
123
- const body = [
207
+ const bodyParts = [
124
208
  `# Worktree stuck: ${basename}`,
125
209
  '',
126
210
  `Worktree dir **${resolvedPath}** has failed removal ${rec.failures} consecutive times.`,
@@ -153,16 +237,116 @@ function _maybeEscalateStuck(resolvedPath, errorMsg, opts = {}) {
153
237
  '',
154
238
  `Per-tick warn spam is suppressed for the next`,
155
239
  `\`ENGINE_DEFAULTS.worktreeStuckSuppressMs\` (default 60min).`,
156
- ].join('\n');
157
- writer('engine', `worktree-stuck-${basename}`, body);
240
+ ];
241
+
242
+ // W-mq6f2fe0000557fa — append the holder section for orphan-sweep callers.
243
+ if (isOrphanSweep) {
244
+ bodyParts.push('', '## Live holders');
245
+ bodyParts.push('');
246
+ if (holders.length === 0) {
247
+ bodyParts.push(
248
+ '_No holder process found via cwd scan._',
249
+ '',
250
+ 'If the dir is still stuck, the holder may be a non-process resource (',
251
+ 'antivirus scan-in-progress, OneDrive sync, indexer); try again after',
252
+ '~60s, or run `handle.exe` interactively for a deeper search.'
253
+ );
254
+ } else {
255
+ for (const h of holders) {
256
+ const tail = String(h.cmdline || '').replace(/\r?\n/g, ' ').slice(0, 300);
257
+ const ageStr = h.ageMs ? `${Math.floor(h.ageMs / 1000)}s` : 'unknown';
258
+ bodyParts.push(`- **PID ${h.pid}** (age: ${ageStr})`);
259
+ bodyParts.push(' ```');
260
+ bodyParts.push(` ${tail}`);
261
+ bodyParts.push(' ```');
262
+ }
263
+ bodyParts.push('');
264
+ if (cfgEng.autoReapOrphanWorktreeHolders === true) {
265
+ if (reapedPids.length > 0) {
266
+ bodyParts.push(
267
+ `## Auto-reap`,
268
+ '',
269
+ `Engine auto-killed ${reapedPids.length} unambiguous orphan`,
270
+ `spawn-agent process${reapedPids.length === 1 ? '' : 'es'}: PID${reapedPids.length === 1 ? '' : 's'} ${reapedPids.join(', ')}.`,
271
+ `If the kill released the cwd lock, the next sweep tick will`,
272
+ `successfully remove the worktree and emit a`,
273
+ `\`worktree-recovered-${basename}-via-holder-reap\` recovery note.`
274
+ );
275
+ } else {
276
+ bodyParts.push(
277
+ `## Auto-reap`,
278
+ '',
279
+ `\`engine.autoReapOrphanWorktreeHolders\` is ON but no holder`,
280
+ `qualified for auto-kill (cmdline must match \`spawn-agent.js\`,`,
281
+ `reference the worktree basename, and process age must exceed`,
282
+ `\`engine.agentTimeout * 2\`). Manual intervention may be needed.`
283
+ );
284
+ }
285
+ } else {
286
+ bodyParts.push(
287
+ `_To auto-kill orphan spawn-agent processes holding this worktree,_`,
288
+ `_flip \`engine.autoReapOrphanWorktreeHolders\` ON in dashboard Settings._`
289
+ );
290
+ }
291
+ }
292
+ }
293
+
294
+ writer('engine', `worktree-stuck-${basename}`, bodyParts.join('\n'));
158
295
  } catch { /* note best-effort */ }
159
296
 
160
- return { escalated: !alreadyEscalated, alreadyEscalated };
297
+ return { escalated: !alreadyEscalated, alreadyEscalated, holders, reapedPids };
161
298
  }
162
299
 
163
300
  // Test-only reset hook. Production code never calls this.
164
301
  function _resetStuckPathsForTesting() { _stuckPaths.clear(); }
165
302
 
303
+ // W-mq6f2fe0000557fa — post-auto-reap retry. Called only when
304
+ // `_maybeEscalateStuck` reports `reapedPids.length > 0`. Clears the per-path
305
+ // failure cooldown (otherwise the post-3-strike suppression in
306
+ // `shared.removeWorktree` silently skips the retry), waits briefly for the
307
+ // OS to release the killed processes' file handles, then attempts ONE more
308
+ // removal. On success, writes the recovered-via-holder-reap note via
309
+ // `_markStuckSuccess` with the reaped-pid attribution.
310
+ function _postReapRetry(wtPath, gitRoot, parentDir, resolvedPath, _removeWorktree, opts, projStats, result, log, reapedPids) {
311
+ try { shared.clearWorktreeFailureCache(resolvedPath); } catch { /* optional */ }
312
+ // Brief settle window for OS to release file handles from killed processes.
313
+ try {
314
+ const sleepFn = typeof opts.sleepSyncFn === 'function'
315
+ ? opts.sleepSyncFn
316
+ : (ms) => {
317
+ try {
318
+ require('child_process').execFileSync(process.execPath, ['-e', `setTimeout(()=>process.exit(0), ${Number(ms) || 0})`], { stdio: 'ignore' });
319
+ } catch { /* timeout / spawn fail — proceed anyway */ }
320
+ };
321
+ sleepFn(2000);
322
+ } catch { /* sleep failures should not block the retry */ }
323
+
324
+ try {
325
+ const removed = _removeWorktree(wtPath, gitRoot, parentDir);
326
+ if (removed) {
327
+ // Down-count the failure we recorded just before; this dispatch
328
+ // ultimately succeeded after the auto-reap.
329
+ if (projStats) { projStats.failed = Math.max(0, projStats.failed - 1); projStats.evicted++; }
330
+ if (result) { result.failed = Math.max(0, result.failed - 1); result.evicted++; }
331
+ _markStuckSuccess(resolvedPath, {
332
+ writeToInbox: opts.writeToInbox,
333
+ recoveryReason: 'holder-reap',
334
+ reapedPids,
335
+ });
336
+ try { shared.bumpWorktreeGcMetric('recoveredViaHolderReap'); } catch { /* optional */ }
337
+ if (typeof log === 'function') {
338
+ log('info', `worktree-gc: removed ${wtPath} after auto-reaping holder(s) ${reapedPids.join(', ')}`);
339
+ }
340
+ return true;
341
+ }
342
+ } catch (retryErr) {
343
+ if (typeof log === 'function') {
344
+ log('warn', `worktree-gc: post-reap retry threw for ${wtPath}: ${retryErr && retryErr.message}`);
345
+ }
346
+ }
347
+ return false;
348
+ }
349
+
166
350
  /**
167
351
  * Decide whether a dispatch-end worktree should be GC'd.
168
352
  *
@@ -494,19 +678,39 @@ function pruneOrphanWorktrees(opts) {
494
678
  log('info', `worktree-gc: boot-evicted orphan ${name} for project ${project.name || 'default'}`);
495
679
  } else {
496
680
  projStats.failed++; result.failed++;
497
- const { alreadyEscalated } = _maybeEscalateStuck(wtResolved, 'remove returned false', { config: opts.config, writeToInbox: opts.writeToInbox });
498
- if (!alreadyEscalated && !suppressed) {
681
+ const escResult = _maybeEscalateStuck(wtResolved, 'remove returned false', {
682
+ config: opts.config, writeToInbox: opts.writeToInbox,
683
+ reason: 'orphan-sweep',
684
+ findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
685
+ killImmediate: opts.killImmediate,
686
+ });
687
+ if (!escResult.alreadyEscalated && !suppressed) {
499
688
  log('warn', `worktree-gc: boot-evict returned false for ${wtPath}`);
500
689
  }
690
+ // W-mq6f2fe0000557fa — if auto-reap killed any holders, retry the
691
+ // removal once after a brief settle window. Clear the failure
692
+ // cache entry so the post-reap retry isn't suppressed by the
693
+ // 3-strike cooldown.
694
+ if (escResult.reapedPids && escResult.reapedPids.length > 0) {
695
+ _postReapRetry(wtPath, rootDir, wtParent, wtResolved, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
696
+ }
501
697
  }
502
698
  } catch (rmErr) {
503
699
  projStats.failed++; result.failed++;
504
700
  const wtResolved = path.resolve(wtPath);
505
701
  const suppressed = _isStuckPathSuppressed(wtResolved);
506
- const { alreadyEscalated } = _maybeEscalateStuck(wtResolved, rmErr && rmErr.message, { config: opts.config, writeToInbox: opts.writeToInbox });
507
- if (!alreadyEscalated && !suppressed) {
702
+ const escResult = _maybeEscalateStuck(wtResolved, rmErr && rmErr.message, {
703
+ config: opts.config, writeToInbox: opts.writeToInbox,
704
+ reason: 'orphan-sweep',
705
+ findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
706
+ killImmediate: opts.killImmediate,
707
+ });
708
+ if (!escResult.alreadyEscalated && !suppressed) {
508
709
  log('warn', `worktree-gc: boot-evict threw for ${wtPath}: ${rmErr.message}`);
509
710
  }
711
+ if (escResult.reapedPids && escResult.reapedPids.length > 0) {
712
+ _postReapRetry(wtPath, rootDir, wtParent, wtResolved, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
713
+ }
510
714
  }
511
715
  }
512
716
  result.perProject[project.name || rootDir] = projStats;
@@ -693,17 +897,33 @@ function pruneOrphanWorktreesFromGitRegistry(opts) {
693
897
  log('info', `worktree-gc: out-of-root evicted ${wtAbs} for project ${project.name || 'default'}`);
694
898
  } else {
695
899
  projStats.failed++; result.failed++;
696
- const { alreadyEscalated } = _maybeEscalateStuck(wtAbs, 'remove returned false', { config: opts.config, writeToInbox: opts.writeToInbox });
697
- if (!alreadyEscalated && !suppressed) {
900
+ const escResult = _maybeEscalateStuck(wtAbs, 'remove returned false', {
901
+ config: opts.config, writeToInbox: opts.writeToInbox,
902
+ reason: 'orphan-sweep',
903
+ findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
904
+ killImmediate: opts.killImmediate,
905
+ });
906
+ if (!escResult.alreadyEscalated && !suppressed) {
698
907
  log('warn', `worktree-gc: out-of-root remove returned false for ${wtAbs}`);
699
908
  }
909
+ if (escResult.reapedPids && escResult.reapedPids.length > 0) {
910
+ _postReapRetry(wtAbs, rootDir, parentDir, wtAbs, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
911
+ }
700
912
  }
701
913
  } catch (rmErr) {
702
914
  projStats.failed++; result.failed++;
703
- const { alreadyEscalated } = _maybeEscalateStuck(wtAbs, rmErr && rmErr.message, { config: opts.config, writeToInbox: opts.writeToInbox });
704
- if (!alreadyEscalated && !suppressed) {
915
+ const escResult = _maybeEscalateStuck(wtAbs, rmErr && rmErr.message, {
916
+ config: opts.config, writeToInbox: opts.writeToInbox,
917
+ reason: 'orphan-sweep',
918
+ findProcessesWithCwdInside: opts.findProcessesWithCwdInside,
919
+ killImmediate: opts.killImmediate,
920
+ });
921
+ if (!escResult.alreadyEscalated && !suppressed) {
705
922
  log('warn', `worktree-gc: out-of-root remove threw for ${wtAbs}: ${rmErr.message}`);
706
923
  }
924
+ if (escResult.reapedPids && escResult.reapedPids.length > 0) {
925
+ _postReapRetry(wtAbs, rootDir, parentDir, wtAbs, _removeWorktree, opts, projStats, result, log, escResult.reapedPids);
926
+ }
707
927
  }
708
928
  }
709
929