feat(primer): add usage-window alignment primer on GitHub-main base
Port the local primer subsystem onto the upstream base by intent: - add src/usage-primer.ts (KST-slot primer firing Claude + Codex signals) - extend codex-warmup with the ignoreZeroUsageWindow runtime option used by the primer so a slot is never skipped just because Codex is partway through its weekly window - Codex primer fires unconditionally (maxUsagePct/maxD7UsagePct=100), mirroring the Claude primer, refreshing usage before selection - wire startUsagePrimer() into the runtime bootstrap Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
@@ -225,6 +225,71 @@ describe('Codex warm-up scheduler', () => {
|
|||||||
expect(childProcess.spawn).not.toHaveBeenCalled();
|
expect(childProcess.spawn).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lets the fixed-slot primer warm an account again when 5h usage is fresh but weekly usage is nonzero', async () => {
|
||||||
|
const childProcess = await import('child_process');
|
||||||
|
const rotation = await import('./codex-token-rotation.js');
|
||||||
|
const { runCodexWarmupCycle } = await import('./codex-warmup.js');
|
||||||
|
const now = new Date('2026-04-24T14:00:00Z').getTime();
|
||||||
|
|
||||||
|
fs.writeFileSync(
|
||||||
|
statePath,
|
||||||
|
JSON.stringify({
|
||||||
|
lastWarmupAt: '2026-04-24T09:00:00.000Z',
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
accounts: {
|
||||||
|
'0': {
|
||||||
|
lastWarmupAt: '2026-04-24T09:00:00.000Z',
|
||||||
|
zeroUsageWarmupUntil: '2026-05-01T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
vi.mocked(rotation.getAllCodexAccounts).mockReturnValue([
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
accountId: 'same-account-next-slot',
|
||||||
|
planType: 'pro',
|
||||||
|
isActive: true,
|
||||||
|
isRateLimited: false,
|
||||||
|
cachedUsagePct: 0,
|
||||||
|
cachedUsageD7Pct: 37,
|
||||||
|
resetAt: '2026-04-24T19:00:00.000Z',
|
||||||
|
resetD7At: '2026-05-01T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
vi.mocked(rotation.getCodexAuthPath).mockImplementation(
|
||||||
|
(accountIndex = 0) => authPathFor(tempHome, accountIndex),
|
||||||
|
);
|
||||||
|
vi.mocked(childProcess.spawn).mockImplementation(
|
||||||
|
() => createFakeCodexProcess(0) as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runCodexWarmupCycle(
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
prompt: 'Reply exactly OK. Do not run tools.',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
intervalMs: 300_000,
|
||||||
|
minIntervalMs: 18_000_000,
|
||||||
|
staggerMs: 0,
|
||||||
|
maxUsagePct: 0,
|
||||||
|
maxD7UsagePct: 100,
|
||||||
|
commandTimeoutMs: 120_000,
|
||||||
|
failureCooldownMs: 21_600_000,
|
||||||
|
maxConsecutiveFailures: 2,
|
||||||
|
},
|
||||||
|
{ nowMs: now, statePath, ignoreZeroUsageWindow: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ status: 'warmed', accountIndex: 0 });
|
||||||
|
expect(childProcess.spawn).toHaveBeenCalledTimes(1);
|
||||||
|
const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
|
||||||
|
expect(state.accounts['0'].lastWarmupAt).toBe('2026-04-24T14:00:00.000Z');
|
||||||
|
expect(state.accounts['0'].zeroUsageWarmupUntil).toBe(
|
||||||
|
'2026-04-24T14:00:00.000Z',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('auto-backs off after repeated codex exec failures so OpenAI-side blocking does not hammer accounts', async () => {
|
it('auto-backs off after repeated codex exec failures so OpenAI-side blocking does not hammer accounts', async () => {
|
||||||
const childProcess = await import('child_process');
|
const childProcess = await import('child_process');
|
||||||
const rotation = await import('./codex-token-rotation.js');
|
const rotation = await import('./codex-token-rotation.js');
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ interface CodexWarmupRuntimeOptions {
|
|||||||
nowMs?: number;
|
nowMs?: number;
|
||||||
statePath?: string;
|
statePath?: string;
|
||||||
shouldSkip?: () => boolean;
|
shouldSkip?: () => boolean;
|
||||||
|
ignoreZeroUsageWindow?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CodexWarmupCycleResult =
|
export type CodexWarmupCycleResult =
|
||||||
@@ -105,6 +106,7 @@ function selectWarmupCandidate(
|
|||||||
config: CodexWarmupConfig,
|
config: CodexWarmupConfig,
|
||||||
state: CodexWarmupState,
|
state: CodexWarmupState,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
|
options: { ignoreZeroUsageWindow?: boolean } = {},
|
||||||
): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } {
|
): { accountIndex: number; zeroUsageWarmupUntil: string } | { reason: string } {
|
||||||
const disabledUntilMs = parseTimestamp(state.disabledUntil);
|
const disabledUntilMs = parseTimestamp(state.disabledUntil);
|
||||||
if (disabledUntilMs != null && disabledUntilMs > nowMs) {
|
if (disabledUntilMs != null && disabledUntilMs > nowMs) {
|
||||||
@@ -134,7 +136,11 @@ function selectWarmupCandidate(
|
|||||||
const zeroUsageWarmupUntilMs = parseTimestamp(
|
const zeroUsageWarmupUntilMs = parseTimestamp(
|
||||||
accountState?.zeroUsageWarmupUntil,
|
accountState?.zeroUsageWarmupUntil,
|
||||||
);
|
);
|
||||||
if (zeroUsageWarmupUntilMs != null && zeroUsageWarmupUntilMs > nowMs) {
|
if (
|
||||||
|
!options.ignoreZeroUsageWindow &&
|
||||||
|
zeroUsageWarmupUntilMs != null &&
|
||||||
|
zeroUsageWarmupUntilMs > nowMs
|
||||||
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,10 +150,13 @@ function selectWarmupCandidate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resetD7Ms = parseTimestamp(account.resetD7At);
|
const resetD7Ms = parseTimestamp(account.resetD7At);
|
||||||
const zeroUsageWarmupUntilMsForState =
|
let zeroUsageWarmupUntilMsForState = nowMs;
|
||||||
resetD7Ms != null && resetD7Ms > nowMs
|
if (!options.ignoreZeroUsageWindow) {
|
||||||
? resetD7Ms
|
zeroUsageWarmupUntilMsForState =
|
||||||
: nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS;
|
resetD7Ms != null && resetD7Ms > nowMs
|
||||||
|
? resetD7Ms
|
||||||
|
: nowMs + DEFAULT_ZERO_USAGE_WARMUP_WINDOW_MS;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountIndex: account.index,
|
accountIndex: account.index,
|
||||||
@@ -254,7 +263,9 @@ export async function runCodexWarmupCycle(
|
|||||||
const nowIso = new Date(nowMs).toISOString();
|
const nowIso = new Date(nowMs).toISOString();
|
||||||
const statePath = runtime.statePath ?? DEFAULT_STATE_FILE;
|
const statePath = runtime.statePath ?? DEFAULT_STATE_FILE;
|
||||||
const state = readWarmupState(statePath);
|
const state = readWarmupState(statePath);
|
||||||
const selected = selectWarmupCandidate(config, state, nowMs);
|
const selected = selectWarmupCandidate(config, state, nowMs, {
|
||||||
|
ignoreZeroUsageWindow: runtime.ignoreZeroUsageWindow,
|
||||||
|
});
|
||||||
if ('reason' in selected) {
|
if ('reason' in selected) {
|
||||||
return { status: 'skipped', reason: selected.reason };
|
return { status: 'skipped', reason: selected.reason };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import { createMessageRuntime } from './message-runtime.js';
|
|||||||
import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js';
|
import { nudgeSchedulerLoop, startSchedulerLoop } from './task-scheduler.js';
|
||||||
import { startUnifiedDashboard } from './unified-dashboard.js';
|
import { startUnifiedDashboard } from './unified-dashboard.js';
|
||||||
import { startWebDashboardServer } from './web-dashboard-server.js';
|
import { startWebDashboardServer } from './web-dashboard-server.js';
|
||||||
|
import { startUsagePrimer } from './usage-primer.js';
|
||||||
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
import { Channel, NewMessage, RegisteredGroup } from './types.js';
|
||||||
import { logger } from './logger.js';
|
import { logger } from './logger.js';
|
||||||
import { initCodexTokenRotation } from './codex-token-rotation.js';
|
import { initCodexTokenRotation } from './codex-token-rotation.js';
|
||||||
@@ -575,6 +576,7 @@ async function main(): Promise<void> {
|
|||||||
queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder)),
|
queue.enqueueMessageCheck(chatJid, resolveGroupIpcPath(groupFolder)),
|
||||||
nudgeScheduler: nudgeSchedulerLoop,
|
nudgeScheduler: nudgeSchedulerLoop,
|
||||||
});
|
});
|
||||||
|
startUsagePrimer();
|
||||||
|
|
||||||
leaseRecoveryTimer = setInterval(() => {
|
leaseRecoveryTimer = setInterval(() => {
|
||||||
const failover = getGlobalFailoverInfo();
|
const failover = getGlobalFailoverInfo();
|
||||||
|
|||||||
77
src/usage-primer.test.ts
Normal file
77
src/usage-primer.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const callOrder: string[] = [];
|
||||||
|
|
||||||
|
const refreshAllCodexAccountUsage = vi.fn(async () => {
|
||||||
|
callOrder.push('refreshAll');
|
||||||
|
return { rows: [], fetchedAt: '2026-06-01T00:00:00.000Z' };
|
||||||
|
});
|
||||||
|
const refreshActiveCodexUsage = vi.fn(async () => {
|
||||||
|
callOrder.push('refreshActive');
|
||||||
|
return { rows: [], fetchedAt: '2026-06-01T00:00:01.000Z' };
|
||||||
|
});
|
||||||
|
const runCodexWarmupCycle = vi.fn(async () => {
|
||||||
|
callOrder.push('warmup');
|
||||||
|
return { status: 'warmed', accountIndex: 0 };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('./config.js', () => ({
|
||||||
|
CODEX_WARMUP_CONFIG: {
|
||||||
|
enabled: false,
|
||||||
|
prompt: 'Reply exactly OK. Do not run tools.',
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
intervalMs: 300_000,
|
||||||
|
minIntervalMs: 18_300_000,
|
||||||
|
staggerMs: 1_800_000,
|
||||||
|
maxUsagePct: 0,
|
||||||
|
maxD7UsagePct: 0,
|
||||||
|
commandTimeoutMs: 120_000,
|
||||||
|
failureCooldownMs: 21_600_000,
|
||||||
|
maxConsecutiveFailures: 2,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./logger.js', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: vi.fn(),
|
||||||
|
info: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./token-rotation.js', () => ({
|
||||||
|
getAllTokens: vi.fn(() => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./codex-usage-collector.js', () => ({
|
||||||
|
refreshAllCodexAccountUsage,
|
||||||
|
refreshActiveCodexUsage,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./codex-warmup.js', () => ({
|
||||||
|
runCodexWarmupCycle,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('usage-primer', () => {
|
||||||
|
it('refreshes Codex usage before primer selection and fires regardless of usage level', async () => {
|
||||||
|
callOrder.length = 0;
|
||||||
|
refreshAllCodexAccountUsage.mockClear();
|
||||||
|
refreshActiveCodexUsage.mockClear();
|
||||||
|
runCodexWarmupCycle.mockClear();
|
||||||
|
|
||||||
|
const { runCodexPrimerCycle } = await import('./usage-primer.js');
|
||||||
|
await runCodexPrimerCycle();
|
||||||
|
|
||||||
|
expect(callOrder).toEqual(['refreshAll', 'refreshActive', 'warmup']);
|
||||||
|
expect(runCodexWarmupCycle).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
enabled: true,
|
||||||
|
minIntervalMs: 18_000_000,
|
||||||
|
maxUsagePct: 100,
|
||||||
|
maxD7UsagePct: 100,
|
||||||
|
}),
|
||||||
|
{ ignoreZeroUsageWindow: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
212
src/usage-primer.ts
Normal file
212
src/usage-primer.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
import { CODEX_WARMUP_CONFIG } from './config.js';
|
||||||
|
import {
|
||||||
|
refreshActiveCodexUsage,
|
||||||
|
refreshAllCodexAccountUsage,
|
||||||
|
} from './codex-usage-collector.js';
|
||||||
|
import { runCodexWarmupCycle } from './codex-warmup.js';
|
||||||
|
import { logger } from './logger.js';
|
||||||
|
import { getAllTokens } from './token-rotation.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage-window alignment primer.
|
||||||
|
*
|
||||||
|
* Anthropic/OpenAI enforce 5h rolling usage windows that start on first
|
||||||
|
* inference call. We anchor windows to fixed KST times by firing a tiny
|
||||||
|
* primer call at 08, 13, 18, 23 KST (4 windows × 5h = 20h, leaving a
|
||||||
|
* 04:00–08:00 gap blocked by message-runtime-gating).
|
||||||
|
*
|
||||||
|
* Daily reset point ends up being 08:00 KST (start of first slot after gap).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PRIMER_HOURS_KST = [8, 13, 18, 23];
|
||||||
|
const KST_OFFSET_MS = 9 * 60 * 60 * 1000;
|
||||||
|
const CLAUDE_PRIMER_TIMEOUT_MS = 60_000;
|
||||||
|
const CODEX_PRIMER_MIN_INTERVAL_MS = 5 * 60 * 60 * 1000;
|
||||||
|
// Fire the Codex primer unconditionally at every slot, mirroring the Claude
|
||||||
|
// primer (which always fires unless the account is rate-limited). The 5h usage
|
||||||
|
// level no longer gates the call (maxUsagePct=100), so a slot is never skipped
|
||||||
|
// just because Codex happens to be partway through its window. The d7 gate
|
||||||
|
// stays at 100 so we still skip an account whose weekly quota is exhausted,
|
||||||
|
// which is the Codex analogue of Claude's rate-limit skip.
|
||||||
|
const CODEX_PRIMER_MAX_USAGE_PCT = 100;
|
||||||
|
const CODEX_PRIMER_MAX_D7_USAGE_PCT = 100;
|
||||||
|
|
||||||
|
function resolveClaudeBinary(): string {
|
||||||
|
const candidates = [
|
||||||
|
path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/claude',
|
||||||
|
),
|
||||||
|
path.resolve(
|
||||||
|
process.cwd(),
|
||||||
|
'runners/agent-runner/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl/claude',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (const p of candidates) {
|
||||||
|
if (fs.existsSync(p)) return p;
|
||||||
|
}
|
||||||
|
return candidates[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function runClaudePrimerOnce(
|
||||||
|
token: string,
|
||||||
|
): Promise<{ ok: boolean; reason: string }> {
|
||||||
|
const binary = resolveClaudeBinary();
|
||||||
|
if (!fs.existsSync(binary)) {
|
||||||
|
return Promise.resolve({ ok: false, reason: 'binary_missing' });
|
||||||
|
}
|
||||||
|
const args = [
|
||||||
|
'-p',
|
||||||
|
'ok',
|
||||||
|
'--model',
|
||||||
|
'haiku',
|
||||||
|
'--output-format',
|
||||||
|
'text',
|
||||||
|
'--max-turns',
|
||||||
|
'1',
|
||||||
|
'--dangerously-skip-permissions',
|
||||||
|
];
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let done = false;
|
||||||
|
let stderr = '';
|
||||||
|
const finish = (result: { ok: boolean; reason: string }): void => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
proc.kill();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
finish({ ok: false, reason: 'timeout' });
|
||||||
|
}, CLAUDE_PRIMER_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const proc = spawn(binary, args, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
CLAUDE_CODE_OAUTH_TOKEN: token,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
proc.stderr?.on('data', (chunk: Buffer) => {
|
||||||
|
stderr += chunk.toString();
|
||||||
|
if (stderr.length > 2000) stderr = stderr.slice(-2000);
|
||||||
|
});
|
||||||
|
proc.on('error', (err) => {
|
||||||
|
logger.warn({ err }, 'Claude primer spawn error');
|
||||||
|
finish({ ok: false, reason: 'spawn_error' });
|
||||||
|
});
|
||||||
|
proc.on('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
finish({ ok: true, reason: 'ok' });
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
{ exitCode: code, stderr: stderr.trim().slice(-500) || undefined },
|
||||||
|
'Claude primer command failed',
|
||||||
|
);
|
||||||
|
finish({ ok: false, reason: `exit_${code ?? 'unknown'}` });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runClaudePrimerCycle(): Promise<void> {
|
||||||
|
const tokens = getAllTokens();
|
||||||
|
const candidates = tokens.filter((t) => !t.isRateLimited);
|
||||||
|
if (candidates.length === 0) {
|
||||||
|
logger.info('Claude primer skipped: no eligible tokens');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const target = candidates[0];
|
||||||
|
logger.info(
|
||||||
|
{ tokenIndex: target.index },
|
||||||
|
'Starting Claude primer (haiku, prompt=ok)',
|
||||||
|
);
|
||||||
|
const result = await runClaudePrimerOnce(target.token);
|
||||||
|
if (result.ok) {
|
||||||
|
logger.info({ tokenIndex: target.index }, 'Claude primer completed');
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
{ tokenIndex: target.index, reason: result.reason },
|
||||||
|
'Claude primer failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runCodexPrimerCycle(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await refreshAllCodexAccountUsage();
|
||||||
|
await refreshActiveCodexUsage();
|
||||||
|
const result = await runCodexWarmupCycle(
|
||||||
|
{
|
||||||
|
...CODEX_WARMUP_CONFIG,
|
||||||
|
enabled: true,
|
||||||
|
minIntervalMs: CODEX_PRIMER_MIN_INTERVAL_MS,
|
||||||
|
maxUsagePct: CODEX_PRIMER_MAX_USAGE_PCT,
|
||||||
|
maxD7UsagePct: CODEX_PRIMER_MAX_D7_USAGE_PCT,
|
||||||
|
},
|
||||||
|
{ ignoreZeroUsageWindow: true },
|
||||||
|
);
|
||||||
|
logger.info({ result }, 'Codex primer cycle finished');
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn({ err }, 'Codex primer cycle threw');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPrimerCycle(): Promise<void> {
|
||||||
|
logger.info('Running scheduled usage primer cycle');
|
||||||
|
await Promise.allSettled([runClaudePrimerCycle(), runCodexPrimerCycle()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function msUntilNextPrimerSlotKST(nowMs: number = Date.now()): number {
|
||||||
|
// KST = UTC+9 (no DST). Shift so KST clock can be read via getUTC*.
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
let primerTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
export function startUsagePrimer(): void {
|
||||||
|
if (primerTimer) return;
|
||||||
|
const scheduleNext = (): void => {
|
||||||
|
const delay = msUntilNextPrimerSlotKST();
|
||||||
|
primerTimer = setTimeout(() => {
|
||||||
|
void runPrimerCycle().finally(scheduleNext);
|
||||||
|
}, delay);
|
||||||
|
const fireAt = new Date(Date.now() + delay).toISOString();
|
||||||
|
logger.info(
|
||||||
|
{ delayMinutes: Math.round(delay / 60_000), fireAt },
|
||||||
|
'Next usage primer scheduled',
|
||||||
|
);
|
||||||
|
};
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopUsagePrimer(): void {
|
||||||
|
if (primerTimer) {
|
||||||
|
clearTimeout(primerTimer);
|
||||||
|
primerTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user