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.
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +71 -0
- package/README.md +105 -89
- package/docs/ROADMAP.md +6 -8
- package/docs/configuration.md +16 -0
- package/docs/council.md +5 -2
- package/docs/usage.md +1 -1
- package/package.json +3 -2
- package/schemas/council-verdict.schema.json +183 -29
- package/src/cli-handlers-doctor.js +10 -0
- package/src/council/run-assemble.js +7 -3
- package/src/council/run.js +2 -1
- package/src/council/verdict.js +44 -1
- package/src/headless.js +6 -0
- package/src/mcp-council-run.js +4 -2
- package/src/opencode-client.js +79 -2
- package/src/sidecar/electron-state.js +61 -0
- package/src/sidecar/session-utils.js +4 -0
- package/src/sidecar/unzip.js +16 -1
- package/src/sidecar/workspace-auto-open.js +18 -4
- package/src/utils/doctor-electron-mcp-check.js +150 -0
- package/src/utils/server-setup.js +74 -7
|
@@ -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' &&
|
|
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 || !
|
|
145
|
-
logger.warn('OpenCode server start
|
|
146
|
-
attempt: i + 1,
|
|
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
|
};
|