@yemi33/minions 0.1.2142 → 0.1.2143

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.
@@ -109,5 +109,60 @@
109
109
  "removalGate": "Two conditions must hold simultaneously: (a) grep `engine/ado.js` for `isAdoThrottled\\s*\\(\\s*\\)` and confirm zero arg-less call sites remain across the engine — every caller passes a concrete `orgBase` resolved via `shared.getAdoOrgBase(project)`; (b) `GET /api/diagnostics/ado-throttle` on a live engine has been observed for >=2 consecutive weeks reporting per-org keys (proves the per-org Map is populated under load and the global-OR isn't masking a regression). Once both hold, removal deletes the arg-less branch in isAdoThrottled and the global-OR fold; callers that still pass no argument become an immediate, surfaced bug rather than a silent over-throttle.",
110
110
  "targetRemovalDate": "2026-08-03",
111
111
  "notes": "Introduced by W-mq03l6zh0006f0a1 (Per-org ADO throttle isolation). 60-day window (2 release cycles + buffer) gives the in-flight per-org migration time to land + observe per-org keys on the diagnostics endpoint. Observable live at GET /api/diagnostics/ado-throttle — the endpoint reports a single `global` key while the arg-less shim is still load-bearing, and per-org `<orgBase>` keys once isolation is complete; that key shape is the human-readable signal for whether this shim can retire."
112
+ },
113
+ {
114
+ "id": "pr-record-contextOnly-underscore-alias",
115
+ "description": "Legacy `pr._contextOnly` field on PR records (engine/<scope>/pull-requests.json + SQL). Renamed to canonical `pr.contextOnly` by the W-mq5s5ttx000j7ab8 PR-gate consolidation (the foundation sub-WI adds canonical `contextOnly` + a one-shot boot migration that copies `_contextOnly` → `contextOnly` for existing records). The underscore-prefixed alias is preserved on writes for one release so any out-of-process readers (dashboards, ops scripts) that still inspect `_contextOnly` continue to work.",
116
+ "code": [
117
+ { "file": "engine/shared.js", "note": "isAutoManagedPrRecord + normalizePrRecords write both `contextOnly` and `_contextOnly` until removal." },
118
+ { "file": "dashboard.js", "note": "GET /api/pull-requests responses include `_contextOnly` alias; POST /api/pull-requests/observe writes both fields until removal." },
119
+ { "file": "engine.js", "note": "discoverFromPrs reads `pr.contextOnly` only — `_contextOnly` is write-only legacy alias post-consolidation." }
120
+ ],
121
+ "deprecated": "2026-06-08",
122
+ "targetRemovalDate": "2026-06-16",
123
+ "notes": "Cleanup-date is 7 days from PR merge per the W-mq5s5ttx000j7ab8 docs sub-WI. Pair this entry's removal with `pr-record-autoObserve-underscore-alias`, `pr-record-manual-underscore-alias`, and `pr-link-autoObserve-body-param` — they share the same boot migration + dual-write window. Removal scope: delete the `_contextOnly` write site in `normalizePrRecords` (engine/shared.js), drop the `_contextOnly` field from `GET /api/pull-requests` shaping in dashboard.js, and remove the boot migration in engine/shared.js once telemetry confirms no record-with-`_contextOnly`-only on disk."
124
+ },
125
+ {
126
+ "id": "pr-record-autoObserve-underscore-alias",
127
+ "description": "Legacy `pr._autoObserve` boolean on PR records. Was one of 6 clauses in the old `isAutoManagedPrRecord` check; replaced by the canonical `pr.contextOnly` field (foreign-author PRs linked with `autoObserve: true` now land with `contextOnly: false` directly, so the dispatch gate no longer needs to inspect `_autoObserve`).",
128
+ "code": [
129
+ { "file": "engine/shared.js", "note": "isAutoManagedPrRecord no longer reads `_autoObserve`; normalizePrRecords still writes it for one release as a backward-compat alias." },
130
+ { "file": "dashboard.js", "note": "POST /api/pull-requests/link + POST /api/pull-requests/observe accept `contextOnly` as the canonical body param; `_autoObserve` is mirrored on the record for legacy readers." }
131
+ ],
132
+ "deprecated": "2026-06-08",
133
+ "targetRemovalDate": "2026-06-16",
134
+ "notes": "Cleanup-date is 7 days from PR merge per the W-mq5s5ttx000j7ab8 docs sub-WI. Removal is paired with `pr-record-contextOnly-underscore-alias` — same dual-write window, same boot migration. After removal, only `pr.contextOnly` exists on records; any external reader that still reads `_autoObserve` will get `undefined` and must migrate."
135
+ },
136
+ {
137
+ "id": "pr-record-manual-underscore-alias",
138
+ "description": "Legacy `pr._manual` boolean on PR records (set by `POST /api/pull-requests/link` to mark manually-linked PRs). Was the third clause in the old `isAutoManagedPrRecord` check (`pr._manual && !pr._contextOnly` → auto-managed). Post-consolidation, the dispatch gate reads only `pr.contextOnly`; the `_manual` provenance flag is preserved on records for dashboard display + audit but is no longer load-bearing for dispatch decisions.",
139
+ "code": [
140
+ { "file": "engine.js", "note": "discoverFromPrs no longer reads `_manual` post-consolidation (was at the inline `isAgentPr` check, now removed)." },
141
+ { "file": "engine/shared.js", "note": "isAutoManagedPrRecord no longer reads `_manual`; normalizePrRecords still writes it for one release as a provenance/UI alias." },
142
+ { "file": "dashboard.js", "note": "POST /api/pull-requests/link continues to set `_manual: true` for UI/audit; remove once dashboards stop displaying the badge." }
143
+ ],
144
+ "deprecated": "2026-06-08",
145
+ "targetRemovalDate": "2026-06-16",
146
+ "notes": "Disambiguation: this entry covers ONLY the PR-record `_manual` flag (engine/<scope>/pull-requests.json). Other `_manual`-named flags elsewhere in the engine (work items, dispatch records, etc.) are NOT covered by this entry and remain in active use. Cleanup-date is 7 days from PR merge per the W-mq5s5ttx000j7ab8 docs sub-WI; remove the write site in dashboard.js (POST /api/pull-requests/link record-build) and any remaining `_manual` reads in render-prs.js when dashboard UI no longer displays a 'manual link' badge."
147
+ },
148
+ {
149
+ "id": "pr-link-autoObserve-body-param",
150
+ "description": "Legacy `autoObserve` body parameter on `POST /api/pull-requests/link`. Replaced by canonical `contextOnly` body param (inverse boolean: `autoObserve: false` ⇔ `contextOnly: true`). Endpoint accepts both for one release: if `contextOnly` is present it wins; otherwise the handler maps `autoObserve` → `contextOnly = !autoObserve` and proceeds.",
151
+ "code": [
152
+ { "file": "dashboard.js", "note": "POST /api/pull-requests/link handler reads `body.contextOnly` first, then falls back to `!body.autoObserve` for backwards compat. Route registry params string still lists `autoObserve?` for one release." }
153
+ ],
154
+ "deprecated": "2026-06-08",
155
+ "targetRemovalDate": "2026-06-16",
156
+ "notes": "Cleanup-date is 7 days from PR merge per the W-mq5s5ttx000j7ab8 docs sub-WI. After removal, callers that still send `autoObserve` will see their value silently ignored (the record's `contextOnly` will fall back to whatever default the endpoint applies). Removal scope: drop the `body.autoObserve` fallback in the link handler in dashboard.js, drop `autoObserve?` from the route registry `params` string, and update any client (dashboard JS, ops scripts) that still POSTs `autoObserve`."
157
+ },
158
+ {
159
+ "id": "pr-observe-observe-body-param",
160
+ "description": "Legacy `observe` body parameter on `POST /api/pull-requests/observe`. The W-mq5s5ttx000j7ab8 endpoint sub-WI introduces canonical `contextOnly` as the inverse (`observe: false` ⇔ `contextOnly: true`) and keeps `observe` accepted for backward compat. Registering the deprecation here so the alias has a documented removal path; the WI explicitly notes this entry is the implementer's call (it is kept for backward compat and may live longer than the underscore-prefixed record fields).",
161
+ "code": [
162
+ { "file": "dashboard.js", "note": "POST /api/pull-requests/observe handler reads `body.contextOnly` first, then falls back to `!body.observe` for backwards compat." }
163
+ ],
164
+ "deprecated": "2026-06-08",
165
+ "targetRemovalDate": null,
166
+ "notes": "targetRemovalDate intentionally null — unlike the record-field aliases (`_contextOnly`, `_autoObserve`, `_manual`) which carry a 7-day clock, the `observe` body param is documented as a longer-lived back-compat alias. Set targetRemovalDate to a concrete future date once the dashboard UI + any client scripts are confirmed to POST `contextOnly` exclusively. Removal scope when the date is set: drop the `body.observe` fallback in dashboard.js, drop `observe` from the route registry params, and update any client still POSTing `observe`."
112
167
  }
