@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.13.1",
3
+ "version": "2.13.3",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
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}/${retryLimits.maxTransientErrorRetries}`)}`);
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
- if (retryCount < maxRetries) {
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}/${maxRetries} in ${delayLabel}${sessionId ? ' (session preserved)' : ''}...`, { level: 'warning' });
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 after ${maxRetries} retries`, { level: 'error' });
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
- // Escape _ and * in display name to prevent "can't find end of entity" errors (issue #1460)
46
- const escapedMarkdownName = displayName.replace(/_/g, '\\_').replace(/\*/g, '\\*');
47
- return `[${escapedMarkdownName}](${link})`;
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
- const maxRetries = 3;
18
- const baseDelay = timeouts.retryBaseDelay;
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}/${maxRetries} for Claude CLI validation...`);
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 (retryCount < maxRetries) {
87
- const delay = baseDelay * Math.pow(2, retryCount);
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 (retryCount < maxRetries) {
120
- const delay = baseDelay * Math.pow(2, retryCount);
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 (retryCount < maxRetries) {
144
- const delay = baseDelay * Math.pow(2, retryCount);
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' });
@@ -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
- await log(`\n${formatAligned('🔄', 'Retry attempt:', `${retryCount}/${retryLimits.maxTransientErrorRetries}`)}`);
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 ((commandFailed || isTransientError) && isTransientError && !subscriptionError) {
1044
- // Issue #1472/#1475: Startup/activity timeout → 30s–2min backoff; #1353: Request timeout → 5min–1hr; general → 2min–30min
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
- const maxRetries = isTimeoutRetry ? retryLimits.maxTransientErrorRetries : isRequestTimeout ? retryLimits.maxRequestTimeoutRetries : retryLimits.maxTransientErrorRetries;
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 (retryCount < maxRetries) {
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}/${maxRetries} in ${delayLabel}${retryMode}${notRetryableHint}...`, { level: 'warning' });
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
- await log(`\n\n❌ Transient API error persisted after ${maxRetries} retries\n Please try again later or check https://status.anthropic.com/`, { level: 'error' });
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 after ${maxRetries} retries`, toolLabel: 'Claude' }), exitCode },
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
- if (retryCount < maxRetries) {
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}/${maxRetries} in ${delayLabel} (session preserved)...`, { level: 'warning' });
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}/${retryLimits.maxTransientErrorRetries}`)}`);
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
- if (retryCount < maxRetries) {
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}/${maxRetries} in ${delayLabel}${sessionId ? ' (session preserved)' : ''}...`, { level: 'warning' });
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 after ${maxRetries} retries`, { level: 'error' });
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
- if (retryCount < maxRetries) {
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}/${maxRetries} in ${delayLabel}${sessionId ? ' (session preserved)' : ''}...`, { level: 'warning' });
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 after ${maxRetries} retries`, { level: 'error' });
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);
@@ -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
- // 10 max retries, 2 minute initial delay, 30 minute max delay (exponential backoff), session preserved
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
- maxTransientErrorRetries: parseIntWithDefault('HIVE_MIND_MAX_TRANSIENT_ERROR_RETRIES', 10),
118
- initialTransientErrorDelayMs: parseIntWithDefault('HIVE_MIND_INITIAL_TRANSIENT_ERROR_DELAY_MS', 2 * 60 * 1000), // 2 minutes
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
- maxRequestTimeoutRetries: parseIntWithDefault('HIVE_MIND_MAX_REQUEST_TIMEOUT_RETRIES', 10),
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)