@anyslate/cli 0.3.1 → 0.4.0
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/README.md +20 -0
- package/package.json +2 -2
- package/src/auth.mjs +189 -32
- package/src/backoff.mjs +178 -0
- package/src/commands/checkpoint.mjs +13 -3
- package/src/commands/doctor.mjs +19 -0
- package/src/commands/hook.mjs +71 -3
- package/src/commands/login.mjs +7 -0
- package/src/commands/logout.mjs +4 -0
- package/src/commands/upload-artifact.mjs +12 -4
- package/src/credentials.mjs +32 -5
- package/src/guard.mjs +381 -0
- package/src/index.mjs +12 -0
- package/src/mcp-client.mjs +16 -5
package/README.md
CHANGED
|
@@ -196,6 +196,8 @@ export ANYSLATE_HANDLE=mh_xxxxxxxx # optional, scope to one capab
|
|
|
196
196
|
export ANYSLATE_DISABLE=1 # optional, disables capture for this shell
|
|
197
197
|
export ANYSLATE_HOME=/path/to/dir # optional, overrides ~/.anyslate
|
|
198
198
|
export ANYSLATE_STDIN_TIMEOUT_MS=10000 # optional, stdin idle timeout (0 disables)
|
|
199
|
+
export ANYSLATE_HOOK_TIMEOUT_MS=5000 # optional, request budget for one hook
|
|
200
|
+
export ANYSLATE_MAX_CALLS_PER_MINUTE=60 # optional, local call ceiling (0 disables)
|
|
199
201
|
```
|
|
200
202
|
|
|
201
203
|
Env vars override `~/.anyslate/cli.json`.
|
|
@@ -214,6 +216,24 @@ Capability handles are `mh_` + a 32-character id (e.g. `mh_V1StGXR8Z5jdHi6BmyT0a
|
|
|
214
216
|
|
|
215
217
|
**If your token was minted with a memory scope, it already carries that scope server-side and `ANYSLATE_HANDLE` / `--handle` is ignored.** Scoped tokens cannot be widened per-call by design. To capture workspace-wide, mint an unscoped token. `anyslate doctor` reports which case you're in.
|
|
216
218
|
|
|
219
|
+
### When capture pauses itself
|
|
220
|
+
|
|
221
|
+
Since 0.4.0 the CLI refuses to keep calling a server that is refusing it. Three things changed:
|
|
222
|
+
|
|
223
|
+
- **No unbounded retries.** `checkpoint` and `upload-artifact` retry at most 3 times, with exponential backoff and jitter and a 15-second total sleep budget. `hook` does not retry at all: it fires on every tool call, so a retry there is a doubled request rate in exchange for one row in an activity feed.
|
|
224
|
+
- **`Retry-After` is obeyed.** A `429` or `503` that names a wait pauses this machine for exactly that long. The CLI never sleeps through a long one - the process exits and the pause is honoured by whichever process runs next.
|
|
225
|
+
- **A circuit breaker that outlives the process.** Every hook is a separate process, so an in-memory breaker would be worthless. After 3 consecutive `401`s - or one definitively dead credential, such as a refresh token the server has already replaced - capture pauses, the pause is recorded in `~/.anyslate/cli-guard.json`, and the CLI tells you once:
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
anyslate: capture paused until 2026-08-02T12:15:00.000Z — repeated authentication failures (invalid_grant). Run `anyslate login` to sign in again; no requests are sent until then.
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
After that one line, paused hooks are completely silent and make **no** network request. Repeated pauses escalate: 15 minutes, then 1 hour, 6 hours, 24 hours.
|
|
232
|
+
|
|
233
|
+
The pause is tied to the `apiUrl` + credential that earned it. `anyslate login` clears it immediately, and so does pointing `--api-url` somewhere else or swapping the token - fixing your setup is never punished by a wait. `anyslate doctor` always reports an active pause, and is itself never blocked by one.
|
|
234
|
+
|
|
235
|
+
`ANYSLATE_MAX_CALLS_PER_MINUTE` is the last line of defence: a machine-wide ceiling (default 60/minute) on capture calls, so a runaway agent loop cannot become a load test. It is far above any real session; set it to `0` to disable.
|
|
236
|
+
|
|
217
237
|
### The two URL conventions - the one thing people get wrong
|
|
218
238
|
|
|
219
239
|
AnySlate ships two clients and they want **different** URLs. This trips up nearly everyone:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anyslate/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "AnySlate CLI - lifecycle hooks, git/CI capture, and manual checkpoints for AI memory. Validates its connection at login, diagnoses itself with `anyslate doctor`, and fails open without failing silent.",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "AnySlate CLI - lifecycle hooks, git/CI capture, and manual checkpoints for AI memory. Validates its connection at login, diagnoses itself with `anyslate doctor`, backs off and circuit-breaks rather than retrying, and fails open without failing silent.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"anyslate": "./bin/anyslate.mjs"
|
package/src/auth.mjs
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// The rules that fall out of that:
|
|
10
10
|
//
|
|
11
11
|
// * Refresh PROACTIVELY, 5 minutes ahead of expiry, before the call. Waiting
|
|
12
|
-
// for the 401 spends a round trip and, on the hook path, risks the
|
|
12
|
+
// for the 401 spends a round trip and, on the hook path, risks the timeout
|
|
13
13
|
// budget.
|
|
14
14
|
// * On a 401, refresh EXACTLY ONCE and retry EXACTLY ONCE. A loop here is a
|
|
15
15
|
// self-inflicted rate limit against an endpoint that is already refusing us.
|
|
@@ -19,10 +19,37 @@
|
|
|
19
19
|
// a process that must not hang.
|
|
20
20
|
// * Never throw. Callers are fail-open paths; a rejected promise here would
|
|
21
21
|
// become an uncaught exception in a hook and a non-zero exit.
|
|
22
|
+
//
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// 2026-08-02 — this file is where the client-side storm was possible
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// "Refresh once, retry once" was already true PER PROCESS, and it was not
|
|
27
|
+
// enough: every hook is a fresh process, so one machine with a stale refresh
|
|
28
|
+
// token re-derived the same doomed `POST /oauth/token` + `POST /mcp` pair fifty
|
|
29
|
+
// times a minute for hours, and no single invocation was misbehaving.
|
|
30
|
+
//
|
|
31
|
+
// Three additions close that, all funnelled through `callToolWithAuth` because
|
|
32
|
+
// it is the one choke point every capture command already goes through:
|
|
33
|
+
//
|
|
34
|
+
// 1. a PERSISTED circuit breaker (guard.mjs) consulted BEFORE any credential
|
|
35
|
+
// is resolved — an open breaker costs the server exactly zero requests,
|
|
36
|
+
// 2. bounded retry with jitter and a hard attempt cap (backoff.mjs), with
|
|
37
|
+
// `Retry-After` honoured rather than ignored,
|
|
38
|
+
// 3. the refresh stays paired with the ONE call that needed it, even across
|
|
39
|
+
// retries — a retried call never re-mints.
|
|
22
40
|
|
|
23
41
|
import { readConfigFile, updateConfigFile, withRefreshLock } from './credentials.mjs';
|
|
24
|
-
import { callTool } from './mcp-client.mjs';
|
|
42
|
+
import { callTool, formatCallFailure } from './mcp-client.mjs';
|
|
25
43
|
import { discover, refreshAccessToken, REFRESH_SKEW_MS } from './oauth.mjs';
|
|
44
|
+
import { DEFAULT_RETRY, classifyFailure, planRetry, retryAfterMsOf, sleep } from './backoff.mjs';
|
|
45
|
+
import {
|
|
46
|
+
breakerGate,
|
|
47
|
+
breakerNotice,
|
|
48
|
+
ceilingNotice,
|
|
49
|
+
recordCallFailure,
|
|
50
|
+
recordCallSuccess,
|
|
51
|
+
reserveCallSlot,
|
|
52
|
+
} from './guard.mjs';
|
|
26
53
|
|
|
27
54
|
export { REFRESH_SKEW_MS };
|
|
28
55
|
|
|
@@ -105,12 +132,17 @@ async function refreshEndpoints({ oauth, root, fetchImpl }) {
|
|
|
105
132
|
* "somebody else already refreshed" short-circuit hands the caller back the
|
|
106
133
|
* very token that just 401'd, and the retry fails identically.
|
|
107
134
|
*
|
|
135
|
+
* `lockWaitMs` exists for the hook path. The default 10s wait is fine for a
|
|
136
|
+
* user-initiated command but is 10s of a lifecycle hook standing in front of
|
|
137
|
+
* the user's editor, and a machine whose refreshes are all failing is exactly
|
|
138
|
+
* the machine where every hook queues behind the last one.
|
|
139
|
+
*
|
|
108
140
|
* @param {{env: NodeJS.ProcessEnv, root: string, fetchImpl?: typeof fetch, now?: number,
|
|
109
|
-
* staleToken?: string|null}} opts
|
|
141
|
+
* staleToken?: string|null, lockWaitMs?: number}} opts
|
|
110
142
|
* @returns {Promise<{ok: true, token: string, rotated: boolean, oauth: object}
|
|
111
143
|
* | {ok: false, code: string, message: string}>}
|
|
112
144
|
*/
|
|
113
|
-
export async function refreshCredentials({ env, root, fetchImpl = fetch, now, staleToken = null }) {
|
|
145
|
+
export async function refreshCredentials({ env, root, fetchImpl = fetch, now, staleToken = null, lockWaitMs }) {
|
|
114
146
|
return withRefreshLock(
|
|
115
147
|
async () => {
|
|
116
148
|
// Re-read INSIDE the lock. While we queued, a sibling hook may have done
|
|
@@ -180,7 +212,7 @@ export async function refreshCredentials({ env, root, fetchImpl = fetch, now, st
|
|
|
180
212
|
}
|
|
181
213
|
return { ok: true, token: next.access_token, rotated: true, oauth: next };
|
|
182
214
|
},
|
|
183
|
-
{ env },
|
|
215
|
+
{ env, waitMs: lockWaitMs },
|
|
184
216
|
);
|
|
185
217
|
}
|
|
186
218
|
|
|
@@ -194,11 +226,12 @@ export async function refreshCredentials({ env, root, fetchImpl = fetch, now, st
|
|
|
194
226
|
* static token also exists, we fall back to it rather than failing the call —
|
|
195
227
|
* with `warning` set, so the caller can say so.
|
|
196
228
|
*
|
|
197
|
-
* @param {{cfg: object, env: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, force?: boolean,
|
|
229
|
+
* @param {{cfg: object, env: NodeJS.ProcessEnv, fetchImpl?: typeof fetch, force?: boolean,
|
|
230
|
+
* now?: number, lockWaitMs?: number}} opts
|
|
198
231
|
* @returns {Promise<{ok: true, token: string, mode: 'env'|'oauth'|'static', refreshed: boolean, warning?: string}
|
|
199
232
|
* | {ok: false, code: string, message: string, mode: string}>}
|
|
200
233
|
*/
|
|
201
|
-
export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false, now }) {
|
|
234
|
+
export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false, now, lockWaitMs }) {
|
|
202
235
|
if (cfg?.sources?.mcpToken === 'env' && cfg.mcpToken) {
|
|
203
236
|
return { ok: true, token: cfg.mcpToken, mode: 'env', refreshed: false };
|
|
204
237
|
}
|
|
@@ -216,6 +249,7 @@ export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false
|
|
|
216
249
|
fetchImpl,
|
|
217
250
|
now,
|
|
218
251
|
staleToken: force ? (oauth.access_token ?? null) : null,
|
|
252
|
+
lockWaitMs,
|
|
219
253
|
});
|
|
220
254
|
if (refreshed.ok) {
|
|
221
255
|
return { ok: true, token: refreshed.token, mode: 'oauth', refreshed: refreshed.rotated };
|
|
@@ -246,44 +280,137 @@ export async function resolveBearer({ cfg, env, fetchImpl = fetch, force = false
|
|
|
246
280
|
}
|
|
247
281
|
|
|
248
282
|
/**
|
|
249
|
-
* `callTool` with the
|
|
250
|
-
*
|
|
283
|
+
* `callTool` with the whole availability contract wrapped around it: the
|
|
284
|
+
* persisted breaker, the machine ceiling, proactive refresh, at most one
|
|
285
|
+
* refresh-and-retry on a 401, and bounded backoff on anything transient.
|
|
286
|
+
*
|
|
287
|
+
* The 401 retry is gated on `mode === 'oauth'`: a static token that 401s is
|
|
288
|
+
* revoked or mistyped, and re-sending it cannot help.
|
|
251
289
|
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
290
|
+
* ATTEMPTS ARE PROVABLY BOUNDED. The loop condition is a counter compared to
|
|
291
|
+
* `policy.maxAttempts + 1` — the `+1` is the single post-refresh retry, which
|
|
292
|
+
* must be allowed even on the hook path (`maxAttempts: 1`) because an access
|
|
293
|
+
* token that aged out mid-session is the normal case, not an anomaly. There is
|
|
294
|
+
* no `while (true)` and no recursion anywhere on this path.
|
|
254
295
|
*
|
|
255
296
|
* @param {{cfg: object, env: NodeJS.ProcessEnv, toolName: string, args: object,
|
|
256
|
-
* fetchImpl?: typeof fetch, timeoutMs?: number
|
|
297
|
+
* fetchImpl?: typeof fetch, timeoutMs?: number, retry?: object,
|
|
298
|
+
* lockWaitMs?: number}} opts
|
|
257
299
|
* @returns {Promise<object>} the callTool result, plus `authMode` / `authWarning`,
|
|
258
|
-
* or an auth failure shaped like a callTool failure (`authError: true`)
|
|
300
|
+
* or an auth failure shaped like a callTool failure (`authError: true`), or a
|
|
301
|
+
* locally-refused call (`breakerOpen` / `rateCapped`) that never left the machine.
|
|
259
302
|
*/
|
|
260
|
-
export async function callToolWithAuth({ cfg, env, toolName, args, fetchImpl, timeoutMs }) {
|
|
261
|
-
const
|
|
303
|
+
export async function callToolWithAuth({ cfg, env, toolName, args, fetchImpl, timeoutMs, retry, lockWaitMs }) {
|
|
304
|
+
const policy = { ...DEFAULT_RETRY, ...(retry ?? {}) };
|
|
305
|
+
|
|
306
|
+
// Cheapest possible check first: an open breaker resolves no credential,
|
|
307
|
+
// opens no socket, and mints no token. That is the entire point — the
|
|
308
|
+
// incident cost ~50 requests a minute for days precisely because a client
|
|
309
|
+
// with no hope of succeeding still asked.
|
|
310
|
+
const gate = breakerGate({ cfg, env });
|
|
311
|
+
if (gate.open) {
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
status: 0,
|
|
315
|
+
data: breakerNotice(gate),
|
|
316
|
+
raw: null,
|
|
317
|
+
breakerOpen: true,
|
|
318
|
+
breaker: gate,
|
|
319
|
+
authMode: cfg?.authMode ?? 'none',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const slot = reserveCallSlot({ env });
|
|
324
|
+
if (!slot.allowed) {
|
|
325
|
+
return { ok: false, status: 0, data: ceilingNotice(slot), raw: null, rateCapped: true, rateSlot: slot };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const first = await resolveBearer({ cfg, env, fetchImpl, lockWaitMs });
|
|
262
329
|
if (!first.ok) {
|
|
263
|
-
|
|
330
|
+
const breaker = recordCallFailure({ cfg, env, kind: 'auth', code: first.code, detail: first.code });
|
|
331
|
+
return {
|
|
332
|
+
ok: false,
|
|
333
|
+
status: 0,
|
|
334
|
+
data: first.message,
|
|
335
|
+
raw: null,
|
|
336
|
+
authError: true,
|
|
337
|
+
authMode: first.mode,
|
|
338
|
+
breaker,
|
|
339
|
+
};
|
|
264
340
|
}
|
|
265
341
|
|
|
266
|
-
|
|
267
|
-
|
|
342
|
+
let token = first.token;
|
|
343
|
+
let mode = first.mode;
|
|
344
|
+
let warning = first.warning;
|
|
345
|
+
// A proactive refresh already spent this call's one re-mint.
|
|
346
|
+
let refreshSpent = first.refreshed;
|
|
347
|
+
let retried = false;
|
|
268
348
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
349
|
+
const hardCap = policy.maxAttempts + 1;
|
|
350
|
+
let attempt = 0;
|
|
351
|
+
let spentDelayMs = 0;
|
|
352
|
+
let res = null;
|
|
272
353
|
|
|
273
|
-
|
|
274
|
-
|
|
354
|
+
while (attempt < hardCap) {
|
|
355
|
+
attempt += 1;
|
|
356
|
+
res = await callTool({ apiUrl: cfg.apiUrl, token, toolName, args, fetchImpl, timeoutMs });
|
|
357
|
+
res.authMode = mode;
|
|
358
|
+
if (warning) res.authWarning = warning;
|
|
359
|
+
if (retried) res.authRetried = true;
|
|
360
|
+
if (attempt > 1) res.attempts = attempt;
|
|
275
361
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
362
|
+
if (res.ok) break;
|
|
363
|
+
|
|
364
|
+
const kind = classifyFailure(res);
|
|
365
|
+
|
|
366
|
+
// One refresh, paired with the call that needed it — not with each attempt.
|
|
367
|
+
// Re-minting per attempt is what turned a dead credential into two requests
|
|
368
|
+
// per hook instead of one.
|
|
369
|
+
if (kind === 'auth' && mode === 'oauth' && !refreshSpent) {
|
|
370
|
+
refreshSpent = true;
|
|
371
|
+
const second = await resolveBearer({ cfg, env, fetchImpl, force: true, lockWaitMs });
|
|
372
|
+
if (!second.ok) {
|
|
373
|
+
res.authError = true;
|
|
374
|
+
res.authRefreshFailed = second.message;
|
|
375
|
+
res.authCode = second.code;
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
token = second.token;
|
|
379
|
+
mode = second.mode;
|
|
380
|
+
warning = second.warning;
|
|
381
|
+
retried = true;
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const retryAfterMs = retryAfterMsOf(res);
|
|
386
|
+
const plan = planRetry({ kind, attempt, spentDelayMs, retryAfterMs, policy });
|
|
387
|
+
if (!plan.retry) break;
|
|
388
|
+
await sleep(plan.delayMs);
|
|
389
|
+
spentDelayMs += plan.delayMs;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (res.ok) {
|
|
393
|
+
recordCallSuccess({ cfg, env });
|
|
280
394
|
return res;
|
|
281
395
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
396
|
+
|
|
397
|
+
const kind = classifyFailure(res);
|
|
398
|
+
res.breaker = recordCallFailure({
|
|
399
|
+
cfg,
|
|
400
|
+
env,
|
|
401
|
+
kind,
|
|
402
|
+
code: res.authCode,
|
|
403
|
+
detail: failureDetail(res, kind),
|
|
404
|
+
retryAfterMs: retryAfterMsOf(res),
|
|
405
|
+
});
|
|
406
|
+
return res;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** A short, token-free reason to persist alongside the breaker state. */
|
|
410
|
+
function failureDetail(res, kind) {
|
|
411
|
+
if (kind === 'auth' && res.authRefreshFailed) return 'OAuth refresh failed';
|
|
412
|
+
if (res.networkError) return typeof res.data === 'string' ? res.data : 'request failed';
|
|
413
|
+
return `HTTP ${res.status}`;
|
|
287
414
|
}
|
|
288
415
|
|
|
289
416
|
/**
|
|
@@ -308,3 +435,33 @@ export function formatAuthFailure(prefix, res) {
|
|
|
308
435
|
}
|
|
309
436
|
return null;
|
|
310
437
|
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* The failure line for a call this machine refused to make.
|
|
441
|
+
*
|
|
442
|
+
* `formatCallFailure` would render `server 0 — …` for these, inventing an HTTP
|
|
443
|
+
* exchange that never happened. Both cases are local decisions and must read
|
|
444
|
+
* that way, or the user goes looking for a server problem that is not there.
|
|
445
|
+
*
|
|
446
|
+
* @param {string} prefix
|
|
447
|
+
* @param {object} res
|
|
448
|
+
* @returns {string|null}
|
|
449
|
+
*/
|
|
450
|
+
export function formatLocalRefusal(prefix, res) {
|
|
451
|
+
if (res?.breakerOpen || res?.rateCapped) {
|
|
452
|
+
return `${prefix}: ${typeof res.data === 'string' ? res.data : JSON.stringify(res.data)}\n`;
|
|
453
|
+
}
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The one failure formatter every capture command uses, so a new failure mode
|
|
459
|
+
* can never reach a command that does not know how to render it.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} prefix
|
|
462
|
+
* @param {object} res
|
|
463
|
+
* @returns {string}
|
|
464
|
+
*/
|
|
465
|
+
export function formatFailure(prefix, res) {
|
|
466
|
+
return formatLocalRefusal(prefix, res) ?? formatAuthFailure(prefix, res) ?? formatCallFailure(prefix, res);
|
|
467
|
+
}
|
package/src/backoff.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Retry policy — the client half of the 2026-08-02 database overload.
|
|
2
|
+
//
|
|
3
|
+
// The request tail from that outage is ONE machine running anyslate-cli/0.3.1
|
|
4
|
+
// with a stale refresh token, emitting `POST /oauth/token` followed by
|
|
5
|
+
// `POST /mcp` roughly fifty times a minute, for hours. Nothing in the client
|
|
6
|
+
// ever said "wait": an auth failure re-minted a token and re-issued the call
|
|
7
|
+
// immediately, so the only ceiling on request rate was how fast a process could
|
|
8
|
+
// loop. When the database then answered "overloaded", every client retried at
|
|
9
|
+
// once and deepened the overload.
|
|
10
|
+
//
|
|
11
|
+
// Nothing here is about making a call succeed. It is about making failure cheap
|
|
12
|
+
// for the server:
|
|
13
|
+
//
|
|
14
|
+
// * a HARD attempt cap per process — no path in this CLI may loop unbounded,
|
|
15
|
+
// * exponential backoff with jitter between attempts, so a fleet recovering
|
|
16
|
+
// from one outage does not resynchronise into the next one,
|
|
17
|
+
// * `Retry-After` on a 429/503 OVERRIDES our own schedule, and when the
|
|
18
|
+
// server asks for longer than the whole retry budget we do NOT sleep on it —
|
|
19
|
+
// the wait is handed to the circuit breaker (guard.mjs), the process exits,
|
|
20
|
+
// and the *next* process is the one that honours it. Sleeping instead would
|
|
21
|
+
// hold a hook open across the user's editor.
|
|
22
|
+
//
|
|
23
|
+
// Pure module: no I/O, no ambient timers, injectable clock/randomness, so the
|
|
24
|
+
// policy is unit-testable without a server.
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The default budget, used by the user-initiated commands (`checkpoint`,
|
|
28
|
+
* `upload-artifact`) where an exit code is meaningful and a second or two of
|
|
29
|
+
* waiting is cheaper than a lost capture.
|
|
30
|
+
*/
|
|
31
|
+
export const DEFAULT_RETRY = {
|
|
32
|
+
maxAttempts: 3,
|
|
33
|
+
baseDelayMs: 500,
|
|
34
|
+
maxDelayMs: 8_000,
|
|
35
|
+
/** Ceiling on time spent sleeping across ALL attempts of one call. */
|
|
36
|
+
maxTotalDelayMs: 15_000,
|
|
37
|
+
/** A `Retry-After` longer than this is never slept on — the breaker holds it. */
|
|
38
|
+
maxRetryAfterSleepMs: 5_000,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Hooks get NO retries at all.
|
|
43
|
+
*
|
|
44
|
+
* A hook fires on every tool call, in front of the user's editor, and the event
|
|
45
|
+
* it carries is one row in an activity feed. Re-sending it doubles the request
|
|
46
|
+
* rate the incident was made of, in exchange for a row nobody will miss. One
|
|
47
|
+
* attempt, short timeout, move on.
|
|
48
|
+
*/
|
|
49
|
+
export const HOOK_RETRY = {
|
|
50
|
+
...DEFAULT_RETRY,
|
|
51
|
+
maxAttempts: 1,
|
|
52
|
+
maxTotalDelayMs: 0,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Why a call failed, in the only four buckets that change what we do next.
|
|
57
|
+
*
|
|
58
|
+
* auth — 401/invalid_token. One paired refresh may fix it; repetition
|
|
59
|
+
* never will, so this is what trips the breaker.
|
|
60
|
+
* throttled — 429/503. The server named a wait; honour it, never retry inside it.
|
|
61
|
+
* transient — 5xx, or the request never landed. Worth one backed-off retry.
|
|
62
|
+
* fatal — 4xx we caused (bad payload, unknown kind, denied handle). The
|
|
63
|
+
* server already spent the work; re-sending is pure waste.
|
|
64
|
+
*
|
|
65
|
+
* @param {{ok?: boolean, status?: number, networkError?: boolean}} res a callTool result
|
|
66
|
+
* @returns {'auth'|'throttled'|'transient'|'fatal'|null} null when the call succeeded
|
|
67
|
+
*/
|
|
68
|
+
export function classifyFailure(res) {
|
|
69
|
+
if (!res || res.ok) return null;
|
|
70
|
+
if (res.networkError) return 'transient';
|
|
71
|
+
const status = Number(res.status);
|
|
72
|
+
if (status === 401) return 'auth';
|
|
73
|
+
if (status === 429 || status === 503) return 'throttled';
|
|
74
|
+
if (status === 0 || status >= 500) return 'transient';
|
|
75
|
+
return 'fatal';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The wait the server asked for, in milliseconds.
|
|
80
|
+
*
|
|
81
|
+
* `callTool` already parses `Retry-After` and the body's `retry_after_seconds`
|
|
82
|
+
* into `retryAfterSeconds` (both the transport 429 and the tool-level one), so
|
|
83
|
+
* this only has to normalise it.
|
|
84
|
+
*
|
|
85
|
+
* @param {{retryAfterSeconds?: number|null}} res
|
|
86
|
+
* @returns {number|null}
|
|
87
|
+
*/
|
|
88
|
+
export function retryAfterMsOf(res) {
|
|
89
|
+
const seconds = Number(res?.retryAfterSeconds);
|
|
90
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return null;
|
|
91
|
+
return Math.round(seconds * 1000);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Exponential backoff with jitter.
|
|
96
|
+
*
|
|
97
|
+
* Equal jitter (half fixed, half random) rather than full jitter: it still
|
|
98
|
+
* decorrelates a fleet of clients that all failed on the same server-side
|
|
99
|
+
* second, while guaranteeing that attempt N really does wait longer than
|
|
100
|
+
* attempt N-1. Full jitter can draw ~0ms and re-send instantly, which is the
|
|
101
|
+
* behaviour this module exists to remove.
|
|
102
|
+
*
|
|
103
|
+
* @param {number} attempt 1-based; the delay BEFORE attempt+1
|
|
104
|
+
* @param {typeof DEFAULT_RETRY} policy
|
|
105
|
+
* @param {() => number} [random]
|
|
106
|
+
* @returns {number}
|
|
107
|
+
*/
|
|
108
|
+
export function backoffDelayMs(attempt, policy = DEFAULT_RETRY, random = Math.random) {
|
|
109
|
+
const exponential = policy.baseDelayMs * 2 ** Math.max(0, attempt - 1);
|
|
110
|
+
const capped = Math.min(policy.maxDelayMs, exponential);
|
|
111
|
+
return Math.round(capped / 2 + random() * (capped / 2));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The whole retry decision for one failed attempt.
|
|
116
|
+
*
|
|
117
|
+
* Returns `retry: false` for every reason a request must NOT be repeated, and
|
|
118
|
+
* says which one — the caller records that reason, so a machine that stops
|
|
119
|
+
* retrying can explain itself instead of just going quiet.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} opts
|
|
122
|
+
* @param {'auth'|'throttled'|'transient'|'fatal'|null} opts.kind
|
|
123
|
+
* @param {number} opts.attempt how many attempts have already been made
|
|
124
|
+
* @param {number} [opts.spentDelayMs] time already slept on this call
|
|
125
|
+
* @param {number|null} [opts.retryAfterMs]
|
|
126
|
+
* @param {typeof DEFAULT_RETRY} [opts.policy]
|
|
127
|
+
* @param {() => number} [opts.random]
|
|
128
|
+
* @returns {{retry: boolean, delayMs: number, reason: string}}
|
|
129
|
+
*/
|
|
130
|
+
export function planRetry({
|
|
131
|
+
kind,
|
|
132
|
+
attempt,
|
|
133
|
+
spentDelayMs = 0,
|
|
134
|
+
retryAfterMs = null,
|
|
135
|
+
policy = DEFAULT_RETRY,
|
|
136
|
+
random = Math.random,
|
|
137
|
+
}) {
|
|
138
|
+
if (kind !== 'throttled' && kind !== 'transient') {
|
|
139
|
+
return { retry: false, delayMs: 0, reason: kind === 'auth' ? 'auth' : 'not_retryable' };
|
|
140
|
+
}
|
|
141
|
+
if (attempt >= policy.maxAttempts) {
|
|
142
|
+
return { retry: false, delayMs: 0, reason: 'attempt_cap' };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A named wait always wins over our schedule — but only if we can afford to
|
|
146
|
+
// sit through it. A server that says "60s" gets an exiting process and a
|
|
147
|
+
// persisted cool-down, not a hook blocking the editor for a minute.
|
|
148
|
+
if (retryAfterMs != null) {
|
|
149
|
+
if (retryAfterMs > policy.maxRetryAfterSleepMs) {
|
|
150
|
+
return { retry: false, delayMs: 0, reason: 'retry_after_too_long' };
|
|
151
|
+
}
|
|
152
|
+
if (spentDelayMs + retryAfterMs > policy.maxTotalDelayMs) {
|
|
153
|
+
return { retry: false, delayMs: 0, reason: 'delay_budget' };
|
|
154
|
+
}
|
|
155
|
+
return { retry: true, delayMs: retryAfterMs, reason: 'retry_after' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const delayMs = backoffDelayMs(attempt, policy, random);
|
|
159
|
+
if (spentDelayMs + delayMs > policy.maxTotalDelayMs) {
|
|
160
|
+
return { retry: false, delayMs: 0, reason: 'delay_budget' };
|
|
161
|
+
}
|
|
162
|
+
return { retry: true, delayMs, reason: 'backoff' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Deliberately a REF'd timer. An `unref`ed one lets Node drain its event loop
|
|
167
|
+
* and exit mid-backoff, which would turn "wait, then retry" into "exit 0 having
|
|
168
|
+
* silently dropped the call". The bound on how long this can hold a process is
|
|
169
|
+
* `maxTotalDelayMs`, not the timer flag.
|
|
170
|
+
*
|
|
171
|
+
* @param {number} ms
|
|
172
|
+
* @returns {Promise<void>}
|
|
173
|
+
*/
|
|
174
|
+
export function sleep(ms) {
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
setTimeout(resolve, ms);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
// stdout carries results only. Every error goes to stderr.
|
|
13
13
|
|
|
14
14
|
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
15
|
+
import { callToolWithAuth, formatFailure } from '../auth.mjs';
|
|
16
|
+
import { markNotified } from '../guard.mjs';
|
|
17
17
|
import { recordRun } from '../runlog.mjs';
|
|
18
18
|
import { VERSION } from '../version.mjs';
|
|
19
19
|
import { makeIo } from '../io.mjs';
|
|
@@ -84,8 +84,18 @@ export async function runCheckpoint(argv, deps = {}) {
|
|
|
84
84
|
fetchImpl: deps.fetchImpl,
|
|
85
85
|
});
|
|
86
86
|
if (res.authWarning) err.write(`${res.authWarning}\n`);
|
|
87
|
+
|
|
88
|
+
// A paused breaker or the local ceiling means no request was made, so there
|
|
89
|
+
// is no round trip to record. Unlike `hook` this is a command the user just
|
|
90
|
+
// typed, so it always says why rather than staying quiet.
|
|
91
|
+
if (res.breakerOpen || res.rateCapped) {
|
|
92
|
+
err.write(formatFailure('anyslate checkpoint', res));
|
|
93
|
+
if (res.breakerOpen) markNotified(env);
|
|
94
|
+
return 1;
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
if (!res.ok) {
|
|
88
|
-
const message =
|
|
98
|
+
const message = formatFailure('anyslate checkpoint', res);
|
|
89
99
|
err.write(message);
|
|
90
100
|
recordRun(
|
|
91
101
|
{
|
package/src/commands/doctor.mjs
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from '../config.mjs';
|
|
28
28
|
import { checkUrlShape, hasWriteScope, isValidTokenFormat, probeVerify, tokenPreview } from '../verify.mjs';
|
|
29
29
|
import { hasOauthCredentials, isExpired, minutesUntilExpiry, resolveBearer } from '../auth.mjs';
|
|
30
|
+
import { breakerGate, guardPath } from '../guard.mjs';
|
|
30
31
|
import { lastRunPath, readLastRun } from '../runlog.mjs';
|
|
31
32
|
import { callTool } from '../mcp-client.mjs';
|
|
32
33
|
import { VERSION, USER_AGENT } from '../version.mjs';
|
|
@@ -128,6 +129,24 @@ export async function runDoctor(argv = [], deps = {}) {
|
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
// ---- 1b. Circuit breaker ------------------------------------------------
|
|
133
|
+
// Pure local state, so it is answerable when the service is down — which is
|
|
134
|
+
// exactly when it will be open. It sits this early because "capture is paused
|
|
135
|
+
// and no request is being made" reframes every check below it: the token may
|
|
136
|
+
// be perfect and capture still dead until the pause expires or `login` runs.
|
|
137
|
+
// `doctor` itself is never gated by it; diagnosing must always be possible.
|
|
138
|
+
const pause = breakerGate({ cfg, env });
|
|
139
|
+
if (pause.open) {
|
|
140
|
+
add(
|
|
141
|
+
WARN,
|
|
142
|
+
'breaker',
|
|
143
|
+
`Capture is paused until ${pause.until} (${pause.reason}${pause.detail ? `: ${pause.detail}` : ''}) — no requests are being sent.`,
|
|
144
|
+
pause.reason === 'auth'
|
|
145
|
+
? 'Run `anyslate login` to sign in again; that clears the pause immediately.'
|
|
146
|
+
: `The pause clears itself, or delete ${guardPath(env)} to clear it now.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
131
150
|
// ---- 2. Token present --------------------------------------------------
|
|
132
151
|
// "Present" now has three shapes: an env/static token, a live OAuth access
|
|
133
152
|
// token, or OAuth credentials whose access token has aged out but which carry
|
package/src/commands/hook.mjs
CHANGED
|
@@ -17,10 +17,19 @@
|
|
|
17
17
|
// Tool-level errors (HTTP 200 + result.isError) now count as failures and are
|
|
18
18
|
// therefore visible to --strict — previously a 403 handle denial printed
|
|
19
19
|
// nothing and exited 0.
|
|
20
|
+
//
|
|
21
|
+
// COST CEILING (2026-08-02). This command is the one the incident was made of:
|
|
22
|
+
// PostToolUse fires per tool call, so a busy session runs it dozens of times a
|
|
23
|
+
// minute, and every run is a fresh process with no memory of the last. It is
|
|
24
|
+
// therefore the strictest caller in the CLI — one attempt, never a retry, a
|
|
25
|
+
// short timeout, and a persisted breaker that makes a hopeless call cost the
|
|
26
|
+
// server nothing at all. A dropped activity row is one line in a feed; a hook
|
|
27
|
+
// that retries is a doubled request rate in front of the user's editor.
|
|
20
28
|
|
|
21
29
|
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
30
|
+
import { callToolWithAuth, formatFailure } from '../auth.mjs';
|
|
31
|
+
import { HOOK_RETRY } from '../backoff.mjs';
|
|
32
|
+
import { markNotified } from '../guard.mjs';
|
|
24
33
|
import { buildHookSubmission, parseHookEvent } from '../hooks.mjs';
|
|
25
34
|
import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
|
|
26
35
|
import { recordRun, shouldEscalate, escalationPayload } from '../runlog.mjs';
|
|
@@ -29,6 +38,27 @@ import { makeIo } from '../io.mjs';
|
|
|
29
38
|
|
|
30
39
|
const ALLOWED = new Set(['session-start', 'post-tool-use', 'stop']);
|
|
31
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Total budget for the two requests a submission costs (initialize +
|
|
43
|
+
* tools/call). Deliberately far below the 15s every other command gets: a hook
|
|
44
|
+
* runs in front of a human, and a slow server must not become a slow editor.
|
|
45
|
+
* `ANYSLATE_HOOK_TIMEOUT_MS` raises it for pathologically slow links.
|
|
46
|
+
*/
|
|
47
|
+
export const HOOK_TIMEOUT_MS = 5_000;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How long a hook will queue behind another process's token refresh. The shared
|
|
51
|
+
* default is 10s, which on a machine where every refresh is failing means every
|
|
52
|
+
* hook waits the full ten seconds to learn nothing.
|
|
53
|
+
*/
|
|
54
|
+
export const HOOK_LOCK_WAIT_MS = 2_000;
|
|
55
|
+
|
|
56
|
+
/** @param {NodeJS.ProcessEnv} env */
|
|
57
|
+
function hookTimeoutMs(env) {
|
|
58
|
+
const raw = Number(env.ANYSLATE_HOOK_TIMEOUT_MS);
|
|
59
|
+
return Number.isFinite(raw) && raw > 0 ? raw : HOOK_TIMEOUT_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
32
62
|
/**
|
|
33
63
|
* @param {string[]} argv arguments after `hook`
|
|
34
64
|
* @param {{env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch}} [deps]
|
|
@@ -107,10 +137,31 @@ export async function runHook(argv, deps = {}) {
|
|
|
107
137
|
toolName: 'activity_submit',
|
|
108
138
|
args,
|
|
109
139
|
fetchImpl: deps.fetchImpl,
|
|
140
|
+
timeoutMs: hookTimeoutMs(env),
|
|
141
|
+
retry: HOOK_RETRY,
|
|
142
|
+
lockWaitMs: HOOK_LOCK_WAIT_MS,
|
|
110
143
|
});
|
|
111
144
|
if (res.authWarning) err.write(`${res.authWarning}\n`);
|
|
145
|
+
|
|
146
|
+
// A locally-refused call made no request, so there is no run outcome to
|
|
147
|
+
// record — the run log is a record of round trips, and filling it with
|
|
148
|
+
// "we did not try" would bury the failure that caused the pause. The user
|
|
149
|
+
// is told once per pause; after that a paused hook is completely silent,
|
|
150
|
+
// which is the whole contract: cost the server nothing, cost the user
|
|
151
|
+
// nothing, keep the fix on screen exactly once.
|
|
152
|
+
if (res.breakerOpen || res.rateCapped) {
|
|
153
|
+
if (!res.breakerOpen || res.breaker?.noticeDue) {
|
|
154
|
+
err.write(formatFailure(prefix, res));
|
|
155
|
+
if (res.breakerOpen) {
|
|
156
|
+
markNotified(env);
|
|
157
|
+
escalatePause(out, sub, res);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return strict ? 1 : 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
112
163
|
if (!res.ok) {
|
|
113
|
-
return fail(
|
|
164
|
+
return fail(formatFailure(prefix, res), {
|
|
114
165
|
status: res.status,
|
|
115
166
|
isError: !!res.isError,
|
|
116
167
|
networkError: !!res.networkError,
|
|
@@ -142,6 +193,23 @@ function escalateIfNeeded(out, sub, state, error) {
|
|
|
142
193
|
out.write(escalationPayload({ ...state, error }));
|
|
143
194
|
}
|
|
144
195
|
|
|
196
|
+
/**
|
|
197
|
+
* A paused breaker is the one state where stderr is guaranteed to be read by
|
|
198
|
+
* nobody AND the fix is a single command. Say it through the channel Claude
|
|
199
|
+
* Code renders, once, at the only point in a session where acting on it is
|
|
200
|
+
* cheap.
|
|
201
|
+
*/
|
|
202
|
+
function escalatePause(out, sub, res) {
|
|
203
|
+
if (sub !== 'session-start') return;
|
|
204
|
+
out.write(
|
|
205
|
+
escalationPayload({
|
|
206
|
+
consecutiveFailures: 0,
|
|
207
|
+
failingSince: null,
|
|
208
|
+
error: typeof res.data === 'string' ? res.data : 'capture paused',
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
145
213
|
/** @param {string[]} argv */
|
|
146
214
|
function parseFlags(argv) {
|
|
147
215
|
const out = { strict: false };
|
package/src/commands/login.mjs
CHANGED
|
@@ -26,6 +26,7 @@ import { join } from 'node:path';
|
|
|
26
26
|
import { DEFAULT_API_URL, anyslateDir, isCaptureDisabled, normalizeApiRoot } from '../config.mjs';
|
|
27
27
|
import { checkUrlShape, isValidTokenFormat, probeVerify, scopeWarning, tokenPreview } from '../verify.mjs';
|
|
28
28
|
import { readConfigFile, writeConfigFile } from '../credentials.mjs';
|
|
29
|
+
import { clearBreaker } from '../guard.mjs';
|
|
29
30
|
import {
|
|
30
31
|
DEFAULT_CALLBACK_TIMEOUT_S,
|
|
31
32
|
REGISTERED_REDIRECT_URI,
|
|
@@ -199,6 +200,10 @@ async function runTokenLogin(flags, deps) {
|
|
|
199
200
|
err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
|
|
200
201
|
return 1;
|
|
201
202
|
}
|
|
203
|
+
// A pause earned by the credential the user has just replaced must not
|
|
204
|
+
// outlive it — otherwise the documented fix ("run `anyslate login`") appears
|
|
205
|
+
// to do nothing for the next fifteen minutes.
|
|
206
|
+
clearBreaker(env);
|
|
202
207
|
|
|
203
208
|
out.write(`anyslate: wrote ${path}\n`);
|
|
204
209
|
out.write(` apiUrl: ${next.apiUrl}\n`);
|
|
@@ -386,6 +391,8 @@ async function runOauthLogin(flags, deps) {
|
|
|
386
391
|
err.write(`anyslate login: write failed (${e?.message ?? e})\n`);
|
|
387
392
|
return 1;
|
|
388
393
|
}
|
|
394
|
+
// See the static path: a fresh sign-in cancels the pause its predecessor earned.
|
|
395
|
+
clearBreaker(env);
|
|
389
396
|
|
|
390
397
|
out.write(`anyslate: wrote ${path}\n`);
|
|
391
398
|
out.write(` apiUrl: ${root}\n`);
|
package/src/commands/logout.mjs
CHANGED
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import { join } from 'node:path';
|
|
23
23
|
import { anyslateDir, loadConfig } from '../config.mjs';
|
|
24
24
|
import { readConfigFile, writeConfigFile } from '../credentials.mjs';
|
|
25
|
+
import { clearBreaker } from '../guard.mjs';
|
|
25
26
|
import { discover, revocationEndpointFor, revokeToken } from '../oauth.mjs';
|
|
26
27
|
import { makeIo } from '../io.mjs';
|
|
27
28
|
|
|
@@ -101,6 +102,9 @@ export async function runLogout(argv = [], deps = {}) {
|
|
|
101
102
|
err.write(`anyslate logout: could not update ${path} (${e?.message ?? e})\n`);
|
|
102
103
|
return 1;
|
|
103
104
|
}
|
|
105
|
+
// The breaker is state about a credential that no longer exists. Leaving it
|
|
106
|
+
// behind would make the next `login` look broken.
|
|
107
|
+
clearBreaker(env);
|
|
104
108
|
|
|
105
109
|
const cleared = [hadOauth && 'OAuth session', hadStatic && 'static token'].filter(Boolean).join(' and ');
|
|
106
110
|
out.write(`anyslate: cleared the ${cleared} from ${path}.\n`);
|
|
@@ -16,8 +16,8 @@
|
|
|
16
16
|
import { readFileSync } from 'node:fs';
|
|
17
17
|
import { basename } from 'node:path';
|
|
18
18
|
import { loadConfig, requireToken, apiUrlNormalizationNotice, DISABLED_NOTICE } from '../config.mjs';
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
19
|
+
import { callToolWithAuth, formatFailure } from '../auth.mjs';
|
|
20
|
+
import { markNotified } from '../guard.mjs';
|
|
21
21
|
import { readStdin, stdinTimeoutFromEnv } from '../stdin.mjs';
|
|
22
22
|
import { recordRun } from '../runlog.mjs';
|
|
23
23
|
import { VERSION } from '../version.mjs';
|
|
@@ -143,9 +143,17 @@ export async function runUploadArtifact(argv, deps = {}) {
|
|
|
143
143
|
fetchImpl: deps.fetchImpl,
|
|
144
144
|
});
|
|
145
145
|
if (res.authWarning) err.write(`${res.authWarning}\n`);
|
|
146
|
+
|
|
147
|
+
// No request left the machine, so nothing is recorded as a round trip. See
|
|
148
|
+
// the same branch in checkpoint.mjs.
|
|
149
|
+
if (res.breakerOpen || res.rateCapped) {
|
|
150
|
+
err.write(formatFailure('anyslate upload-artifact', res));
|
|
151
|
+
if (res.breakerOpen) markNotified(env);
|
|
152
|
+
return 1;
|
|
153
|
+
}
|
|
154
|
+
|
|
146
155
|
if (!res.ok) {
|
|
147
|
-
const message =
|
|
148
|
-
formatAuthFailure('anyslate upload-artifact', res) ?? formatCallFailure('anyslate upload-artifact', res);
|
|
156
|
+
const message = formatFailure('anyslate upload-artifact', res);
|
|
149
157
|
err.write(message);
|
|
150
158
|
recordRun(
|
|
151
159
|
{
|
package/src/credentials.mjs
CHANGED
|
@@ -40,12 +40,21 @@ export function cliConfigPath(env = process.env) {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
+
* Read any JSON file out of `~/.anyslate`, tolerating every way it can be
|
|
44
|
+
* missing or malformed.
|
|
45
|
+
*
|
|
46
|
+
* Generic because the credential file is no longer the only state the CLI keeps
|
|
47
|
+
* across processes: the circuit breaker (guard.mjs) has to survive a process
|
|
48
|
+
* exit for the same reason a refresh token does, and it must inherit the same
|
|
49
|
+
* atomic-write discipline rather than growing its own.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} name file name inside the AnySlate directory
|
|
43
52
|
* @param {NodeJS.ProcessEnv} [env]
|
|
44
53
|
* @returns {Record<string, any>}
|
|
45
54
|
*/
|
|
46
|
-
export function
|
|
55
|
+
export function readJsonFile(name, env = process.env) {
|
|
47
56
|
try {
|
|
48
|
-
const parsed = JSON.parse(readFileSync(
|
|
57
|
+
const parsed = JSON.parse(readFileSync(join(anyslateDir(env), name), 'utf8'));
|
|
49
58
|
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
50
59
|
} catch {
|
|
51
60
|
return {};
|
|
@@ -55,15 +64,16 @@ export function readConfigFile(env = process.env) {
|
|
|
55
64
|
/**
|
|
56
65
|
* Serialize + rename. Never writes the target path in place.
|
|
57
66
|
*
|
|
67
|
+
* @param {string} name
|
|
58
68
|
* @param {Record<string, any>} next
|
|
59
69
|
* @param {NodeJS.ProcessEnv} [env]
|
|
60
70
|
* @returns {string} the path written
|
|
61
71
|
*/
|
|
62
|
-
export function
|
|
72
|
+
export function writeJsonFile(name, next, env = process.env) {
|
|
63
73
|
const dir = anyslateDir(env);
|
|
64
74
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
65
|
-
const path = join(dir,
|
|
66
|
-
const tmp = join(dir, `${
|
|
75
|
+
const path = join(dir, name);
|
|
76
|
+
const tmp = join(dir, `${name}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
|
|
67
77
|
try {
|
|
68
78
|
writeFileSync(tmp, JSON.stringify(next, null, 2), { mode: 0o600 });
|
|
69
79
|
renameSync(tmp, path);
|
|
@@ -78,6 +88,23 @@ export function writeConfigFile(next, env = process.env) {
|
|
|
78
88
|
return path;
|
|
79
89
|
}
|
|
80
90
|
|
|
91
|
+
/**
|
|
92
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
93
|
+
* @returns {Record<string, any>}
|
|
94
|
+
*/
|
|
95
|
+
export function readConfigFile(env = process.env) {
|
|
96
|
+
return readJsonFile(CONFIG_FILE, env);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* @param {Record<string, any>} next
|
|
101
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
102
|
+
* @returns {string} the path written
|
|
103
|
+
*/
|
|
104
|
+
export function writeConfigFile(next, env = process.env) {
|
|
105
|
+
return writeJsonFile(CONFIG_FILE, next, env);
|
|
106
|
+
}
|
|
107
|
+
|
|
81
108
|
/**
|
|
82
109
|
* Read-modify-write. `mutate` receives the CURRENT on-disk object (not a
|
|
83
110
|
* snapshot the caller took earlier) and returns the object to persist.
|
package/src/guard.mjs
ADDED
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// Client-side circuit breaker and local call ceiling — `~/.anyslate/cli-guard.json`.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS HAS TO BE ON DISK. Every hook invocation is its own process. An
|
|
4
|
+
// in-memory breaker would be reconstructed, empty, fifty times a minute, which
|
|
5
|
+
// is exactly the shape of the 2026-08-02 overload: one machine with a stale
|
|
6
|
+
// refresh token issued `POST /oauth/token` + `POST /mcp` continuously for hours
|
|
7
|
+
// because each new process started with no memory that the last fifty had all
|
|
8
|
+
// been rejected. The breaker is only useful if it OUTLIVES the process.
|
|
9
|
+
//
|
|
10
|
+
// WHAT IT ENFORCES
|
|
11
|
+
// * after repeated 401s (or one definitively dead credential) capture is
|
|
12
|
+
// paused for an escalating period, the user is told ONCE how to fix it
|
|
13
|
+
// (`anyslate login`), and until then the client makes no request at all,
|
|
14
|
+
// * a `Retry-After` from a 429/503 becomes a persisted pause, so the wait is
|
|
15
|
+
// honoured by whichever process runs next rather than by a sleeping hook,
|
|
16
|
+
// * repeated transport failures (the server saying "overloaded") pause too,
|
|
17
|
+
// so a struggling service is not kept under load by its own clients,
|
|
18
|
+
// * a generous per-minute ceiling on calls from this machine, as a backstop
|
|
19
|
+
// against a runaway agent that is succeeding at fifty calls a minute.
|
|
20
|
+
//
|
|
21
|
+
// FINGERPRINTING, SO A FIX IS NOT PUNISHED. The breaker is bound to the
|
|
22
|
+
// (apiUrl, credential) pair that tripped it. Point the CLI at a different
|
|
23
|
+
// service root, swap the token, or run `anyslate login`, and it no longer
|
|
24
|
+
// applies — otherwise "I fixed my config" would still mean fifteen minutes of
|
|
25
|
+
// dead capture. An OAuth session is fingerprinted by its `client_id`, which
|
|
26
|
+
// survives refresh-token rotation; if it did not, every refresh would silently
|
|
27
|
+
// reset the breaker and we would be back to the storm.
|
|
28
|
+
//
|
|
29
|
+
// FAILS OPEN, ALWAYS. Every function here swallows its own I/O errors. A
|
|
30
|
+
// guard file we cannot read or write must never be the reason a user's capture
|
|
31
|
+
// or login stops working — a local cache blip converting into a lockout is a
|
|
32
|
+
// worse failure than the one this module prevents.
|
|
33
|
+
|
|
34
|
+
import { createHash } from 'node:crypto';
|
|
35
|
+
import { unlinkSync } from 'node:fs';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import { anyslateDir } from './config.mjs';
|
|
38
|
+
import { readJsonFile, writeJsonFile } from './credentials.mjs';
|
|
39
|
+
|
|
40
|
+
export const GUARD_FILE = 'cli-guard.json';
|
|
41
|
+
|
|
42
|
+
/** Consecutive 401s before capture is paused. One is a blip; three is a state. */
|
|
43
|
+
export const AUTH_FAILURES_BEFORE_OPEN = 3;
|
|
44
|
+
|
|
45
|
+
/** Consecutive transport failures before we stop leaning on a struggling host. */
|
|
46
|
+
export const TRANSIENT_FAILURES_BEFORE_OPEN = 5;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Escalating pauses. A credential that is still dead after 15 minutes is not
|
|
50
|
+
* going to be alive at 16, and the user has already been told what to do — so
|
|
51
|
+
* the client backs further off rather than re-asking every quarter hour.
|
|
52
|
+
*/
|
|
53
|
+
export const AUTH_PAUSE_LADDER_MS = [15 * 60_000, 60 * 60_000, 6 * 60 * 60_000, 24 * 60 * 60_000];
|
|
54
|
+
export const TRANSIENT_PAUSE_LADDER_MS = [30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000];
|
|
55
|
+
|
|
56
|
+
/** Clamp on a server-named wait: long enough to respect, short enough to recover from. */
|
|
57
|
+
export const MIN_THROTTLE_MS = 1_000;
|
|
58
|
+
export const MAX_THROTTLE_MS = 60 * 60_000;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Machine-wide ceiling on capture calls per rolling minute. Set well above any
|
|
62
|
+
* human session (a busy Claude Code hour is single-digit calls per minute) —
|
|
63
|
+
* it exists to cap a runaway loop, not to shape normal traffic.
|
|
64
|
+
* `ANYSLATE_MAX_CALLS_PER_MINUTE=0` disables it.
|
|
65
|
+
*/
|
|
66
|
+
export const DEFAULT_CALLS_PER_MINUTE = 60;
|
|
67
|
+
|
|
68
|
+
/** Auth failures that no amount of retrying can resolve — pause on the first one. */
|
|
69
|
+
const TERMINAL_AUTH_CODES = new Set([
|
|
70
|
+
'invalid_grant',
|
|
71
|
+
'no_refresh_token',
|
|
72
|
+
'no_oauth_credentials',
|
|
73
|
+
'no_credentials',
|
|
74
|
+
'unauthorized_client',
|
|
75
|
+
'invalid_client',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
export function guardPath(env = process.env) {
|
|
79
|
+
return join(anyslateDir(env), GUARD_FILE);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
84
|
+
* @returns {Record<string, any>}
|
|
85
|
+
*/
|
|
86
|
+
export function readGuard(env = process.env) {
|
|
87
|
+
return readJsonFile(GUARD_FILE, env);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {Record<string, any>} next
|
|
92
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
93
|
+
* @returns {boolean} whether it landed — callers proceed either way
|
|
94
|
+
*/
|
|
95
|
+
export function writeGuard(next, env = process.env) {
|
|
96
|
+
try {
|
|
97
|
+
writeJsonFile(GUARD_FILE, next, env);
|
|
98
|
+
return true;
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Identify the (endpoint, credential) pair a breaker state belongs to, without
|
|
106
|
+
* storing anything a leaked guard file could be used with. Hashed for the same
|
|
107
|
+
* reason the run log is redacted: this file is written to a shared home
|
|
108
|
+
* directory and read by `doctor`.
|
|
109
|
+
*
|
|
110
|
+
* @param {{apiUrl?: string, authMode?: string, oauth?: object|null, mcpToken?: string|null,
|
|
111
|
+
* staticToken?: string|null, sources?: {mcpToken?: string}}} cfg
|
|
112
|
+
* @returns {string}
|
|
113
|
+
*/
|
|
114
|
+
export function authFingerprint(cfg) {
|
|
115
|
+
const apiUrl = String(cfg?.apiUrl ?? '');
|
|
116
|
+
let identity = 'none';
|
|
117
|
+
if (cfg?.sources?.mcpToken === 'env' && cfg?.mcpToken) {
|
|
118
|
+
identity = `env:${cfg.mcpToken}`;
|
|
119
|
+
} else if (cfg?.oauth && (cfg.oauth.access_token || cfg.oauth.refresh_token)) {
|
|
120
|
+
// client_id, NOT the tokens: rotation replaces those on every refresh, and a
|
|
121
|
+
// fingerprint that changes hourly is a breaker that never holds.
|
|
122
|
+
identity = `oauth:${cfg.oauth.client_id ?? ''}:${cfg.oauth.root ?? ''}`;
|
|
123
|
+
} else if (cfg?.staticToken || cfg?.mcpToken) {
|
|
124
|
+
identity = `static:${cfg.staticToken ?? cfg.mcpToken}`;
|
|
125
|
+
}
|
|
126
|
+
return createHash('sha256').update(`${apiUrl}|${identity}`).digest('hex').slice(0, 16);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Is capture paused right now for this config?
|
|
131
|
+
*
|
|
132
|
+
* Pure read — the caller decides whether to speak, then calls `markNotified`.
|
|
133
|
+
* Splitting it that way keeps "tell the user once" honest across processes: the
|
|
134
|
+
* flag is only set once something was actually printed.
|
|
135
|
+
*
|
|
136
|
+
* @param {{cfg: object, env?: NodeJS.ProcessEnv, now?: number}} opts
|
|
137
|
+
* @returns {{open: boolean, reason: string|null, detail: string|null, until: string|null,
|
|
138
|
+
* pausedForMs: number, noticeDue: boolean}}
|
|
139
|
+
*/
|
|
140
|
+
export function breakerGate({ cfg, env = process.env, now = Date.now() }) {
|
|
141
|
+
const closed = { open: false, reason: null, detail: null, until: null, pausedForMs: 0, noticeDue: false };
|
|
142
|
+
const state = readGuard(env);
|
|
143
|
+
if (!state.open_until) return closed;
|
|
144
|
+
|
|
145
|
+
// A different endpoint or credential is a different problem; do not serve a
|
|
146
|
+
// stale verdict about a setup the user has since changed.
|
|
147
|
+
if (state.fingerprint && state.fingerprint !== authFingerprint(cfg)) return closed;
|
|
148
|
+
|
|
149
|
+
const until = Date.parse(String(state.open_until));
|
|
150
|
+
if (!Number.isFinite(until) || until <= now) return closed;
|
|
151
|
+
|
|
152
|
+
return {
|
|
153
|
+
open: true,
|
|
154
|
+
reason: state.reason ?? 'auth',
|
|
155
|
+
detail: state.detail ?? null,
|
|
156
|
+
until: new Date(until).toISOString(),
|
|
157
|
+
pausedForMs: until - now,
|
|
158
|
+
noticeDue: !state.notified_at,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The one line a paused client is allowed to say. Names the fix, names when it
|
|
164
|
+
* will try again, and never repeats itself on the hook path.
|
|
165
|
+
*
|
|
166
|
+
* @param {{reason: string|null, detail: string|null, until: string|null}} gate
|
|
167
|
+
* @returns {string}
|
|
168
|
+
*/
|
|
169
|
+
export function breakerNotice(gate) {
|
|
170
|
+
const until = gate.until ? ` until ${gate.until}` : '';
|
|
171
|
+
const detail = gate.detail ? ` (${gate.detail})` : '';
|
|
172
|
+
if (gate.reason === 'auth') {
|
|
173
|
+
return (
|
|
174
|
+
`anyslate: capture paused${until} — repeated authentication failures${detail}. ` +
|
|
175
|
+
'Run `anyslate login` to sign in again; no requests are sent until then.'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
if (gate.reason === 'throttled') {
|
|
179
|
+
return `anyslate: capture paused${until} — the server asked this client to back off${detail}.`;
|
|
180
|
+
}
|
|
181
|
+
return (
|
|
182
|
+
`anyslate: capture paused${until} — repeated failures reaching the service${detail}. ` +
|
|
183
|
+
'Run `anyslate doctor` to check it.'
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Record that a call was rejected, and open the breaker if this is a pattern.
|
|
189
|
+
*
|
|
190
|
+
* @param {object} opts
|
|
191
|
+
* @param {object} opts.cfg
|
|
192
|
+
* @param {'auth'|'throttled'|'transient'|'fatal'|null} opts.kind
|
|
193
|
+
* @param {string} [opts.code] OAuth/refresh error code, when there is one
|
|
194
|
+
* @param {string} [opts.detail] short human reason, stored for `doctor`
|
|
195
|
+
* @param {number|null} [opts.retryAfterMs]
|
|
196
|
+
* @param {NodeJS.ProcessEnv} [opts.env]
|
|
197
|
+
* @param {number} [opts.now]
|
|
198
|
+
* @returns {{opened: boolean, open: boolean, reason: string|null, until: string|null, detail: string|null}}
|
|
199
|
+
*/
|
|
200
|
+
export function recordCallFailure({ cfg, kind, code, detail, retryAfterMs = null, env = process.env, now = Date.now() }) {
|
|
201
|
+
const quiet = { opened: false, open: false, reason: null, until: null, detail: null };
|
|
202
|
+
// A 4xx we caused is not a reason to stop capturing: the request was cheap to
|
|
203
|
+
// reject and the next one may be well-formed.
|
|
204
|
+
if (kind !== 'auth' && kind !== 'throttled' && kind !== 'transient') return quiet;
|
|
205
|
+
|
|
206
|
+
const fingerprint = authFingerprint(cfg);
|
|
207
|
+
const previous = readGuard(env);
|
|
208
|
+
// Counters belong to one (endpoint, credential) pair; a switch resets them.
|
|
209
|
+
const carried = previous.fingerprint === fingerprint ? previous : {};
|
|
210
|
+
|
|
211
|
+
const next = {
|
|
212
|
+
fingerprint,
|
|
213
|
+
auth_failures: Number(carried.auth_failures) || 0,
|
|
214
|
+
transient_failures: Number(carried.transient_failures) || 0,
|
|
215
|
+
trips: Number(carried.trips) || 0,
|
|
216
|
+
open_until: carried.open_until ?? null,
|
|
217
|
+
reason: carried.reason ?? null,
|
|
218
|
+
detail: carried.detail ?? null,
|
|
219
|
+
notified_at: carried.notified_at ?? null,
|
|
220
|
+
window_started_at: carried.window_started_at ?? null,
|
|
221
|
+
window_calls: Number(carried.window_calls) || 0,
|
|
222
|
+
updated_at: new Date(now).toISOString(),
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
let pauseMs = 0;
|
|
226
|
+
if (kind === 'throttled') {
|
|
227
|
+
// One is enough. `Retry-After` is an instruction, not a hint, and the
|
|
228
|
+
// machine that ignored it is the one that took the database down.
|
|
229
|
+
pauseMs = clamp(retryAfterMs ?? TRANSIENT_PAUSE_LADDER_MS[0], MIN_THROTTLE_MS, MAX_THROTTLE_MS);
|
|
230
|
+
next.reason = 'throttled';
|
|
231
|
+
} else if (kind === 'auth') {
|
|
232
|
+
next.auth_failures += 1;
|
|
233
|
+
const terminal = code ? TERMINAL_AUTH_CODES.has(code) : false;
|
|
234
|
+
if (terminal || next.auth_failures >= AUTH_FAILURES_BEFORE_OPEN) {
|
|
235
|
+
pauseMs = ladder(AUTH_PAUSE_LADDER_MS, next.trips);
|
|
236
|
+
next.reason = 'auth';
|
|
237
|
+
}
|
|
238
|
+
} else {
|
|
239
|
+
next.transient_failures += 1;
|
|
240
|
+
if (next.transient_failures >= TRANSIENT_FAILURES_BEFORE_OPEN) {
|
|
241
|
+
pauseMs = ladder(TRANSIENT_PAUSE_LADDER_MS, next.trips);
|
|
242
|
+
next.reason = 'server';
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (!pauseMs) {
|
|
247
|
+
writeGuard(next, env);
|
|
248
|
+
return quiet;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
next.open_until = new Date(now + pauseMs).toISOString();
|
|
252
|
+
next.detail = detail ? String(detail).slice(0, 200) : (code ?? null);
|
|
253
|
+
next.trips += 1;
|
|
254
|
+
next.auth_failures = 0;
|
|
255
|
+
next.transient_failures = 0;
|
|
256
|
+
// Re-arm the "say it once" flag: a NEW pause deserves a fresh notice.
|
|
257
|
+
next.notified_at = null;
|
|
258
|
+
writeGuard(next, env);
|
|
259
|
+
|
|
260
|
+
return { opened: true, open: true, reason: next.reason, until: next.open_until, detail: next.detail };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A call landed. Everything the breaker knew is stale.
|
|
265
|
+
*
|
|
266
|
+
* The rolling-minute window is deliberately preserved — a successful call still
|
|
267
|
+
* counts against the machine ceiling.
|
|
268
|
+
*
|
|
269
|
+
* @param {{cfg: object, env?: NodeJS.ProcessEnv}} opts
|
|
270
|
+
*/
|
|
271
|
+
export function recordCallSuccess({ cfg, env = process.env }) {
|
|
272
|
+
const previous = readGuard(env);
|
|
273
|
+
if (!previous.open_until && !previous.auth_failures && !previous.transient_failures && !previous.trips) return;
|
|
274
|
+
writeGuard(
|
|
275
|
+
{
|
|
276
|
+
fingerprint: authFingerprint(cfg),
|
|
277
|
+
auth_failures: 0,
|
|
278
|
+
transient_failures: 0,
|
|
279
|
+
trips: 0,
|
|
280
|
+
open_until: null,
|
|
281
|
+
reason: null,
|
|
282
|
+
detail: null,
|
|
283
|
+
notified_at: null,
|
|
284
|
+
window_started_at: previous.window_started_at ?? null,
|
|
285
|
+
window_calls: Number(previous.window_calls) || 0,
|
|
286
|
+
updated_at: new Date().toISOString(),
|
|
287
|
+
},
|
|
288
|
+
env,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Mark the pause as explained, so the next hook stays quiet.
|
|
294
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
295
|
+
* @param {number} [now]
|
|
296
|
+
*/
|
|
297
|
+
export function markNotified(env = process.env, now = Date.now()) {
|
|
298
|
+
const state = readGuard(env);
|
|
299
|
+
if (!state.open_until) return;
|
|
300
|
+
writeGuard({ ...state, notified_at: new Date(now).toISOString() }, env);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Drop the breaker entirely. `login` calls this: the user has just proved they
|
|
305
|
+
* can authenticate, so making them wait out a pause earned by the credential
|
|
306
|
+
* they just replaced would be absurd.
|
|
307
|
+
*
|
|
308
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
309
|
+
*/
|
|
310
|
+
export function clearBreaker(env = process.env) {
|
|
311
|
+
try {
|
|
312
|
+
unlinkSync(guardPath(env));
|
|
313
|
+
} catch {
|
|
314
|
+
// Never existed, or the home directory is not ours to write. Either way the
|
|
315
|
+
// breaker reads as closed, which is the fail-open answer.
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The machine-wide ceiling. Read-modify-write, so two hooks racing in the same
|
|
321
|
+
* second can undercount — that is the intended direction to be wrong in: this
|
|
322
|
+
* is a runaway backstop, not an accounting system, and a lost increment lets a
|
|
323
|
+
* legitimate call through rather than dropping it.
|
|
324
|
+
*
|
|
325
|
+
* @param {{env?: NodeJS.ProcessEnv, now?: number, limit?: number}} [opts]
|
|
326
|
+
* @returns {{allowed: boolean, count: number, limit: number, resetInMs: number}}
|
|
327
|
+
*/
|
|
328
|
+
export function reserveCallSlot({ env = process.env, now = Date.now(), limit } = {}) {
|
|
329
|
+
const ceiling = limit ?? callsPerMinuteLimit(env);
|
|
330
|
+
if (!ceiling) return { allowed: true, count: 0, limit: 0, resetInMs: 0 };
|
|
331
|
+
|
|
332
|
+
const state = readGuard(env);
|
|
333
|
+
const startedAt = Date.parse(String(state.window_started_at ?? ''));
|
|
334
|
+
const fresh = !Number.isFinite(startedAt) || now - startedAt >= 60_000;
|
|
335
|
+
const windowStart = fresh ? now : startedAt;
|
|
336
|
+
const count = (fresh ? 0 : Number(state.window_calls) || 0) + 1;
|
|
337
|
+
|
|
338
|
+
writeGuard(
|
|
339
|
+
{ ...state, window_started_at: new Date(windowStart).toISOString(), window_calls: count },
|
|
340
|
+
env,
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
return {
|
|
344
|
+
allowed: count <= ceiling,
|
|
345
|
+
count,
|
|
346
|
+
limit: ceiling,
|
|
347
|
+
resetInMs: Math.max(0, windowStart + 60_000 - now),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* @param {NodeJS.ProcessEnv} [env]
|
|
353
|
+
* @returns {number} 0 disables the ceiling
|
|
354
|
+
*/
|
|
355
|
+
export function callsPerMinuteLimit(env = process.env) {
|
|
356
|
+
const raw = env.ANYSLATE_MAX_CALLS_PER_MINUTE;
|
|
357
|
+
if (raw === undefined || raw === null || String(raw).trim() === '') return DEFAULT_CALLS_PER_MINUTE;
|
|
358
|
+
const n = Number(raw);
|
|
359
|
+
if (!Number.isFinite(n) || n < 0) return DEFAULT_CALLS_PER_MINUTE;
|
|
360
|
+
return Math.floor(n);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* @param {{count: number, limit: number, resetInMs: number}} slot
|
|
365
|
+
* @returns {string}
|
|
366
|
+
*/
|
|
367
|
+
export function ceilingNotice(slot) {
|
|
368
|
+
return (
|
|
369
|
+
`anyslate: local rate ceiling reached (${slot.count} calls in the last minute, limit ${slot.limit}) — ` +
|
|
370
|
+
`skipping this one for ${Math.ceil(slot.resetInMs / 1000)}s. ` +
|
|
371
|
+
'Raise or disable it with ANYSLATE_MAX_CALLS_PER_MINUTE.'
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function ladder(steps, index) {
|
|
376
|
+
return steps[Math.min(index, steps.length - 1)];
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function clamp(value, min, max) {
|
|
380
|
+
return Math.min(max, Math.max(min, value));
|
|
381
|
+
}
|
package/src/index.mjs
CHANGED
|
@@ -78,6 +78,18 @@ env:
|
|
|
78
78
|
ANYSLATE_DISABLE=1 kill switch: hook / checkpoint / upload-artifact make no
|
|
79
79
|
network call and exit 0. \`doctor\` and \`login\` still run
|
|
80
80
|
so you can diagnose and set up while capture is off.
|
|
81
|
+
ANYSLATE_HOOK_TIMEOUT_MS
|
|
82
|
+
total request budget for one \`hook\` submission
|
|
83
|
+
(default 5000). Hooks never retry.
|
|
84
|
+
ANYSLATE_MAX_CALLS_PER_MINUTE
|
|
85
|
+
machine-wide ceiling on capture calls (default 60;
|
|
86
|
+
0 disables). A backstop against a runaway loop.
|
|
87
|
+
|
|
88
|
+
capture pauses:
|
|
89
|
+
After repeated authentication failures — or when the server answers 429/503
|
|
90
|
+
with a Retry-After — the CLI stops calling entirely and records the pause in
|
|
91
|
+
~/.anyslate/cli-guard.json. It says so once, then stays silent. \`anyslate
|
|
92
|
+
login\` clears an auth pause immediately; \`anyslate doctor\` always reports one.
|
|
81
93
|
`;
|
|
82
94
|
|
|
83
95
|
/**
|
package/src/mcp-client.mjs
CHANGED
|
@@ -99,9 +99,20 @@ export function formatErrorPayload(raw) {
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
/**
|
|
102
|
-
*
|
|
102
|
+
* Statuses that carry a server-named wait.
|
|
103
|
+
*
|
|
104
|
+
* 503 joined 429 after 2026-08-02: when the database was overloaded the
|
|
105
|
+
* handlers returned bare 500s, every client read that as "try again now", and
|
|
106
|
+
* the retries deepened the outage. The service now answers 503 + `Retry-After`
|
|
107
|
+
* on that path, so the client has to read it there too — an unread Retry-After
|
|
108
|
+
* is the same as no Retry-After.
|
|
109
|
+
*/
|
|
110
|
+
const THROTTLE_STATUSES = new Set([429, 503]);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Extract throttle metadata. `Retry-After` was never read and
|
|
103
114
|
* `retry_after_seconds`/`reset_at`/`reason` survived only in `raw`
|
|
104
|
-
* (defect #31). Surfacing only —
|
|
115
|
+
* (defect #31). Surfacing only — backoff.mjs/guard.mjs decide what to do with it.
|
|
105
116
|
*
|
|
106
117
|
* @param {Response} res
|
|
107
118
|
* @param {unknown} raw
|
|
@@ -294,7 +305,7 @@ export async function callTool({
|
|
|
294
305
|
const formatted = formatErrorPayload(init.raw);
|
|
295
306
|
const errLike = formatted ?? { message: `initialize failed (HTTP ${init.status})` };
|
|
296
307
|
const out = { ok: false, status: init.status, data: errLike, raw: init.raw };
|
|
297
|
-
if (init.status
|
|
308
|
+
if (THROTTLE_STATUSES.has(init.status)) {
|
|
298
309
|
const rl = extractRateLimit(init.res, init.raw);
|
|
299
310
|
out.rateLimit = rl;
|
|
300
311
|
out.retryAfterSeconds = rl.retry_after_seconds;
|
|
@@ -322,7 +333,7 @@ export async function callTool({
|
|
|
322
333
|
|
|
323
334
|
const status = res.status;
|
|
324
335
|
const raw = await readBody(res);
|
|
325
|
-
const rateLimit = status
|
|
336
|
+
const rateLimit = THROTTLE_STATUSES.has(status) ? extractRateLimit(res, raw) : null;
|
|
326
337
|
|
|
327
338
|
const finish = (out) => {
|
|
328
339
|
if (rateLimit) {
|
|
@@ -356,7 +367,7 @@ export async function callTool({
|
|
|
356
367
|
const embeddedStatus = Number.isFinite(parsed.status) ? Number(parsed.status) : status;
|
|
357
368
|
const data = formatErrorPayload(parsed) ?? parsed.error ?? text ?? 'tool returned isError';
|
|
358
369
|
const out = { ok: false, status: embeddedStatus, data, raw, isError: true };
|
|
359
|
-
if (embeddedStatus
|
|
370
|
+
if (THROTTLE_STATUSES.has(embeddedStatus)) {
|
|
360
371
|
const rl = extractRateLimit(res, parsed);
|
|
361
372
|
out.rateLimit = rl;
|
|
362
373
|
out.retryAfterSeconds = rl.retry_after_seconds;
|