amicus 4.5.1 → 4.5.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.
@@ -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
  };