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:
@@ -32,6 +32,7 @@ interface ContainerInput {
|
||||
secrets?: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 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<string, string> = {
|
||||
'.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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
19
src/agent-protocol.ts
Normal file
19
src/agent-protocol.ts
Normal file
@@ -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';
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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)}`,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<Record<string, UsageCacheEntry>>(USAGE_CACHE_FILE) ?? {};
|
||||
}
|
||||
|
||||
function saveUsageDiskCache(): void {
|
||||
try {
|
||||
fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(usageDiskCache));
|
||||
writeJsonFile(USAGE_CACHE_FILE, usageDiskCache);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
20
src/ipc.ts
20
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<typeof processTaskIpc>[0], sourceGroup, isMain, deps);
|
||||
fs.unlinkSync(filePath);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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<RestartContext>(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<RestartContext>(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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> => {
|
||||
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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<CredentialsFile>(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',
|
||||
);
|
||||
|
||||
@@ -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. */
|
||||
|
||||
12
src/types.ts
12
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;
|
||||
|
||||
69
src/utils.ts
Normal file
69
src/utils.ts
Normal file
@@ -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<T = unknown>(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<Response> {
|
||||
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(' ');
|
||||
}
|
||||
Reference in New Issue
Block a user