mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
+14
-4
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user