@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/CHANGELOG.md +19 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +5 -1
- package/src/claude.lib.mjs +5 -158
- package/src/claude.session-tokens.lib.mjs +180 -0
- package/src/codex.diagnostics.lib.mjs +135 -0
- package/src/codex.lib.mjs +8 -121
- package/src/config.lib.mjs +9 -0
- package/src/gemini.lib.mjs +5 -1
- package/src/git-retry.lib.mjs +97 -0
- package/src/github-pr-idempotency.lib.mjs +83 -0
- package/src/github-rate-limit.lib.mjs +44 -41
- package/src/hive.mjs +8 -150
- package/src/hive.repository-fallback.lib.mjs +125 -0
- package/src/hive.startup-checks.lib.mjs +57 -0
- package/src/isolation-runner.lib.mjs +4 -277
- package/src/isolation-runner.parsers.lib.mjs +292 -0
- package/src/lib.mjs +79 -18
- package/src/opencode.lib.mjs +5 -1
- package/src/qwen.lib.mjs +5 -1
- package/src/session-monitor.lib.mjs +12 -272
- package/src/session-monitor.queries.lib.mjs +304 -0
- package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
- package/src/solve.auto-pr.lib.mjs +40 -154
- package/src/solve.mjs +8 -158
- package/src/solve.mode.lib.mjs +191 -0
- package/src/telegram-solve-queue.lib.mjs +19 -272
- package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
- package/src/transient-errors.lib.mjs +238 -0
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
|
-
|
|
51
|
-
|
|
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,
|
package/src/config.lib.mjs
CHANGED
|
@@ -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
|
package/src/gemini.lib.mjs
CHANGED
|
@@ -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
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
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
|
-
*
|
|
322
|
-
* `
|
|
323
|
-
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
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 =
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|