fix: type safety, defensive defaults, and unbounded retry prevention

QueryEngine.ts:
- Import PERMISSION_MODES runtime constant and validate permissionMode
  before casting in submitMessage — invalid mode strings fall back to
  'default' instead of crashing with ReferenceError: PERMISSION_MODES is
  not defined (fixes the runtime gap from the original PR)
- Use splice(0, length, ...messages) instead of length=0 + push() for
  atomic array replacement in snip replay, so concurrent readers of
  getMessages() never observe an empty state

withRetry.ts:
- Cap persistent retry loop at 100 attempts via PERSISTENT_RETRY_MAX_ATTEMPTS
  constant — prevents unbounded retry (~8 hours max with exponential backoff
  and 6-hour reset cap) when the unattended retry path is enabled

autoCompact.ts:
- Add MIN_AUTOCOMPACT_FAILURE_COOLDOWN_MS = 10_000 floor for
  OPENCLAUDE_AUTOCOMPACT_FAILURE_COOLDOWN_MS override — prevents
  misconfiguration from effectively disabling the circuit breaker

autoCompact.test.ts:
- Update test override from 5000 to 15000 to respect the new 10s minimum floor
- Add test case verifying values below the floor (5000, 9999) are rejected
  and that the floor value (10000) is accepted
- Update circuit breaker retry-time expectation from 111_000 to 121_000
  to account for the new 15s cooldown override
