From 5ea3439c5f123cfda0bd523bae1a9cac402178d3 Mon Sep 17 00:00:00 2001 From: Eyejoker Date: Wed, 25 Mar 2026 04:59:49 +0900 Subject: [PATCH] refactor: extract shared utilities, protocol constants, and retry loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- runners/agent-runner/src/index.ts | 10 ++ runners/codex-runner/src/index.ts | 2 + src/agent-protocol.ts | 19 +++ src/agent-runner-environment.ts | 8 ++ src/agent-runner-snapshot.ts | 12 +- src/agent-runner.test.ts | 7 +- src/agent-runner.ts | 12 +- src/claude-usage.ts | 11 +- src/codex-token-rotation.ts | 167 +++++++++++------------ src/db.ts | 9 +- src/group-queue-ipc.ts | 4 +- src/ipc.ts | 20 +-- src/message-agent-executor.ts | 212 +++++++----------------------- src/message-runtime.ts | 3 +- src/message-turn-controller.ts | 23 ++-- src/provider-retry.ts | 169 ++++++++++++++++++++++++ src/restart-context.ts | 38 +++--- src/status-dashboard.ts | 3 +- src/task-scheduler.ts | 112 ++++++---------- src/task-watch-status.ts | 12 +- src/token-refresh.ts | 20 +-- src/token-rotation.ts | 50 ++++--- src/types.ts | 12 ++ src/utils.ts | 69 ++++++++++ 24 files changed, 562 insertions(+), 442 deletions(-) create mode 100644 src/agent-protocol.ts create mode 100644 src/provider-retry.ts create mode 100644 src/utils.ts diff --git a/runners/agent-runner/src/index.ts b/runners/agent-runner/src/index.ts index 6cbbcab..a7226fb 100644 --- a/runners/agent-runner/src/index.ts +++ b/runners/agent-runner/src/index.ts @@ -32,6 +32,7 @@ interface ContainerInput { secrets?: Record; } +/** Mirrors AgentOutput in src/agent-runner.ts (separate package, can't import directly). */ interface ContainerOutput { status: 'success' | 'error'; phase?: 'progress' | 'final' | 'tool-activity' | 'intermediate'; @@ -88,6 +89,7 @@ const IPC_INPUT_DIR = path.join(IPC_DIR, 'input'); const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); const IPC_POLL_MS = 500; +/** SSOT: src/agent-protocol.ts — keep in sync */ const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g; const MIME_TYPES: Record = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', @@ -339,6 +341,14 @@ function createPreCompactHook(assistantName?: string): HookCallback { const preCompact = input as PreCompactHookInput; const transcriptPath = preCompact.transcript_path; const sessionId = preCompact.session_id; + const trigger = preCompact.trigger || 'auto'; + + // Show compact status in chat so users know it's not just slow loading + writeOutput({ + status: 'success', + phase: 'progress', + result: trigger === 'auto' ? '대화 요약 중...' : '컴팩트 중...', + }); if (!transcriptPath || !fs.existsSync(transcriptPath)) { log('No transcript found for archiving'); diff --git a/runners/codex-runner/src/index.ts b/runners/codex-runner/src/index.ts index c717401..e170fee 100644 --- a/runners/codex-runner/src/index.ts +++ b/runners/codex-runner/src/index.ts @@ -50,6 +50,7 @@ const IPC_INPUT_DIR = path.join(IPC_DIR, 'input'); const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, '_close'); const IPC_POLL_MS = 500; +/** SSOT: src/agent-protocol.ts — keep in sync */ const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---'; const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---'; @@ -134,6 +135,7 @@ function drainIpcInput(): string[] { } function extractImagePaths(text: string): { cleanText: string; imagePaths: string[] } { + /** SSOT: src/agent-protocol.ts — keep in sync */ const imagePattern = /\[Image:\s*(\/[^\]]+)\]/g; const imagePaths: string[] = []; let match: RegExpExecArray | null; diff --git a/src/agent-protocol.ts b/src/agent-protocol.ts new file mode 100644 index 0000000..4ade8e7 --- /dev/null +++ b/src/agent-protocol.ts @@ -0,0 +1,19 @@ +/** + * Agent Protocol Constants (SSOT). + * + * Shared constants for host ↔ runner communication. + * Runners are separate packages and can't import this directly — + * they define local copies with comments referencing this file. + */ + +/** Sentinel markers for robust stdout output parsing. */ +export const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---'; +export const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---'; + +/** Regex to extract [Image: /path/to/file] tags from agent text. */ +export const IMAGE_TAG_RE = /\[Image:\s*(\/[^\]]+)\]/g; + +/** IPC polling interval (ms) used by runners to check for follow-up messages. */ +export const IPC_POLL_MS = 500; +export const IPC_INPUT_SUBDIR = 'input'; +export const IPC_CLOSE_SENTINEL = '_close'; diff --git a/src/agent-runner-environment.ts b/src/agent-runner-environment.ts index 685f842..24a4339 100644 --- a/src/agent-runner-environment.ts +++ b/src/agent-runner-environment.ts @@ -162,6 +162,14 @@ function prepareClaudeEnvironment(args: { if (args.group.agentConfig?.claudeEffort) { args.env.CLAUDE_EFFORT = args.group.agentConfig.claudeEffort; } + if (args.group.agentConfig?.claudeThinking) { + args.env.CLAUDE_THINKING = args.group.agentConfig.claudeThinking; + } + if (args.group.agentConfig?.claudeThinkingBudget) { + args.env.CLAUDE_THINKING_BUDGET = String( + args.group.agentConfig.claudeThinkingBudget, + ); + } } function prepareCodexSessionEnvironment(args: { diff --git a/src/agent-runner-snapshot.ts b/src/agent-runner-snapshot.ts index ef3c4eb..224f3ff 100644 --- a/src/agent-runner-snapshot.ts +++ b/src/agent-runner-snapshot.ts @@ -5,6 +5,7 @@ import { resolveGroupIpcPath, resolveTaskRuntimeIpcPath, } from './group-folder.js'; +import { writeJsonFile } from './utils.js'; export function writeTasksSnapshot( groupFolder: string, @@ -28,7 +29,7 @@ export function writeTasksSnapshot( ? tasks : tasks.filter((t) => t.groupFolder === groupFolder); const tasksFile = path.join(groupIpcDir, 'current_tasks.json'); - fs.writeFileSync(tasksFile, JSON.stringify(filteredTasks, null, 2)); + writeJsonFile(tasksFile, filteredTasks, true); } export interface AvailableGroup { @@ -48,12 +49,5 @@ export function writeGroupsSnapshot( fs.mkdirSync(groupIpcDir, { recursive: true }); const visibleGroups = isMain ? groups : []; const groupsFile = path.join(groupIpcDir, 'available_groups.json'); - fs.writeFileSync( - groupsFile, - JSON.stringify( - { groups: visibleGroups, lastSync: new Date().toISOString() }, - null, - 2, - ), - ); + writeJsonFile(groupsFile, { groups: visibleGroups, lastSync: new Date().toISOString() }, true); } diff --git a/src/agent-runner.test.ts b/src/agent-runner.test.ts index db7d4e6..7057fd6 100644 --- a/src/agent-runner.test.ts +++ b/src/agent-runner.test.ts @@ -4,9 +4,10 @@ import { PassThrough } from 'stream'; import fs from 'fs'; import { spawn } from 'child_process'; -// Sentinel markers must match agent-runner.ts -const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---'; +import { + OUTPUT_START_MARKER, + OUTPUT_END_MARKER, +} from './agent-protocol.js'; // Mock config vi.mock('./config.js', () => ({ diff --git a/src/agent-runner.ts b/src/agent-runner.ts index 574fd47..174baaa 100644 --- a/src/agent-runner.ts +++ b/src/agent-runner.ts @@ -6,6 +6,7 @@ import { ChildProcess, spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; +import { getErrorMessage } from './utils.js'; import { AGENT_MAX_OUTPUT_SIZE, AGENT_TIMEOUT, @@ -18,11 +19,8 @@ export { writeTasksSnapshot, } from './agent-runner-snapshot.js'; import { logger } from './logger.js'; -import { AgentType, RegisteredGroup } from './types.js'; - -// Sentinel markers for robust output parsing (must match agent-runner) -const OUTPUT_START_MARKER = '---EJCLAW_OUTPUT_START---'; -const OUTPUT_END_MARKER = '---EJCLAW_OUTPUT_END---'; +import { OUTPUT_END_MARKER, OUTPUT_START_MARKER } from './agent-protocol.js'; +import { AgentOutputPhase, AgentType, RegisteredGroup } from './types.js'; export interface AgentInput { prompt: string; @@ -42,7 +40,7 @@ export interface AgentInput { export interface AgentOutput { status: 'success' | 'error'; result: string | null; - phase?: 'progress' | 'final' | 'tool-activity' | 'intermediate'; + phase?: AgentOutputPhase; agentId?: string; agentLabel?: string; agentDone?: boolean; @@ -441,7 +439,7 @@ export async function runAgentProcess( resolve({ status: 'error', result: null, - error: `Failed to parse agent output: ${err instanceof Error ? err.message : String(err)}`, + error: `Failed to parse agent output: ${getErrorMessage(err)}`, }); } }); diff --git a/src/claude-usage.ts b/src/claude-usage.ts index 5193e3e..ff57e2a 100644 --- a/src/claude-usage.ts +++ b/src/claude-usage.ts @@ -11,6 +11,7 @@ import path from 'path'; import { DATA_DIR } from './config.js'; import { logger } from './logger.js'; import { getCurrentToken, getAllTokens } from './token-rotation.js'; +import { readJsonFile, writeJsonFile } from './utils.js'; const USAGE_CACHE_FILE = path.join(DATA_DIR, 'claude-usage-cache.json'); @@ -54,18 +55,12 @@ let diskCacheLoaded = false; function loadUsageDiskCache(): void { if (diskCacheLoaded) return; diskCacheLoaded = true; - try { - if (fs.existsSync(USAGE_CACHE_FILE)) { - usageDiskCache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf-8')); - } - } catch { - /* start fresh */ - } + usageDiskCache = readJsonFile>(USAGE_CACHE_FILE) ?? {}; } function saveUsageDiskCache(): void { try { - fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(usageDiskCache)); + writeJsonFile(USAGE_CACHE_FILE, usageDiskCache); } catch { /* best effort */ } diff --git a/src/codex-token-rotation.ts b/src/codex-token-rotation.ts index 48ced65..55521f3 100644 --- a/src/codex-token-rotation.ts +++ b/src/codex-token-rotation.ts @@ -25,6 +25,7 @@ import { findNextAvailable, parseRetryAfterFromError, } from './token-rotation-base.js'; +import { readJsonFile, writeJsonFile } from './utils.js'; const STATE_FILE = path.join(DATA_DIR, 'codex-rotation-state.json'); @@ -93,22 +94,22 @@ export function initCodexTokenRotation(): void { const authPath = path.join(ACCOUNTS_DIR, dir, 'auth.json'); if (!fs.existsSync(authPath)) continue; - try { - const data = JSON.parse(fs.readFileSync(authPath, 'utf-8')); - const accountId = data?.tokens?.account_id || `account-${dir}`; - const jwt = parseJwtAuth(data?.tokens?.id_token || ''); - const planType = jwt.planType; - accounts.push({ - index: accounts.length, - authPath, - accountId, - planType, - subscriptionUntil: jwt.expiresAt, - rateLimitedUntil: null, - }); - } catch { + const data = readJsonFile<{ tokens?: { account_id?: string; id_token?: string } }>(authPath); + if (!data) { logger.warn({ authPath }, 'Failed to parse codex account auth.json'); + continue; } + const accountId = data?.tokens?.account_id || `account-${dir}`; + const jwt = parseJwtAuth(data?.tokens?.id_token || ''); + const planType = jwt.planType; + accounts.push({ + index: accounts.length, + authPath, + accountId, + planType, + subscriptionUntil: jwt.expiresAt, + rateLimitedUntil: null, + }); } if (accounts.length > 1) loadCodexState(); @@ -128,80 +129,84 @@ function saveCodexState(): void { resetAts: accounts.map((a) => a.resetAt ?? null), resetD7Ats: accounts.map((a) => a.resetD7At ?? null), }; - fs.writeFileSync(STATE_FILE, JSON.stringify(state)); + writeJsonFile(STATE_FILE, state); } catch { /* best effort */ } } function loadCodexState(): void { - try { - if (!fs.existsSync(STATE_FILE)) return; - const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); - const now = Date.now(); - if ( - typeof state.currentIndex === 'number' && - state.currentIndex < accounts.length - ) { - currentIndex = state.currentIndex; - } - if (Array.isArray(state.rateLimits)) { - for ( - let i = 0; - i < Math.min(state.rateLimits.length, accounts.length); - i++ - ) { - const until = state.rateLimits[i]; - if (typeof until === 'number' && until > now) { - accounts[i].rateLimitedUntil = until; - } - } - } - if (Array.isArray(state.usagePcts)) { - for ( - let i = 0; - i < Math.min(state.usagePcts.length, accounts.length); - i++ - ) { - if (typeof state.usagePcts[i] === 'number') - accounts[i].lastUsagePct = state.usagePcts[i]; - } - } - if (Array.isArray(state.usageD7Pcts)) { - for ( - let i = 0; - i < Math.min(state.usageD7Pcts.length, accounts.length); - i++ - ) { - if (typeof state.usageD7Pcts[i] === 'number') - accounts[i].lastUsageD7Pct = state.usageD7Pcts[i]; - } - } - if (Array.isArray(state.resetAts)) { - for ( - let i = 0; - i < Math.min(state.resetAts.length, accounts.length); - i++ - ) { - if (state.resetAts[i]) accounts[i].resetAt = state.resetAts[i]; - } - } - if (Array.isArray(state.resetD7Ats)) { - for ( - let i = 0; - i < Math.min(state.resetD7Ats.length, accounts.length); - i++ - ) { - if (state.resetD7Ats[i]) accounts[i].resetD7At = state.resetD7Ats[i]; - } - } - logger.info( - { currentIndex, accountCount: accounts.length }, - 'Codex rotation state restored', - ); - } catch { - /* start fresh */ + const state = readJsonFile<{ + currentIndex?: number; + rateLimits?: (number | null)[]; + usagePcts?: (number | null)[]; + usageD7Pcts?: (number | null)[]; + resetAts?: (string | null)[]; + resetD7Ats?: (string | null)[]; + }>(STATE_FILE); + if (!state) return; + + const now = Date.now(); + if ( + typeof state.currentIndex === 'number' && + state.currentIndex < accounts.length + ) { + currentIndex = state.currentIndex; } + if (Array.isArray(state.rateLimits)) { + for ( + let i = 0; + i < Math.min(state.rateLimits.length, accounts.length); + i++ + ) { + const until = state.rateLimits[i]; + if (typeof until === 'number' && until > now) { + accounts[i].rateLimitedUntil = until; + } + } + } + if (Array.isArray(state.usagePcts)) { + for ( + let i = 0; + i < Math.min(state.usagePcts.length, accounts.length); + i++ + ) { + if (typeof state.usagePcts[i] === 'number') + accounts[i].lastUsagePct = state.usagePcts[i]!; + } + } + if (Array.isArray(state.usageD7Pcts)) { + for ( + let i = 0; + i < Math.min(state.usageD7Pcts.length, accounts.length); + i++ + ) { + if (typeof state.usageD7Pcts[i] === 'number') + accounts[i].lastUsageD7Pct = state.usageD7Pcts[i]!; + } + } + if (Array.isArray(state.resetAts)) { + for ( + let i = 0; + i < Math.min(state.resetAts.length, accounts.length); + i++ + ) { + if (state.resetAts[i]) accounts[i].resetAt = state.resetAts[i]!; + } + } + if (Array.isArray(state.resetD7Ats)) { + for ( + let i = 0; + i < Math.min(state.resetD7Ats.length, accounts.length); + i++ + ) { + if (state.resetD7Ats[i]) accounts[i].resetD7At = state.resetD7Ats[i]!; + } + } + logger.info( + { currentIndex, accountCount: accounts.length }, + 'Codex rotation state restored', + ); } /** Get the auth.json path for the current active account. */ diff --git a/src/db.ts b/src/db.ts index 0f2dc05..4a1b232 100644 --- a/src/db.ts +++ b/src/db.ts @@ -18,6 +18,7 @@ import { ScheduledTask, TaskRunLog, } from './types.js'; +import { readJsonFile } from './utils.js'; let db: Database.Database; @@ -1300,14 +1301,14 @@ export function isPairedRoomJid(jid: string): boolean { function migrateJsonState(): void { const migrateFile = (filename: string) => { const filePath = path.join(DATA_DIR, filename); - if (!fs.existsSync(filePath)) return null; + const data = readJsonFile(filePath); + if (data === null) return null; try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); fs.renameSync(filePath, `${filePath}.migrated`); - return data; } catch { - return null; + /* best effort */ } + return data; }; // Migrate router_state.json diff --git a/src/group-queue-ipc.ts b/src/group-queue-ipc.ts index 11a69d8..e035a25 100644 --- a/src/group-queue-ipc.ts +++ b/src/group-queue-ipc.ts @@ -1,6 +1,8 @@ import fs from 'fs'; import path from 'path'; +import { writeJsonFile } from './utils.js'; + function resolveInputDir(ipcDir: string): string { return path.join(ipcDir, 'input'); } @@ -11,7 +13,7 @@ export function queueFollowUpMessage(ipcDir: string, text: string): string { const filename = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}.json`; const filepath = path.join(inputDir, filename); const tempPath = `${filepath}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify({ type: 'message', text })); + writeJsonFile(tempPath, { type: 'message', text }); fs.renameSync(tempPath, filepath); return filename; } diff --git a/src/ipc.ts b/src/ipc.ts index c17f3c1..153c876 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -9,6 +9,7 @@ import { SERVICE_AGENT_TYPE, TIMEZONE, } from './config.js'; +import { readJsonFile } from './utils.js'; import { AvailableGroup } from './agent-runner.js'; import { createTask, deleteTask, getTaskById, updateTask } from './db.js'; import { isValidGroupFolder } from './group-folder.js'; @@ -78,22 +79,24 @@ export function startIpcWatcher(deps: IpcDeps): void { for (const file of messageFiles) { const filePath = path.join(messagesDir, file); try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); - if (data.type === 'message' && data.chatJid && data.text) { + const data = readJsonFile(filePath); + if (!data || typeof data !== 'object') throw new Error('Invalid JSON'); + const msg = data as { type?: string; chatJid?: string; text?: string }; + if (msg.type === 'message' && msg.chatJid && msg.text) { // Authorization: verify this group can send to this chatJid - const targetGroup = registeredGroups[data.chatJid]; + const targetGroup = registeredGroups[msg.chatJid]; if ( isMain || (targetGroup && targetGroup.folder === sourceGroup) ) { - await deps.sendMessage(data.chatJid, data.text); + await deps.sendMessage(msg.chatJid, msg.text); logger.info( - { chatJid: data.chatJid, sourceGroup }, + { chatJid: msg.chatJid, sourceGroup }, 'IPC message sent', ); } else { logger.warn( - { chatJid: data.chatJid, sourceGroup }, + { chatJid: msg.chatJid, sourceGroup }, 'Unauthorized IPC message attempt blocked', ); } @@ -129,9 +132,10 @@ export function startIpcWatcher(deps: IpcDeps): void { for (const file of taskFiles) { const filePath = path.join(tasksDir, file); try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')); + const data = readJsonFile(filePath); + if (!data || typeof data !== 'object') throw new Error('Invalid JSON'); // Pass source group identity to processTaskIpc for authorization - await processTaskIpc(data, sourceGroup, isMain, deps); + await processTaskIpc(data as Parameters[0], sourceGroup, isMain, deps); fs.unlinkSync(filePath); } catch (err) { logger.error( diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index c014f68..504aaa7 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -1,4 +1,5 @@ import path from 'path'; +import { getErrorMessage } from './utils.js'; import { AgentOutput, @@ -16,7 +17,6 @@ import { isClaudeAuthError, isClaudeAuthExpiredMessage, isClaudeUsageExhaustedMessage, - shouldRotateClaudeToken, } from './agent-error-detection.js'; import { detectFallbackTrigger, @@ -28,6 +28,7 @@ import { isUsageExhausted, markPrimaryCooldown, } from './provider-fallback.js'; +import { runClaudeRotationLoop } from './provider-retry.js'; import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; import { detectCodexRotationTrigger, @@ -35,11 +36,7 @@ import { getCodexAccountCount, markCodexTokenHealthy, } from './codex-token-rotation.js'; -import { - rotateToken, - getTokenCount, - markTokenHealthy, -} from './token-rotation.js'; +import { getTokenCount } from './token-rotation.js'; import type { RegisteredGroup } from './types.js'; export interface MessageAgentExecutorDeps { @@ -392,10 +389,7 @@ export async function runAgentForGroup( const retryAttempt = await runAttempt('codex'); if (retryAttempt.error) { - const errMsg = - retryAttempt.error instanceof Error - ? retryAttempt.error.message - : String(retryAttempt.error); + const errMsg = getErrorMessage(retryAttempt.error); const retryTrigger = detectCodexRotationTrigger(errMsg); if (retryTrigger.shouldRotate) { trigger = { reason: retryTrigger.reason }; @@ -486,156 +480,54 @@ export async function runAgentForGroup( }, rotationMessage?: string, ): Promise<'success' | 'error'> => { - let trigger = initialTrigger; - let lastRotationMessage = rotationMessage; + const logCtx = { + chatJid, + group: group.name, + groupFolder: group.folder, + runId, + }; - while ( - shouldRotateClaudeToken(trigger.reason) && - getTokenCount() > 1 && - rotateToken(lastRotationMessage, { ignoreRateLimits: true }) - ) { - logger.info( - { chatJid, group: group.name, runId, reason: trigger.reason }, - 'Claude rate-limited, retrying with rotated account', - ); - - const retryAttempt = await runAttempt('claude'); - - if (retryAttempt.error) { - if (!retryAttempt.sawOutput) { - const errMsg = - retryAttempt.error instanceof Error - ? retryAttempt.error.message - : String(retryAttempt.error); - const retryTrigger = retryAttempt.streamedTriggerReason - ? { - shouldFallback: true, - reason: retryAttempt.streamedTriggerReason.reason, - retryAfterMs: retryAttempt.streamedTriggerReason.retryAfterMs, - } - : detectFallbackTrigger(errMsg); - if (retryTrigger.shouldFallback) { - trigger = { - reason: retryTrigger.reason, - retryAfterMs: retryTrigger.retryAfterMs, - }; - lastRotationMessage = errMsg; - continue; - } - } - - logger.error( - { - chatJid, - group: group.name, - groupFolder: group.folder, - runId, - provider: 'claude', - err: retryAttempt.error, - }, - 'Rotated Claude account also threw', - ); - return 'error'; - } - - const retryOutput = retryAttempt.output; - if (!retryOutput) { - logger.error( - { - chatJid, - group: group.name, - groupFolder: group.folder, - runId, - provider: 'claude', - }, - 'Rotated Claude account produced no output object', - ); - return 'error'; - } - - if ( - !retryAttempt.sawOutput && - retryAttempt.streamedTriggerReason && - retryOutput.status !== 'error' - ) { - trigger = { - reason: retryAttempt.streamedTriggerReason.reason, - retryAfterMs: retryAttempt.streamedTriggerReason.retryAfterMs, + const outcome = await runClaudeRotationLoop( + initialTrigger, + async () => { + const attempt = await runAttempt('claude'); + return { + output: attempt.output, + thrownError: attempt.error, + sawOutput: attempt.sawOutput, + sawSuccessNullResult: attempt.sawSuccessNullResultWithoutOutput, + streamedTriggerReason: attempt.streamedTriggerReason, }; - lastRotationMessage = - typeof retryOutput.result === 'string' - ? retryOutput.result - : undefined; - continue; - } + }, + logCtx, + rotationMessage, + ); - if ( - !retryAttempt.sawOutput && - retryAttempt.sawSuccessNullResultWithoutOutput - ) { - return canFallback - ? runFallbackAttempt('success-null-result') - : 'error'; - } - - if (retryOutput.status === 'error') { - if (!retryAttempt.sawOutput) { - const retryTrigger = retryAttempt.streamedTriggerReason - ? { - shouldFallback: true, - reason: retryAttempt.streamedTriggerReason.reason, - retryAfterMs: retryAttempt.streamedTriggerReason.retryAfterMs, - } - : detectFallbackTrigger(retryOutput.error); - if (retryTrigger.shouldFallback) { - trigger = { - reason: retryTrigger.reason, - retryAfterMs: retryTrigger.retryAfterMs, - }; - lastRotationMessage = retryOutput.error ?? undefined; - continue; - } - } - - logger.error( - { - group: group.name, - chatJid, - runId, - provider: 'claude', - error: retryOutput.error, - }, - 'Rotated Claude account failed', - ); + switch (outcome.type) { + case 'success': + return 'success'; + case 'error': return 'error'; - } - - markTokenHealthy(); - return 'success'; + case 'no-fallback': + return 'error'; + case 'needs-fallback': + if (outcome.trigger.reason === 'success-null-result') { + return canFallback + ? runFallbackAttempt('success-null-result') + : 'error'; + } + if (!canFallback) { + logger.warn( + { ...logCtx, reason: outcome.trigger.reason }, + 'All Claude tokens exhausted and fallback disabled', + ); + return 'error'; + } + return runFallbackAttempt( + outcome.trigger.reason, + outcome.trigger.retryAfterMs, + ); } - - // Usage exhausted or auth-expired: don't fall back to Kimi — log only - if ( - trigger.reason === 'usage-exhausted' || - trigger.reason === 'auth-expired' - ) { - markPrimaryCooldown(trigger.reason, trigger.retryAfterMs); - logger.info( - { chatJid, group: group.name, runId, reason: trigger.reason }, - `All Claude tokens ${trigger.reason}, silently skipping (no Kimi fallback)`, - ); - return 'error'; - } - - if (!canFallback) { - logger.warn( - { chatJid, group: group.name, runId, reason: trigger.reason }, - 'All Claude tokens exhausted and fallback disabled', - ); - return 'error'; - } - - return runFallbackAttempt(trigger.reason, trigger.retryAfterMs); }; const provider = canFallback ? await getActiveProvider() : 'claude'; @@ -657,10 +549,7 @@ export async function runAgentForGroup( provider === 'claude' && !primaryAttempt.sawOutput ) { - const errMsg = - primaryAttempt.error instanceof Error - ? primaryAttempt.error.message - : String(primaryAttempt.error); + const errMsg = getErrorMessage(primaryAttempt.error); const trigger = primaryAttempt.streamedTriggerReason ? { shouldFallback: true, @@ -680,10 +569,7 @@ export async function runAgentForGroup( } if (!isClaudeCodeAgent) { - const errMsg = - primaryAttempt.error instanceof Error - ? primaryAttempt.error.message - : String(primaryAttempt.error); + const errMsg = getErrorMessage(primaryAttempt.error); const trigger = detectCodexRotationTrigger(errMsg); if (trigger.shouldRotate && getCodexAccountCount() > 1) { return retryCodexWithRotation({ reason: trigger.reason }, errMsg); diff --git a/src/message-runtime.ts b/src/message-runtime.ts index 8339632..1b55897 100644 --- a/src/message-runtime.ts +++ b/src/message-runtime.ts @@ -1,4 +1,5 @@ import { AgentOutput } from './agent-runner.js'; +import { getErrorMessage } from './utils.js'; import { getMessagesSinceSeq, getNewMessagesBySeq, @@ -119,7 +120,7 @@ export function createMessageRuntime(deps: MessageRuntimeDeps): { ); return true; } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err); + const errorMessage = getErrorMessage(err); markWorkItemDeliveryRetry(item.id, errorMessage); logger.warn( { diff --git a/src/message-turn-controller.ts b/src/message-turn-controller.ts index 7f72ae0..64be9ae 100644 --- a/src/message-turn-controller.ts +++ b/src/message-turn-controller.ts @@ -3,9 +3,14 @@ import { logger } from './logger.js'; import { formatOutbound } from './router.js'; import { shouldResetSessionOnAgentFailure } from './session-recovery.js'; import { TASK_STATUS_MESSAGE_PREFIX } from './task-watch-status.js'; -import { type Channel, type RegisteredGroup } from './types.js'; +import { formatElapsedKorean } from './utils.js'; +import { + type Channel, + type RegisteredGroup, + type VisiblePhase, +} from './types.js'; -export type VisiblePhase = 'silent' | 'progress' | 'final'; +export type { VisiblePhase }; interface SubagentTrack { label: string; @@ -324,20 +329,12 @@ export class MessageTurnController { } private renderProgressMessage(text: string): string { - const elapsedSeconds = + const elapsedMs = this.progressStartedAt === null ? 0 - : Math.floor((Date.now() - this.progressStartedAt) / 5_000) * 5; - const hours = Math.floor(elapsedSeconds / 3600); - const minutes = Math.floor((elapsedSeconds % 3600) / 60); - const seconds = elapsedSeconds % 60; - const elapsedParts: string[] = []; + : Math.floor((Date.now() - this.progressStartedAt) / 5_000) * 5000; - if (hours > 0) elapsedParts.push(`${hours}시간`); - if (minutes > 0) elapsedParts.push(`${minutes}분`); - elapsedParts.push(`${seconds}초`); - - const suffix = `\n\n${elapsedParts.join(' ')}`; + const suffix = `\n\n${formatElapsedKorean(elapsedMs)}`; let body: string; if (this.subagents.size > 1) { diff --git a/src/provider-retry.ts b/src/provider-retry.ts new file mode 100644 index 0000000..060c706 --- /dev/null +++ b/src/provider-retry.ts @@ -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, + logContext: Record, + rotationMessage?: string, +): Promise { + 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 }; +} diff --git a/src/restart-context.ts b/src/restart-context.ts index 480f1bc..838976a 100644 --- a/src/restart-context.ts +++ b/src/restart-context.ts @@ -5,6 +5,7 @@ import path from 'path'; import { DATA_DIR, SERVICE_ID, TIMEZONE } from './config.js'; import { logger } from './logger.js'; import type { RegisteredGroup } from './types.js'; +import { readJsonFile, writeJsonFile } from './utils.js'; export interface RestartInterruptedGroup { chatJid: string; @@ -61,16 +62,14 @@ function getMainGroupJid( } function readRestartContextFile(filePath: string): RestartContext | null { - if (!fs.existsSync(filePath)) return null; - try { - return JSON.parse(fs.readFileSync(filePath, 'utf8')) as RestartContext; - } catch (err) { + const data = readJsonFile(filePath); + if (data === null && fs.existsSync(filePath)) { logger.warn( - { err, filePath }, + { filePath }, 'Failed to parse restart context file; ignoring invalid content', ); - return null; } + return data; } function getLatestDistBuildTime(): number | null { @@ -134,7 +133,7 @@ export function writeRestartContext( const writtenPaths: string[] = []; for (const serviceId of serviceIds) { const filePath = getRestartContextPath(serviceId); - fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8'); + writeJsonFile(filePath, payload, true); writtenPaths.push(filePath); } return writtenPaths; @@ -175,7 +174,7 @@ export function writeShutdownRestartContext( interruptedGroups: mergedInterrupted, }; - fs.writeFileSync(filePath, JSON.stringify(payload, null, 2), 'utf8'); + writeJsonFile(filePath, payload, true); writtenPaths.push(filePath); } @@ -205,24 +204,19 @@ export function buildInterruptedRestartAnnouncement( export function consumeRestartContext(): RestartContext | null { const filePath = getRestartContextPath(); if (!fs.existsSync(filePath)) return null; - try { - const parsed = JSON.parse( - fs.readFileSync(filePath, 'utf8'), - ) as RestartContext; - fs.unlinkSync(filePath); - return parsed; - } catch (err) { + const parsed = readJsonFile(filePath); + if (!parsed) { logger.warn( - { err, filePath }, + { filePath }, 'Failed to read restart context; removing invalid file', ); - try { - fs.unlinkSync(filePath); - } catch { - // Ignore cleanup failure. - } - return null; } + try { + fs.unlinkSync(filePath); + } catch { + // Ignore cleanup failure. + } + return parsed; } export function buildRestartAnnouncement(context: RestartContext): string { diff --git a/src/status-dashboard.ts b/src/status-dashboard.ts index a8ca85f..8efacb1 100644 --- a/src/status-dashboard.ts +++ b/src/status-dashboard.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import { CACHE_DIR } from './config.js'; +import { writeJsonFile } from './utils.js'; import type { GroupStatus } from './group-queue.js'; import type { AgentType } from './types.js'; @@ -32,7 +33,7 @@ export function writeStatusSnapshot(snapshot: StatusSnapshot): void { `${snapshot.agentType}.json`, ); const tempPath = `${targetPath}.tmp`; - fs.writeFileSync(tempPath, JSON.stringify(snapshot, null, 2)); + writeJsonFile(tempPath, snapshot, true); fs.renameSync(tempPath, targetPath); } diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 5d84d61..63d6efe 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -2,6 +2,7 @@ import { ChildProcess } from 'child_process'; import { CronExpressionParser } from 'cron-parser'; import fs from 'fs'; import path from 'path'; +import { getErrorMessage } from './utils.js'; import { ASSISTANT_NAME, @@ -34,7 +35,6 @@ import { createTaskStatusTracker } from './task-status-tracker.js'; import { isClaudeAuthExpiredMessage, isClaudeUsageExhaustedMessage, - shouldRotateClaudeToken, } from './agent-error-detection.js'; import { detectFallbackTrigger, @@ -46,6 +46,7 @@ import { isUsageExhausted, markPrimaryCooldown, } from './provider-fallback.js'; +import { runClaudeRotationLoop } from './provider-retry.js'; import { detectCodexRotationTrigger, rotateCodexToken, @@ -53,9 +54,9 @@ import { markCodexTokenHealthy, } from './codex-token-rotation.js'; import { - rotateToken, getTokenCount, markTokenHealthy, + rotateToken, } from './token-rotation.js'; import { evaluateTaskSuspension, @@ -222,7 +223,7 @@ async function runTask( try { context = resolveTaskExecutionContext(task, deps); } catch (err) { - const error = err instanceof Error ? err.message : String(err); + const error = getErrorMessage(err); if (error.startsWith('Group not found:')) { logger.error( { taskId: task.id, groupFolder: task.group_folder, error }, @@ -441,83 +442,44 @@ async function runTask( }, rotationMessage?: string, ): Promise => { - let trigger = initialTrigger; - let lastRotationMessage = rotationMessage; + const logCtx = { + taskId: task.id, + group: context.group.name, + groupFolder: task.group_folder, + }; - while ( - shouldRotateClaudeToken(trigger.reason) && - getTokenCount() > 1 && - rotateToken(lastRotationMessage, { ignoreRateLimits: true }) - ) { - logger.info( - { - taskId: task.id, - group: context.group.name, - groupFolder: task.group_folder, - reason: trigger.reason, - }, - 'Scheduled task Claude rate-limited, retrying with rotated account', - ); - - const retryAttempt = await runTaskAttempt('claude'); - result = retryAttempt.attemptResult; - error = retryAttempt.attemptError; - - if ( - retryAttempt.streamedTriggerReason && - !retryAttempt.sawOutput && - retryAttempt.output.status !== 'error' - ) { - trigger = { - reason: retryAttempt.streamedTriggerReason.reason, - retryAfterMs: retryAttempt.streamedTriggerReason.retryAfterMs, + const outcome = await runClaudeRotationLoop( + initialTrigger, + async () => { + const attempt = await runTaskAttempt('claude'); + result = attempt.attemptResult; + error = attempt.attemptError; + return { + output: attempt.output, + sawOutput: attempt.sawOutput, + streamedTriggerReason: attempt.streamedTriggerReason, }; - lastRotationMessage = - typeof retryAttempt.output.result === 'string' - ? retryAttempt.output.result - : undefined; - continue; - } + }, + logCtx, + rotationMessage, + ); - if (retryAttempt.output.status === 'error' && !retryAttempt.sawOutput) { - const retryTrigger = retryAttempt.streamedTriggerReason - ? { - shouldFallback: true, - reason: retryAttempt.streamedTriggerReason.reason, - retryAfterMs: retryAttempt.streamedTriggerReason.retryAfterMs, - } - : detectFallbackTrigger(retryAttempt.attemptError); - if (retryTrigger.shouldFallback) { - trigger = { - reason: retryTrigger.reason, - retryAfterMs: retryTrigger.retryAfterMs, - }; - lastRotationMessage = retryAttempt.attemptError || undefined; - continue; - } - } - - if (retryAttempt.output.status === 'success') { - markTokenHealthy(); + switch (outcome.type) { + case 'success': error = null; return; - } - - return; + case 'error': + return; + case 'no-fallback': + error = `Claude ${outcome.trigger.reason}`; + return; + case 'needs-fallback': + await runFallbackTaskAttempt( + outcome.trigger.reason, + outcome.trigger.retryAfterMs, + ); + return; } - - // Usage exhausted: don't fall back to Kimi — just mark cooldown and skip - if (trigger.reason === 'usage-exhausted') { - markPrimaryCooldown(trigger.reason, trigger.retryAfterMs); - logger.info( - { taskId: task.id, group: context.group.name }, - 'All Claude tokens usage-exhausted, skipping Kimi fallback for scheduled task', - ); - error = 'Claude usage exhausted'; - return; - } - - await runFallbackTaskAttempt(trigger.reason, trigger.retryAfterMs); }; const provider = canFallback ? await getActiveProvider() : 'claude'; @@ -569,7 +531,7 @@ async function runTask( 'Task completed', ); } catch (err) { - error = err instanceof Error ? err.message : String(err); + error = getErrorMessage(err); logger.error({ taskId: task.id, error }, 'Task failed'); } diff --git a/src/task-watch-status.ts b/src/task-watch-status.ts index ea198db..1e9daea 100644 --- a/src/task-watch-status.ts +++ b/src/task-watch-status.ts @@ -1,5 +1,6 @@ import { TIMEZONE } from './config.js'; import type { ScheduledTask } from './types.js'; +import { formatElapsedKorean } from './utils.js'; export type WatcherStatusPhase = | 'checking' @@ -65,16 +66,7 @@ function formatElapsedLabel( 0, new Date(checkedAtIso).getTime() - new Date(startedAtIso).getTime(), ); - const totalSeconds = Math.floor(elapsedMs / 1000); - const hours = Math.floor(totalSeconds / 3600); - const minutes = Math.floor((totalSeconds % 3600) / 60); - const seconds = totalSeconds % 60; - - const parts: string[] = []; - if (hours > 0) parts.push(`${hours}시간`); - if (minutes > 0) parts.push(`${minutes}분`); - parts.push(`${seconds}초`); - return parts.join(' '); + return formatElapsedKorean(elapsedMs); } export function renderWatchCiStatusMessage(args: { diff --git a/src/token-refresh.ts b/src/token-refresh.ts index fc9b92d..6606440 100644 --- a/src/token-refresh.ts +++ b/src/token-refresh.ts @@ -13,6 +13,7 @@ */ import fs from 'fs'; import os from 'os'; +import { getErrorMessage, readJsonFile } from './utils.js'; import path from 'path'; import { logger } from './logger.js'; @@ -72,13 +73,12 @@ function getCredentialsPath(accountIndex: number): string { function readCredentials(accountIndex: number): CredentialsFile | null { const credsPath = getCredentialsPath(accountIndex); - try { - if (!fs.existsSync(credsPath)) return null; - return JSON.parse(fs.readFileSync(credsPath, 'utf-8')); - } catch (err) { - logger.warn({ err, accountIndex }, 'Failed to read Claude credentials'); - return null; + if (!fs.existsSync(credsPath)) return null; + const data = readJsonFile(credsPath); + if (!data) { + logger.warn({ accountIndex }, 'Failed to read Claude credentials'); } + return data; } function writeCredentials(accountIndex: number, creds: CredentialsFile): void { @@ -121,7 +121,7 @@ function syncToSessionDirs(credsPath: string): void { } } catch (err) { logger.warn( - { err: err instanceof Error ? err.message : String(err) }, + { err: getErrorMessage(err) }, 'Failed to sync credentials to sessions', ); } @@ -165,7 +165,7 @@ function updateEnvTokens(): void { logger.debug('Updated .env with refreshed tokens'); } catch (err) { logger.warn( - { err: err instanceof Error ? err.message : String(err) }, + { err: getErrorMessage(err) }, 'Failed to update .env with refreshed tokens', ); } @@ -208,7 +208,7 @@ async function refreshToken( ); } catch (err) { logger.warn( - { url: TOKEN_URL, err: err instanceof Error ? err.message : String(err) }, + { url: TOKEN_URL, err: getErrorMessage(err) }, 'Token refresh request error', ); } @@ -276,7 +276,7 @@ async function checkAndRefreshAccount( logger.error( { accountIndex, - err: err instanceof Error ? err.message : String(err), + err: getErrorMessage(err), }, 'Failed to refresh Claude OAuth token — manual re-login may be required', ); diff --git a/src/token-rotation.ts b/src/token-rotation.ts index 6c23418..eeb29ab 100644 --- a/src/token-rotation.ts +++ b/src/token-rotation.ts @@ -20,6 +20,7 @@ import { computeCooldownUntil, findNextAvailable, } from './token-rotation-base.js'; +import { readJsonFile, writeJsonFile } from './utils.js'; const STATE_FILE = path.join(DATA_DIR, 'token-rotation-state.json'); @@ -67,42 +68,39 @@ function saveState(): void { currentIndex, rateLimits: tokens.map((t) => t.rateLimitedUntil), }; - fs.writeFileSync(STATE_FILE, JSON.stringify(state)); + writeJsonFile(STATE_FILE, state); } catch { /* best effort */ } } function loadState(): void { - try { - if (!fs.existsSync(STATE_FILE)) return; - const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); - const now = Date.now(); - if ( - typeof state.currentIndex === 'number' && - state.currentIndex < tokens.length + const state = readJsonFile<{ currentIndex?: number; rateLimits?: (number | null)[] }>(STATE_FILE); + if (!state) return; + + const now = Date.now(); + if ( + typeof state.currentIndex === 'number' && + state.currentIndex < tokens.length + ) { + currentIndex = state.currentIndex; + } + if (Array.isArray(state.rateLimits)) { + for ( + let i = 0; + i < Math.min(state.rateLimits.length, tokens.length); + i++ ) { - currentIndex = state.currentIndex; - } - if (Array.isArray(state.rateLimits)) { - for ( - let i = 0; - i < Math.min(state.rateLimits.length, tokens.length); - i++ - ) { - const until = state.rateLimits[i]; - if (typeof until === 'number' && until > now) { - tokens[i].rateLimitedUntil = until; - } + const until = state.rateLimits[i]; + if (typeof until === 'number' && until > now) { + tokens[i].rateLimitedUntil = until; } } - logger.info( - { currentIndex, tokenCount: tokens.length }, - 'Token rotation state restored', - ); - } catch { - /* start fresh */ } + logger.info( + { currentIndex, tokenCount: tokens.length }, + 'Token rotation state restored', + ); } /** Get the current active token. */ diff --git a/src/types.ts b/src/types.ts index 27c2ea6..8211111 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,10 +5,22 @@ export interface AgentConfig { codexEffort?: string; claudeModel?: string; claudeEffort?: string; + claudeThinking?: 'adaptive' | 'enabled' | 'disabled'; + claudeThinkingBudget?: number; } export type AgentType = 'claude-code' | 'codex'; +/** Phase of agent output as emitted by the runner. */ +export type AgentOutputPhase = + | 'progress' + | 'final' + | 'tool-activity' + | 'intermediate'; + +/** Phase as visible in the UI (mapped from AgentOutputPhase). */ +export type VisiblePhase = 'silent' | 'progress' | 'final'; + export interface RegisteredGroup { name: string; folder: string; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..2879f09 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,69 @@ +/** + * Shared utilities (SSOT). + * + * Small helpers that were previously copy-pasted across 10+ files. + */ + +import fs from 'fs'; + +// ── Error handling ────────────────────────────────────────────── + +/** Extract a human-readable message from an unknown caught value. */ +export function getErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +// ── JSON file I/O ─────────────────────────────────────────────── + +/** Read and parse a JSON file. Returns null on any failure. */ +export function readJsonFile(filePath: string): T | null { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')) as T; + } catch { + return null; + } +} + +/** Write data as JSON to a file. */ +export function writeJsonFile( + filePath: string, + data: unknown, + pretty = false, +): void { + fs.writeFileSync( + filePath, + JSON.stringify(data, null, pretty ? 2 : undefined), + ); +} + +// ── Fetch with timeout ────────────────────────────────────────── + +/** Wrapper around fetch() that aborts after timeoutMs. */ +export async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +// ── Time formatting ───────────────────────────────────────────── + +/** Format milliseconds as Korean elapsed time (e.g. "1시간 2분 30초"). */ +export function formatElapsedKorean(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const parts: string[] = []; + if (hours > 0) parts.push(`${hours}시간`); + if (minutes > 0) parts.push(`${minutes}분`); + parts.push(`${seconds}초`); + return parts.join(' '); +}