Files
EJClaw/src/message-runtime-gating.ts
Codex f4de795b1e fix(primer): refresh Codex usage before primer and allow 1% fresh accounts
Codex primer was skipping with no_eligible_accounts because it read a
stale usage cache and required exactly 0% usage. Re-query usage right
before the primer call and treat freshly-reset 0~1% accounts as eligible
so the 5h window can be anchored at the fixed KST slot. Also hold new
Codex-owner turns at fresh usage until the next primer slot.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-01 16:29:12 +09:00

233 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
CLAUDE_EXHAUSTION_THRESHOLD_PCT,
getClaudeUsageExhaustion,
} from './claude-usage.js';
import {
CODEX_EXHAUSTION_THRESHOLD_PCT,
getCodexUsageExhaustion,
} from './codex-usage-collector.js';
import { getAllCodexAccounts } from './codex-token-rotation.js';
import { logger } from './logger.js';
import {
handleSessionCommand,
type SessionCommandDeps,
} from './session-commands.js';
import type { AgentType, NewMessage, RegisteredGroup } from './types.js';
/**
* Test seam — overridable from unit tests so we don't need a real disk
* cache or rotation state to exercise the exhaustion gate.
*/
export const exhaustionChecks = {
claude: getClaudeUsageExhaustion,
codex: getCodexUsageExhaustion,
};
/** Render an ISO timestamp as "약 N분 뒤" / "약 Nh Nm 뒤" relative to now. */
function formatResetCountdown(iso: string | null): string | null {
if (!iso) return null;
const target = Date.parse(iso);
if (!Number.isFinite(target)) return null;
const diffMs = target - Date.now();
if (diffMs <= 0) return '곧';
const totalMinutes = Math.ceil(diffMs / 60_000);
if (totalMinutes < 60) return `${totalMinutes}분 뒤`;
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours < 24) {
return minutes > 0
? `${hours}시간 ${minutes}분 뒤`
: `${hours}시간 뒤`;
}
const days = Math.floor(hours / 24);
const remH = hours % 24;
return remH > 0 ? `${days}${remH}시간 뒤` : `${days}일 뒤`;
}
function exhaustionMessageFor(
agentType: AgentType,
nextResetAt: string | null,
): string {
const label = agentType === 'codex' ? 'Codex' : 'Claude';
const pct =
agentType === 'codex'
? CODEX_EXHAUSTION_THRESHOLD_PCT
: CLAUDE_EXHAUSTION_THRESHOLD_PCT;
const countdown = formatResetCountdown(nextResetAt);
const head = `토큰 부족으로 종료됨 (${label} 모든 계정 사용량 ${pct}% 초과)`;
return countdown ? `${head}${countdown} 초기화 예정` : head;
}
// ── Usage-window alignment gap ──
// Bot primer fires at 08/13/18/23 KST to anchor 5h windows. The 04:0008:00
// KST gap before the day's first primer must be free of inference calls,
// otherwise an off-cycle window starts and the daily reset alignment drifts.
// During the gap, send an auto-reply instead of running the model.
// Urgent bypass: any message containing an @-mention (bot's trigger pattern)
// is processed anyway — user explicitly tagged the bot, accept the drift.
const GAP_START_HOUR_KST = 4;
const GAP_END_HOUR_KST = 8;
const PRIMER_HOURS_KST = [8, 13, 18, 23];
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
const CODEX_ALIGNMENT_FRESH_USAGE_PCT = 1;
const PRIMER_GRACE_MS = 2 * 60 * 1000;
export function isInUsageAlignmentGap(nowMs: number = Date.now()): boolean {
const kstHour = new Date(nowMs + KST_OFFSET_MS).getUTCHours();
return kstHour >= GAP_START_HOUR_KST && kstHour < GAP_END_HOUR_KST;
}
function msUntilNextPrimerSlotKST(nowMs: number): number {
const kstNowShifted = nowMs + KST_OFFSET_MS;
const kstDate = new Date(kstNowShifted);
const kstH = kstDate.getUTCHours();
let nextH = PRIMER_HOURS_KST.find((h) => h > kstH);
let dayOffset = 0;
if (nextH === undefined) {
nextH = PRIMER_HOURS_KST[0];
dayOffset = 1;
}
const target = new Date(kstNowShifted);
target.setUTCHours(nextH, 0, 0, 0);
if (dayOffset === 1) target.setUTCDate(target.getUTCDate() + 1);
return target.getTime() - kstNowShifted;
}
function formatPrimerHourKST(nowMs: number): string {
const nextMs = nowMs + msUntilNextPrimerSlotKST(nowMs);
const kst = new Date(nextMs + KST_OFFSET_MS);
return `${String(kst.getUTCHours()).padStart(2, '0')}:00 KST`;
}
export function shouldHoldFreshCodexUntilPrimer(
nowMs: number = Date.now(),
): boolean {
const accounts = getAllCodexAccounts();
const active = accounts.find((a) => a.isActive) ?? accounts[0];
if (!active || active.isRateLimited) return false;
const h5 = active.cachedUsagePct;
if (h5 == null || h5 < 0 || h5 > CODEX_ALIGNMENT_FRESH_USAGE_PCT) {
return false;
}
const elapsedFromPreviousSlot =
(5 * 60 * 60 * 1000 - msUntilNextPrimerSlotKST(nowMs)) %
(5 * 60 * 60 * 1000);
return elapsedFromPreviousSlot > PRIMER_GRACE_MS;
}
const GAP_AUTO_REPLY =
'사용량 정렬 시간(04~08 KST)입니다. 08시 이후 다시 보내주세요. 긴급한 경우 @태그하면 처리됩니다.';
const CODEX_ALIGNMENT_AUTO_REPLY = (nowMs: number) =>
`Codex 사용량 정렬 대기 중입니다. 다음 프라이머 시각(${formatPrimerHourKST(
nowMs,
)}) 이후 다시 보내주세요. 긴급한 경우 @태그하면 처리됩니다.`;
export async function handleQueuedRunGates(args: {
chatJid: string;
group: RegisteredGroup;
runId: string;
missedMessages: NewMessage[];
triggerPattern: RegExp;
timezone: string;
hasImplicitContinuationWindow: (
chatJid: string,
messages: NewMessage[],
) => boolean;
sessionCommandDeps: SessionCommandDeps;
}): Promise<{ handled: true; success: boolean } | { handled: false }> {
const cmdResult = await handleSessionCommand({
missedMessages: args.missedMessages,
isMainGroup: args.group.isMain === true,
groupName: args.group.name,
runId: args.runId,
triggerPattern: args.triggerPattern,
timezone: args.timezone,
deps: args.sessionCommandDeps,
});
if (cmdResult.handled) {
return cmdResult;
}
// ── Usage-window alignment gap gate ──
if (isInUsageAlignmentGap()) {
const hasUrgentMention = args.missedMessages.some((m) =>
args.triggerPattern.test(m.content),
);
if (!hasUrgentMention) {
logger.info(
{
chatJid: args.chatJid,
groupName: args.group.name,
runId: args.runId,
},
'Blocking new turn — usage alignment gap (04-08 KST)',
);
try {
await args.sessionCommandDeps.sendMessage(GAP_AUTO_REPLY);
} catch (err) {
logger.warn({ err }, 'Failed to send alignment gap notice');
}
return { handled: true, success: true };
}
}
const agentType: AgentType = args.group.agentType ?? 'claude-code';
if (agentType === 'codex' && shouldHoldFreshCodexUntilPrimer()) {
const hasUrgentMention = args.missedMessages.some((m) =>
args.triggerPattern.test(m.content),
);
if (!hasUrgentMention) {
logger.info(
{
chatJid: args.chatJid,
groupName: args.group.name,
runId: args.runId,
},
'Blocking new Codex turn — waiting for fixed primer slot',
);
try {
await args.sessionCommandDeps.sendMessage(
CODEX_ALIGNMENT_AUTO_REPLY(Date.now()),
);
} catch (err) {
logger.warn({ err }, 'Failed to send Codex alignment notice');
}
return { handled: true, success: true };
}
}
// ── Usage-exhaustion gate ──
// Block new turns when the agent provider that would handle this room has
// every account at ≥95% on either window (or rate-limited). In-flight
// runs are not affected; this only stops *new* turns from starting.
const info =
agentType === 'codex'
? exhaustionChecks.codex()
: exhaustionChecks.claude();
if (info.exhausted) {
const text = exhaustionMessageFor(agentType, info.nextResetAt);
logger.warn(
{
chatJid: args.chatJid,
groupName: args.group.name,
agentType,
runId: args.runId,
nextResetAt: info.nextResetAt,
},
'Blocking new turn — all accounts exhausted',
);
try {
await args.sessionCommandDeps.sendMessage(text);
} catch (err) {
logger.warn({ err }, 'Failed to send exhaustion notice');
}
return { handled: true, success: true };
}
return { handled: false };
}