@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.
- package/bin/minions.js +99 -6
- package/dashboard/docs/typography-audit.md +128 -0
- package/dashboard/docs/typography.md +114 -0
- package/dashboard/js/render-agents.js +1 -1
- package/dashboard/js/render-work-items.js +21 -13
- package/dashboard/js/utils.js +1 -1
- package/dashboard/slim/body.html +2 -2
- package/dashboard/slim/styles.css +112 -77
- package/dashboard/styles.css +37 -3
- package/dashboard.js +67 -10
- package/docs/README.md +1 -1
- package/docs/auto-discovery.md +2 -1
- package/docs/branch-derivation.md +68 -0
- package/docs/cooldown-merge-semantics.md +4 -4
- package/docs/design-state-storage.md +5 -5
- package/docs/kb-sweep.md +2 -2
- package/docs/managed-spawn.md +1 -1
- package/docs/timeouts-and-liveness.md +120 -0
- package/docs/watches.md +9 -9
- package/docs/worktree-lifecycle.md +164 -0
- package/engine/cli.js +8 -5
- package/engine/dispatch.js +26 -1
- package/package.json +6 -1
|
@@ -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.
|
|
167
|
-
//
|
|
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(
|
|
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(
|
|
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
|
|
package/engine/dispatch.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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",
|