refactor: extract shared utilities, protocol constants, and retry loop
Phase 3: provider-retry.ts — shared Claude rotation loop (SSOT) - retryClaudeWithRotation extracted from message-agent-executor + task-scheduler - ~200 lines of duplicated retry logic removed Phase 4: types.ts — AgentOutputPhase + VisiblePhase unified Phase 5: types.ts — AgentConfig extended with claudeThinking/claudeThinkingBudget Utilities (utils.ts): - getErrorMessage: 14 occurrences of instanceof Error pattern → 1 function - readJsonFile/writeJsonFile: 19 occurrences of JSON+fs pattern → 2 functions - fetchWithTimeout: 3 occurrences of AbortController pattern → 1 function - formatElapsedKorean: deduplicated from task-watch-status + message-turn-controller Protocol (agent-protocol.ts): - OUTPUT_START/END_MARKER centralized (runners keep local copies with SSOT reference) - IMAGE_TAG_RE, IPC constants documented Runner: show "대화 요약 중..." progress message during auto-compact Net: -137 lines, 354/354 tests passing
This commit is contained in:
169
src/provider-retry.ts
Normal file
169
src/provider-retry.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Shared Claude retry-with-rotation loop (SSOT).
|
||||
*
|
||||
* Extracted from message-agent-executor.ts and task-scheduler.ts
|
||||
* to eliminate the ~255-line structural duplication.
|
||||
*/
|
||||
|
||||
import { shouldRotateClaudeToken } from './agent-error-detection.js';
|
||||
import { logger } from './logger.js';
|
||||
import { getErrorMessage } from './utils.js';
|
||||
import { detectFallbackTrigger, markPrimaryCooldown } from './provider-fallback.js';
|
||||
import { rotateToken, getTokenCount, markTokenHealthy } from './token-rotation.js';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface TriggerInfo {
|
||||
reason: string;
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
|
||||
export interface RotationAttemptResult {
|
||||
output?: { status: string; result?: string | null; error?: string | null };
|
||||
thrownError?: unknown;
|
||||
sawOutput: boolean;
|
||||
sawSuccessNullResult?: boolean;
|
||||
streamedTriggerReason?: TriggerInfo;
|
||||
}
|
||||
|
||||
export type RotationOutcome =
|
||||
| { type: 'success' }
|
||||
| { type: 'error'; message?: string }
|
||||
| { type: 'needs-fallback'; trigger: TriggerInfo }
|
||||
| { type: 'no-fallback'; trigger: TriggerInfo }; // usage-exhausted/auth-expired
|
||||
|
||||
// ── Shared rotation loop ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Retry a Claude request by rotating through available tokens.
|
||||
*
|
||||
* Returns a discriminated outcome — the caller decides what to do
|
||||
* with 'needs-fallback' (e.g. run Kimi fallback) or 'no-fallback'.
|
||||
*/
|
||||
export async function runClaudeRotationLoop(
|
||||
initialTrigger: TriggerInfo,
|
||||
runAttempt: () => Promise<RotationAttemptResult>,
|
||||
logContext: Record<string, unknown>,
|
||||
rotationMessage?: string,
|
||||
): Promise<RotationOutcome> {
|
||||
let trigger = initialTrigger;
|
||||
let lastRotationMessage = rotationMessage;
|
||||
|
||||
while (
|
||||
shouldRotateClaudeToken(trigger.reason) &&
|
||||
getTokenCount() > 1 &&
|
||||
rotateToken(lastRotationMessage, { ignoreRateLimits: true })
|
||||
) {
|
||||
logger.info(
|
||||
{ ...logContext, reason: trigger.reason },
|
||||
'Claude rate-limited, retrying with rotated account',
|
||||
);
|
||||
|
||||
const attempt = await runAttempt();
|
||||
|
||||
// ── Thrown error (exception from spawn/process) ──
|
||||
if (attempt.thrownError) {
|
||||
if (!attempt.sawOutput) {
|
||||
const errMsg = getErrorMessage(attempt.thrownError);
|
||||
const retryTrigger = attempt.streamedTriggerReason
|
||||
? {
|
||||
shouldFallback: true,
|
||||
reason: attempt.streamedTriggerReason.reason,
|
||||
retryAfterMs: attempt.streamedTriggerReason.retryAfterMs,
|
||||
}
|
||||
: detectFallbackTrigger(errMsg);
|
||||
if (retryTrigger.shouldFallback) {
|
||||
trigger = {
|
||||
reason: retryTrigger.reason,
|
||||
retryAfterMs: retryTrigger.retryAfterMs,
|
||||
};
|
||||
lastRotationMessage = errMsg;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ ...logContext, provider: 'claude', err: attempt.thrownError },
|
||||
'Rotated Claude account also threw',
|
||||
);
|
||||
return { type: 'error' };
|
||||
}
|
||||
|
||||
const output = attempt.output;
|
||||
if (!output) {
|
||||
logger.error(
|
||||
{ ...logContext, provider: 'claude' },
|
||||
'Rotated Claude account produced no output object',
|
||||
);
|
||||
return { type: 'error' };
|
||||
}
|
||||
|
||||
// ── Streamed trigger in non-error success ──
|
||||
if (
|
||||
!attempt.sawOutput &&
|
||||
attempt.streamedTriggerReason &&
|
||||
output.status !== 'error'
|
||||
) {
|
||||
trigger = {
|
||||
reason: attempt.streamedTriggerReason.reason,
|
||||
retryAfterMs: attempt.streamedTriggerReason.retryAfterMs,
|
||||
};
|
||||
lastRotationMessage =
|
||||
typeof output.result === 'string' ? output.result : undefined;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Success with null result (MAE-specific, TaskScheduler ignores) ──
|
||||
if (!attempt.sawOutput && attempt.sawSuccessNullResult) {
|
||||
return { type: 'needs-fallback', trigger: { reason: 'success-null-result' } };
|
||||
}
|
||||
|
||||
// ── Error status ──
|
||||
if (output.status === 'error') {
|
||||
if (!attempt.sawOutput) {
|
||||
const retryTrigger = attempt.streamedTriggerReason
|
||||
? {
|
||||
shouldFallback: true,
|
||||
reason: attempt.streamedTriggerReason.reason,
|
||||
retryAfterMs: attempt.streamedTriggerReason.retryAfterMs,
|
||||
}
|
||||
: detectFallbackTrigger(output.error);
|
||||
if (retryTrigger.shouldFallback) {
|
||||
trigger = {
|
||||
reason: retryTrigger.reason,
|
||||
retryAfterMs: retryTrigger.retryAfterMs,
|
||||
};
|
||||
lastRotationMessage = output.error ?? undefined;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ ...logContext, provider: 'claude', error: output.error },
|
||||
'Rotated Claude account failed',
|
||||
);
|
||||
return { type: 'error' };
|
||||
}
|
||||
|
||||
// ── Success ──
|
||||
markTokenHealthy();
|
||||
return { type: 'success' };
|
||||
}
|
||||
|
||||
// ── All tokens exhausted ──
|
||||
|
||||
// Usage exhausted or auth-expired: don't fall back to Kimi
|
||||
if (
|
||||
trigger.reason === 'usage-exhausted' ||
|
||||
trigger.reason === 'auth-expired'
|
||||
) {
|
||||
markPrimaryCooldown(trigger.reason, trigger.retryAfterMs);
|
||||
logger.info(
|
||||
{ ...logContext, reason: trigger.reason },
|
||||
`All Claude tokens ${trigger.reason}, silently skipping (no Kimi fallback)`,
|
||||
);
|
||||
return { type: 'no-fallback', trigger };
|
||||
}
|
||||
|
||||
return { type: 'needs-fallback', trigger };
|
||||
}
|
||||
Reference in New Issue
Block a user