Reviewer/arbiter/owner Codex turns are no longer deferred during the [03:00-08:00 KST] dawn window — they dispatch immediately at all hours. Deletes codex-primer-alignment.ts and every hold call site (gating owner hold + alignment notice, reviewer/arbiter queue hold, warm-up skip, primer anchor-lock). The 08:00 primer still fires; it just no longer holds other consumers. Tradeoff: the 13:00 KST 5h-reset anchoring is no longer enforced. Also adds a user-visible next-step indicator after owner turns that do not end the task (review_ready -> reviewer requested, arbiter_requested -> arbiter called) so a same-looking status line is no longer ambiguous; no extra line on completion to avoid duplicating the owner's final message. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
import { ChildProcess, spawn } from 'child_process';
|
|
import fs from 'fs';
|
|
import { createRequire } from 'module';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
import { DATA_DIR } from './config.js';
|
|
import type { AppConfig } from './config/schema.js';
|
|
import {
|
|
getAllCodexAccounts,
|
|
getCodexAuthPath,
|
|
} from './codex-token-rotation.js';
|
|
import { logger } from './logger.js';
|
|
|
|
export type CodexWarmupConfig = AppConfig['codexWarmup'];
|
|
|
|
interface CodexWarmupAccountState {
|
|
lastWarmupAt?: string;
|
|
lastAttemptAt?: string;
|
|
lastErrorAt?: string;
|
|
zeroUsageWarmupUntil?: string;
|
|
failures?: number;
|
|
}
|
|
|
|
interface CodexWarmupState {
|
|
lastWarmupAt?: string;
|
|
lastAttemptAt?: string;
|
|
disabledUntil?: string;
|
|
consecutiveFailures?: number;
|
|
accounts?: Record<string, CodexWarmupAccountState>;
|
|
}
|
|
|
|
interface CodexWarmupRuntimeOptions {
|
|
nowMs?: number;
|
|
statePath?: string;
|
|
shouldSkip?: () => boolean;
|
|
ignoreZeroUsageWindow?: boolean;
|
|
/**
|
|
* Fixed-slot primer mode: bypass the opportunistic warm-up skip gates
|
|
* (stagger, post-failure cooldown, min-interval, per-account usage/rate-limit
|
|
* filters) so a real Codex command is attempted at every 08/13/18/23 slot and
|
|
* its success/failure recorded. Without this, two slots exactly 5h apart fall
|
|
* just under minIntervalMs (the primer fires a few hundred ms after the slot)
|
|
* and the later slot is silently skipped as `no_eligible_accounts`.
|
|
*/
|
|
forceAttempt?: boolean;
|
|
}
|
|
|
|
export type CodexWarmupCycleResult =
|
|
| { status: 'disabled' }
|
|
| { status: 'skipped'; reason: string }
|
|
| { status: 'warmed'; accountIndex: number }
|
|
| { status: 'failed'; accountIndex: number; reason: string };
|
|
|
|
const DEFAULT_STATE_FILE = path.join(DATA_DIR, 'codex-warmup-state.json');
|
|
const DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
|
function parseTimestamp(value?: string): number | null {
|
|
if (!value) return null;
|
|
const parsed = Date.parse(value);
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function readWarmupState(statePath: string): CodexWarmupState {
|
|
try {
|
|
if (!fs.existsSync(statePath)) return { accounts: {} };
|
|
const parsed = JSON.parse(
|
|
fs.readFileSync(statePath, 'utf8'),
|
|
) as CodexWarmupState;
|
|
if (!parsed.accounts || typeof parsed.accounts !== 'object') {
|
|
parsed.accounts = {};
|
|
}
|
|
return parsed;
|
|
} catch (err) {
|
|
logger.warn(
|
|
{ err, statePath },
|
|
'Failed to read Codex warm-up state; starting fresh',
|
|
);
|
|
return { accounts: {} };
|
|
}
|
|
}
|
|
|
|
function writeWarmupState(statePath: string, state: CodexWarmupState): void {
|
|
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
fs.writeFileSync(`${statePath}.tmp`, `${JSON.stringify(state, null, 2)}\n`);
|
|
fs.renameSync(`${statePath}.tmp`, statePath);
|
|
}
|
|
|
|
function getPreferredCodexPathEntries(): string[] {
|
|
const entries = [
|
|
path.dirname(process.execPath),
|
|
path.join(os.homedir(), '.npm-global', 'bin'),
|
|
];
|
|
if (process.versions.bun || path.basename(process.execPath) === 'bun') {
|
|
entries.push(path.join(os.homedir(), '.hermes', 'node', 'bin'));
|
|
}
|
|
return [...new Set(entries)];
|
|
}
|
|
|
|
const require = createRequire(import.meta.url);
|
|
|
|
interface CodexLauncher {
|
|
command: string;
|
|
prefixArgs: string[];
|
|
}
|
|
|
|
/**
|
|
* Resolve how to spawn the Codex CLI for warm-up.
|
|
*
|
|
* The bot runs under bun/systemd where a global `codex` binary usually is NOT
|
|
* on PATH, so the old bare-`codex` fallback failed with ENOENT and the primer
|
|
* never fired. Prefer the bundled `@openai/codex` JS launcher (the same path
|
|
* the codex-runner spawns) and run it through the current JS runtime, falling
|
|
* back to a globally installed binary only when the package is unavailable.
|
|
*/
|
|
function resolveCodexLauncher(): CodexLauncher {
|
|
const launcherCandidates: string[] = [];
|
|
try {
|
|
launcherCandidates.push(
|
|
path.join(
|
|
path.dirname(require.resolve('@openai/codex/package.json')),
|
|
'bin',
|
|
'codex.js',
|
|
),
|
|
);
|
|
} catch {
|
|
/* @openai/codex not resolvable from this module — try explicit paths */
|
|
}
|
|
// The codex-runner workspace always vendors @openai/codex.
|
|
const repoRoot = path.resolve(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'..',
|
|
);
|
|
launcherCandidates.push(
|
|
path.join(
|
|
repoRoot,
|
|
'runners',
|
|
'codex-runner',
|
|
'node_modules',
|
|
'@openai',
|
|
'codex',
|
|
'bin',
|
|
'codex.js',
|
|
),
|
|
);
|
|
for (const launcher of launcherCandidates) {
|
|
if (fs.existsSync(launcher)) {
|
|
return { command: process.execPath, prefixArgs: [launcher] };
|
|
}
|
|
}
|
|
// Fall back to a globally installed codex binary, then bare PATH lookup.
|
|
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
|
if (fs.existsSync(npmGlobalBin)) {
|
|
return { command: npmGlobalBin, prefixArgs: [] };
|
|
}
|
|
return { command: 'codex', prefixArgs: [] };
|
|
}
|
|
|
|
function ensureAccountState(
|
|
state: CodexWarmupState,
|
|
accountIndex: number,
|
|
): CodexWarmupAccountState {
|
|
state.accounts ??= {};
|
|
const key = String(accountIndex);
|
|
state.accounts[key] ??= {};
|
|
return state.accounts[key];
|
|
}
|
|
|
|
function selectWarmupCandidate(
|
|
config: CodexWarmupConfig,
|
|
state: CodexWarmupState,
|
|
nowMs: number,
|
|
options: { ignoreZeroUsageWindow?: boolean; forceAttempt?: boolean } = {},
|
|
): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } {
|
|
const disabledUntilMs = parseTimestamp(state.disabledUntil);
|
|
if (
|
|
!options.forceAttempt &&
|
|
disabledUntilMs != null &&
|
|
disabledUntilMs > nowMs
|
|
) {
|
|
return { reason: 'disabled_cooldown' };
|
|
}
|
|
|
|
const lastGlobalWarmupMs = parseTimestamp(state.lastWarmupAt);
|
|
if (
|
|
!options.forceAttempt &&
|
|
lastGlobalWarmupMs != null &&
|
|
config.staggerMs > 0 &&
|
|
nowMs - lastGlobalWarmupMs < config.staggerMs
|
|
) {
|
|
return { reason: 'stagger_wait' };
|
|
}
|
|
|
|
const accounts = getAllCodexAccounts();
|
|
if (accounts.length === 0) return { reason: 'no_accounts' };
|
|
|
|
// Fixed-slot primer: attempt a real command on the active account every slot,
|
|
// bypassing the per-account usage/rate-limit/min-interval filters below.
|
|
if (options.forceAttempt) {
|
|
const active = accounts.find((a) => a.isActive) ?? accounts[0];
|
|
return {
|
|
accountIndex: active.index,
|
|
zeroUsageWarmupUntil: new Date(nowMs).toISOString(),
|
|
};
|
|
}
|
|
|
|
for (const account of accounts) {
|
|
if (account.isRateLimited) continue;
|
|
if (typeof account.cachedUsagePct !== 'number') continue;
|
|
if (typeof account.cachedUsageD7Pct !== 'number') continue;
|
|
if (account.cachedUsagePct > config.maxUsagePct) continue;
|
|
if (account.cachedUsageD7Pct > config.maxD7UsagePct) continue;
|
|
|
|
const accountState = state.accounts?.[String(account.index)];
|
|
const zeroUsageWarmupUntilMs = parseTimestamp(
|
|
accountState?.zeroUsageWarmupUntil,
|
|
);
|
|
if (
|
|
!options.ignoreZeroUsageWindow &&
|
|
zeroUsageWarmupUntilMs != null &&
|
|
zeroUsageWarmupUntilMs > nowMs
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
const lastWarmupMs = parseTimestamp(accountState?.lastWarmupAt);
|
|
if (lastWarmupMs != null && nowMs - lastWarmupMs < config.minIntervalMs) {
|
|
continue;
|
|
}
|
|
|
|
const resetD7Ms = parseTimestamp(account.resetD7At);
|
|
let zeroUsageWarmupUntilMsForState = nowMs;
|
|
if (!options.ignoreZeroUsageWindow) {
|
|
zeroUsageWarmupUntilMsForState =
|
|
resetD7Ms != null && resetD7Ms > nowMs
|
|
? resetD7Ms
|
|
: nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS;
|
|
}
|
|
|
|
return {
|
|
accountIndex: account.index,
|
|
zeroUsageWarmupUntil: new Date(
|
|
zeroUsageWarmupUntilMsForState,
|
|
).toISOString(),
|
|
};
|
|
}
|
|
|
|
return { reason: 'no_eligible_accounts' };
|
|
}
|
|
|
|
function runCodexWarmupCommand(
|
|
accountDir: string,
|
|
config: CodexWarmupConfig,
|
|
): Promise<{ ok: boolean; reason: string }> {
|
|
const args = [
|
|
'exec',
|
|
'--ephemeral',
|
|
'--ignore-rules',
|
|
'--skip-git-repo-check',
|
|
'--sandbox',
|
|
'read-only',
|
|
'-C',
|
|
os.tmpdir(),
|
|
'-m',
|
|
config.model,
|
|
config.prompt,
|
|
];
|
|
|
|
const spawnEnv: Record<string, string> = {
|
|
...(process.env as Record<string, string>),
|
|
CODEX_HOME: accountDir,
|
|
PATH: [...getPreferredCodexPathEntries(), process.env.PATH || '']
|
|
.filter(Boolean)
|
|
.join(path.delimiter),
|
|
};
|
|
|
|
return new Promise((resolve) => {
|
|
let done = false;
|
|
let proc: ChildProcess | null = null;
|
|
let stderr = '';
|
|
const finish = (result: { ok: boolean; reason: string }) => {
|
|
if (done) return;
|
|
done = true;
|
|
clearTimeout(timer);
|
|
if (proc && result.reason === 'timeout') {
|
|
try {
|
|
proc.kill();
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
resolve(result);
|
|
};
|
|
|
|
const timer = setTimeout(
|
|
() => finish({ ok: false, reason: 'timeout' }),
|
|
config.commandTimeoutMs,
|
|
);
|
|
|
|
try {
|
|
const launcher = resolveCodexLauncher();
|
|
proc = spawn(launcher.command, [...launcher.prefixArgs, ...args], {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
env: spawnEnv,
|
|
});
|
|
} catch (err) {
|
|
logger.warn({ err }, 'Failed to spawn Codex warm-up command');
|
|
finish({ ok: false, reason: 'spawn_error' });
|
|
return;
|
|
}
|
|
|
|
proc.stderr?.on('data', (chunk: Buffer) => {
|
|
stderr += chunk.toString();
|
|
if (stderr.length > 2000) stderr = stderr.slice(-2000);
|
|
});
|
|
proc.on('error', (err) => {
|
|
logger.warn({ err }, 'Codex warm-up process error');
|
|
finish({ ok: false, reason: 'process_error' });
|
|
});
|
|
proc.on('close', (code) => {
|
|
if (code === 0) {
|
|
finish({ ok: true, reason: 'ok' });
|
|
return;
|
|
}
|
|
logger.warn(
|
|
{ exitCode: code, stderr: stderr.trim() || undefined },
|
|
'Codex warm-up command failed',
|
|
);
|
|
finish({ ok: false, reason: `exit_${code ?? 'unknown'}` });
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function runCodexWarmupCycle(
|
|
config: CodexWarmupConfig,
|
|
runtime: CodexWarmupRuntimeOptions = {},
|
|
): Promise<CodexWarmupCycleResult> {
|
|
if (!config.enabled) return { status: 'disabled' };
|
|
if (runtime.shouldSkip?.())
|
|
return { status: 'skipped', reason: 'runtime_busy' };
|
|
|
|
const nowMs = runtime.nowMs ?? Date.now();
|
|
const nowIso = new Date(nowMs).toISOString();
|
|
const statePath = runtime.statePath ?? DEFAULT_STATE_FILE;
|
|
const state = readWarmupState(statePath);
|
|
const selected = selectWarmupCandidate(config, state, nowMs, {
|
|
ignoreZeroUsageWindow: runtime.ignoreZeroUsageWindow,
|
|
forceAttempt: runtime.forceAttempt,
|
|
});
|
|
if ('reason' in selected) {
|
|
return { status: 'skipped', reason: selected.reason };
|
|
}
|
|
|
|
const authPath = getCodexAuthPath(selected.accountIndex);
|
|
if (!authPath || !fs.existsSync(authPath)) {
|
|
return { status: 'skipped', reason: 'missing_auth' };
|
|
}
|
|
const accountDir = path.dirname(authPath);
|
|
const accountState = ensureAccountState(state, selected.accountIndex);
|
|
state.lastAttemptAt = nowIso;
|
|
accountState.lastAttemptAt = nowIso;
|
|
|
|
logger.info(
|
|
{ account: selected.accountIndex + 1, model: config.model },
|
|
'Starting Codex warm-up prompt',
|
|
);
|
|
const result = await runCodexWarmupCommand(accountDir, config);
|
|
|
|
if (result.ok) {
|
|
state.lastWarmupAt = nowIso;
|
|
state.consecutiveFailures = 0;
|
|
delete state.disabledUntil;
|
|
accountState.lastWarmupAt = nowIso;
|
|
accountState.zeroUsageWarmupUntil = selected.zeroUsageWarmupUntil;
|
|
accountState.failures = 0;
|
|
writeWarmupState(statePath, state);
|
|
logger.info(
|
|
{ account: selected.accountIndex + 1 },
|
|
'Codex warm-up prompt completed',
|
|
);
|
|
return { status: 'warmed', accountIndex: selected.accountIndex };
|
|
}
|
|
|
|
state.consecutiveFailures = (state.consecutiveFailures ?? 0) + 1;
|
|
accountState.lastErrorAt = nowIso;
|
|
accountState.failures = (accountState.failures ?? 0) + 1;
|
|
if (state.consecutiveFailures >= config.maxConsecutiveFailures) {
|
|
state.disabledUntil = new Date(
|
|
nowMs + config.failureCooldownMs,
|
|
).toISOString();
|
|
}
|
|
writeWarmupState(statePath, state);
|
|
logger.warn(
|
|
{
|
|
account: selected.accountIndex + 1,
|
|
reason: result.reason,
|
|
consecutiveFailures: state.consecutiveFailures,
|
|
disabledUntil: state.disabledUntil,
|
|
},
|
|
'Codex warm-up prompt failed',
|
|
);
|
|
return {
|
|
status: 'failed',
|
|
accountIndex: selected.accountIndex,
|
|
reason: result.reason,
|
|
};
|
|
}
|