amicus 1.7.2 → 1.7.3
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 +12 -0
- package/package.json +1 -1
- package/src/sidecar/electron-install.js +10 -19
- package/src/sidecar/electron-lock.js +95 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"author": { "name": "Christian Wagner" },
|
|
6
6
|
"homepage": "https://bourbondog.github.io/amicus/",
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to Amicus are documented here. Format follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.7.3] - 2026-06-30
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
- **The Electron self-heal no longer wedges itself.** A repair that was killed or hung mid-run (or a
|
|
12
|
+
pre-1.7.3 build) could leave an orphaned single-flight lockfile, after which *every* subsequent repair
|
|
13
|
+
— including `amicus doctor --fix` and the GUI launch — reported "another electron repair is already in
|
|
14
|
+
progress" and did nothing. The lock now records the holder's PID + timestamp and reclaims an orphaned
|
|
15
|
+
lock (dead holder, older than a 15-minute TTL, or the old empty format), so the GUI can self-heal
|
|
16
|
+
again; a live, recent holder still yields honest contention (no double-extract). The controlled
|
|
17
|
+
download is time-boxed (and the last-resort installer bounded) so a stalled fetch can't recreate the
|
|
18
|
+
stuck lock. **After upgrading, an already-stuck lock clears itself on the next repair.**
|
|
19
|
+
|
|
8
20
|
## [1.7.2] - 2026-06-30
|
|
9
21
|
|
|
10
22
|
The Electron self-heal now tells the truth, heals the cases it can, and clearly explains the ones it can't.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "amicus",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"description": "Multi-model LLM Council + parallel AI window for Claude Code. Run structured council reviews across Gemini, GPT, DeepSeek and more — or fork a conversation to any model and fold the results back.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -22,11 +22,11 @@
|
|
|
22
22
|
|
|
23
23
|
const fsDefault = require('fs');
|
|
24
24
|
const path = require('path');
|
|
25
|
-
const os = require('os');
|
|
26
25
|
const { spawnSync } = require('child_process');
|
|
27
26
|
|
|
28
27
|
const { resolveCacheRoots } = require('./electron-cache');
|
|
29
28
|
const { avHint, verifyExtractOutcome: verifyQuarantine } = require('./electron-quarantine');
|
|
29
|
+
const { acquireRepairLock } = require('./electron-lock');
|
|
30
30
|
|
|
31
31
|
/** Default on-disk location of the installed electron package. */
|
|
32
32
|
function defaultElectronDir() {
|
|
@@ -115,22 +115,6 @@ function cachedZip({ version, platform = process.platform, arch = process.arch,
|
|
|
115
115
|
return null;
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/**
|
|
119
|
-
* Single-flight lockfile: only one caller may extract/install at a time, so
|
|
120
|
-
* concurrent callers don't double-extract (Windows EBUSY). Throws an
|
|
121
|
-
* EEXIST-coded error when another holder is active.
|
|
122
|
-
*/
|
|
123
|
-
function defaultAcquireLock({ electronDir, fs = fsDefault }) {
|
|
124
|
-
const lockPath = path.join(os.tmpdir(), `amicus-electron-repair-${Buffer.from(electronDir).toString('hex').slice(0, 16)}.lock`);
|
|
125
|
-
const fd = fs.openSync(lockPath, 'wx'); // EEXIST if held
|
|
126
|
-
return {
|
|
127
|
-
release() {
|
|
128
|
-
try { fs.closeSync(fd); } catch { /* ignore */ }
|
|
129
|
-
try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
118
|
/** Restore path.txt so electron/index.js resolves the freshly-extracted exe. */
|
|
135
119
|
function writePathTxt({ electronDir, platform, fs }) {
|
|
136
120
|
fs.writeFileSync(path.join(electronDir, 'path.txt'), platformExe(platform));
|
|
@@ -166,6 +150,10 @@ async function controlledProvision({
|
|
|
166
150
|
platform,
|
|
167
151
|
arch,
|
|
168
152
|
checksums: undefined,
|
|
153
|
+
// Bound the fetch so a stalled/blocked network aborts (got v11 timeouts:
|
|
154
|
+
// socket = inactivity, request = total) instead of hanging the repair —
|
|
155
|
+
// a hung-then-killed download is what orphaned the single-flight lock.
|
|
156
|
+
downloadOptions: { timeout: { socket: 60000, request: 480000 } },
|
|
169
157
|
});
|
|
170
158
|
await extractFromCache({ zip, electronDir, platform, extract, fs });
|
|
171
159
|
}
|
|
@@ -212,9 +200,12 @@ async function repairElectron({
|
|
|
212
200
|
} = {}) {
|
|
213
201
|
const fs = deps.fs || fsDefault;
|
|
214
202
|
const extract = deps.extract || require('extract-zip');
|
|
215
|
-
|
|
203
|
+
// Default-bound the last-resort installer spawn (8 min) so a first-GUI-use
|
|
204
|
+
// provision that reaches runInstaller without an explicit timeoutMs can't hang
|
|
205
|
+
// the holder — the caller's timeoutMs still wins when provided.
|
|
206
|
+
const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs || 480000 }));
|
|
216
207
|
const findZip = deps.cachedZip || ((o) => cachedZip(o));
|
|
217
|
-
const acquireLock = deps.acquireLock || ((o) =>
|
|
208
|
+
const acquireLock = deps.acquireLock || ((o) => acquireRepairLock({ ...o, fs }));
|
|
218
209
|
const downloadArtifact = deps.downloadArtifact || require('@electron/get').downloadArtifact;
|
|
219
210
|
|
|
220
211
|
if (!version) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-aware single-flight lock for the electron self-heal (#53).
|
|
3
|
+
*
|
|
4
|
+
* Only one process may extract/install electron at a time (concurrent extracts
|
|
5
|
+
* trip Windows EBUSY). The lock is a file in the OS temp dir keyed by the
|
|
6
|
+
* electron dir. It records the holder PID + a timestamp so a LATER caller can
|
|
7
|
+
* detect — and STEAL — a lock orphaned by a killed/crashed/rebooted repair (or
|
|
8
|
+
* left by a pre-v1.7.3 build, which wrote an empty lockfile). A bare wx lock
|
|
9
|
+
* with no staleness recovery wedges EVERY future repair forever with "another
|
|
10
|
+
* electron repair is already in progress" once the holder dies without
|
|
11
|
+
* releasing — which is exactly what it did in the field.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const fsDefault = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
|
|
20
|
+
/** A real repair (even a ~170MB download) finishes well within this. */
|
|
21
|
+
const STALE_MS = 15 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/** Temp-dir lockfile path, keyed by the electron install dir. */
|
|
24
|
+
function lockPathFor(electronDir) {
|
|
25
|
+
const key = Buffer.from(electronDir).toString('hex').slice(0, 16);
|
|
26
|
+
return path.join(os.tmpdir(), `amicus-electron-repair-${key}.lock`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Is an existing lockfile orphaned? Stale when it predates STALE_MS, its holder
|
|
31
|
+
* process is gone (ESRCH), or it is empty / corrupt / pre-v1.7.3 format. A lock
|
|
32
|
+
* held by a LIVE, recent process is NOT stale (real contention).
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
function isStaleLock(lockPath, fs = fsDefault, now = Date.now()) {
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = fs.readFileSync(lockPath, 'utf-8');
|
|
39
|
+
} catch {
|
|
40
|
+
return false; // already gone — absent, not stale
|
|
41
|
+
}
|
|
42
|
+
let meta;
|
|
43
|
+
try {
|
|
44
|
+
meta = JSON.parse(raw);
|
|
45
|
+
} catch {
|
|
46
|
+
return true; // empty / corrupt / pre-v1.7.3 empty lockfile -> orphaned
|
|
47
|
+
}
|
|
48
|
+
if (typeof meta.at === 'number' && now - meta.at > STALE_MS) {
|
|
49
|
+
return true; // older than any real repair
|
|
50
|
+
}
|
|
51
|
+
if (typeof meta.pid === 'number') {
|
|
52
|
+
try {
|
|
53
|
+
process.kill(meta.pid, 0); // throws if the process is gone
|
|
54
|
+
return false; // holder alive
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return !!(err && err.code === 'ESRCH'); // ESRCH => dead/stale; EPERM => alive (not ours)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return true; // no usable pid/timestamp -> treat as orphaned
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Acquire the single-flight repair lock. Throws an EEXIST-coded error ONLY when
|
|
64
|
+
* a live, recent process genuinely holds it; otherwise steals an orphaned lock.
|
|
65
|
+
* @returns {{ release: () => void }}
|
|
66
|
+
*/
|
|
67
|
+
function acquireRepairLock({ electronDir, fs = fsDefault }) {
|
|
68
|
+
const lockPath = lockPathFor(electronDir);
|
|
69
|
+
|
|
70
|
+
function create() {
|
|
71
|
+
// Atomic exclusive create WITH content in a single call: the file is never
|
|
72
|
+
// observable empty (no open-then-write gap a concurrent reader could
|
|
73
|
+
// misjudge as a corrupt orphan and steal). 'wx' throws EEXIST if held.
|
|
74
|
+
fs.writeFileSync(lockPath, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
create();
|
|
79
|
+
} catch (e) {
|
|
80
|
+
if (!e || e.code !== 'EEXIST') { throw e; }
|
|
81
|
+
if (!isStaleLock(lockPath, fs)) { throw e; } // genuinely held by a live process
|
|
82
|
+
// Steal the orphan, then re-create. If a real process races us into the
|
|
83
|
+
// gap, the second create() throws EEXIST and we surface honest contention.
|
|
84
|
+
try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
|
|
85
|
+
create();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
release() {
|
|
90
|
+
try { fs.rmSync(lockPath, { force: true }); } catch { /* ignore */ }
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = { acquireRepairLock, isStaleLock, lockPathFor, STALE_MS };
|