113
168
  ]
package/engine/cleanup.js CHANGED
@@ -1519,10 +1519,64 @@ function scrubStaleMetrics() {
1519
1519
  });
1520
1520
  }
1521
1521
 
1522
+ // ─── Periodic worktree GC sweep (W-mq5o6bvy000x7191 Layer 2 + 3) ────────────
1523
+
1524
+ /**
1525
+ * Periodic per-tick worktree sweep. Aggregates the two pruners in
1526
+ * `engine/worktree-gc.js` so a long-lived engine catches orphans the
1527
+ * boot-only reconcile would miss:
1528
+ *
1529
+ * - `pruneOrphanWorktrees`: walks `<localPath>/<worktreeRoot>` and reaps
1530
+ * orphan W-* dirs with no matching dispatch.
1531
+ * - `pruneOrphanWorktreesFromGitRegistry`: walks `git worktree list` per
1532
+ * project and reaps registered worktrees outside the configured
1533
+ * `worktreeRoot` (covers `D:/tmp-*`, `D:/squad-worktrees/*`, etc.).
1534
+ *
1535
+ * Both pruners share the SAME `dispatchSnap` from `queries.getDispatch()`
1536
+ * so live work isn't yanked between scans. Returns aggregate stats for
1537
+ * tick-level logging.
1538
+ */
1539
+ function runPeriodicWorktreeSweep(config) {
1540
+ const worktreeGc = require('./worktree-gc');
1541
+ const projects = getProjects(config);
1542
+ if (projects.length === 0) {
1543
+ return { scanned: 0, kept: 0, evicted: 0, failed: 0, outOfRootEvicted: 0, prunedRegistry: 0 };
1544
+ }
1545
+ const dispatchSnap = getDispatch();
1546
+ const worktreeRootRel = config?.engine?.worktreeRoot || ENGINE_DEFAULTS.worktreeRoot;
1547
+ const _log = (lvl, msg) => log(lvl, msg);
1548
+
1549
+ let scanned = 0, kept = 0, evicted = 0, failed = 0, outOfRootEvicted = 0, prunedRegistry = 0;
1550
+ const _writeToInbox = (a, s, c) => { try { return shared.writeToInbox(a, s, c); } catch (_e) { return false; } };
1551
+ try {
1552
+ const r1 = worktreeGc.pruneOrphanWorktrees({
1553
+ projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
1554
+ });
1555
+ scanned += r1.scanned || 0;
1556
+ kept += r1.kept || 0;
1557
+ evicted += r1.evicted || 0;
1558
+ failed += r1.failed || 0;
1559
+ } catch (e) { log('warn', `worktree-gc periodic in-root: ${e.message}`); }
1560
+
1561
+ try {
1562
+ const r2 = worktreeGc.pruneOrphanWorktreesFromGitRegistry({
1563
+ projects, dispatchSnap, worktreeRootRel, log: _log, config, writeToInbox: _writeToInbox,
1564
+ });
1565
+ scanned += r2.scanned || 0;
1566
+ kept += r2.kept || 0;
1567
+ outOfRootEvicted += r2.evicted || 0;
1568
+ failed += r2.failed || 0;
1569
+ prunedRegistry += r2.prunedRegistry || 0;
1570
+ } catch (e) { log('warn', `worktree-gc periodic out-of-root: ${e.message}`); }
1571
+
1572
+ return { scanned, kept, evicted, failed, outOfRootEvicted, prunedRegistry };
1573
+ }
1574
+
1522
1575
  // ─── Exports ─────────────────────────────────────────────────────────────────
