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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amicus",
3
- "version": "1.7.2",
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.2",
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
- const spawn = deps.spawn || ((cmd, args, o) => spawnSync(cmd, args, { ...o, timeout: timeoutMs }));
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) => defaultAcquireLock({ ...o, fs }));
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 };