@link-assistant/hive-mind 2.13.4 → 2.13.5

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/src/codex.lib.mjs CHANGED
@@ -7,7 +7,11 @@ import { ensureUseM } from './use-m-bootstrap.lib.mjs';
7
7
  if (typeof globalThis.use === 'undefined') {
8
8
  await ensureUseM();
9
9
  }
10
- const { $ } = await use('command-stream');
10
+ const { $: __rawDollar$ } = await use('command-stream');
11
+ // Issue #2168: retry transient git network failures (push/fetch/pull) the same
12
+ // way `gh` calls are retried, for every command run through this module's `$`.
13
+ const { wrapDollarWithGitRetry } = await import('./git-retry.lib.mjs');
14
+ const $ = wrapDollarWithGitRetry(__rawDollar$);
11
15
  const fs = (await use('fs')).promises;
12
16
  const path = (await use('path')).default;
13
17
  const os = (await use('os')).default;
@@ -44,127 +48,10 @@ import Decimal from 'decimal.js-light';
44
48
  import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
45
49
  import { CODEX_CACHE_READ_USAGE_PATHS, CODEX_CACHE_WRITE_USAGE_PATHS, CODEX_MODEL_DIAGNOSTIC_PATHS, CODEX_REASONING_USAGE_PATHS, CODEX_USAGE_FIELD_NAMES, createCodexTokenFieldAvailability, getFirstObservedNumber, hasAnyObservedPath, hasOwnPath } from './codex.usage-fields.lib.mjs';
46
50
  const CODEX_LONG_CONTEXT_PRICE_THRESHOLD = 272000;
47
- const CODEX_COMPACT_API_ENDPOINT = '/responses/compact';
48
51
  const getCodexExecEnv = (verbose = false) => (verbose ? { ...process.env, RUST_LOG: 'debug' } : { ...process.env });
