amicus 4.5.0 → 4.5.2

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,150 @@
1
+ /**
2
+ * @module utils/doctor-electron-mcp-check
3
+ * The `electron-mcp` doctor check ("Electron (MCP launch path)"), split out of
4
+ * src/cli-handlers-doctor.js to keep that file under the 300-line gate
5
+ * (mirrors doctor-engine-check.js, which did the same for the engine — #76 is
6
+ * the electron-flavored recurrence of that check's bug report #1).
7
+ *
8
+ * The existing `electron` check verifies Electron in the RUNNING install. This
9
+ * one verifies the copies the MCP actually launches from — the npx-cache
10
+ * installs `npx -y amicus@latest mcp` resolves to — so a green doctor can no
11
+ * longer hide an npx copy whose `ui: true` will fail with electron-absent.
12
+ *
13
+ * Severity is WARN at worst (never error): a broken Electron only costs the
14
+ * GUI; headless councils still work — same tier the running-copy check uses.
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const HINTS = require('./remediation-hints');
20
+
21
+ const plural = (n, one, many) => (n === 1 ? one : many);
22
+
23
+ /** One-line detail for a broken copy, distinguishing the two states (#76). */
24
+ const describeBroken = (i) => (i.state === 'binary-missing'
25
+ ? `${i.pkgDir} (binary missing; electron dir: ${i.electronDir})`
26
+ : `${i.pkgDir} (not installed)`);
27
+
28
+ /**
29
+ * Enumerate the amicus installs that could serve the MCP and probe Electron in
30
+ * each via the dual-root resolver (#69 lesson: npx HOISTS electron to a
31
+ * sibling; a global install nests it).
32
+ * @param {object} [deps] engine-install-scan seams plus {readAmicusMcpConfig}
33
+ * @returns {{installs:Array<{kind,pkgDir,electronDir,state}>, mcpLaunch:string}}
34
+ */
35
+ function scanElectronInstalls(deps = {}) {
36
+ const fs = deps.fs || require('fs');
37
+ const platform = deps.platform || process.platform;
38
+ const readAmicusMcpConfig = deps.readAmicusMcpConfig
39
+ || (() => require('./mcp-discovery').readAmicusMcpConfig());
40
+ const { listAmicusInstalls, classifyLaunch } = require('./engine-install-scan');
41
+ const { electronDirFor, probeElectronState } = require('../sidecar/electron-state');
42
+
43
+ const installs = listAmicusInstalls(deps).map((i) => {
44
+ const probe = probeElectronState({ electronDir: electronDirFor(i.pkgDir, { fs }), fs, platform });
45
+ return { ...i, electronDir: probe.electronDir, state: probe.state };
46
+ });
47
+ let config = null;
48
+ try { config = readAmicusMcpConfig(); } catch { /* unreadable config */ }
49
+ return { installs, mcpLaunch: classifyLaunch(config) };
50
+ }
51
+
52
+ /**
53
+ * @param {{scanElectronInstalls: () => {installs:Array, mcpLaunch:string}}} d
54
+ * @returns {{id,name,status,message,hint}}
55
+ */
56
+ function evaluateElectronInstalls(d) {
57
+ const id = 'electron-mcp';
58
+ const name = 'Electron (MCP launch path)';
59
+ const { installs, mcpLaunch } = d.scanElectronInstalls();
60
+
61
+ if (mcpLaunch === 'none') {
62
+ return { id, name, status: 'ok', message: 'no amicus MCP registered — not checked', hint: null };
63
+ }
64
+ if (mcpLaunch === 'path') {
65
+ return {
66
+ id, name, status: 'ok',
67
+ message: 'MCP launches from a fixed path — covered by the Electron (interactive GUI) check', hint: null,
68
+ };
69
+ }
70
+
71
+ // 'npx' (and the 'unknown' fallback): verify the npx-cache copies, the ones
72
+ // whose optional-dependency electron install can silently half-complete.
73
+ const npxCopies = installs.filter((i) => i.kind === 'npx');
74
+ if (npxCopies.length === 0) {
75
+ return {
76
+ id, name, status: 'warn',
77
+ message: 'MCP launches via npx; no cached copy to inspect yet — run one council, then re-run doctor',
78
+ hint: null,
79
+ };
80
+ }
81
+
82
+ const broken = npxCopies.filter((i) => i.state !== 'ok');
83
+ if (broken.length === 0) {
84
+ return {
85
+ id, name, status: 'ok',
86
+ message: `electron present in ${npxCopies.length} npx-cache ${plural(npxCopies.length, 'copy', 'copies')}`,
87
+ hint: null,
88
+ };
89
+ }
90
+
91
+ // `doctor --fix` can heal binary-missing (repairElectron); a never-installed
92
+ // package it cannot — keep the hint honest about which state is fixable.
93
+ const anyRepairable = broken.some((i) => i.state === 'binary-missing');
94
+ if (npxCopies.length === 1) {
95
+ const [only] = broken;
96
+ const message = only.state === 'binary-missing'
97
+ ? `electron binary missing in the npx-cache copy the MCP launches: ${only.pkgDir} (electron dir: ${only.electronDir})`
98
+ : `electron not installed in the npx-cache copy the MCP launches: ${only.pkgDir} — GUI auto-open unavailable; headless still works`;
99
+ return { id, name, status: 'warn', message, hint: anyRepairable ? HINTS.doctorFix : null };
100
+ }
101
+ const detail = broken.map(describeBroken).join('; ');
102
+ return {
103
+ id, name, status: 'warn',
104
+ message: `electron unavailable in ${broken.length}/${npxCopies.length} npx-cache copies: ${detail}`,
105
+ hint: anyRepairable ? HINTS.doctorFix : null,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Fix-aware wrapper. When d.fix and the scan shows binary-missing npx copies,
111
+ * heal each in place via d.repairElectron({electronDir}) — the package dir is
112
+ * already on disk, so repairElectron can read its version and provision the
113
+ * exe — then re-report from a fresh scan. package-missing copies are never
114
+ * repaired (no package to repair into). Mirrors evaluateEngineMcp.
115
+ * @param {object} d doctor deps (scanElectronInstalls, fix?, repairElectron?, fixTimeoutMs?)
116
+ * @returns {Promise<{id,name,status,message,hint}>}
117
+ */
118
+ async function evaluateElectronMcp(d) {
119
+ const verdict = evaluateElectronInstalls(d);
120
+ if (!d.fix || verdict.status === 'ok') { return verdict; }
121
+
122
+ const { installs } = d.scanElectronInstalls();
123
+ const repairable = installs.filter((i) => i.kind === 'npx' && i.state === 'binary-missing');
124
+ if (repairable.length === 0) { return verdict; }
125
+
126
+ const results = [];
127
+ for (const b of repairable) {
128
+ let r;
129
+ try {
130
+ r = await d.repairElectron({
131
+ electronDir: b.electronDir,
132
+ ...(d.fixTimeoutMs ? { timeoutMs: d.fixTimeoutMs } : {}),
133
+ });
134
+ } catch (e) { r = { repaired: false, reason: e && e.message }; }
135
+ results.push({ electronDir: b.electronDir, ...r });
136
+ }
137
+
138
+ const after = evaluateElectronInstalls(d); // fresh scan reflects the repairs
139
+ if (after.status === 'ok') {
140
+ const n = results.length;
141
+ return { ...after, message: `${after.message} (self-healed ${n} npx-cache ${plural(n, 'copy', 'copies')})` };
142
+ }
143
+ const failed = results.filter((r) => !r.repaired)
144
+ .map((r) => `${r.electronDir}${r.reason ? ` — ${r.reason}` : ''}`).join('; ');
145
+ return failed
146
+ ? { ...after, message: `${after.message}; self-heal incomplete: ${failed}` }
147
+ : after;
148
+ }
149
+
150
+ module.exports = { scanElectronInstalls, evaluateElectronInstalls, evaluateElectronMcp };
@@ -89,6 +89,28 @@ function ensurePortAvailable(port = DEFAULT_PORT) {
89
89
  */
90
90
  const LOCK_CLASS_START_FAILURE = /database is locked|database table is locked|SQLITE_BUSY/i;
91
91
 
92
+ /**
93
+ * A start failure that is a TIMEOUT, not a deterministic error.
94
+ *
95
+ * `@opencode-ai/sdk` rejects with `Timeout waiting for server to start after
96
+ * ${timeout}ms` when OpenCode has not printed its listening line inside the
97
+ * caller-supplied window (SDK default: 5000ms — see AMICUS_SERVER_START_TIMEOUT_MS
98
+ * in src/opencode-client.js for why amicus no longer accepts that default).
99
+ *
100
+ * ⚠️ ADDED v4.5.2 from a field report. A start timeout is TRANSIENT — a cold
101
+ * SQLite open on a sync-backed volume with an AV scanner attached simply takes
102
+ * longer than the window — and is therefore *more* retryable than a lock race,
103
+ * since retrying costs nothing but the backoff. Before this, it matched no
104
+ * alternative in LOCK_CLASS_START_FAILURE and so fell straight through
105
+ * `retryOnLockRace` with ZERO retries. A reporter's council degraded to per-wave
106
+ * servers on this error and then lost its entire Stage-1 bench
107
+ * (`COUNCIL_QUORUM: Only 0 Stage-1 review(s) survived`).
108
+ *
109
+ * Deliberately anchored to "…server to start". A REQUEST timeout, an ETIMEDOUT
110
+ * connect, and a generic "timeout" are NOT this class and must not sleep here.
111
+ */
112
+ const TIMEOUT_CLASS_START_FAILURE = /Timeout waiting for server to start/i;
113
+
92
114
  /**
93
115
  * Backoff between start attempts; 5 attempts total, ≤3.75s of added latency.
94
116
  *
@@ -109,12 +131,48 @@ const LOCK_RETRY_DELAYS_MS = [250, 500, 1000, 2000];
109
131
  * @returns {boolean} true only for a lock-class (retryable) start failure
110
132
  */
111
133
  function isLockClassStartFailure(error) {
134
+ return matchesStartFailure(error, LOCK_CLASS_START_FAILURE);
135
+ }
136
+
137
+ /**
138
+ * @param {Error|null} error
139
+ * @returns {boolean} true only for a timeout-class (retryable) start failure
140
+ */
141
+ function isTimeoutClassStartFailure(error) {
142
+ return matchesStartFailure(error, TIMEOUT_CLASS_START_FAILURE);
143
+ }
144
+
145
+ /**
146
+ * The union the retry actually applies to: lock-class OR timeout-class.
147
+ *
148
+ * Kept separate from the two predicates so each class keeps its own narrow,
149
+ * accurate meaning — `isLockClassStartFailure` still answers "was this a lock
150
+ * race?" and nothing else, so its docblock does not quietly become a lie.
151
+ *
152
+ * @param {Error|null} error
153
+ * @returns {boolean} true for any transient (retryable) start failure
154
+ */
155
+ function isRetryableStartFailure(error) {
156
+ return isLockClassStartFailure(error) || isTimeoutClassStartFailure(error);
157
+ }
158
+
159
+ /**
160
+ * Test `pattern` against every carrier an error might arrive on.
161
+ *
162
+ * The real failure arrives as a message with the server's own stdout inlined
163
+ * ("Server exited with code 1 / Server output: … database is locked"), and
164
+ * amicus prefixes it again at the fanout boundary (`Failed to start server:
165
+ * …`), so check the usual carriers too — a wrapped/spawn-shaped error still
166
+ * has to match.
167
+ *
168
+ * @param {Error|null} error
169
+ * @param {RegExp} pattern
170
+ * @returns {boolean}
171
+ */
172
+ function matchesStartFailure(error, pattern) {
112
173
  if (!error) { return false; }
113
- // The real failure arrives as a message with the server's own stdout inlined
114
- // ("Server exited with code 1 / Server output: … database is locked"), but
115
- // check the usual carriers too so a wrapped/spawn-shaped error still matches.
116
174
  const carriers = [error.message, error.stderr, error.stdout, error.cause && error.cause.message];
117
- return carriers.some(c => typeof c === 'string' && LOCK_CLASS_START_FAILURE.test(c));
175
+ return carriers.some(c => typeof c === 'string' && pattern.test(c));
118
176
  }
119
177
 
120
178
  /**
@@ -141,9 +199,16 @@ async function retryOnLockRace(attempt, opts = {}) {
141
199
  try {
142
200
  return await attempt(i);
143
201
  } catch (error) {
144
- if (i >= delays.length || !isLockClassStartFailure(error)) { throw error; }
145
- logger.warn('OpenCode server start lost a lock race — retrying', {
146
- attempt: i + 1, of: delays.length + 1, delayMs: delays[i], error: error.message,
202
+ if (i >= delays.length || !isRetryableStartFailure(error)) { throw error; }
203
+ logger.warn('OpenCode server start failed transiently — retrying', {
204
+ attempt: i + 1,
205
+ of: delays.length + 1,
206
+ delayMs: delays[i],
207
+ // Name WHICH transient class fired: a run that retried on `timeout`
208
+ // wants a bigger AMICUS_SERVER_START_TIMEOUT_MS, one that retried on
209
+ // `lock` wants less concurrency. Same retry, different operator action.
210
+ failureClass: isLockClassStartFailure(error) ? 'lock' : 'timeout',
211
+ error: error.message,
147
212
  });
148
213
  await new Promise(resolve => setTimeout(resolve, delays[i]));
149
214
  }
@@ -158,5 +223,7 @@ module.exports = {
158
223
  killPortProcess,
159
224
  ensurePortAvailable,
160
225
  isLockClassStartFailure,
226
+ isTimeoutClassStartFailure,
227
+ isRetryableStartFailure,
161
228
  retryOnLockRace
162
229
  };