@yemi33/minions 0.1.2149 → 0.1.2151

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.
@@ -0,0 +1,164 @@
1
+ # Worktree Lifecycle
2
+
3
+ Deep-dive for the four engine pieces that own git-worktree state for dispatch:
4
+ the **pool** (recycling), the **live guard** (don't wipe an agent's work),
5
+ the **quarantine path** (dirty/divergent → quarantine dir + retry), and the
6
+ **Windows file-lock retry** (EPERM/EBUSY footgun). CLAUDE.md → Worktree
7
+ Lifecycle keeps the cross-cutting invariants; the detail lives here.
8
+
9
+ > Source of truth: `engine/worktree-pool.js`, `engine/shared.js#removeWorktree`
10
+ > + `_retryFsOp`, `engine.js` (`_quarantineDirtyWorktree`, `_renameWithRetry`,
11
+ > `_killGitDescendantsForWorktree`, `pruneOrphanWorktrees*`, `gcDispatchWorktreeIfOrphan`),
12
+ > `engine/cleanup.js`. Last verified: 2026-06-09.
13
+
14
+ ## Live-worktree guard (W-mq5rwwss000f30a7)
15
+
16
+ **Invariant:** every code path that wipes, renames, or recycles a worktree
17
+ MUST call `shared.isWorktreePathLive(path, { db?, excludeDispatchId? })`
18
+ first and skip on `true`. Originally added after W-mq5n1zx5000hcfb5 — an
19
+ agent's worktree was wiped 4× consecutively by the reaper while the
20
+ dispatch was still active.
21
+
22
+ - **Backed by SQL** against `dispatches` (status IN ('pending','active')),
23
+ reading `json_extract(data, '$.worktreePath')` with a `data.meta.worktreePath`
24
+ fallback. The pending → active transition persists `item.worktreePath`
25
+ on the dispatch row so the guard has a path to correlate.
26
+ - **Fails OPEN** (returns `true`) when SQLite is unreachable or the query
27
+ throws — better to leak a worktree than nuke an agent's unpushed work.
28
+ - **Wired sites:** `shared.removeWorktree` (accepts `{ excludeDispatchId }`
29
+ forwarded to the guard); pool-return chain in `engine.js`
30
+ (`excludeDispatchId: id` so the dispatch can clean up its own worktree);
31
+ dispatch-end orphan GC (`gcDispatchWorktreeIfOrphan`); `_quarantineDirtyWorktree`
32
+ (returns `{ skipped: true, quarantinedPath: null }` on skip — callers
33
+ MUST honor `skipped` and not set `quarantined: true`); `engine/cleanup.js`
34
+ orphan-dir sweep.
35
+ - **On skip:** drops a deduped operator note at
36
+ `notes/inbox/engine-worktree-skip-live-<basename>-<date>.md`.
37
+
38
+ ## Worktree pool (opt-in)
39
+
40
+ `ENGINE_DEFAULTS.worktreePoolSize > 0` enables `engine/worktree-pool.js` to
41
+ recycle worktree dirs across branches.
42
+
43
+ - **Borrow** in `spawnAgent` only when (a) the new branch doesn't exist on
44
+ origin AND (b) the dispatch is not shared-branch / `useExistingBranch`.
45
+ - **Return** in `onAgentClose` BEFORE `completeDispatch`:
46
+ `git reset --hard HEAD` → `git clean -fd` → `git fetch origin <main>`
47
+ → `git checkout --detach origin/<main>` → mark IDLE.
48
+ - **State** at `engine/worktree-pool.json`; git ops outside any lock.
49
+
50
+ ## Quarantine path (dirty / divergent)
51
+
52
+ When `discoverFromWorkItems` finds a worktree in a `WORKTREE_DIRTY`,
53
+ `WORKTREE_DIVERGENT`, or post-stuck state, `_quarantineDirtyWorktree`
54
+ moves it to `<root>/.quarantined/<basename>-<utc>` and lets the next tick
55
+ recreate a clean worktree. Six layered defenses cover the Windows EBUSY
56
+ race when git status descendants still hold packfile handles
57
+ (W-mq5n1zx5000hcfb5; PR #3156).
58
+
59
+ ### Layer 1b — `--no-optional-locks` on every status probe (load-bearing)
60
+
61
+ `_statusPorcelainCmd()` emits `git --no-optional-locks status …`. Skips
62
+ the `.git/index.lock` acquire around the untracked-cache refresh inside
63
+ `status`. Typical probe duration under AV scanning drops from 6–12s to
64
+ <500ms, which removes the timeout that leaks the child in the first
65
+ place. **Most incidents are closed by 1b alone.**
66
+ Toggle: `ENGINE_DEFAULTS.statusProbeUseNoOptionalLocks` (default `true`).
67
+
68
+ ### Layer 1a — `_renameWithRetry` with jittered backoff
69
+
70
+ 6 attempts × 250 ms base × 2^N exponential + 200 ms random jitter
71
+ (~16 s worst-case). Only retries on `EBUSY|EPERM|EACCES|ENOTEMPTY`;
72
+ rethrows other codes immediately. Toggles:
73
+ `ENGINE_DEFAULTS.quarantineRenameRetryAttempts` (6),
74
+ `quarantineRenameRetryBaseMs` (250).
75
+
76
+ ### Layer 2a — `_killGitDescendantsForWorktree` (Windows-only)
77
+
78
+ PowerShell sweep, 2 s timeout. Shell-out built via single-quoted literal
79
+ (`'` escaped as `''`) so worktree-path interpolation is injection-safe.
80
+ POSIX no-op. Best-effort — failure logged, never throws.
81
+ Toggle: `ENGINE_DEFAULTS.statusProbeKillDescendantsWin32` (default `true`).
82
+
83
+ ### Layer 2b — `git worktree remove --force` fallback
84
+
85
+ Triggers only when 1a exhausts retries; destroys worktree contents (no
86
+ quarantine dir preserved). Toggle:
87
+ `ENGINE_DEFAULTS.quarantineForceRemoveFallback` (default `true`).
88
+
89
+ ### Layer 1c — `FAILURE_CLASS.WORKTREE_QUARANTINE_ENV_BLOCKED`
90
+
91
+ Routed when `cleanResult.quarantineError && !cleanResult.quarantined`.
92
+ Added to `dispatch.js#isRetryableFailureReason` never-retry set so the
93
+ per-agent retry counter doesn't bump (env failure, not the agent's fault).
94
+ The auto-recovery loop in `engine.js#discoverFromWorkItems` recognizes
95
+ the new class via both enum check and regex on legacy `failReason`
96
+ strings, then re-queues under the existing
97
+ `ENGINE_DEFAULTS.quarantineAutoRecoveryMax` (default 2) cap.
98
+
99
+ ### Layer 3a/3b — metrics + inbox alert
100
+
101
+ Counters at
102
+ `metrics._engine.worktreeQuarantineOutcomes.{attempts, success, successAfterRetry, fallbackForceRemove, totalFailure}`.
103
+ Total-failure path writes a structured inbox alert with the exact
104
+ PowerShell/POSIX recovery commands and `git worktree prune` instructions.
105
+
106
+ ### Auto-recovery cap
107
+
108
+ `discoverFromWorkItems` auto-recovers `WORKTREE_DIRTY`/`WORKTREE_DIVERGENT`
109
+ quarantine (#2996) up to `ENGINE_DEFAULTS.quarantineAutoRecoveryMax`
110
+ (default 2, tracked on `_quarantineRecoveryCount`); after the cap,
111
+ `_quarantineRecoveryGaveUp` triggers a warn-once.
112
+
113
+ ## Windows file-lock on removal (footgun #6 detail)
114
+
115
+ `fs.rmSync` against an active git worktree can lose to lingering file
116
+ handles (CC log streams, virus scanners, Explorer previews).
117
+ `shared.removeWorktree` retries via `shared._retryFsOp`
118
+ (`worktreeRemoveRetryAttempts` × exponential `worktreeRemoveRetryBaseMs`,
119
+ codes `EPERM|EBUSY|EACCES|ENOTEMPTY`).
120
+
121
+ After `worktreeStuckThreshold` consecutive failures the path is
122
+ **escalated**:
123
+
124
+ - A `notes/inbox/engine-worktree-stuck-<basename>-<date>.md` note with
125
+ Windows holder-identification hints is written (deduped per UTC day).
126
+ - Per-tick warns are suppressed for `worktreeStuckSuppressMs`.
127
+ - The retry cadence drops to `worktreeStuckSlowRetryMs`.
128
+ - When the holder finally releases, a
129
+ `worktree-recovered-<basename>` note clears the alert.
130
+
131
+ **Don't fight this with `fs.rmSync({force:true})` outside
132
+ `removeWorktree`** — you'll bypass the retry, escalation, and metrics
133
+ layers.
134
+
135
+ ## Periodic prune (W-mq5o6bvy000x7191)
136
+
137
+ `pruneWorktreesPeriodic` runs `pruneOrphanWorktrees` (in-root) +
138
+ `pruneOrphanWorktreesFromGitRegistry` (out-of-root via
139
+ `git worktree list --porcelain`) per project at
140
+ `ENGINE_DEFAULTS.worktreePruneIntervalTicks` cadence — catches Windows
141
+ EPERM/EBUSY stragglers that the dispatch-end GC couldn't reap and sweeps
142
+ the `git worktree list` registry for OUT-of-root entries the in-root
143
+ scanner is blind to.
144
+
145
+ ## Holder identification + opt-in auto-reap (W-mq6f2fe0000557fa)
146
+
147
+ Orphan-sweep escalations also run `shared.findProcessesWithCwdInside(wt)`
148
+ (cross-platform: PowerShell `Get-CimInstance Win32_Process` on Windows;
149
+ `/proc/*/cwd` walk on Linux; `lsof -d cwd` + `ps` on macOS) and append a
150
+ `## Live holders` section listing pid / cmdline / age to the
151
+ `engine-worktree-stuck-<basename>` escalation note.
152
+
153
+ Setting `engine.autoReapOrphanWorktreeHolders: true`
154
+ (Settings → Worker Pool & Worktrees → "Auto-reap orphan worktree holders")
155
+ additionally kills any holder whose `cmdline` matches `spawn-agent.js`
156
+ AND references the worktree basename AND whose age exceeds
157
+ `engine.agentTimeout * 2`, then retries `removeWorktree` once. Default
158
+ **OFF** — killing a foreign process is destructive.
159
+
160
+ - Scan timeout: `engine.orphanHolderScanTimeoutMs` (default 5000ms,
161
+ clamped 1000–30000).
162
+ - Motivating incident: W-mq1k8z6o003acd89 / stuck `spawn-agent.js`
163
+ PID 16828 that the periodic prune couldn't shake loose without manual
164
+ intervention.
package/engine/cli.js CHANGED
@@ -163,8 +163,9 @@ function handleCommand(cmd, args) {
163
163
  //
164
164
  // `minions work --help` used to create ghost work items with title='--help'
165
165
  // because the bare-string `title` was truthy and bypassed the `!title`
166
- // usage check. Same class of bug exists in `spawn`/`plan`/`complete` —
167
- // every command that takes a positional arg and tests it with `if (!arg)`.
166
+ // usage check. The fix lives in per-command guards (`_isHelpArg` /
167
+ // `looksLikeFlagOrHelp`) on `work`/`spawn`/`plan`/`complete`, which print
168
+ // command-specific `Usage:` output. `pr` and `bridge` handle help inline.
168
169
  //
169
170
  // Intercept here so a single guard covers the whole command set. `pr` and
170
171
  // `bridge` already handle `help`/`--help`/`-h` inline (see their own
@@ -1303,15 +1304,17 @@ const commands = {
1303
1304
  console.log(`Engine: ${control.state} (PID ${control.pid || 'N/A'})`);
1304
1305
  }
1305
1306
 
1306
- // Dashboard check
1307
+ // Dashboard check. Honors MINIONS_PORT so `minions --dev status` probes the
1308
+ // dev dashboard (7332 by default) rather than the binary install's 7331.
1307
1309
  const http = require('http');
1310
+ const dashPort = Number(process.env.MINIONS_PORT) || 7331;
1308
1311
  const dashCheck = new Promise(resolve => {
1309
- const req = http.get('http://localhost:7331/api/health', { timeout: 2000 }, () => resolve(true));
1312
+ const req = http.get(`http://localhost:${dashPort}/api/health`, { timeout: 2000 }, () => resolve(true));
1310
1313
  req.on('error', () => resolve(false));
1311
1314
  req.on('timeout', () => { req.destroy(); resolve(false); });
1312
1315
  });
1313
1316
  dashCheck.then(dashUp => {
1314
- if (dashUp) console.log('Dashboard: running (http://localhost:7331)');
1317
+ if (dashUp) console.log(`Dashboard: running (http://localhost:${dashPort})`);
1315
1318
  else console.log('Dashboard: not running — start with: minions dash');
1316
1319
  }).catch(() => {});
1317
1320
 
@@ -18,6 +18,18 @@ const { getConfig, INBOX_DIR } = queries;
18
18
 
19
19
  const MINIONS_DIR = shared.MINIONS_DIR;
20
20
 
21
+ // Dispatch types that do not push code to a PR branch. These remain dispatchable
22
+ // against `_contextOnly: true` PRs because casting a review vote, posting a
23
+ // comment, asking a question, or running a read-only explore never mutates the
24
+ // PR's source branch. Auto-discovery (engine.js#discoverFromPrs) is still
25
+ // gated on `_contextOnly` separately — only explicit WIs (dashboard, watches,
26
+ // CC) can target a context-only PR, and only via these types.
27
+ const NON_MUTATING_DISPATCH_TYPES = new Set([
28
+ WORK_TYPE.REVIEW,
29
+ WORK_TYPE.ASK,
30
+ WORK_TYPE.EXPLORE,
31
+ ]);
32
+
21
33
  // Lazy require to break circular dependency with engine.js
22
34
  let _lifecycle = null;
23
35
  function lifecycle() { if (!_lifecycle) _lifecycle = require('./lifecycle'); return _lifecycle; }
@@ -358,7 +370,19 @@ function getStalePrDispatchReason(entry, config) {
358
370
  const prLabel = entry.meta.pr?.id || entry.meta.pr?.url || entry.id;
359
371
  if (!tracked) return `PR ${prLabel} is no longer tracked`;
360
372
  if (tracked.status !== PR_STATUS.ACTIVE) return `PR ${tracked.id || prLabel} is ${tracked.status || 'missing status'}`;
361
- if (tracked._contextOnly && entry.meta?.source !== 'work-item') return `PR ${tracked.id || prLabel} is context-only`;
373
+ // Combined gate (W-mq5v612m001g1545 + #3126):
374
+ // 1. `entry.meta?.source !== 'work-item'` — blocks any non-work-item dispatch
375
+ // (e.g. legacy `pr`/`pr-human-feedback` discovery paths) from running
376
+ // against a context-only PR. Auto-discovery in engine.js#discoverFromPrs
377
+ // already skips _contextOnly PRs at the source, so this is defense-in-depth.
378
+ // 2. `!NON_MUTATING_DISPATCH_TYPES.has(entry.type)` — even an explicit
379
+ // work-item dispatch is dropped if it's a mutating type (fix, implement,
380
+ // test, verify, decompose, docs). Only review/ask/explore — which never
381
+ // push to the PR's source branch — are allowed through.
382
+ if (tracked._contextOnly
383
+ && (entry.meta?.source !== 'work-item' || !NON_MUTATING_DISPATCH_TYPES.has(entry.type))) {
384
+ return `PR ${tracked.id || prLabel} is context-only`;
385
+ }
362
386
 
363
387
  const queuedBranch = entry.meta.branch || entry.meta.pr?.branch || '';
364
388
  const trackedBranch = tracked.branch || '';
@@ -1048,4 +1072,5 @@ module.exports = {
1048
1072
  findActivePrOrBranchLock,
1049
1073
  normalizeRetryableDecision,
1050
1074
  isCompletedWorkItemForFailure,
1075
+ NON_MUTATING_DISPATCH_TYPES,
1051
1076
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2149",
3
+ "version": "0.1.2151",
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"
@@ -9,6 +9,11 @@
9
9
  "package:check": "node tools/check-package-boundary.js public",
10
10
  "package:prepare:public": "node tools/prepare-package.js public",
11
11
  "package:prepare:internal": "node tools/prepare-package.js internal",
12
+ "dev": "node bin/minions.js --dev restart",
13
+ "dev:start": "node bin/minions.js --dev start",
14
+ "dev:stop": "node bin/minions.js --dev stop",
15
+ "dev:dash": "node bin/minions.js --dev dash",
16
+ "dev:status": "node bin/minions.js --dev status",
12
17
  "test": "node test/run-parallel.js",
13
18
  "test:sequential": "node test/unit.test.js",
14
19
  "test:unit": "node test/run-parallel.js",