1523
1576
 
1524
1577
  module.exports = {
1525
1578
  runCleanup,
1579
+ runPeriodicWorktreeSweep, // W-mq5o6bvy000x7191 — exported for engine.js tick wiring + testing
1526
1580
  scrubStaleMetrics,
1527
1581
  sweepLeakedTestMeetings, // exported for testing
1528
1582
  pruneStaleBackupSidecars, // P-c91d4f76 — exported for testing
package/engine/cli.js CHANGED
@@ -192,15 +192,25 @@ function isHelpToken(arg) {
192
192
  return arg === '--help' || arg === '-h' || arg === 'help';
193
193
  }
194
194
 
195
- // W-mq07mjzi000s1cc9: Stricter guard for command first-positionals.
195
+ // W-mq07mjzi000s1cc9 + W-mq5hk4u200081043: Stricter guard for command first-positionals.
196
196
  //
197
197
  // Real work-item titles, agent ids, plan source paths, and dispatch ids never
198
- // start with `--`. Rejecting any leading-`--` token catches the exact bug
199
- // reported (`--help`) plus typos like `--hep`, `--h`, `-help` that would
200
- // otherwise still slip through as ghost-WI titles.
198
+ // start with `-` and are never bare `?`/`help`. Rejecting these catches the
199
+ // exact bug reported (`--help`) plus single-dash typos (`-X`, `-quiet`),
200
+ // question-mark probing (`?`), case-variant help (`Help`, `HELP`), and
201
+ // whitespace-only arguments that would otherwise create ghost WIs.
202
+ //
203
+ // handleCommand's narrow `isHelpToken` interceptor still handles the canonical
204
+ // `--help`/`-h`/`help` triplet by printing the global command list; anything
205
+ // else falls through to this defensive per-command guard which prints
206
+ // command-specific usage and exits 2.
201
207
  function looksLikeFlagOrHelp(arg) {
202
- if (isHelpToken(arg)) return true;
203
- return typeof arg === 'string' && arg.startsWith('--');
208
+ if (typeof arg !== 'string') return false;
209
+ const t = arg.trim();
210
+ if (!t) return true;
211
+ if (t.startsWith('-')) return true;
212
+ if (/^(help|\?)$/i.test(t)) return true;
213
+ return false;
204
214
  }
205
215
 
206
216
  // SoT for engine-CLI metadata: drives handleCommand's help text and the
@@ -920,10 +930,29 @@ const commands = {
920
930
  dispatchSnap,
921
931
  worktreeRootRel,
922
932
  log: (lvl, msg) => e.log(lvl, msg),
933
+ config: cfg,
934
+ writeToInbox: (a, s, c) => { try { return sharedMod.writeToInbox(a, s, c); } catch (_e) { return false; } },
923
935
  });
924
936
  if (result.evicted > 0 || result.failed > 0) {
925
937
  console.log(` Worktree boot reconcile: evicted ${result.evicted} orphan worktree(s)${result.failed ? `, ${result.failed} failed` : ''} (scanned ${result.scanned}, kept ${result.kept})`);
926
938
  }
