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:
Eyejoker
2026-03-25 04:59:49 +09:00
parent b9f98fcc19
commit 5ea3439c5f
24 changed files with 562 additions and 442 deletions

169
src/provider-retry.ts Normal file
View 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 };
}