@link-assistant/hive-mind 2.13.1 → 2.13.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +19 -5
- package/src/buildUserMention.lib.mjs +30 -3
- package/src/claude.connection.lib.mjs +35 -43
- package/src/claude.lib.mjs +44 -13
- package/src/codex.lib.mjs +33 -10
- package/src/config.lib.mjs +23 -5
- package/src/gemini.lib.mjs +19 -5
- package/src/github-url-parser.lib.mjs +26 -1
- package/src/opencode.lib.mjs +19 -5
- package/src/qwen.lib.mjs +19 -5
- package/src/session-monitor.lib.mjs +3 -2
- package/src/telegram-accept-invitations.lib.mjs +5 -3
- package/src/telegram-bot.mjs +18 -9
- package/src/telegram-command-execution.lib.mjs +2 -1
- package/src/telegram-context-safety.lib.mjs +70 -0
- package/src/telegram-fix-command.lib.mjs +68 -4
- package/src/telegram-language-command.lib.mjs +4 -3
- package/src/telegram-log-command.lib.mjs +14 -12
- package/src/telegram-markdown-validator.lib.mjs +192 -0
- package/src/telegram-merge-command.lib.mjs +18 -16
- package/src/telegram-message-filters.lib.mjs +1 -1
- package/src/telegram-safe-reply.lib.mjs +290 -21
- package/src/telegram-solve-queue-command.lib.mjs +2 -1
- package/src/telegram-solve-queue.lib.mjs +16 -7
- package/src/telegram-start-stop-command.lib.mjs +38 -27
- package/src/telegram-subscribers.lib.mjs +6 -4
- package/src/telegram-terminal-watch-command.lib.mjs +8 -7
- package/src/telegram-tokens-command.lib.mjs +2 -1
- package/src/telegram-top-command.lib.mjs +8 -9
- package/src/tool-retry.lib.mjs +117 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.13.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- bf38253: Stop losing hours (and money) to retries of runs that already succeeded, and keep retrying a real provider outage for up to 12 hours. A gateway-error check matched any standalone `520`–`524` anywhere in the final message, so a solve run on issue #523 / PR #524 had each of its eleven _successful_ results re-classified as "Gateway error (502/504/52x)" and re-run — $7.65 and 3 h 55 min before `❌ Transient API error persisted after 10 retries`. HTTP status codes are now only recognised next to an error context (`API Error: 502`, `error code: 522`, `HTTP 504`) or a canonical gateway phrase, a run that Claude reported as successful is never retried (the near-miss is logged in verbose mode), and every tool's transient-retry loop is now driven by a wall-clock budget — 12 hours by default (`HIVE_MIND_TRANSIENT_ERROR_RETRY_BUDGET_MS`, `0` disables) with a 3-minute minimum delay (`HIVE_MIND_MIN_TRANSIENT_ERROR_DELAY_MS`) and a `3 → 30` min exponential backoff — instead of a fixed attempt count that gave up after ~3.5 hours. Full analysis in `docs/case-studies/issue-2169`.
|
|
8
|
+
|
|
9
|
+
## 2.13.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 6b0435a: Make every Telegram message the bot sends visible. A single unpaired `_` in a repository name (`save_visiogetbb`) made `parse_mode: 'Markdown'` messages fail with `400: can't parse entities`, and because the plain-text fallback was installed on `bot.telegram` — while telegraf hands each handler a _different_ `Telegram` instance — the fallback never ran, so `/stop` cancelled the task but reported nothing. All sends now go through one funnel that validates the text against a port of TDLib's `parse_markdown()` before the call, logs every attempt/success/rejection, chunks at 4096 chars, and retries as plain text on any `400`; the funnel is re-installed on every per-update context, covers document captions, and a new `telegram-safety/no-unsafe-telegram-send` ESLint rule makes a raw `parse_mode` send a build error. Also: `/stop` and queue cards echo only the URL actually interpreted (no `#issuecomment-…` anchor), mentions no longer render a literal `\_`, and `/fix` rejects unsupported options (`--ci-de`) up front instead of silently spawning a wrong run.
|
|
14
|
+
|
|
3
15
|
## 2.13.1
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
package/package.json
CHANGED
package/src/agent.lib.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { checkPlaywrightMcpPackageAvailability, getAgentPlaywrightMcpDisableEnv
|
|
|
28
28
|
import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage } from './agent-token-usage.lib.mjs';
|
|
29
29
|
import { createJsonStreamScanner, parseJsonRecords } from './json-stream.lib.mjs';
|
|
30
30
|
import { firstErrorText, stringifyErrorValue } from './error-text.lib.mjs';
|
|
31
|
-
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
31
|
+
import { classifyRetryableError, createTransientRetryBudget, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
32
32
|
import { attachStreamingInput, finalizeBidirectionalHandler, setupBidirectionalHandler } from './bidirectional-interactive.lib.mjs';
|
|
33
33
|
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
34
34
|
import { buildAgentArgs, detectFormalAiAgentRoutingMismatch, formatAgentArgsForDisplay, isAgentIdleEvent, isAgentStrongCompletionEvent } from './agent-command.lib.mjs';
|
|
@@ -571,13 +571,16 @@ export const executeAgentCommand = async params => {
|
|
|
571
571
|
|
|
572
572
|
// Retry configuration
|
|
573
573
|
let retryCount = 0;
|
|
574
|
+
// Issue #2169: retries are bounded by a wall-clock budget (12 h by default, configurable via
|
|
575
|
+
// HIVE_MIND_TRANSIENT_ERROR_RETRY_BUDGET_MS) instead of a low attempt count.
|
|
576
|
+
const transientRetryBudget = createTransientRetryBudget();
|
|
574
577
|
|
|
575
578
|
const executeWithRetry = async () => {
|
|
576
579
|
// Execute agent command from the cloned repository directory
|
|
577
580
|
if (retryCount === 0) {
|
|
578
581
|
await log(`\n${formatAligned('🤖', 'Executing Agent:', argv.model.toUpperCase())}`);
|
|
579
582
|
} else {
|
|
580
|
-
await log(`\n${formatAligned('🔄', 'Retry attempt:', `${retryCount}
|
|
583
|
+
await log(`\n${formatAligned('🔄', 'Retry attempt:', `${retryCount} (${transientRetryBudget.describeProgress()})`)}`);
|
|
581
584
|
}
|
|
582
585
|
|
|
583
586
|
if (argv.verbose) {
|
|
@@ -986,7 +989,17 @@ export const executeAgentCommand = async params => {
|
|
|
986
989
|
if (retryableError.isRetryable) {
|
|
987
990
|
const isRequestTimeoutRetry = retryableError.label === 'Request timeout';
|
|
988
991
|
const maxRetries = isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
|
|
989
|
-
|
|
992
|
+
// Issue #2169: the attempt count is only a runaway backstop — the 12-hour wall-clock budget
|
|
993
|
+
// (configurable) decides when to stop, and every wait honours the 3-minute minimum.
|
|
994
|
+
const retryDecision = transientRetryBudget.evaluate({
|
|
995
|
+
retryCount,
|
|
996
|
+
maxRetries,
|
|
997
|
+
initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
|
|
998
|
+
maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
|
|
999
|
+
minDelayMs: retryLimits.minTransientErrorDelayMs,
|
|
1000
|
+
});
|
|
1001
|
+
if (retryDecision.allowed) {
|
|
1002
|
+
transientRetryBudget.grant();
|
|
990
1003
|
if (sessionId && !argv.resume) argv.resume = sessionId;
|
|
991
1004
|
// Issue #2037: retry the same model on capacity errors before falling back;
|
|
992
1005
|
// after a capacity-driven model switch, retry quickly instead of waiting the
|
|
@@ -999,17 +1012,18 @@ export const executeAgentCommand = async params => {
|
|
|
999
1012
|
retryCount,
|
|
1000
1013
|
initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
|
|
1001
1014
|
maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
|
|
1015
|
+
minDelayMs: retryLimits.minTransientErrorDelayMs,
|
|
1002
1016
|
});
|
|
1003
1017
|
const delay = retryPlan.delay;
|
|
1004
1018
|
const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
|
|
1005
|
-
await log(`\n⚠️ ${retryableError.label} detected. Retry ${retryCount + 1}
|
|
1019
|
+
await log(`\n⚠️ ${retryableError.label} detected. Retry ${retryCount + 1} in ${delayLabel}${sessionId ? ' (session preserved)' : ''} (${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
|
|
1006
1020
|
await finalizeAgentBidirectionalHandler();
|
|
1007
1021
|
await waitForRetryDelay(delay, log);
|
|
1008
1022
|
await log('\n🔄 Retrying now...');
|
|
1009
1023
|
retryCount++;
|
|
1010
1024
|
return await executeWithRetry();
|
|
1011
1025
|
}
|
|
1012
|
-
await log(`\n\n❌ ${retryableError.label} persisted
|
|
1026
|
+
await log(`\n\n❌ ${retryableError.label} persisted: ${transientRetryBudget.describeExhaustion(retryDecision)}`, { level: 'error' });
|
|
1013
1027
|
}
|
|
1014
1028
|
|
|
1015
1029
|
// Build JSON error structure for consistent error reporting
|
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Make a display name safe to use as the label of a legacy-Markdown entity
|
|
3
|
+
* (`[label](url)`).
|
|
4
|
+
*
|
|
5
|
+
* Inside an entity TDLib copies bytes verbatim, so nothing needs escaping — but
|
|
6
|
+
* a literal `]` would terminate the entity early and turn the rest of the
|
|
7
|
+
* message into garbage. Those two delimiters are therefore dropped; `_` and `*`
|
|
8
|
+
* are deliberately left alone (issue #2166).
|
|
9
|
+
*
|
|
10
|
+
* @param {string} label - Raw display name.
|
|
11
|
+
* @returns {string} Label safe to embed between `[` and `]`.
|
|
12
|
+
*/
|
|
13
|
+
export function escapeMarkdownEntityLabel(label) {
|
|
14
|
+
if (!label || typeof label !== 'string') return label;
|
|
15
|
+
return label.replace(/[[\]]/g, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
1
18
|
/**
|
|
2
19
|
* Build a Telegram user mention link in various parse modes.
|
|
3
20
|
*
|
|
@@ -42,9 +59,19 @@ export function buildUserMention({ user, id: idParam, username: usernameParam, f
|
|
|
42
59
|
switch (parseMode) {
|
|
43
60
|
case 'Markdown': {
|
|
44
61
|
// Legacy Markdown: [text](url)
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
62
|
+
//
|
|
63
|
+
// Issue #2166: do NOT backslash-escape `_` / `*` here. TDLib's
|
|
64
|
+
// `parse_markdown()` only unescapes `\_ \* \` \[` at the *top level*; once it
|
|
65
|
+
// is inside an entity it copies bytes verbatim until the closing `]`:
|
|
66
|
+
//
|
|
67
|
+
// while (i < size && text[i] != end_character) { … text[result_size++] = text[i++]; }
|
|
68
|
+
//
|
|
69
|
+
// So `[@my\_user](…)` renders the backslashes literally — that is the
|
|
70
|
+
// unpolished `\_` the issue reports. The label is already inside the entity,
|
|
71
|
+
// which is what actually prevents the "can't find end of entity" error from
|
|
72
|
+
// issue #1460; only the delimiters themselves are dangerous.
|
|
73
|
+
const labelName = escapeMarkdownEntityLabel(displayName);
|
|
74
|
+
return `[${labelName}](${link})`;
|
|
48
75
|
}
|
|
49
76
|
case 'MarkdownV2': {
|
|
50
77
|
// MarkdownV2 requires escaping special characters
|
|
@@ -5,7 +5,8 @@ if (typeof globalThis.use === 'undefined') {
|
|
|
5
5
|
const { $ } = await use('command-stream');
|
|
6
6
|
import { log } from './lib.mjs';
|
|
7
7
|
import { reportError } from './sentry.lib.mjs';
|
|
8
|
-
import { timeouts, getThinkingLevelToTokens, getTokensToThinkingLevel, supportsThinkingBudget, DEFAULT_MAX_THINKING_BUDGET } from './config.lib.mjs';
|
|
8
|
+
import { timeouts, retryLimits, getThinkingLevelToTokens, getTokensToThinkingLevel, supportsThinkingBudget, DEFAULT_MAX_THINKING_BUDGET } from './config.lib.mjs';
|
|
9
|
+
import { createTransientRetryBudget, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
9
10
|
import { buildAuthRemedyLines } from './formal-ai.lib.mjs';
|
|
10
11
|
import { stringifyErrorValue } from './error-text.lib.mjs';
|
|
11
12
|
import { mapModelToId } from './claude.model-utils.lib.mjs';
|
|
@@ -14,15 +15,39 @@ export const validateClaudeConnection = async (model = 'haiku') => {
|
|
|
14
15
|
const mappedModel = mapModelToId(model);
|
|
15
16
|
// Issue #2130: "run claude login" is wrong advice for a Formal-AI-served model.
|
|
16
17
|
const authRemedyLines = buildAuthRemedyLines({ model, vendorRemedy: 'Please run: claude login' });
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// Issue #2169: a provider outage during validation used to abort the whole run after 3 quick
|
|
19
|
+
// retries (~seconds). Validation now shares the same wall-clock retry budget as execution —
|
|
20
|
+
// 12 h by default, 3-minute minimum wait, all configurable through HIVE_MIND_* env vars.
|
|
21
|
+
const maxRetries = retryLimits.maxTransientErrorRetries;
|
|
19
22
|
let retryCount = 0;
|
|
23
|
+
const transientRetryBudget = createTransientRetryBudget();
|
|
24
|
+
// Returns true when a retry was performed (caller should recurse), false when the budget is spent.
|
|
25
|
+
const retryAfterOverload = async context => {
|
|
26
|
+
const retryDecision = transientRetryBudget.evaluate({
|
|
27
|
+
retryCount,
|
|
28
|
+
maxRetries,
|
|
29
|
+
initialDelayMs: retryLimits.initialTransientErrorDelayMs,
|
|
30
|
+
maxDelayMs: retryLimits.maxTransientErrorDelayMs,
|
|
31
|
+
minDelayMs: retryLimits.minTransientErrorDelayMs,
|
|
32
|
+
});
|
|
33
|
+
if (!retryDecision.allowed) {
|
|
34
|
+
await log(`❌ API overload error persisted: ${transientRetryBudget.describeExhaustion(retryDecision)}`, { level: 'error' });
|
|
35
|
+
await log(' The API appears to be heavily loaded. Please try again later.', { level: 'error' });
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
transientRetryBudget.grant();
|
|
39
|
+
const delayLabel = retryDecision.delayMs >= 60000 ? `${Math.round(retryDecision.delayMs / 60000)} min` : `${Math.round(retryDecision.delayMs / 1000)}s`;
|
|
40
|
+
await log(`⚠️ API overload error ${context}. Retrying in ${delayLabel} (${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
|
|
41
|
+
await waitWithCountdown(retryDecision.delayMs, log);
|
|
42
|
+
retryCount++;
|
|
43
|
+
return true;
|
|
44
|
+
};
|
|
20
45
|
const attemptValidation = async () => {
|
|
21
46
|
try {
|
|
22
47
|
if (retryCount === 0) {
|
|
23
48
|
await log('🔍 Validating Claude CLI connection...');
|
|
24
49
|
} else {
|
|
25
|
-
await log(`🔄 Retry attempt ${retryCount}
|
|
50
|
+
await log(`🔄 Retry attempt ${retryCount} for Claude CLI validation (${transientRetryBudget.describeProgress()})...`);
|
|
26
51
|
}
|
|
27
52
|
try {
|
|
28
53
|
const versionResult = await $`timeout ${Math.floor(timeouts.claudeCli / 6000)} claude --version`;
|
|
@@ -83,21 +108,8 @@ export const validateClaudeConnection = async (model = 'haiku') => {
|
|
|
83
108
|
const jsonError = checkForJsonError(stdout) || checkForJsonError(stderr);
|
|
84
109
|
const isOverloadError = (stdout.includes('API Error: 500') && stdout.includes('Overloaded')) || (stdout.includes('API Error: 529') && stdout.includes('Overloaded')) || (stderr.includes('API Error: 500') && stderr.includes('Overloaded')) || (stderr.includes('API Error: 529') && stderr.includes('Overloaded')) || (jsonError && (jsonError.type === 'api_error' || jsonError.type === 'overloaded_error') && jsonError.message === 'Overloaded');
|
|
85
110
|
if (isOverloadError) {
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
await log(`⚠️ API overload error during validation. Retrying in ${delay / 1000} seconds...`, {
|
|
89
|
-
level: 'warning',
|
|
90
|
-
});
|
|
91
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
92
|
-
retryCount++;
|
|
93
|
-
return await attemptValidation();
|
|
94
|
-
} else {
|
|
95
|
-
await log(`❌ API overload error persisted after ${maxRetries} retries during validation`, {
|
|
96
|
-
level: 'error',
|
|
97
|
-
});
|
|
98
|
-
await log(' The API appears to be heavily loaded. Please try again later.', { level: 'error' });
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
111
|
+
if (await retryAfterOverload('during validation')) return await attemptValidation();
|
|
112
|
+
return false;
|
|
101
113
|
}
|
|
102
114
|
const exitCode = result.code ?? result.exitCode ?? 0; // Bun shell compat
|
|
103
115
|
if (exitCode !== 0) {
|
|
@@ -116,18 +128,8 @@ export const validateClaudeConnection = async (model = 'haiku') => {
|
|
|
116
128
|
}
|
|
117
129
|
if (jsonError) {
|
|
118
130
|
if ((jsonError.type === 'api_error' || jsonError.type === 'overloaded_error') && jsonError.message === 'Overloaded') {
|
|
119
|
-
if (
|
|
120
|
-
|
|
121
|
-
await log(`⚠️ API overload error in response. Retrying in ${delay / 1000} seconds...`, {
|
|
122
|
-
level: 'warning',
|
|
123
|
-
});
|
|
124
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
125
|
-
retryCount++;
|
|
126
|
-
return await attemptValidation();
|
|
127
|
-
} else {
|
|
128
|
-
await log(`❌ API overload error persisted after ${maxRetries} retries`, { level: 'error' });
|
|
129
|
-
return false;
|
|
130
|
-
}
|
|
131
|
+
if (await retryAfterOverload('in response')) return await attemptValidation();
|
|
132
|
+
return false;
|
|
131
133
|
}
|
|
132
134
|
await log(`❌ Claude CLI returned error: ${jsonError.type} - ${jsonError.message}`, { level: 'error' });
|
|
133
135
|
if (jsonError.type === 'forbidden') {
|
|
@@ -140,18 +142,8 @@ export const validateClaudeConnection = async (model = 'haiku') => {
|
|
|
140
142
|
} catch (error) {
|
|
141
143
|
const errorStr = error.message || error.toString();
|
|
142
144
|
if ((errorStr.includes('API Error: 500') && errorStr.includes('Overloaded')) || (errorStr.includes('API Error: 529') && errorStr.includes('Overloaded')) || (errorStr.includes('api_error') && errorStr.includes('Overloaded')) || (errorStr.includes('overloaded_error') && errorStr.includes('Overloaded'))) {
|
|
143
|
-
if (
|
|
144
|
-
|
|
145
|
-
await log(`⚠️ API overload error during validation. Retrying in ${delay / 1000} seconds...`, {
|
|
146
|
-
level: 'warning',
|
|
147
|
-
});
|
|
148
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
149
|
-
retryCount++;
|
|
150
|
-
return await attemptValidation();
|
|
151
|
-
} else {
|
|
152
|
-
await log(`❌ API overload error persisted after ${maxRetries} retries`, { level: 'error' });
|
|
153
|
-
return false;
|
|
154
|
-
}
|
|
145
|
+
if (await retryAfterOverload('during validation')) return await attemptValidation();
|
|
146
|
+
return false;
|
|
155
147
|
}
|
|
156
148
|
await log(`❌ Failed to validate Claude CLI connection: ${error.message}`, { level: 'error' });
|
|
157
149
|
await log(' 💡 Make sure Claude CLI is installed and accessible', { level: 'error' });
|
package/src/claude.lib.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { buildMcpConfigWithoutPlaywright, ensureClaudePlaywrightMcpServer } from
|
|
|
28
28
|
import { resolveClaudeSessionToolFlags } from './useless-tools.lib.mjs';
|
|
29
29
|
import { ensureClaudeQuietConfig } from './claude-quiet-config.lib.mjs';
|
|
30
30
|
import { fetchModelInfo } from './model-info.lib.mjs';
|
|
31
|
-
import { classifyRetryableError, logExecutionContext, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
31
|
+
import { classifyRetryableError, createTransientRetryBudget, describeClassificationEvidence, logExecutionContext, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
32
32
|
import { resolveSubSessionSize } from './sub-session-size.lib.mjs'; // Issue #1706
|
|
33
33
|
import { withAgentsMdAsClaudeMd } from './agents-md-claude-support.lib.mjs';
|
|
34
34
|
import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
|
|
@@ -341,6 +341,9 @@ export const executeClaudeCommand = async params => {
|
|
|
341
341
|
const escapePromptForShell = promptText => String(promptText).replace(/"/g, '\\"').replace(/\$/g, '\\$');
|
|
342
342
|
await validateBidirectionalModeConfig(argv, log);
|
|
343
343
|
let retryCount = 0;
|
|
344
|
+
// Issue #2169: total-time budget shared by every transient-error retry of this run (default
|
|
345
|
+
// 12 h). Created outside executeWithRetry so the elapsed clock survives the recursive calls.
|
|
346
|
+
const transientRetryBudget = createTransientRetryBudget();
|
|
344
347
|
let baseBranchInterventionPrompt = null;
|
|
345
348
|
let baseBranchInterventionResumeCount = 0;
|
|
346
349
|
// Issue #1834 (PR #1835 feedback): corrupted-thinking-block recovery — resume the session first,
|
|
@@ -353,7 +356,8 @@ export const executeClaudeCommand = async params => {
|
|
|
353
356
|
if (retryCount === 0) {
|
|
354
357
|
await log(`\n${formatAligned('🤖', 'Executing Claude:', argv.model.toUpperCase())}`);
|
|
355
358
|
} else {
|
|
356
|
-
|
|
359
|
+
// Issue #2169: the count cap is a backstop now, so report the retry budget instead.
|
|
360
|
+
await log(`\n${formatAligned('🔄', 'Retry attempt:', `${retryCount} (${transientRetryBudget.describeProgress()})`)}`);
|
|
357
361
|
}
|
|
358
362
|
if (argv.verbose) {
|
|
359
363
|
// Issue #1949: logExecutionContext shows the requested alias with its resolved
|
|
@@ -1035,17 +1039,32 @@ export const executeClaudeCommand = async params => {
|
|
|
1035
1039
|
}
|
|
1036
1040
|
// Issues #1331, #1353, #1472/#1475: Unified transient error retry (exponential backoff, session preservation)
|
|
1037
1041
|
const isTransientError = isStartupTimeout || isActivityTimeout || isOverloadError || isInternalServerError || is503Error || isRequestTimeout || isRateLimitError || retryableLastError.isRetryable || (lastMessage.includes('API Error: 500') && (lastMessage.includes('Overloaded') || lastMessage.includes('Internal server error'))) || (lastMessage.includes('API Error: 529') && (lastMessage.includes('overloaded_error') || lastMessage.includes('Overloaded'))) || (lastMessage.includes('api_error') && lastMessage.includes('Overloaded')) || (lastMessage.includes('overloaded_error') && lastMessage.includes('Overloaded')) || lastMessage.includes('API Error: 503') || (lastMessage.includes('503') && (lastMessage.includes('upstream connect error') || lastMessage.includes('remote connection failure'))) || lastMessage === 'Request timed out' || lastMessage.includes('Request timed out');
|
|
1042
|
+
// Issue #2169: a run that ended in a *successful* result event must never be retried.
|
|
1043
|
+
// `lastMessage` holds the agent's own last text, so any prose that merely looks like an API
|
|
1044
|
+
// error ("PR #524", "API Error: 503" quoted while working on an issue about it) used to flip
|
|
1045
|
+
// `isTransientError` on and send a finished session into the retry loop. In the reported run
|
|
1046
|
+
// all 11 attempts succeeded and were retried anyway, burning 3 h 54 min before the process
|
|
1047
|
+
// exited 1 with the summary text presented as the error.
|
|
1048
|
+
const runProducedSuccess = resultSuccessReceived && !commandFailed && !errorDuringExecution && exitCode === 0;
|
|
1049
|
+
if (runProducedSuccess && isTransientError) {
|
|
1050
|
+
await log(`🔍 Transient-error pattern seen in a successful run — not retrying (Issue #2169). Pattern: ${retryableLastError.label || 'flagged by stream detector'}; last message: ${JSON.stringify(lastMessage.substring(0, 200))}`, { verbose: true });
|
|
1051
|
+
await log(` Classification evidence: ${describeClassificationEvidence(lastMessage, retryableLastError.label)}`, { verbose: true });
|
|
1052
|
+
}
|
|
1038
1053
|
// Issue #2161: an account/subscription block short-circuits every retry
|
|
1039
1054
|
// path. Stale transient flags from earlier in the run (an overload at hour
|
|
1040
1055
|
// one, say) must not schedule a retry that is guaranteed to fail the same
|
|
1041
1056
|
// way — and each retry would burn another full startup against a provider
|
|
1042
1057
|
// that has already refused the credentials.
|
|
1043
|
-
if (
|
|
1044
|
-
// Issue #1472/#1475: Startup/activity timeout → 30s–2min backoff; #1353: Request timeout → 5min–1hr; general →
|
|
1058
|
+
if (!runProducedSuccess && isTransientError && !subscriptionError) {
|
|
1059
|
+
// Issue #1472/#1475: Startup/activity timeout → 30s–2min backoff; #1353: Request timeout → 5min–1hr; general → 3min–30min
|
|
1045
1060
|
const isTimeoutRetry = isStartupTimeout || isActivityTimeout;
|
|
1046
|
-
|
|
1061
|
+
// Issue #2169: stream timeouts keep their own short count cap; API errors are governed by
|
|
1062
|
+
// the 12-hour budget with a 3-minute floor on every wait.
|
|
1063
|
+
const maxRetries = isTimeoutRetry ? retryLimits.maxStreamTimeoutRetries : isRequestTimeout ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
|
|
1047
1064
|
const initialDelay = isTimeoutRetry ? 30000 : isRequestTimeout ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs;
|
|
1048
1065
|
const maxDelay = isTimeoutRetry ? 120000 : isRequestTimeout ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs;
|
|
1066
|
+
const minDelay = isTimeoutRetry ? 0 : retryLimits.minTransientErrorDelayMs;
|
|
1067
|
+
const retryDecision = transientRetryBudget.evaluate({ retryCount, maxRetries, initialDelayMs: initialDelay, maxDelayMs: maxDelay, minDelayMs: minDelay });
|
|
1049
1068
|
// Issue #1437: Fail fast when API signals x-should-retry: false AND session made no progress
|
|
1050
1069
|
const isStuckRetry = apiMarkedNotRetryable && retryCount >= retryLimits.maxNotRetryableAttempts && resultNumTurns <= 1;
|
|
1051
1070
|
if (isStuckRetry) {
|
|
@@ -1072,18 +1091,22 @@ export const executeClaudeCommand = async params => {
|
|
|
1072
1091
|
queuedFeedback, // Issue #817: Bidirectional mode feedback
|
|
1073
1092
|
};
|
|
1074
1093
|
}
|
|
1075
|
-
if (
|
|
1094
|
+
if (retryDecision.allowed) {
|
|
1095
|
+
transientRetryBudget.grant();
|
|
1076
1096
|
// Activity timeout preserves session (work was started), startup timeout does not (no session created)
|
|
1077
1097
|
if (!isStartupTimeout && sessionId && !argv.resume) argv.resume = sessionId;
|
|
1078
1098
|
// Issue #2037: retry same model on capacity errors before falling back; a switch retries fast.
|
|
1079
|
-
const retryPlan = await prepareRetryAfterError({ tool: 'claude', argv, log, errorMessage: retryableLastError.message || lastMessage, retryCount, initialDelayMs: initialDelay, maxDelayMs: maxDelay });
|
|
1099
|
+
const retryPlan = await prepareRetryAfterError({ tool: 'claude', argv, log, errorMessage: retryableLastError.message || lastMessage, retryCount, initialDelayMs: initialDelay, maxDelayMs: maxDelay, minDelayMs: minDelay });
|
|
1080
1100
|
const delay = retryPlan.delay;
|
|
1081
1101
|
const errorLabel = isStartupTimeout ? 'Stream startup timeout (Issue #1472/#1475)' : isActivityTimeout ? 'Stream activity timeout (Issue #1472)' : isRequestTimeout ? 'Request timeout' : retryableLastError.label || (isOverloadError || (lastMessage.includes('API Error: 500') && lastMessage.includes('Overloaded')) || (lastMessage.includes('API Error: 529') && lastMessage.includes('Overloaded')) ? `API overload (${lastMessage.includes('529') ? '529' : '500'})` : isInternalServerError || lastMessage.includes('Internal server error') ? 'Internal server error (500)' : isRateLimitError ? 'Server rate limited (429)' : '503 network error');
|
|
1082
1102
|
const notRetryableHint = apiMarkedNotRetryable ? ' (API says not retryable — will stop early if no progress)' : '';
|
|
1083
1103
|
const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
|
|
1084
1104
|
const retryMode = isStartupTimeout ? ' (fresh start)' : ' (session preserved)';
|
|
1085
|
-
await log(`\n⚠️ ${errorLabel} detected. Retry ${retryCount + 1}
|
|
1105
|
+
await log(`\n⚠️ ${errorLabel} detected. Retry ${retryCount + 1} in ${delayLabel}${retryMode}${notRetryableHint} (${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
|
|
1086
1106
|
await log(` Error: ${isStartupTimeout ? `No output from Claude CLI within ${timeouts.streamStartupMs / 1000}s` : isActivityTimeout ? `No output for ${timeouts.streamActivityMs / 1000}s after previous activity` : lastMessage.substring(0, 200)}`, { verbose: true });
|
|
1107
|
+
// Issue #2169: the 200-character excerpt above hid the token that actually triggered the
|
|
1108
|
+
// classifier in the reported run, so also log where every status-looking token sits.
|
|
1109
|
+
await log(` Classification evidence: ${describeClassificationEvidence(retryableLastError.message || lastMessage, errorLabel)}`, { verbose: true });
|
|
1087
1110
|
// Issue #1510: Post PR comment when force-killing and auto-resuming so reviewers can follow the session lifecycle
|
|
1088
1111
|
if ((isActivityTimeout || isStartupTimeout) && owner && repo && prNumber && $) {
|
|
1089
1112
|
try {
|
|
@@ -1102,7 +1125,10 @@ export const executeClaudeCommand = async params => {
|
|
|
1102
1125
|
retryCount++;
|
|
1103
1126
|
return await executeWithRetry();
|
|
1104
1127
|
} else {
|
|
1105
|
-
|
|
1128
|
+
// Issue #2169: report *why* we stopped — count backstop vs 12-hour budget — and how long
|
|
1129
|
+
// the run actually spent retrying, so an exhausted window is diagnosable from one line.
|
|
1130
|
+
const exhaustionReason = transientRetryBudget.describeExhaustion(retryDecision);
|
|
1131
|
+
await log(`\n\n❌ Transient API error persisted: ${exhaustionReason}\n Please try again later or check https://status.anthropic.com/\n Raise HIVE_MIND_TRANSIENT_ERROR_RETRY_BUDGET_MS to keep retrying for longer.`, { level: 'error' });
|
|
1106
1132
|
// Issue #1886: fold captured cost so the carried-forward cost survives this retries-exhausted path.
|
|
1107
1133
|
seedCumulativeAnthropicCost(argv.previousAnthropicCost);
|
|
1108
1134
|
const cumulativeAnthropicCostUSDOnRetriesExhausted = addAnthropicRunCost(anthropicTotalCostUSD ?? anthropicCostFromAnyResult);
|
|
@@ -1118,7 +1144,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1118
1144
|
anthropicTotalCostUSD: cumulativeAnthropicCostUSDOnRetriesExhausted, // Issue #1104/#1886: Include cumulative cost even on failure
|
|
1119
1145
|
resultSummary, // Issue #1263: Include result summary
|
|
1120
1146
|
// Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
|
|
1121
|
-
errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: `Transient API error persisted
|
|
1147
|
+
errorInfo: { message: buildToolErrorMessage({ lastMessage, exitCode, fallback: `Transient API error persisted: ${transientRetryBudget.describeExhaustion(retryDecision)}`, toolLabel: 'Claude' }), exitCode },
|
|
1122
1148
|
subscriptionError, // Issue #2161
|
|
1123
1149
|
queuedFeedback, // Issue #817: Bidirectional mode feedback
|
|
1124
1150
|
};
|
|
@@ -1253,19 +1279,24 @@ export const executeClaudeCommand = async params => {
|
|
|
1253
1279
|
const maxRetries = isTimeoutException ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
|
|
1254
1280
|
const initialDelay = isTimeoutException ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs;
|
|
1255
1281
|
const maxDelay = isTimeoutException ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs;
|
|
1256
|
-
|
|
1282
|
+
// Issue #2169: the same 12-hour budget with a 3-minute floor governs the exception path.
|
|
1283
|
+
const minDelay = retryLimits.minTransientErrorDelayMs;
|
|
1284
|
+
const retryDecision = transientRetryBudget.evaluate({ retryCount, maxRetries, initialDelayMs: initialDelay, maxDelayMs: maxDelay, minDelayMs: minDelay });
|
|
1285
|
+
if (retryDecision.allowed) {
|
|
1286
|
+
transientRetryBudget.grant();
|
|
1257
1287
|
if (sessionId && !argv.resume) argv.resume = sessionId;
|
|
1258
1288
|
// Issue #2037: retry same model on capacity errors before falling back; a switch retries fast.
|
|
1259
|
-
const retryPlan = await prepareRetryAfterError({ tool: 'claude', argv, log, errorMessage: errorStr, retryCount, initialDelayMs: initialDelay, maxDelayMs: maxDelay });
|
|
1289
|
+
const retryPlan = await prepareRetryAfterError({ tool: 'claude', argv, log, errorMessage: errorStr, retryCount, initialDelayMs: initialDelay, maxDelayMs: maxDelay, minDelayMs: minDelay });
|
|
1260
1290
|
const delay = retryPlan.delay;
|
|
1261
1291
|
const errorLabel = isTimeoutException ? 'Request timeout' : retryableException.label || (errorStr.includes('Overloaded') ? `API overload (${errorStr.includes('529') ? '529' : '500'})` : errorStr.includes('Internal server error') ? 'Internal server error (500)' : '503 network error');
|
|
1262
1292
|
const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
|
|
1263
|
-
await log(`\n⚠️ ${errorLabel} in exception. Retry ${retryCount + 1}
|
|
1293
|
+
await log(`\n⚠️ ${errorLabel} in exception. Retry ${retryCount + 1} in ${delayLabel} (session preserved, ${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
|
|
1264
1294
|
await waitWithCountdown(delay, log);
|
|
1265
1295
|
await log('\n🔄 Retrying now...');
|
|
1266
1296
|
retryCount++;
|
|
1267
1297
|
return await executeWithRetry();
|
|
1268
1298
|
}
|
|
1299
|
+
await log(`\n⏹️ Stopped retrying: ${transientRetryBudget.describeExhaustion(retryDecision)}`, { level: 'warning' });
|
|
1269
1300
|
}
|
|
1270
1301
|
await log(`\n\n❌ Error executing Claude command: ${error.message}`, { level: 'error' });
|
|
1271
1302
|
// Issue #1886: fold captured cost so the carried-forward cost survives this exception path too.
|
package/src/codex.lib.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { fetchModelInfo } from './model-info.lib.mjs';
|
|
|
34
34
|
import { defaultModels, isFormalAiModel } from './models/index.mjs';
|
|
35
35
|
import { buildAuthRemedyLines, buildFormalAiEnvExports, isPrepareOnly, logPreparedToolCommand, resolveFormalAiToolExecution } from './formal-ai.lib.mjs';
|
|
36
36
|
import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
|
|
37
|
-
import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
37
|
+
import { classifyRetryableError, createTransientRetryBudget, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
|
|
38
38
|
import { parseSubSessionSize, buildCodexSubSessionSizeConfigArgs, buildCodexDisable1mContextConfigArgs } from './sub-session-size.lib.mjs'; // Issue #1706
|
|
39
39
|
import { getCumulativeContextInputTokens } from './context-fill.lib.mjs';
|
|
40
40
|
import { deployHandoffSkill } from './handoff-skill.lib.mjs'; // Issue #1877
|
|
@@ -720,6 +720,9 @@ export const executeCodexCommand = async params => {
|
|
|
720
720
|
const expectedBaseBranch = String(argv?.baseBranch || '').trim();
|
|
721
721
|
// Retry configuration
|
|
722
722
|
let retryCount = 0;
|
|
723
|
+
// Issue #2169: retries are bounded by a wall-clock budget (12 h by default, configurable via
|
|
724
|
+
// HIVE_MIND_TRANSIENT_ERROR_RETRY_BUDGET_MS) instead of a low attempt count.
|
|
725
|
+
const transientRetryBudget = createTransientRetryBudget();
|
|
723
726
|
let baseBranchInterventionPrompt = null;
|
|
724
727
|
let baseBranchInterventionResumeCount = 0;
|
|
725
728
|
const executeWithRetry = async () => {
|
|
@@ -727,7 +730,7 @@ export const executeCodexCommand = async params => {
|
|
|
727
730
|
if (retryCount === 0) {
|
|
728
731
|
await log(`\n${formatAligned('🤖', 'Executing Codex:', argv.model.toUpperCase())}`);
|
|
729
732
|
} else {
|
|
730
|
-
await log(`\n${formatAligned('🔄', 'Retry attempt:', `${retryCount}
|
|
733
|
+
await log(`\n${formatAligned('🔄', 'Retry attempt:', `${retryCount} (${transientRetryBudget.describeProgress()})`)}`);
|
|
731
734
|
}
|
|
732
735
|
if (argv.verbose) {
|
|
733
736
|
await log(` Model: ${argv.model}`, { verbose: true });
|
|
@@ -1103,20 +1106,30 @@ export const executeCodexCommand = async params => {
|
|
|
1103
1106
|
} else if (retryableError.isRetryable) {
|
|
1104
1107
|
const isRequestTimeoutRetry = retryableError.label === 'Request timeout';
|
|
1105
1108
|
const maxRetries = isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
|
|
1106
|
-
|
|
1109
|
+
// Issue #2169: the attempt count is only a runaway backstop — the 12-hour wall-clock budget
|
|
1110
|
+
// (configurable) decides when to stop, and every wait honours the 3-minute minimum.
|
|
1111
|
+
const retryDecision = transientRetryBudget.evaluate({
|
|
1112
|
+
retryCount,
|
|
1113
|
+
maxRetries,
|
|
1114
|
+
initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
|
|
1115
|
+
maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
|
|
1116
|
+
minDelayMs: retryLimits.minTransientErrorDelayMs,
|
|
1117
|
+
});
|
|
1118
|
+
if (retryDecision.allowed) {
|
|
1119
|
+
transientRetryBudget.grant();
|
|
1107
1120
|
if (sessionId && !argv.resume) argv.resume = sessionId;
|
|
1108
1121
|
// Issue #2037: retry same model on capacity errors before falling back; a
|
|
1109
1122
|
// capacity-driven switch retries fast, other transient errors use standard backoff.
|
|
1110
|
-
const retryPlan = await prepareRetryAfterError({ tool: 'codex', argv, log, errorMessage: retryableError.message, retryCount, initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs, maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs });
|
|
1123
|
+
const retryPlan = await prepareRetryAfterError({ tool: 'codex', argv, log, errorMessage: retryableError.message, retryCount, initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs, maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs, minDelayMs: retryLimits.minTransientErrorDelayMs });
|
|
1111
1124
|
const delay = retryPlan.delay;
|
|
1112
1125
|
const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
|
|
1113
|
-
await log(`\n⚠️ ${retryableError.label} detected. Retry ${retryCount + 1}
|
|
1126
|
+
await log(`\n⚠️ ${retryableError.label} detected. Retry ${retryCount + 1} in ${delayLabel}${sessionId ? ' (session preserved)' : ''} (${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
|
|
1114
1127
|
await waitForRetryDelay(delay, log);
|
|
1115
1128
|
await log('\n🔄 Retrying now...');
|
|
1116
1129
|
retryCount++;
|
|
1117
1130
|
return await executeWithRetry();
|
|
1118
1131
|
}
|
|
1119
|
-
await log(`\n\n❌ ${retryableError.label} persisted
|
|
1132
|
+
await log(`\n\n❌ ${retryableError.label} persisted: ${transientRetryBudget.describeExhaustion(retryDecision)}`, { level: 'error' });
|
|
1120
1133
|
} else {
|
|
1121
1134
|
await log(`\n\n❌ Codex emitted error event: ${codexErrorSummary.message}`, { level: 'error' });
|
|
1122
1135
|
await log(` Error events: item=${codexErrorSummary.counts.item}, turn=${codexErrorSummary.counts.turn}, stream=${codexErrorSummary.counts.stream}`, { level: 'error' });
|
|
@@ -1129,20 +1142,30 @@ export const executeCodexCommand = async params => {
|
|
|
1129
1142
|
if (retryableError.isRetryable) {
|
|
1130
1143
|
const isRequestTimeoutRetry = retryableError.label === 'Request timeout';
|
|
1131
1144
|
const maxRetries = isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
|
|
1132
|
-
|
|
1145
|
+
// Issue #2169: the attempt count is only a runaway backstop — the 12-hour wall-clock budget
|
|
1146
|
+
// (configurable) decides when to stop, and every wait honours the 3-minute minimum.
|
|
1147
|
+
const retryDecision = transientRetryBudget.evaluate({
|
|
1148
|
+
retryCount,
|
|
1149
|
+
maxRetries,
|
|
1150
|
+
initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs,
|
|
1151
|
+
maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs,
|
|
1152
|
+
minDelayMs: retryLimits.minTransientErrorDelayMs,
|
|
1153
|
+
});
|
|
1154
|
+
if (retryDecision.allowed) {
|
|
1155
|
+
transientRetryBudget.grant();
|
|
1133
1156
|
if (sessionId && !argv.resume) argv.resume = sessionId;
|
|
1134
1157
|
// Issue #2037: retry same model on capacity errors before falling back; a
|
|
1135
1158
|
// capacity-driven switch retries fast, other transient errors use standard backoff.
|
|
1136
|
-
const retryPlan = await prepareRetryAfterError({ tool: 'codex', argv, log, errorMessage: retryableError.message, retryCount, initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs, maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs });
|
|
1159
|
+
const retryPlan = await prepareRetryAfterError({ tool: 'codex', argv, log, errorMessage: retryableError.message, retryCount, initialDelayMs: isRequestTimeoutRetry ? retryLimits.initialRequestTimeoutDelayMs : retryLimits.initialTransientErrorDelayMs, maxDelayMs: isRequestTimeoutRetry ? retryLimits.maxRequestTimeoutDelayMs : retryLimits.maxTransientErrorDelayMs, minDelayMs: retryLimits.minTransientErrorDelayMs });
|
|
1137
1160
|
const delay = retryPlan.delay;
|
|
1138
1161
|
const delayLabel = delay >= 60000 ? `${Math.round(delay / 60000)} min` : `${Math.round(delay / 1000)}s`;
|
|
1139
|
-
await log(`\n⚠️ ${retryableError.label} detected. Retry ${retryCount + 1}
|
|
1162
|
+
await log(`\n⚠️ ${retryableError.label} detected. Retry ${retryCount + 1} in ${delayLabel}${sessionId ? ' (session preserved)' : ''} (${transientRetryBudget.describeProgress()})...`, { level: 'warning' });
|
|
1140
1163
|
await waitForRetryDelay(delay, log);
|
|
1141
1164
|
await log('\n🔄 Retrying now...');
|
|
1142
1165
|
retryCount++;
|
|
1143
1166
|
return await executeWithRetry();
|
|
1144
1167
|
}
|
|
1145
|
-
await log(`\n\n❌ ${retryableError.label} persisted
|
|
1168
|
+
await log(`\n\n❌ ${retryableError.label} persisted: ${transientRetryBudget.describeExhaustion(retryDecision)}`, { level: 'error' });
|
|
1146
1169
|
}
|
|
1147
1170
|
// Check for usage limit errors first (more specific)
|
|
1148
1171
|
const limitInfo = detectUsageLimit(lastMessage);
|
package/src/config.lib.mjs
CHANGED
|
@@ -106,17 +106,34 @@ export const systemLimits = {
|
|
|
106
106
|
};
|
|
107
107
|
|
|
108
108
|
// Retry configurations
|
|
109
|
-
// Issue #1331: All API error types use unified retry parameters
|
|
110
|
-
//
|
|
109
|
+
// Issue #1331: All API error types use unified retry parameters (exponential backoff, session preserved).
|
|
110
|
+
// Issue #2169: the retry window is now governed by a *total time budget* (default 12 hours) rather
|
|
111
|
+
// than by the retry count alone. A provider outage can last many hours; the previous 10-retry cap
|
|
112
|
+
// with a 2-minute initial delay gave up after ~3.5 hours. The count caps below are kept as
|
|
113
|
+
// backstops against runaway loops — the budget is the knob operators are expected to tune.
|
|
111
114
|
export const retryLimits = {
|
|
112
115
|
maxForkRetries: parseIntWithDefault('HIVE_MIND_MAX_FORK_RETRIES', 5),
|
|
113
116
|
maxVerifyRetries: parseIntWithDefault('HIVE_MIND_MAX_VERIFY_RETRIES', 5),
|
|
114
117
|
maxApiRetries: parseIntWithDefault('HIVE_MIND_MAX_API_RETRIES', 3),
|
|
115
118
|
retryBackoffMultiplier: parseFloatWithDefault('HIVE_MIND_RETRY_BACKOFF_MULTIPLIER', 2),
|
|
116
119
|
// Unified retry config for all transient API errors (Overloaded, 503, Internal Server Error)
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
// Issue #2169: count backstop only. With the defaults below (3 min → 30 min backoff) the 12-hour
|
|
121
|
+
// budget is exhausted after ~26 retries, so this cap never fires unless an operator shortens the
|
|
122
|
+
// delays. Lower it (e.g. HIVE_MIND_MAX_TRANSIENT_ERROR_RETRIES=5) to fail faster than the budget.
|
|
123
|
+
maxTransientErrorRetries: parseIntWithDefault('HIVE_MIND_MAX_TRANSIENT_ERROR_RETRIES', 100),
|
|
124
|
+
// Issue #2169: "minimum of 3 minutes" — the first (and therefore smallest) transient backoff.
|
|
125
|
+
initialTransientErrorDelayMs: parseIntWithDefault('HIVE_MIND_INITIAL_TRANSIENT_ERROR_DELAY_MS', 3 * 60 * 1000), // 3 minutes
|
|
119
126
|
maxTransientErrorDelayMs: parseIntWithDefault('HIVE_MIND_MAX_TRANSIENT_ERROR_DELAY_MS', 30 * 60 * 1000), // 30 minutes
|
|
127
|
+
// Issue #2169: total wall-clock budget for transient-API-error retries, measured from the first
|
|
128
|
+
// retry of a run. Retrying stops as soon as the *next* backoff would push the run past this
|
|
129
|
+
// window. Set to 0 to disable the budget and fall back to the count cap alone.
|
|
130
|
+
transientErrorRetryBudgetMs: parseIntWithDefault('HIVE_MIND_TRANSIENT_ERROR_RETRY_BUDGET_MS', 12 * 60 * 60 * 1000), // 12 hours
|
|
131
|
+
// Issue #2169: floor applied to every transient backoff, including operator-supplied initial
|
|
132
|
+
// delays. Keeps a misconfigured 5-second delay from hammering an API that is already struggling.
|
|
133
|
+
minTransientErrorDelayMs: parseIntWithDefault('HIVE_MIND_MIN_TRANSIENT_ERROR_DELAY_MS', 3 * 60 * 1000), // 3 minutes
|
|
134
|
+
// Issue #2169: stream startup/activity timeouts keep their own (short) count cap. They use 30s–2min
|
|
135
|
+
// backoffs and force-kill the CLI, so they must not inherit the 100-retry transient backstop.
|
|
136
|
+
maxStreamTimeoutRetries: parseIntWithDefault('HIVE_MIND_MAX_STREAM_TIMEOUT_RETRIES', 10),
|
|
120
137
|
// Issue #2037: When a "model is at capacity" error triggers a switch to a *different*
|
|
121
138
|
// fallback model, the long transient backoff is wasteful — the different model is
|
|
122
139
|
// available now, so retry almost immediately instead of stalling for minutes.
|
|
@@ -131,7 +148,8 @@ export const retryLimits = {
|
|
|
131
148
|
maxCapacityRetryDelayMs: parseIntWithDefault('HIVE_MIND_MAX_CAPACITY_RETRY_DELAY_MS', 4 * 60 * 1000), // 4 minutes
|
|
132
149
|
// Request timeout retry configuration (Issue #1353)
|
|
133
150
|
// Network timeouts need longer waits than API errors — Claude CLI already exhausted its own retries
|
|
134
|
-
|
|
151
|
+
// Issue #2169: count backstop only — the shared 12-hour budget governs when to stop.
|
|
152
|
+
maxRequestTimeoutRetries: parseIntWithDefault('HIVE_MIND_MAX_REQUEST_TIMEOUT_RETRIES', 100),
|
|
135
153
|
initialRequestTimeoutDelayMs: parseIntWithDefault('HIVE_MIND_INITIAL_REQUEST_TIMEOUT_DELAY_MS', 5 * 60 * 1000), // 5 minutes
|
|
136
154
|
maxRequestTimeoutDelayMs: parseIntWithDefault('HIVE_MIND_MAX_REQUEST_TIMEOUT_DELAY_MS', 60 * 60 * 1000), // 1 hour
|
|
137
155
|
// Not-retryable error fail-fast configuration (Issue #1437)
|