This commit is contained in:
Sky
2026-06-05 23:08:27 +05:30
parent 1204fe2595
commit 4d54d5d921
4 changed files with 65 additions and 10 deletions
+14 -4
View File
@@ -14,6 +14,7 @@ import type {
SDKStatus,
SDKUserMessageReplay,
} from 'src/entrypoints/agentSdkTypes.js'
import { PERMISSION_MODES } from 'src/types/permissions.js'
import { accumulateUsage, updateUsage } from 'src/services/api/claude.js'
import type { NonNullableUsage } from 'src/services/api/logging.js'
import { EMPTY_USAGE } from 'src/services/api/logging.js'
@@ -548,12 +549,18 @@ export class QueryEngine {
])
headlessProfilerCheckpoint('after_skills_plugins')
const rawPermissionMode = initialAppState.toolPermissionContext.mode
const validPermissionMode: PermissionMode = (
PERMISSION_MODES as readonly string[]
).includes(rawPermissionMode)
? (rawPermissionMode as PermissionMode)
: 'default'
yield buildSystemInitMessage({
tools,
mcpClients,
model: mainLoopModel,
permissionMode: initialAppState.toolPermissionContext
.mode as PermissionMode, // TODO: avoid the cast
permissionMode: validPermissionMode,
commands,
agents,
skills,
@@ -936,8 +943,11 @@ export class QueryEngine {
)
if (snipResult !== undefined) {
if (snipResult.executed) {
this.mutableMessages.length = 0
this.mutableMessages.push(...snipResult.messages)
this.mutableMessages.splice(
0,
this.mutableMessages.length,
...snipResult.messages,
)
}
break
}
+10
View File
@@ -99,6 +99,7 @@ function shouldRetry529(querySource: QuerySource | undefined): boolean {
// until there's a dedicated keep-alive channel.
const PERSISTENT_MAX_BACKOFF_MS = 5 * 60 * 1000
const PERSISTENT_RESET_CAP_MS = 6 * 60 * 60 * 1000
const PERSISTENT_RETRY_MAX_ATTEMPTS = 100
const HEARTBEAT_INTERVAL_MS = 30_000
function isPersistentRetryEnabled(): boolean {
@@ -394,6 +395,15 @@ export async function* withRetry<T>(
throw new CannotRetryError(error, retryContext)
}
// Persistent retry must also be bounded — exponential backoff with
// 5-min cap and 6-hr reset window would otherwise let a single bad
// session burn ~8 hours of API calls before giving up. Cap at 100
// attempts which, with PERSISTENT_MAX_BACKOFF_MS, is the longest we
// are willing to wait.
if (persistent && persistentAttempt >= PERSISTENT_RETRY_MAX_ATTEMPTS) {
throw new CannotRetryError(error, retryContext)
}
// AWS/GCP errors aren't always APIError, but can be retried
const handledCloudAuthError =
handleAwsCredentialError(error) || handleGcpCredentialError(error)
+31 -5
View File
@@ -247,11 +247,37 @@ describe('getAutoCompactThreshold', () => {
})
describe('getAutoCompactFailureCooldownMs', () => {
test('uses valid positive integer override', async () => {
process.env.OPENCLAUDE_AUTOCOMPACT_FAILURE_COOLDOWN_MS = ' 5000 '
test('uses valid positive integer override above the floor', async () => {
process.env.OPENCLAUDE_AUTOCOMPACT_FAILURE_COOLDOWN_MS = ' 15000 '
const { getAutoCompactFailureCooldownMs } = await importAutoCompact()
expect(getAutoCompactFailureCooldownMs()).toBe(5000)
expect(getAutoCompactFailureCooldownMs()).toBe(15000)
})
test('rejects overrides below the minimum cooldown floor', async () => {
const {
AUTOCOMPACT_FAILURE_COOLDOWN_MS,
getAutoCompactFailureCooldownMs,
MIN_AUTOCOMPACT_FAILURE_COOLDOWN_MS,
} = await importAutoCompact()
// 5000 is below the 10_000ms floor — must fall back to the default
// rather than being accepted as a valid test override.
process.env.OPENCLAUDE_AUTOCOMPACT_FAILURE_COOLDOWN_MS = '5000'
expect(getAutoCompactFailureCooldownMs()).toBe(
AUTOCOMPACT_FAILURE_COOLDOWN_MS,
)
expect(MIN_AUTOCOMPACT_FAILURE_COOLDOWN_MS).toBe(10_000)
// Boundary: exactly the floor value is accepted.
process.env.OPENCLAUDE_AUTOCOMPACT_FAILURE_COOLDOWN_MS = '10000'
expect(getAutoCompactFailureCooldownMs()).toBe(10_000)
// One below the floor is rejected.
process.env.OPENCLAUDE_AUTOCOMPACT_FAILURE_COOLDOWN_MS = '9999'
expect(getAutoCompactFailureCooldownMs()).toBe(
AUTOCOMPACT_FAILURE_COOLDOWN_MS,
)
})
test('ignores partial or invalid override values', async () => {
@@ -453,7 +479,7 @@ describe('resolveAutoCompactCircuitBreakerState', () => {
describe('autoCompactIfNeeded circuit breaker', () => {
beforeEach(() => {
process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE = '1'
process.env.OPENCLAUDE_AUTOCOMPACT_FAILURE_COOLDOWN_MS = '5000'
process.env.OPENCLAUDE_AUTOCOMPACT_FAILURE_COOLDOWN_MS = '15000'
})
test('trips after three non-user failures and records a retry time', async () => {
@@ -648,7 +674,7 @@ describe('autoCompactIfNeeded circuit breaker', () => {
)
expect(result.lastFailureAtMs).toBe(106_000)
expect(result.nextRetryAtMs).toBe(111_000)
expect(result.nextRetryAtMs).toBe(121_000)
} finally {
Date.now = originalDateNow
}
+10 -1
View File
@@ -81,6 +81,11 @@ export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000
export const AUTOCOMPACT_FAILURE_COOLDOWN_MS = 5 * 60 * 1000
// Minimum cooldown override allowed via OPENCLAUDE_AUTOCOMPACT_FAILURE_COOLDOWN_MS.
// Values below this floor are rejected (function falls back to the default) so
// misconfiguration cannot effectively disable the circuit breaker.
export const MIN_AUTOCOMPACT_FAILURE_COOLDOWN_MS = 10_000
// Pause autocompact after this many consecutive failures.
// BQ 2026-03-10: 1,279 sessions had 50+ consecutive failures (up to 3,272)
// in a single session, wasting ~250K API calls/day globally.
@@ -91,7 +96,11 @@ export function getAutoCompactFailureCooldownMs(): number {
if (override) {
const trimmed = override.trim()
const parsed = Number(trimmed)
if (/^[1-9]\d*$/.test(trimmed) && Number.isSafeInteger(parsed)) {
if (
/^[1-9]\d*$/.test(trimmed) &&
Number.isSafeInteger(parsed) &&
parsed >= MIN_AUTOCOMPACT_FAILURE_COOLDOWN_MS
) {
return parsed
}
}