49
-
50
- const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
51
- const getCodexDiagnosticValue = (line, key) => {
52
- const match = line.match(new RegExp(`${escapeRegExp(key)}=(?:"([^"]*)"|([^\\s")]+))`));
53
- return match?.[1] ?? match?.[2] ?? null;
54
- };
55
- const getCodexDiagnosticInteger = (line, key) => {
56
- const value = getCodexDiagnosticValue(line, key);
57
- if (value === null) return null;
58
- const parsed = Number.parseInt(value, 10);
59
- return Number.isFinite(parsed) ? parsed : null;
60
- };
61
- const getCodexDiagnosticTimestamp = line => {
62
- const eventTimestamp = getCodexDiagnosticValue(line, 'event.timestamp');
63
- if (eventTimestamp) return eventTimestamp;
64
- const logPrefixMatch = line.match(/^\[(\d{4}-\d{2}-\d{2}T[^\]]+Z)\]/u);
65
- return logPrefixMatch?.[1] ?? null;
66
- };
67
- const isSuccessfulCodexCompactRequestLine = line => {
68
- if (!line.includes('codex_otel.log_only:')) return false;
69
- if (!line.includes('event.name="codex.api_request"')) return false;
70
- if (!line.includes(`endpoint="${CODEX_COMPACT_API_ENDPOINT}"`)) return false;
71
- const statusCode = getCodexDiagnosticInteger(line, 'http.response.status_code');
72
- return statusCode === null || (statusCode >= 200 && statusCode < 300);
73
- };
74
-
75
- const splitTokenCountEvenly = (total, partCount) => {
76
- const safeTotal = Math.max(0, Math.round(total || 0));
77
- const safePartCount = Math.max(1, Math.round(partCount || 1));
78
- const base = Math.floor(safeTotal / safePartCount);
79
- let remainder = safeTotal % safePartCount;
80
- return Array.from({ length: safePartCount }, () => {
81
- const value = base + (remainder > 0 ? 1 : 0);
82
- if (remainder > 0) remainder--;
83
- return value;
84
- });
85
- };
86
- const splitCodexSubSessionInputTokens = (total, partCount, autoCompactTokenLimit = null) => {
87
- const safeTotal = Math.max(0, Math.round(total || 0));
88
- const safePartCount = Math.max(1, Math.round(partCount || 1));
89
- const safeLimit = Number.isFinite(autoCompactTokenLimit) && autoCompactTokenLimit > 0 ? Math.round(autoCompactTokenLimit) : null;
90
- if (safePartCount <= 1) return [safeTotal];
91
- if (safeLimit && safeTotal > safeLimit * (safePartCount - 1)) {
92
- const chunks = [];
93
- let remaining = safeTotal;
94
- for (let i = 0; i < safePartCount - 1; i++) {
95
- const chunk = Math.min(safeLimit, remaining);
96
- chunks.push(chunk);
97
- remaining -= chunk;
98
- }
99
- chunks.push(Math.max(0, remaining));
100
- return chunks;
101
- }
102
- return splitTokenCountEvenly(safeTotal, safePartCount);
103
- };
104
- const splitTokenCountByWeights = (total, weights) => {
105
- const safeTotal = Math.max(0, Math.round(total || 0));
106
- const safeWeights = Array.isArray(weights) && weights.length > 0 ? weights.map(weight => Math.max(0, weight || 0)) : [1];
107
- const weightTotal = safeWeights.reduce((sum, weight) => sum + weight, 0);
108
- if (weightTotal <= 0) return splitTokenCountEvenly(safeTotal, safeWeights.length);
109
- let allocated = 0;
110
- return safeWeights.map((weight, index) => {
111
- if (index === safeWeights.length - 1) return Math.max(0, safeTotal - allocated);
112
- const value = Math.floor((safeTotal * weight) / weightTotal);
113
- allocated += value;
114
- return value;
115
- });
116
- };
117
- const rebuildCodexSubSessionsFromCompactifications = tokenUsage => {
118
- const compactifications = Array.isArray(tokenUsage.compactifications) ? tokenUsage.compactifications : [];
119
- if (compactifications.length === 0 || (tokenUsage.stepCount || 0) === 0) {
120
- tokenUsage.subSessions = Array.isArray(tokenUsage.subSessions) ? tokenUsage.subSessions : [];
121
- return;
122
- }
123
-
124
- const subSessionCount = compactifications.length + 1;
125
- const inputChunks = splitCodexSubSessionInputTokens(tokenUsage.inputTokens || 0, subSessionCount, tokenUsage.autoCompactTokenLimit);
126
- const cacheWriteChunks = splitTokenCountByWeights(tokenUsage.cacheWriteTokens || 0, inputChunks);
127
- const cacheReadChunks = splitTokenCountByWeights(tokenUsage.cacheReadTokens || 0, inputChunks);
128
- const outputChunks = splitTokenCountByWeights(tokenUsage.outputTokens || 0, inputChunks);
129
- tokenUsage.subSessions = inputChunks.map((inputTokens, index) => {
130
- const cacheCreationTokens = cacheWriteChunks[index] || 0;
131
- const outputTokens = outputChunks[index] || 0;
132
- return {
133
- inputTokens,
134
- cacheCreationTokens,
135
- cacheReadTokens: cacheReadChunks[index] || 0,
136
- outputTokens,
137
- messageCount: null,
138
- peakContextUsage: getCumulativeContextInputTokens({ inputTokens, cacheCreationTokens }),
139
- peakOutputUsage: outputTokens,
140
- estimated: true,
141
- source: 'codex.compact-diagnostics',
142
- compactBoundaryBefore: index === 0 ? null : compactifications[index - 1] || null,
143
- };
144
- });
145
- };
146
- const recordCodexCompactification = (line, tokenUsage) => {
147
- if (!isSuccessfulCodexCompactRequestLine(line)) return;
148
- const timestamp = getCodexDiagnosticTimestamp(line);
149
- const conversationId = getCodexDiagnosticValue(line, 'conversation.id');
150
- const existing = tokenUsage.compactifications.find(compact => compact.timestamp === timestamp && compact.conversationId === conversationId);
151
- if (existing) return;
152
- tokenUsage.compactifications.push({
153
- timestamp,
154
- preTokens: null,
155
- trigger: 'auto',
156
- source: 'codex.responses.compact',
157
- conversationId: conversationId || null,
158
- });
159
- };
160
- const parseCodexDiagnosticLine = (line, tokenUsage) => {
161
- const contextLimit = getCodexDiagnosticInteger(line, 'context_window') ?? getCodexDiagnosticInteger(line, 'model_context_window');
162
- if (contextLimit !== null) tokenUsage.contextLimit = contextLimit;
163
-
164
- const autoCompactTokenLimit = getCodexDiagnosticInteger(line, 'auto_compact_token_limit') ?? getCodexDiagnosticInteger(line, 'model_auto_compact_token_limit');
165
- if (autoCompactTokenLimit !== null) tokenUsage.autoCompactTokenLimit = autoCompactTokenLimit;
166
- recordCodexCompactification(line, tokenUsage);
167
- };
52
+ // Issue #2175: diagnostic-line parsing lives in its own module to keep this file
53
+ // under the 1350-line warning threshold.
54
+ import { parseCodexDiagnosticLine, rebuildCodexSubSessionsFromCompactifications } from './codex.diagnostics.lib.mjs';
168
55
  export const createCodexTokenUsage = requestedModelId => ({
169
56
  inputTokens: 0,
170
57
  outputTokens: 0,
@@ -115,6 +115,15 @@ export const retryLimits = {
115
115
  maxForkRetries: parseIntWithDefault('HIVE_MIND_MAX_FORK_RETRIES', 5),
116
116
  maxVerifyRetries: parseIntWithDefault('HIVE_MIND_MAX_VERIFY_RETRIES', 5),
117
117
  maxApiRetries: parseIntWithDefault('HIVE_MIND_MAX_API_RETRIES', 3),
118
+ // Issue #2168: GitHub's own 5xx / GraphQL-internal failures ("Something went
119
+ // wrong while executing your query") are usually over within seconds, but 3
120
+ // attempts at 1s+2s was too tight to ride one out. These budgets are used by
121
+ // `ghWithRateLimitRetry` for the transient (non-rate-limit) branch.
122
+ maxGitHubTransientRetries: parseIntWithDefault('HIVE_MIND_MAX_GITHUB_TRANSIENT_RETRIES', 6),
123
+ initialGitHubTransientDelayMs: parseIntWithDefault('HIVE_MIND_INITIAL_GITHUB_TRANSIENT_DELAY_MS', 2000),
124
+ // Issue #2168: network-facing git operations (push/fetch/clone) get the same
125
+ // treatment via `gitCmdRetry` in src/lib.mjs.
126
+ maxGitRetries: parseIntWithDefault('HIVE_MIND_MAX_GIT_RETRIES', 5),
118
127
  retryBackoffMultiplier: parseFloatWithDefault('HIVE_MIND_RETRY_BACKOFF_MULTIPLIER', 2),
119
128
  // Unified retry config for all transient API errors (Overloaded, 503, Internal Server Error)
120
129
  // Issue #2169: count backstop only. With the defaults below (3 min → 30 min backoff) the 12-hour
@@ -8,7 +8,11 @@ if (typeof globalThis.use === 'undefined') {
8
8
  await ensureUseM();
9
9
  }
10
10
 
11
- const { $ } = await use('command-stream');
11
+ const { $: __rawDollar$ } = await use('command-stream');
12
+ // Issue #2168: retry transient git network failures (push/fetch/pull) the same
13
+ // way `gh` calls are retried, for every command run through this module's `$`.
14
+ const { wrapDollarWithGitRetry } = await import('./git-retry.lib.mjs');
15
+ const $ = wrapDollarWithGitRetry(__rawDollar$);
12
16
 
13
17
  import { log, buildToolErrorMessage } from './lib.mjs';
14
18
  import { reportError } from './sentry.lib.mjs';
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Retry wrapper for network-facing *git* commands.
5
+ *
6
+ * Issue #2168 asked for retry on "any git/github operation". The `gh` half was
7
+ * already covered by src/github-rate-limit.lib.mjs (`wrapDollarWithGhRetry`),
8
+ * but the ~36 `git push` / `git fetch` / `git pull` call sites scattered across
9
+ * src/*.mjs had no retry at all: a single `fatal: unable to access ...` or
10
+ * `RPC failed; curl 56 ... connection reset` aborted the whole session.
11
+ *
12
+ * Wrapping every call site by hand would have to be repeated for every new call
13
+ * site, so the retry is installed on command-stream's `$` tag instead - exactly
14
+ * the shape already used for `gh`. Anything that runs through a wrapped `$`
15
+ * gets the retry for free, and the existing ESLint rules that push code towards
16
+ * the wrapped `$` keep new call sites covered.
17
+ *
18
+ * Only *network* subcommands are retried. Local plumbing (`git commit`,
19
+ * `git checkout`, ...) is deterministic: re-running it cannot turn a failure
20
+ * into a success, and a blind second attempt could mask the real error.
21
+ *
22
+ * `git clone` is intentionally NOT in the list: a partially-written destination
23
+ * directory makes the second attempt fail with "already exists and is not an
24
+ * empty directory", which would replace the real (transient) diagnosis with a
25
+ * confusing one. Repository cloning in this codebase goes through
26
+ * `gh repo clone`, which is already covered by the gh retry wrapper.
27
+ */
28
+
29
+ // Subcommands that talk to the remote and are safe to re-run verbatim.
30
+ const GIT_NETWORK_SUBCOMMANDS = Object.freeze(['push', 'fetch', 'pull', 'ls-remote']);
31
+
32
+ /**
33
+ * Decide whether `command` is a git command that reaches the network.
34
+ *
35
+ * Handles the `git -C <dir> push ...` and `git --no-pager fetch ...` forms by
36
+ * skipping leading option tokens (and their argument, for the options that take
37
+ * one) before looking at the subcommand.
38
+ *
39
+ * @param {string} command - the reconstructed shell command line.
40
+ * @returns {string|null} the matched subcommand, or null when not a git network command.
41
+ */
42
+ export const matchGitNetworkCommand = command => {
43
+ const text = String(command ?? '').trim();
44
+ if (!/^git(?:\s|$)/.test(text)) return null;
45
+ const tokens = text.split(/\s+/).slice(1);
46
+ const optionsWithValue = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--exec-path']);
47
+ for (let i = 0; i < tokens.length; i++) {
48
+ const token = tokens[i];
49
+ if (!token.startsWith('-')) {
50
+ // `git clone` is excluded on purpose - see the module header.
51
+ return GIT_NETWORK_SUBCOMMANDS.includes(token) ? token : null;
52
+ }
53
+ if (optionsWithValue.has(token)) i++;
54
+ }
55
+ return null;
56
+ };
57
+
58
+ /**
59
+ * Wrap command-stream's `$` so that git network commands are retried on
60
+ * transient failures. Non-git commands (and local git plumbing) are passed
61
+ * straight through, so the wrapper is safe to install globally.
62
+ *
63
+ * @template T
64
+ * @param {(strings: TemplateStringsArray, ...values: unknown[]) => Promise<T>} dollar
65
+ * @param {object} [options] - forwarded to gitCmdRetry per call.
66
+ * @returns {(strings: TemplateStringsArray, ...values: unknown[]) => Promise<T>}
67
+ */
68
+ export const wrapDollarWithGitRetry = (dollar, options = {}) => {
69
+ if (typeof dollar !== 'function') {
70
+ throw new TypeError(`Expected command-stream's $ export to be a function, received ${typeof dollar}.`);
71
+ }
72
+ const wrapped = (strings, ...values) => {
73
+ // Options-call form: `$({ cwd })` returns a new tag bound to those options.
74
+ if (strings && !Array.isArray(strings) && typeof strings === 'object') {
75
+ return wrapDollarWithGitRetry(dollar(strings), options);
76
+ }
77
+ let preview = '';
78
+ for (let i = 0; i < strings.length; i++) {
79
+ preview += strings[i];
80
+ if (i < values.length) preview += String(values[i] ?? '');
81
+ }
82
+ const subcommand = matchGitNetworkCommand(preview);
83
+ if (!subcommand) return dollar(strings, ...values);
84
+ // Lazy import keeps this module free of a static cycle with lib.mjs.
85
+ return (async () => {
86
+ const { gitCmdRetry } = await import('./lib.mjs');
87
+ return gitCmdRetry(() => dollar(strings, ...values), {
88
+ label: `git ${subcommand}`,
89
+ ...options,
90
+ });
91
+ })();
92
+ };
93
+ wrapped.raw = typeof dollar.raw === 'function' ? dollar.raw : dollar;
94
+ return wrapped;
95
+ };
96
+
97
+ export default { matchGitNetworkCommand, wrapDollarWithGitRetry };
@@ -0,0 +1,83 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Make `gh pr create` safe to retry.
5
+ *
6
+ * Issue #2168: `gh pr create` aborted a solve run on GitHub's transient
7
+ * GraphQL internal error. The fix is to retry — but a mutation that failed
8
+ * *after* GitHub committed it (a 5xx on the response path, not the request
9
+ * path) would make the retry fail again with
10
+ *
11
+ * GraphQL: A pull request already exists for konard:issue-4804-203a323f30b1.
12
+ *
13
+ * which is not transient and would abort the run just the same, only one step
14
+ * later. Retrying a write is only correct when the write is idempotent, so
15
+ * this module turns "already exists" into "here is the pull request you were
16
+ * trying to create".
17
+ */
18
+
19
+ /**
20
+ * Combined text of an error-ish value, mirroring the collector used by the
21
+ * transient classifier.
22
+ */
23
+ const errorText = error => {
24
+ if (!error) return '';
25
+ if (typeof error === 'string') return error;
26
+ const parts = [];
27
+ if (typeof error.message === 'string') parts.push(error.message);
28
+ if (error.stderr) parts.push(error.stderr.toString());
29
+ if (error.stdout) parts.push(error.stdout.toString());
30
+ if (error.cause) parts.push(errorText(error.cause));
31
+ return parts.join('\n');
32
+ };
33
+
34
+ /**
35
+ * Detect `gh pr create`'s "a pull request already exists" rejection.
36
+ *
37
+ * @param {unknown} error
38
+ * @returns {boolean}
39
+ */
40
+ export const isPullRequestAlreadyExistsError = error => {
41
+ const text = errorText(error).toLowerCase();
42
+ if (!text) return false;
43
+ return text.includes('a pull request already exists') || (text.includes('pull request') && text.includes('already exists'));
44
+ };
45
+
46
+ /**
47
+ * Look up the pull request that already exists for `headRef`.
48
+ *
49
+ * `gh pr list --head` matches on the branch name; for a fork the head is
50
+ * `owner:branch`, and GitHub still indexes it under the bare branch name, so
51
+ * the bare name is what we query with.
52
+ *
53
+ * @param {object} params
54
+ * @param {string} params.owner
55
+ * @param {string} params.repo
56
+ * @param {string} params.headRef - branch name, with or without an `owner:` prefix.
57
+ * @param {(command: string, options?: object) => Promise<{stdout: string}>} params.execGh - retry-wrapped exec (e.g. `execGhWithRetry`).
58
+ * @param {(msg: string, options?: object) => Promise<void>|void} [params.log]
59
+ * @returns {Promise<string|null>} PR URL, or null when none could be resolved.
60
+ */
61
+ export const findExistingPullRequestUrl = async ({ owner, repo, headRef, execGh, log = null }) => {
62
+ const branch = String(headRef || '').includes(':') ? String(headRef).split(':').pop() : String(headRef || '');
63
+ if (!branch) return null;
64
+ try {
65
+ const { stdout } = await execGh(`gh pr list --repo ${owner}/${repo} --head ${branch} --state all --limit 1 --json url,number,state`, {
66
+ label: 'gh pr list (existing PR lookup)',
67
+ });
68
+ const parsed = JSON.parse((stdout || '[]').toString().trim() || '[]');
69
+ const first = Array.isArray(parsed) ? parsed[0] : null;
70
+ if (first?.url) {
71
+ if (log) await Promise.resolve(log(` Recovered existing PR #${first.number} (${first.state}) for head ${branch}: ${first.url}`));
72
+ return first.url;
73
+ }
74
+ } catch (lookupError) {
75
+ if (log) await Promise.resolve(log(` Could not look up the existing PR for head ${branch}: ${lookupError.message}`, { level: 'warn' }));
76
+ }
77
+ return null;
78
+ };
79
+
80
+ export default {
81
+ isPullRequestAlreadyExistsError,
82
+ findExistingPullRequestUrl,
83
+ };
@@ -21,6 +21,8 @@ import { promisify } from 'node:util';
21
21
  import { exec as execCb } from 'node:child_process';
22
22
 
23
23
  import { limitReset, retryLimits } from './config.lib.mjs';
24
+ import { matchGitNetworkCommand } from './git-retry.lib.mjs';
25
+ import { collectErrorText, describeTransientError, formatTransientDiagnostics, isTransientNetworkError, GITHUB_SERVER_TRANSIENT_PATTERNS } from './transient-errors.lib.mjs';
24
26
 
25
27
  const exec = promisify(execCb);
26
28
 
@@ -34,24 +36,6 @@ const githubRateLimitLogging = {
34
36
  lastUsageByResource: null,
35
37
  };
36
38
 
37
- /**
38
- * Pull every plausible string out of a thrown error/result so pattern matches
39
- * survive whatever shape the upstream caller gave us (Error, exec result with
40
- * stdout/stderr, command-stream result, plain string, etc.).
41
- */
42
- const collectErrorText = error => {
43
- if (!error) return '';
44
- if (typeof error === 'string') return error;
45
- const parts = [];
46
- if (typeof error.message === 'string') parts.push(error.message);
47
- if (typeof error.stderr === 'string') parts.push(error.stderr);
48
- else if (error.stderr && typeof error.stderr.toString === 'function') parts.push(error.stderr.toString());
49
- if (typeof error.stdout === 'string') parts.push(error.stdout);
50
- else if (error.stdout && typeof error.stdout.toString === 'function') parts.push(error.stdout.toString());
51
- if (error.cause) parts.push(collectErrorText(error.cause));
52
- return parts.join('\n');
53
- };
54
-
55
39
  /**
56
40
  * Detect whether `error` represents a GitHub rate-limit response.
57
41
  * Recognises both primary (5,000/hr) and secondary (abuse-detection) forms.
@@ -288,22 +272,18 @@ const sleepWithCountdown = async (ms, log) => {
288
272
  };
289
273
 
290
274
  /**
291
- * Patterns matched against an error's combined message/stderr/stdout to decide
292
- * whether the failure is a transient network/edge fault that deserves a retry.
293
- * Mirrors `isTransientNetworkError` in `src/lib.mjs` (issue #1536); duplicated
294
- * here to avoid a circular import `lib.mjs` already imports from this file.
275
+ * Transient-fault classification now lives in `src/transient-errors.lib.mjs`
276
+ * (issue #2168). Both this module and `src/lib.mjs` import from that leaf
277
+ * module so a pattern added once applies everywhere; previously each file kept
278
+ * its own copy of the list and `GraphQL: Something went wrong while executing
279
+ * your query` was missing from both.
295
280
  *
296
281
  * Issue #1756: `gh pr create` failed with `HTTP 504: 504 Gateway Timeout
297
282
  * (https://api.github.com/graphql)`. `execGhWithRetry`/`ghWithRateLimitRetry`
298
283
  * only handled rate-limit errors before — a single 504 was fatal.
284
+ * Issue #2168: the same call failed with GitHub's GraphQL internal error,
285
+ * which is not an HTTP status at all and so was not covered by #1756.
299
286
  */
300
- const TRANSIENT_NETWORK_PATTERNS = ['i/o timeout', 'dial tcp', 'connection refused', 'connection reset', 'econnreset', 'etimedout', 'enotfound', 'ehostunreach', 'enetunreach', 'network is unreachable', 'temporary failure', 'http 502', 'http 503', 'http 504', 'bad gateway', 'service unavailable', 'gateway timeout', 'tls handshake timeout', 'ssl_error', 'socket hang up', 'unexpected eof'];
301
-
302
- const isTransientNetworkError = error => {
303
- const text = collectErrorText(error).toLowerCase();
304
- if (!text) return false;
305
- return TRANSIENT_NETWORK_PATTERNS.some(pattern => text.includes(pattern));
306
- };
307
287
 
308
288
  /**
309
289
  * Patterns that identify a *transient* failure of GitHub's compare/diff
@@ -318,13 +298,15 @@ const isTransientNetworkError = error => {
318
298
  * used to treat this as fatal and abort the whole session. These patterns let
319
299
  * callers recognise the transient case and degrade gracefully instead.
320
300
  *
321
- * Note: HTTP 500 is deliberately matched here (and NOT in
322
- * `TRANSIENT_NETWORK_PATTERNS`) because a bare 500 from arbitrary endpoints is
323
- * too broad to retry blindly; it is only safe to treat as transient for the
324
- * compare endpoint, alongside the explicit "not_available" / "heavy server
325
- * load" markers.
301
+ * Issue #2168: these markers are now the shared
302
+ * `GITHUB_SERVER_TRANSIENT_PATTERNS` list. HTTP 500 used to be matched only
303
+ * here, on the theory that a bare 500 is too broad to retry blindly. That
304
+ * carve-out is what let GitHub's GraphQL internal error abort `gh pr create`,
305
+ * so 5xx/GraphQL-internal responses are now retryable everywhere; writes that
306
+ * could double-apply are made idempotent at the call site instead (see
307
+ * `src/github-pr-idempotency.lib.mjs`).
326
308
  */
327
- const TRANSIENT_COMPARE_API_PATTERNS = ['this diff is temporarily unavailable', 'temporarily unavailable due to heavy server load', 'heavy server load', 'not_available', 'http 500', 'http 502', 'http 503', 'http 504'];
309
+ const TRANSIENT_COMPARE_API_PATTERNS = GITHUB_SERVER_TRANSIENT_PATTERNS;
328
310
 
329
311
  /**
330
312
  * Detect whether `error` represents a transient failure of GitHub's
@@ -364,8 +346,10 @@ const isTransientCompareApiError = error => {
364
346
  */
365
347
  export const ghWithRateLimitRetry = async (fn, options = {}) => {
366
348
  const maxAttempts = options.maxAttempts ?? retryLimits.maxApiRetries;
367
- const transientMaxAttempts = options.transientMaxAttempts ?? retryLimits.maxApiRetries;
368
- const transientDelay = options.transientDelay ?? 1000;
349
+ // Issue #2168: the transient budget used to reuse `maxApiRetries` (3 attempts
350
+ // at 1s + 2s). GitHub's GraphQL internal errors routinely outlast that.
351
+ const transientMaxAttempts = options.transientMaxAttempts ?? retryLimits.maxGitHubTransientRetries;
352
+ const transientDelay = options.transientDelay ?? retryLimits.initialGitHubTransientDelayMs;
369
353
  const transientBackoff = options.transientBackoff ?? 2;
370
354
  const label = options.label || 'gh';
371
355
  const log = options.log || (msg => console.warn(msg));
@@ -403,18 +387,24 @@ export const ghWithRateLimitRetry = async (fn, options = {}) => {
403
387
  continue;
404
388
  }
405
389
 
406
- if (isTransientNetworkError(error)) {
390
+ // Issue #2168: classify once, and log the classification on every path —
391
+ // including the "we are not retrying this" path — so the next unknown
392
+ // failure mode can be diagnosed from the session log without a rerun.
393
+ const description = describeTransientError(error);
394
+
395
+ if (description.transient) {
407
396
  transientAttempts++;
408
397
  if (transientAttempts >= transientMaxAttempts) {
409
- await Promise.resolve(log(`❌ ${label}: transient network error persisted after ${transientAttempts} attempts; giving up.`));
398
+ await Promise.resolve(log(`❌ ${label}: transient network error persisted after ${transientAttempts} attempts; giving up. [${formatTransientDiagnostics(description)}]`));
410
399
  throw error;
411
400
  }
412
401
  const waitMs = transientDelay * Math.pow(transientBackoff, transientAttempts - 1);
413
- await Promise.resolve(log(`⚠️ ${label}: transient network error (attempt ${transientAttempts}/${transientMaxAttempts}), retrying in ${Math.round(waitMs / 1000)}s...`));
402
+ await Promise.resolve(log(`⚠️ ${label}: transient network error (attempt ${transientAttempts}/${transientMaxAttempts}), retrying in ${Math.round(waitMs / 1000)}s... [${formatTransientDiagnostics(description)}]`));
414
403
  await sleepWithCountdown(waitMs, log);
415
404
  continue;
416
405
  }
417
406
 
407
+ await Promise.resolve(log(`ℹ️ ${label}: error is not retryable [${formatTransientDiagnostics(description)}]; propagating to caller.`));
418
408
  throw error;
419
409
  }
420
410
  }
@@ -480,7 +470,20 @@ export const wrapDollarWithGhRetry = (dollar, options = {}) => {
480
470
  if (i < values.length) preview += String(values[i] ?? '');
481
471
  }
482
472
  const isGh = /^\s*gh(?:\s|$)/.test(preview);
483
- if (!isGh) return dollar(strings, ...values);
473
+ if (!isGh) {
474
+ // Issue #2168: "retry for any git/github operation". Network-facing git
475
+ // commands (`git push`/`fetch`/`pull`/`ls-remote`) get the same treatment
476
+ // here so every module already using the wrapped `$` is covered without
477
+ // touching its call sites. Everything else passes straight through.
478
+ const gitSubcommand = matchGitNetworkCommand(preview);
479
+ if (gitSubcommand) {
480
+ return (async () => {
481
+ const { gitCmdRetry } = await import('./lib.mjs');
482
+ return gitCmdRetry(() => dollar(strings, ...values), { label: `$git (${gitSubcommand})`, ...options });
483
+ })();
484
+ }
485
+ return dollar(strings, ...values);
486
+ }
484
487
  return ghWithRateLimitRetry(() => dollar(strings, ...values), {
485
488
  label: `$gh (${preview.trim().split(/\s+/).slice(0, 3).join(' ')})`,
486
489
  ...options,