939
+ // W-mq5o6bvy000x7191 (Layer 3): out-of-root scan — catches worktrees
940
+ // registered in `git worktree list` but living outside the configured
941
+ // `worktreeRoot` (e.g. ad-hoc `D:/tmp-*/` dirs left by previous
942
+ // experiments). Same dispatch snapshot so branch protection is
943
+ // consistent. Also runs `git worktree prune --expire=now` per project
944
+ // so manually-removed dirs drop out of the registry immediately.
945
+ const regResult = worktreeGc.pruneOrphanWorktreesFromGitRegistry({
946
+ projects,
947
+ dispatchSnap,
948
+ worktreeRootRel,
949
+ log: (lvl, msg) => e.log(lvl, msg),
950
+ config: cfg,
951
+ writeToInbox: (a, s, c) => { try { return sharedMod.writeToInbox(a, s, c); } catch (_e) { return false; } },
952
+ });
953
+ if (regResult.evicted > 0 || regResult.failed > 0 || regResult.prunedRegistry > 0) {
954
+ console.log(` Worktree registry reconcile: scanned ${regResult.scanned}, evicted ${regResult.evicted}, kept ${regResult.kept}, failed ${regResult.failed}, registry-pruned ${regResult.prunedRegistry}`);
955
+ }
927
956
  } catch (err) {
928
957
  e.log('warn', `Worktree boot reconcile failed: ${err.message}`);
929
958
  }
