ruvnet-brain 4.7.15 → 4.7.17

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.
@@ -0,0 +1,150 @@
1
+ // DISTINCT-FROM: kb/download-retry.mjs — that retries the customer updater's HTTPS stream by error code; this
2
+ // classifies and retries the pipeline's `gh`/`git` subprocess reads by what they printed.
3
+ //
4
+ // scripts/transient-gh.mjs — ONE rule for what a transient GitHub/API/git failure looks like, and ONE bounded
5
+ // retry for it, shared by every read in the corpus nightly (ADR-0091, fault classes in CONTRIBUTING.md).
6
+ //
7
+ // THE FAILURE THIS EXISTS TO END (measured on 38 corpus nights, 2026-09-21 .. 2026-10-18). Twenty-four nights
8
+ // failed; the causes were a gist API rate limit, a publisher that could not re-read the release it had just
9
+ // created, a canary refused one anonymous read, a git credential prompt on a clone -- each a single blip at a
10
+ // single `gh`/`git`/`fetch` call, each unretried, each the whole night for every customer. Before this module
11
+ // every call site either had no retry or its own private idea of "transient" (the canary's status set, the
12
+ // gist transport's regexes, the reconciler's exit-status classes). The rule is now written once:
13
+ //
14
+ // TRANSIENT a rate limit (primary or secondary), HTTP 429/408/5xx, a connection reset/refused/timed out, a
15
+ // DNS hiccup, a TLS handshake failure, a hung-up socket, git's credential prompt on an anonymous
16
+ // fetch ("could not read Username"), a partial transfer ("early EOF", "RPC failed").
17
+ // NOT HTTP 404/410 (gone: the ifGone carry in source-coverage.mjs owns it), a bare HTTP 403 from an
18
+ // authenticated gh (a permission answer, e.g. "Resource not accessible by integration"), "release
19
+ // not found" (the publisher's absence proof), a signature, digest or schema refusal, a missing
20
+ // binary (ENOENT). Those are answers, not weather; retrying them only delays the honest failure.
21
+ //
22
+ // Anonymous `fetch` reads from a shared runner address are the one place a bare 403 IS transient (GitHub's
23
+ // per-IP throttle answers 403): that status set, TRANSIENT_HTTP, lives here too so the canary and any other
24
+ // anonymous reader share it rather than restating it.
25
+ //
26
+ // WRITES ARE NEVER RETRIED BLINDLY. `gh release create` uploads assets; a retry after a half-finished create
27
+ // collides with the release it left behind. `retryingGh` retries reads and idempotent edits only; a create or
28
+ // any `gh api` POST/PUT/PATCH/DELETE runs exactly once and reports what happened.
29
+
30
+ /** Anonymous-fetch statuses a shared runner address sees for a moment (GitHub throttles anonymous reads with 403). */
31
+ export const TRANSIENT_HTTP = new Set([403, 408, 429, 500, 502, 503, 504]);
32
+
33
+ export const DEFAULT_ATTEMPTS = 3;
34
+ export const DEFAULT_BASE_DELAY_MS = 15_000;
35
+ /** The wait before retry `n` is `base * n`. RUVNET_TRANSIENT_RETRY_DELAY_MS is the test seam (the suite sets 0 so a
36
+ * fake gh that answers "gateway timeout" fails in milliseconds, not after the 45 s a real night would wait). */
37
+ export function defaultBaseDelayMs(fallback = DEFAULT_BASE_DELAY_MS) {
38
+ const seam = Number(process.env.RUVNET_TRANSIENT_RETRY_DELAY_MS);
39
+ return Number.isFinite(seam) && seam >= 0 && process.env.RUVNET_TRANSIENT_RETRY_DELAY_MS !== undefined ? seam : fallback;
40
+ }
41
+
42
+ const TRANSIENT_TEXT = /\brate limit|secondary rate|abuse detection|HTTP (408|429|5\d\d)\b|\b(500|502|503|504) (Internal Server Error|Bad Gateway|Service Unavailable|Gateway Time-?out)|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|EPIPE|connection reset|connection refused|timed out|timeout|TLS handshake|socket hang up|could not resolve host|no such host|dial tcp|i\/o timeout|unexpected EOF|early EOF|RPC failed|remote end hung up|could not read Username|unable to access|temporarily unavailable|temporary failure/i;
43
+
44
+ /** What a failed subprocess said -- stderr, then stdout, then the spawn error -- as one string for classification. */
45
+ export function failureText(result) {
46
+ return [result?.stderr, result?.stdout, result?.error?.message].map((part) => String(part ?? '')).join('\n');
47
+ }
48
+
49
+ /** The one classifier: true when the text describes weather, not an answer. A missing binary is never weather. */
50
+ export function isTransientText(text) {
51
+ const message = String(text ?? '');
52
+ if (/\bENOENT\b/.test(message)) return false;
53
+ return TRANSIENT_TEXT.test(message);
54
+ }
55
+
56
+ /** A spawnSync-shaped result that failed transiently. */
57
+ export function isTransientResult(result) {
58
+ if (!result) return false;
59
+ if (!result.error && result.status === 0) return false;
60
+ if (result.error?.code === 'ENOENT') return false;
61
+ return isTransientText(failureText(result));
62
+ }
63
+
64
+ /** Blocks the thread for `ms`; the pipeline's gh/git callers are synchronous spawnSync loops. Injectable in tests. */
65
+ export function sleepSync(ms) {
66
+ if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
67
+ }
68
+
69
+ const delayFor = (attempt, baseDelayMs) => baseDelayMs * attempt;
70
+
71
+ /**
72
+ * Run `fn` (which THROWS on failure) up to `attempts` times while the failure is transient, waiting
73
+ * baseDelayMs * attempt between tries. The last error is rethrown unchanged, so callers keep their own messages.
74
+ */
75
+ export function retryTransient(fn, { attempts = DEFAULT_ATTEMPTS, baseDelayMs = defaultBaseDelayMs(), sleep = sleepSync,
76
+ isTransient = (error) => isTransientText(error?.message), onRetry = () => {} } = {}) {
77
+ for (let attempt = 1; ; attempt += 1) {
78
+ try {
79
+ return fn(attempt);
80
+ } catch (error) {
81
+ if (attempt >= attempts || !isTransient(error)) throw error;
82
+ onRetry({ attempt, error, delayMs: delayFor(attempt, baseDelayMs) });
83
+ sleep(delayFor(attempt, baseDelayMs));
84
+ }
85
+ }
86
+ }
87
+
88
+ /** The async twin of retryTransient, for callers whose work (or sleep) is a promise. */
89
+ export async function retryTransientAsync(fn, { attempts = DEFAULT_ATTEMPTS, baseDelayMs = defaultBaseDelayMs(),
90
+ sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
91
+ isTransient = (error) => isTransientText(error?.message), onRetry = () => {} } = {}) {
92
+ for (let attempt = 1; ; attempt += 1) {
93
+ try {
94
+ return await fn(attempt);
95
+ } catch (error) {
96
+ if (attempt >= attempts || !isTransient(error)) throw error;
97
+ onRetry({ attempt, error, delayMs: delayFor(attempt, baseDelayMs) });
98
+ await sleep(delayFor(attempt, baseDelayMs));
99
+ }
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Run `fn` (which RETURNS a spawnSync-shaped result, never throws) up to `attempts` times while the result is a
105
+ * transient failure. Returns the last result, so callers that read `status`/`stderr` keep doing exactly that.
106
+ */
107
+ export function retryTransientResult(fn, { attempts = DEFAULT_ATTEMPTS, baseDelayMs = defaultBaseDelayMs(),
108
+ sleep = sleepSync, onRetry = () => {} } = {}) {
109
+ let result;
110
+ for (let attempt = 1; ; attempt += 1) {
111
+ result = fn(attempt) || {};
112
+ if (attempt >= attempts || !isTransientResult(result)) return result;
113
+ onRetry({ attempt, result, delayMs: delayFor(attempt, baseDelayMs) });
114
+ sleep(delayFor(attempt, baseDelayMs));
115
+ }
116
+ }
117
+
118
+ const GH_WRITE_VERBS = new Set(['create', 'delete', 'upload', 'delete-asset']);
119
+ const REST_WRITE_FLAGS = new Set(['-f', '-F', '--input', '--field', '--raw-field']);
120
+ const METHOD_FLAGS = new Set(['-X', '--method']);
121
+
122
+ /**
123
+ * Is this `gh` argument vector a write that must run exactly once? `release edit` is an idempotent PATCH and is
124
+ * NOT a write here: re-sending the same flags after a timeout converges on the same state. `gh api` is a write
125
+ * when it names a non-GET method or carries a field flag (gh then sends POST); `gh api graphql` always POSTs.
126
+ */
127
+ export function isGhWrite(args) {
128
+ const argv = (args || []).map(String);
129
+ if (argv[0] === 'api') {
130
+ const methodAt = argv.findIndex((token) => METHOD_FLAGS.has(token));
131
+ if (methodAt >= 0 && String(argv[methodAt + 1] || '').toUpperCase() !== 'GET') return true;
132
+ if (argv[1] === 'graphql') return true;
133
+ return argv.some((token) => REST_WRITE_FLAGS.has(token));
134
+ }
135
+ return argv[0] === 'release' && GH_WRITE_VERBS.has(argv[1]);
136
+ }
137
+
138
+ /**
139
+ * A `gh` runner over a spawnSync-shaped `run` that retries transient failures of READS and idempotent edits,
140
+ * bounded, and runs a write exactly once. `prefix` is the RUVNET_GH_SCRIPT test seam (scripts/release.mjs).
141
+ */
142
+ export function retryingGh({ run, command = 'gh', prefix = [], options = {}, sleep = sleepSync, attempts = DEFAULT_ATTEMPTS,
143
+ baseDelayMs = defaultBaseDelayMs(), onRetry = () => {} } = {}) {
144
+ if (typeof run !== 'function') throw new Error('retryingGh needs a spawnSync-shaped run function');
145
+ return (args) => {
146
+ const once = () => run(command, [...prefix, ...args], options) || {};
147
+ if (isGhWrite(args)) return once();
148
+ return retryTransientResult(once, { attempts, baseDelayMs, sleep, onRetry: (event) => onRetry({ ...event, args }) });
149
+ };
150
+ }