@@ -358,7 +358,7 @@ function getStalePrDispatchReason(entry, config) {
358
358
  const prLabel = entry.meta.pr?.id || entry.meta.pr?.url || entry.id;
359
359
  if (!tracked) return `PR ${prLabel} is no longer tracked`;
360
360
  if (tracked.status !== PR_STATUS.ACTIVE) return `PR ${tracked.id || prLabel} is ${tracked.status || 'missing status'}`;
361
- if (tracked._contextOnly) return `PR ${tracked.id || prLabel} is context-only`;
361
+ if (tracked._contextOnly && entry.meta?.source !== 'work-item') return `PR ${tracked.id || prLabel} is context-only`;
362
362
 
363
363
  const queuedBranch = entry.meta.branch || entry.meta.pr?.branch || '';
364
364
  const trackedBranch = tracked.branch || '';
package/engine/shared.js CHANGED
@@ -85,6 +85,105 @@ const LOG_PATH = path.join(MINIONS_DIR, 'engine', 'log.json');
85
85
  const CONSTELLATION_BRIDGE_MARKER_PATH = path.join(MINIONS_DIR, 'engine', 'constellation-bridge.json');
86
86
  const CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION = 1;
87
87
 
88
+ // ── Dashboard port runtime file (W-mq5nwl9l) ────────────────────────────────
89
+ // dashboard.js writes this file as soon as `server.listen` resolves so every
90
+ // downstream consumer (CLI status/dash/restart, cli-api-client, supervisor)
91
+ // can discover the ACTUALLY-bound port rather than guessing 7331. The
92
+ // resolver chain (explicit > env MINIONS_DASHBOARD_PORT > config.engine.
93
+ // dashboardPort > default 7331) is shared between dashboard.js (deciding what
94
+ // to try first) and bin/minions.js (deciding what to display/probe when the
95
+ // runtime file is missing or stale). The auto-fallback scan picks the first
96
+ // free port in [requestedPort, requestedPort + DASHBOARD_PORT_SCAN_MAX) on
97
+ // EADDRINUSE so a busy 7331 doesn't break startup.
98
+ const DEFAULT_DASHBOARD_PORT = 7331;
99
+ const DASHBOARD_PORT_SCAN_MAX = 100;
100
+ const DASHBOARD_PORT_PATH = path.join(MINIONS_DIR, 'engine', 'dashboard-port.json');
101
+
102
+ function _dashboardPortPath(minionsHome) {
103
+ return minionsHome
104
+ ? path.join(minionsHome, 'engine', 'dashboard-port.json')
105
+ : DASHBOARD_PORT_PATH;
106
+ }
107
+
108
+ function _validPort(n) {
109
+ const v = Number.isInteger(n) ? n : parseInt(n, 10);
110
+ return Number.isInteger(v) && v > 0 && v <= 65535 ? v : null;
111
+ }
112
+
113
+ /**
114
+ * Read the runtime dashboard-port.json beacon. Returns `{port, pid, boundAt,
115
+ * path}` on success, or `null` when the file is missing, malformed, or
116
+ * contains an out-of-range port. No throw.
117
+ */
118
+ function readDashboardPortFile(minionsHome) {
119
+ const fp = _dashboardPortPath(minionsHome);
120
+ try {
121
+ const raw = fs.readFileSync(fp, 'utf8');
122
+ const data = JSON.parse(raw);
123
+ const port = _validPort(data && data.port);
124
+ if (port === null) return null;
125
+ return {
126
+ port,
127
+ pid: Number.isInteger(data.pid) ? data.pid : null,
128
+ boundAt: typeof data.boundAt === 'string' ? data.boundAt : null,
129
+ path: fp,
130
+ };
131
+ } catch { return null; }
132
+ }
133
+
134
+ /**
135
+ * Atomically write the runtime dashboard-port.json beacon. The dashboard
136
+ * calls this once `server.listen` resolves so every CLI/client consumer
137
+ * agrees on the bound port (which may differ from the requested port after
138
+ * an EADDRINUSE fallback). Best-effort: file IO errors do not throw.
139
+ */
140
+ function writeDashboardPortFile({ port, pid, minionsHome } = {}) {
141
+ const validPort = _validPort(port);
142
+ if (validPort === null) return { ok: false, error: new Error(`invalid port: ${port}`) };
143
+ const fp = _dashboardPortPath(minionsHome);
144
+ const payload = {
145
+ port: validPort,
146
+ pid: Number.isInteger(pid) ? pid : process.pid,
147
+ boundAt: new Date().toISOString(),
148
+ };
149
+ try {
150
+ fs.mkdirSync(path.dirname(fp), { recursive: true });
151
+ const tmp = fp + '.tmp';
152
+ fs.writeFileSync(tmp, JSON.stringify(payload));
153
+ fs.renameSync(tmp, fp);
154
+ return { ok: true, path: fp };
155
+ } catch (err) {
156
+ return { ok: false, error: err };
157
+ }
158
+ }
159
+
160
+ /** Delete the runtime dashboard-port.json beacon. Called on graceful
161
+ * dashboard shutdown so the next CLI status check doesn't probe a stale
162
+ * port. Best-effort. */
163
+ function clearDashboardPortFile(minionsHome) {
164
+ const fp = _dashboardPortPath(minionsHome);
165
+ try { fs.unlinkSync(fp); return { ok: true }; } catch { return { ok: false }; }
166
+ }
167
+
168
+ /**
169
+ * Resolve the *requested* dashboard port via the precedence chain:
170
+ * explicit > env MINIONS_DASHBOARD_PORT > config.engine.dashboardPort > 7331
171
+ *
172
+ * Pure — performs no IO. Callers that want the actually-bound port should
173
+ * prefer `readDashboardPortFile()` first and fall back to this. Returns
174
+ * `{port, source}` so log lines can stay accurate ("from env", "from
175
+ * config", etc.).
176
+ */
177
+ function resolveDashboardPort(opts = {}) {
178
+ const { explicit, env, config, defaultPort = DEFAULT_DASHBOARD_PORT } = opts;
179
+ let p;
180
+ if ((p = _validPort(explicit)) !== null) return { port: p, source: 'explicit' };
181
+ if ((p = _validPort(env)) !== null) return { port: p, source: 'env' };
182
+ if ((p = _validPort(config)) !== null) return { port: p, source: 'config' };
183
+ const def = _validPort(defaultPort) || DEFAULT_DASHBOARD_PORT;
184
+ return { port: def, source: 'default' };
185
+ }
186
+
88
187
  // ── Timestamps & Logging ────────────────────────────────────────────────────
89
188
  // Extracted from engine.js so engine/* modules can import directly without
90
189
  // circular-requiring the orchestrator.
@@ -2420,6 +2519,25 @@ const ENGINE_DEFAULTS = {
2420
2519
  mainBranchCacheMaxEntries: 100, // bound repo/branch detection cache in long-lived dashboard/engine processes
2421
2520
  removeWorktreeFailureTtlMs: 24 * 60 * 60 * 1000, // stale failed paths are forgotten after a day
2422
2521
  removeWorktreeFailureMaxEntries: 1000, // bound failed-worktree retry suppression cache
2522
+ // ── Worktree GC hardening (W-mq5o6bvy000x7191) ──────────────────────────
2523
+ // Windows file-lock (EPERM/EBUSY/EACCES/ENOTEMPTY) class on worktree
2524
+ // removal: AV scans, vscode reopen, Explorer thumbnailers, msbuild watchers
2525
+ // hold transient locks on files inside a worktree we're trying to reap.
2526
+ // Without retry, a single EPERM at the fs.rmSync step in removeWorktree
2527
+ // permanently drops the worktree into the 3-attempt cooldown cache.
2528
+ worktreeRemoveRetryAttempts: 6, // exponential-backoff attempts on EPERM/EBUSY/EACCES/ENOTEMPTY
2529
+ worktreeRemoveRetryBaseMs: 250, // base delay; effective delay = baseMs * 2^i + jitter
2530
+ // Periodic in-tick orphan-worktree sweep (Layer 2 of W-mq5o6bvy000x7191).
2531
+ // Boot-only reconcile in cli.js doesn't catch orphans accumulated by
2532
+ // long-lived engines; this re-runs the same pruner every N ticks.
2533
+ worktreePruneIntervalTicks: 30, // ~5 min at 10s tick (matches keep-processes/managed-spawn cadence factor)
2534
+ // Stuck-dir escalation (Layer 4 of W-mq5o6bvy000x7191). After the same
2535
+ // worktree path has failed removal N consecutive times, the engine
2536
+ // suppresses repeat log spam, writes a dedup'd inbox note, and continues
2537
+ // retrying at a slower cadence in the background.
2538
+ worktreeStuckThreshold: 10, // consecutive removal failures before escalation
2539
+ worktreeStuckSuppressMs: 60 * 60 * 1000, // 60min — suppress per-tick warn after escalation
2540
+ worktreeStuckSlowRetryMs: 30 * 60 * 1000, // 30min — slow-cadence retry window after escalation
2423
2541
  ccMaxTurns: 50, // max tool-use turns per CC/doc-chat call before CLI stops (per response, not per session)
2424
2542
  ccTurnTimeoutMs: 300000, // W-mpmwxni2000c25c7-b/-d: 5min per-turn no-progress watchdog. The window resets on every liveness signal — token chunk, tool-call notification, tool-update — so an actively-streaming CC/doc-chat turn (long shell command, deep search, sub-agent loop) survives indefinitely up to the outer CC_CALL_TIMEOUT_MS (~1h) ceiling. Only true silence past this window with no progress fires the cancel: the in-flight LLM call is aborted and the handler surfaces `{code:'cc-turn-timeout', retryable:true}` via the typed error envelope so the UI can stop the spinner and offer Retry. Clamped to [10000, 3600000] in the settings POST handler. Independent of CC_CALL_TIMEOUT_MS. Non-streaming doc-chat is the lone wall-clock exception (no progress hooks); see _raceCcDocChatTimeout in dashboard.js for the dual factory/promise shape.
2425
2543
  docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity (LRU; sessions are non-expiring otherwise)
@@ -5429,6 +5547,32 @@ function normalizePrLinkItems(value) {
5429
5547
  return [...new Set(items.filter(item => typeof item === 'string' && item))];
5430
5548
  }
5431
5549
 
5550
+ /**
5551
+ * Single source of truth for "should the engine dispatch review/fix for this PR?"
5552
+ *
5553
+ * Used by `discoverFromPrs` in engine.js to gate review + fix dispatch, and by
5554
+ * `upsertPullRequestRecord` to decide whether to preserve auto-managed metadata
5555
+ * on merge. Dispatch-loop callers MUST go through this helper — do NOT inline
5556
+ * variants like `knownAgents.has(pr.agent) || pr.prdItems?.length || pr._manual`
5557
+ * because they silently drop the `_autoObserve` case (PR linked with
5558
+ * autoObserve=true but no prdItems and no configured-agent author) and the
5559
+ * sourcePlan/itemType cases. See W-mq5rs2eq000da8a9 for the bug repro.
5560
+ *
5561
+ * A PR is auto-managed when ANY of the following hold (and `_contextOnly` is
5562
+ * not explicitly true — context-only always wins):
5563
+ * - It carries one or more `prdItems` (linked to work items)
5564
+ * - It was explicitly opted-in via `_autoObserve: true` (manual link with
5565
+ * autoObserve, or per-row toggle via POST /api/pull-requests/observe)
5566
+ * - It was created from a plan or has an itemType (`sourcePlan`/`itemType`)
5567
+ * - It has a non-empty agent author other than the literal string `'human'`
5568
+ * (the helper deliberately does NOT require the agent be in
5569
+ * `config.agents` — once `_autoObserve` is the explicit opt-in for
5570
+ * non-agent authors, the loosened agent-string check has no operational
5571
+ * downside for managed flows and avoids the silent-skip bug.)
5572
+ *
5573
+ * @param {object} pr Pull request record
5574
+ * @returns {boolean} true when the engine should drive review/fix for this PR
5575
+ */
5432
5576
  function isAutoManagedPrRecord(pr) {
5433
5577
  if (!pr || typeof pr !== 'object' || pr._contextOnly === true) return false;
5434
5578
  if (normalizePrLinkItems(pr.prdItems).length > 0) return true;
@@ -6101,6 +6245,82 @@ const _WIN_RESERVED_NAMES = new Set([
6101
6245
  'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
6102
6246
  ]);
6103
6247
 
6248
+ // ── Worktree GC hardening (W-mq5o6bvy000x7191) ──────────────────────────────
6249
+ // Exit codes that mean "transient lock — try again later" on Windows when a
6250
+ // file inside the worktree is held by AV scan, vscode reopen, msbuild watcher,
6251
+ // or Explorer thumbnailer. Anything else (ENOENT, missing dir, real fs error)
6252
+ // is rethrown immediately.
6253
+ const _WORKTREE_RETRYABLE_CODES = new Set(['EBUSY', 'EPERM', 'EACCES', 'ENOTEMPTY']);
6254
+
6255
+ /**
6256
+ * Retry helper for fs ops that can hit Windows file-lock errors.
6257
+ * Used by `removeWorktree`'s `fs.rmSync` call so a single transient EPERM
6258
+ * doesn't drop the worktree into the 3-attempt cooldown.
6259
+ *
6260
+ * - op: thunk that performs (and returns) the fs op
6261
+ * - label: short string used in the final thrown-error message
6262
+ * - opts.attempts: total attempts; default ENGINE_DEFAULTS.worktreeRemoveRetryAttempts
6263
+ * - opts.baseMs: base delay; effective delay per attempt = baseMs * 2^i + jitter
6264
+ * - opts.onAttempt: optional (attempt, err) => void for tests/metrics
6265
+ *
6266
+ * Returns the op's result on first success. On the final attempt that fails,
6267
+ * the underlying error is re-thrown (wrapped with attempt count in `.message`)
6268
+ * so callers can branch on `err.code` per usual.
6269
+ *
6270
+ * Non-retryable errors (code not in `_WORKTREE_RETRYABLE_CODES`) are thrown
6271
+ * immediately without sleeping or counting against the attempts budget.
6272
+ */
6273
+ function _retryFsOp(op, label, opts = {}) {
6274
+ const attempts = Number(opts.attempts) > 0
6275
+ ? Math.floor(opts.attempts)
6276
+ : (ENGINE_DEFAULTS.worktreeRemoveRetryAttempts || 6);
6277
+ const baseMs = Number(opts.baseMs) > 0
6278
+ ? Number(opts.baseMs)
6279
+ : (ENGINE_DEFAULTS.worktreeRemoveRetryBaseMs || 250);
6280
+ const onAttempt = typeof opts.onAttempt === 'function' ? opts.onAttempt : null;
6281
+ let lastErr;
6282
+ for (let i = 0; i < attempts; i++) {
6283
+ try {
6284
+ const result = op();
6285
+ if (onAttempt) { try { onAttempt(i + 1, null); } catch { /* metric optional */ } }
6286
+ return { result, attempt: i + 1 };
6287
+ } catch (e) {
6288
+ lastErr = e;
6289
+ if (onAttempt) { try { onAttempt(i + 1, e); } catch { /* metric optional */ } }
6290
+ if (!e || !_WORKTREE_RETRYABLE_CODES.has(e.code)) throw e;
6291
+ if (i === attempts - 1) break;
6292
+ const delay = baseMs * Math.pow(2, i) + Math.floor(Math.random() * 200);
6293
+ sleepMs(delay);
6294
+ }
6295
+ }
6296
+ const err = new Error(`${label} failed after ${attempts} retries: ${(lastErr && lastErr.message) || 'unknown'}`);
6297
+ err.code = (lastErr && lastErr.code) || 'EUNKNOWN';
6298
+ err.cause = lastErr;
6299
+ err.attempts = attempts;
6300
+ throw err;
6301
+ }
6302
+
6303
+ /**
6304
+ * Bump per-call counters on the `_worktreeGcOutcomes` metric. Best-effort;
6305
+ * silent on lock contention or missing metrics file. The schema lives under
6306
+ * a single key so all worktree-GC behavior is observable from one dashboard
6307
+ * tile.
6308
+ */
6309
+ function bumpWorktreeGcMetric(field, delta = 1) {
6310
+ if (!field || typeof field !== 'string') return;
6311
+ const n = Number(delta);
6312
+ if (!Number.isFinite(n) || n === 0) return;
6313
+ try {
6314
+ mutateMetrics((metrics) => {
6315
+ if (!metrics._worktreeGcOutcomes || typeof metrics._worktreeGcOutcomes !== 'object') {
6316
+ metrics._worktreeGcOutcomes = {};
6317
+ }
6318
+ metrics._worktreeGcOutcomes[field] = (metrics._worktreeGcOutcomes[field] || 0) + n;
6319
+ return metrics;
6320
+ });
6321
+ } catch { /* metric best-effort */ }
6322
+ }
6323
+
6104
6324
  const PR_FIX_CAUSE = {
6105
6325
  HUMAN_FEEDBACK: 'human-feedback',
6106
6326
  REVIEW_FEEDBACK: 'review-feedback',
@@ -6239,15 +6459,26 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot) {
6239
6459
  _purgeReservedFiles(resolved);
6240
6460
  }
6241
6461
 
6462
+ bumpWorktreeGcMetric('attempts');
6242
6463
  try {
6243
6464
  exec(`git worktree remove "${wtPath}" --force`, { cwd: gitRoot, stdio: 'pipe', timeout: 15000, windowsHide: true });
6244
6465
  _removeWorktreeFailures.delete(resolved);
6466
+ bumpWorktreeGcMetric('success');
6245
6467
  return true;
6246
6468
  } catch (gitErr) {
6247
6469
  try {
6248
- fs.rmSync(resolved, { recursive: true, force: true });
6470
+ // W-mq5o6bvy000x7191 (Layer 1): retry fs.rmSync with exponential backoff
6471
+ // for transient Windows file-locks (EPERM/EBUSY/EACCES/ENOTEMPTY) before
6472
+ // falling through to rd /s /q. A single AV/Explorer/vscode lock during
6473
+ // GC must not bury the whole worktree under the 3-attempt cooldown.
6474
+ const { attempt } = _retryFsOp(
6475
+ () => fs.rmSync(resolved, { recursive: true, force: true }),
6476
+ `fs.rmSync(${resolved})`
6477
+ );
6249
6478
  try { exec('git worktree prune', { cwd: gitRoot, stdio: 'pipe', timeout: 10000, windowsHide: true }); } catch {}
6250
6479
  _removeWorktreeFailures.delete(resolved);
6480
+ bumpWorktreeGcMetric('success');
6481
+ if (attempt > 1) bumpWorktreeGcMetric('successAfterRetry');
6251
6482
  return true;
6252
6483
  } catch (rmErr) {
6253
6484
  // Windows: try cmd /c rd /s /q for any error — handles reserved device names,
@@ -6257,6 +6488,7 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot) {
6257
6488
  exec(`cmd /c rd /s /q "${resolved}"`, { stdio: 'pipe', timeout: 15000, windowsHide: true });
6258
6489
  try { exec('git worktree prune', { cwd: gitRoot, stdio: 'pipe', timeout: 10000, windowsHide: true }); } catch {}
6259
6490
  _removeWorktreeFailures.delete(resolved);
6491
+ bumpWorktreeGcMetric('success');
6260
6492
  return true;
6261
6493
  } catch (rdErr) {
6262
6494
  log('warn', `removeWorktree: rd /s /q fallback failed for ${wtPath}: ${rdErr.message}`);
@@ -6265,8 +6497,10 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot) {
6265
6497
  const fail = _removeWorktreeFailures.get(resolved) || { count: 0, lastAttempt: 0 };
6266
6498
  fail.count++;
6267
6499
  fail.lastAttempt = Date.now();
6500
+ fail.lastError = rmErr && rmErr.message ? rmErr.message : 'unknown';
6268
6501
  _removeWorktreeFailures.set(resolved, fail);
6269
6502
  _pruneRemoveWorktreeFailures();
6503
+ bumpWorktreeGcMetric('totalFailure');
6270
6504
  if (fail.count <= 3) log('warn', `removeWorktree: failed for ${wtPath} (attempt ${fail.count}/3): ${rmErr.message}`);
6271
6505
  return false;
6272
6506
  }
@@ -6451,6 +6685,13 @@ module.exports = {
6451
6685
  LOG_PATH,
6452
6686
  CONSTELLATION_BRIDGE_MARKER_PATH,
6453
6687
  CONSTELLATION_BRIDGE_MARKER_SCHEMA_VERSION,
6688
+ DEFAULT_DASHBOARD_PORT,
6689
+ DASHBOARD_PORT_SCAN_MAX,
6690
+ DASHBOARD_PORT_PATH,
6691
+ readDashboardPortFile,
6692
+ writeDashboardPortFile,
6693
+ clearDashboardPortFile,
6694
+ resolveDashboardPort,
6454
6695
  currentLogPath: _currentLogPath,
6455
6696
  ts,
6456
6697
  normalizeIsoTimestamp, // F6 (P-f6commentedit)
@@ -6598,6 +6839,7 @@ module.exports = {
6598
6839
  normalizePrRecords,
6599
6840
  normalizePrLinkItems, // exported for testing
6600
6841
  mergePrLinkItems, // exported for testing
6842
+ isAutoManagedPrRecord,
6601
6843
  upsertPullRequestRecord,
6602
6844
  nextWorkItemId,
6603
6845
  getProjectOrg,
@@ -6651,6 +6893,9 @@ module.exports = {
6651
6893
  listProcessDescendants,
6652
6894
  listProcessReachable,
6653
6895
  removeWorktree,
6896
+ _retryFsOp, // exported for testing (W-mq5o6bvy000x7191)
6897
+ bumpWorktreeGcMetric, // exported for testing (W-mq5o6bvy000x7191)
6898
+ _WORKTREE_RETRYABLE_CODES, // exported for testing (W-mq5o6bvy000x7191)
6654
6899
  _purgeReservedFiles, // exported for testing
6655
6900
  _WIN_RESERVED_NAMES, // exported for testing
6656
6901
  LOCK_STALE_MS,
@@ -52,7 +52,24 @@ const STOP_INTENT_PATH = path.join(STATIC_ENGINE_DIR, 'stop-intent.json');
52
52
  const SUPERVISOR_PID_PATH = path.join(STATIC_ENGINE_DIR, 'supervisor.pid');
53
53
 
54
54
  const SUPERVISOR_INTERVAL_MS = Number(process.env.MINIONS_SUPERVISOR_INTERVAL_MS) || 30000;
55
- const DASH_PORT = Number(process.env.MINIONS_DASHBOARD_PORT) || 7331;
55
+ // Dashboard port resolves dynamically each tick (W-mq5nwl9l). The dashboard
56
+ // auto-falls-back to the next free port when 7331 is in use and persists the
57
+ // actually-bound port to ~/.minions/engine/dashboard-port.json. Hard-coding
58
+ // 7331 here would make the supervisor probe the wrong port and respawn-loop
59
+ // the dashboard. _resolveDashPort() consults the runtime file first, falls
60
+ // back to the env var, then to the static default.
61
+ const DASH_PORT_DEFAULT = Number(process.env.MINIONS_DASHBOARD_PORT) || 7331;
62
+ function _resolveDashPort() {
63
+ const shared = _sharedOrNull();
64
+ if (shared && typeof shared.readDashboardPortFile === 'function') {
65
+ try {
66
+ const home = process.env.MINIONS_HOME || path.join(os.homedir(), '.minions');
67
+ const entry = shared.readDashboardPortFile(home);
68
+ if (entry && Number.isInteger(entry.port) && entry.port > 0) return entry.port;
69
+ } catch { /* fall through */ }
70
+ }
71
+ return DASH_PORT_DEFAULT;
72
+ }
56
73
  // Grace window after we (re)spawn a process — gives it time to bind its port
57
74
  // or write its PID before we re-probe. Without this we'd race the freshly
58
75
  // spawned process and double-spawn.
@@ -216,10 +233,11 @@ function checkEngine(now) {
216
233
 
217
234
  function checkDashboard(now) {
218
235
  if (now - _lastDashboardRespawnAt < POST_SPAWN_GRACE_MS) return;
219
- const pids = listeningPidsForPort(DASH_PORT);
236
+ const dashPort = _resolveDashPort();
237
+ const pids = listeningPidsForPort(dashPort);
220
238
  if (pids.length > 0) return;
221
239
 
222
- console.log(`[supervisor] Dashboard not listening on port ${DASH_PORT} — respawning...`);
240
+ console.log(`[supervisor] Dashboard not listening on port ${dashPort} — respawning...`);
223
241
  const newPid = spawnDashboard();
224
242
  _lastDashboardRespawnAt = now;
225
243
  console.log(`[supervisor] Dashboard respawned (new PID: ${newPid})`);
@@ -267,7 +285,7 @@ function main() {
267
285
  // its PID to control.json on cold Windows boots.
268
286
  _lastEngineRespawnAt = Date.now();
269
287
  _lastDashboardRespawnAt = Date.now();
270
- console.log(`[supervisor] Started (PID ${process.pid}); interval=${SUPERVISOR_INTERVAL_MS}ms, dashboardPort=${DASH_PORT}`);
288
+ console.log(`[supervisor] Started (PID ${process.pid}); interval=${SUPERVISOR_INTERVAL_MS}ms, dashboardPort=${_resolveDashPort()}(dynamic)`);
271
289
 
272
290
  const interval = setInterval(tick, SUPERVISOR_INTERVAL_MS);
273
291
  // Run one tick immediately so a freshly-restarted Minions covers any early