backup current stable ejclaw state
This commit is contained in:
@@ -31,10 +31,120 @@ export interface CodexUsageRefreshResult {
|
||||
/** Full scan interval — exported so the orchestrator can schedule it. */
|
||||
export const CODEX_FULL_SCAN_INTERVAL = 3_600_000; // 1 hour
|
||||
|
||||
/**
|
||||
* Threshold above which a Codex account is considered "out of budget" for
|
||||
* the purpose of admitting new turns. A turn is blocked only when *every*
|
||||
* Codex account is either at/over this threshold on either window or
|
||||
* already in a rate-limit cooldown.
|
||||
*/
|
||||
export const CODEX_EXHAUSTION_THRESHOLD_PCT = 95;
|
||||
|
||||
export interface UsageExhaustionInfo {
|
||||
exhausted: boolean;
|
||||
/** ISO timestamp of the soonest reset that would relieve exhaustion. */
|
||||
nextResetAt: string | null;
|
||||
}
|
||||
|
||||
function parseResetMs(s: string | undefined | null): number | null {
|
||||
if (!s) return null;
|
||||
const t = Date.parse(s);
|
||||
return Number.isFinite(t) ? t : null;
|
||||
}
|
||||
|
||||
/** Coerce a string-or-number resetsAt (numeric epoch in seconds) to ISO. */
|
||||
function toIsoMaybe(value: string | number | undefined): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
if (typeof value === 'number') {
|
||||
return new Date(value * 1000).toISOString();
|
||||
}
|
||||
// Already a string — pass through if it parses, else drop.
|
||||
const ms = Date.parse(value);
|
||||
return Number.isFinite(ms) ? new Date(ms).toISOString() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports whether every configured Codex account is either ≥ 95% on a
|
||||
* window or in a rate-limit cooldown, and — when so — the soonest moment
|
||||
* at which any one account is expected to recover.
|
||||
*
|
||||
* Reads the in-process rotation snapshot; performs no I/O. Returns
|
||||
* `exhausted: false` when there are no configured accounts, or when usage
|
||||
* data is missing for any account (err on letting work through).
|
||||
*/
|
||||
export function getCodexUsageExhaustion(): UsageExhaustionInfo {
|
||||
const accounts = getAllCodexAccounts();
|
||||
if (accounts.length === 0) return { exhausted: false, nextResetAt: null };
|
||||
|
||||
let earliestRecoveryMs = Infinity;
|
||||
|
||||
for (const a of accounts) {
|
||||
if (a.isRateLimited) continue; // counts as exhausted, recovery unknown here
|
||||
const h5 = a.cachedUsagePct;
|
||||
const d7 = a.cachedUsageD7Pct;
|
||||
// -1 / undefined means "unknown" → treat as headroom
|
||||
if (h5 == null || h5 < 0 || d7 == null || d7 < 0) {
|
||||
return { exhausted: false, nextResetAt: null };
|
||||
}
|
||||
if (Math.max(h5, d7) < CODEX_EXHAUSTION_THRESHOLD_PCT) {
|
||||
return { exhausted: false, nextResetAt: null };
|
||||
}
|
||||
|
||||
const h5ResetMs =
|
||||
h5 >= CODEX_EXHAUSTION_THRESHOLD_PCT ? parseResetMs(a.resetAt) : null;
|
||||
const d7ResetMs =
|
||||
d7 >= CODEX_EXHAUSTION_THRESHOLD_PCT ? parseResetMs(a.resetD7At) : null;
|
||||
const candidates = [h5ResetMs, d7ResetMs].filter(
|
||||
(v): v is number => v != null,
|
||||
);
|
||||
if (candidates.length > 0) {
|
||||
const accountRecovery = Math.max(...candidates);
|
||||
if (accountRecovery < earliestRecoveryMs) {
|
||||
earliestRecoveryMs = accountRecovery;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextResetAt = Number.isFinite(earliestRecoveryMs)
|
||||
? new Date(earliestRecoveryMs).toISOString()
|
||||
: null;
|
||||
return { exhausted: true, nextResetAt };
|
||||
}
|
||||
|
||||
/** Backwards-compat boolean wrapper. */
|
||||
export function isCodexUsageExhausted(): boolean {
|
||||
return getCodexUsageExhaustion().exhausted;
|
||||
}
|
||||
|
||||
function getFnmCodexBinDirs(): string[] {
|
||||
const fnmRoot = path.join(os.homedir(), '.local', 'share', 'fnm');
|
||||
const dirs: string[] = [];
|
||||
// Prefer the alias `default` first if it exists.
|
||||
const defaultBin = path.join(fnmRoot, 'aliases', 'default', 'bin');
|
||||
if (fs.existsSync(defaultBin)) dirs.push(defaultBin);
|
||||
// Then fall back to scanning every installed node version.
|
||||
const versionsRoot = path.join(fnmRoot, 'node-versions');
|
||||
try {
|
||||
if (fs.existsSync(versionsRoot)) {
|
||||
for (const entry of fs.readdirSync(versionsRoot)) {
|
||||
const bin = path.join(versionsRoot, entry, 'installation', 'bin');
|
||||
if (fs.existsSync(bin)) dirs.push(bin);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function getPreferredCodexPathEntries(): string[] {
|
||||
const entries = [
|
||||
path.dirname(process.execPath),
|
||||
path.join(os.homedir(), '.npm-global', 'bin'),
|
||||
// fnm-managed node installs (where `npm i -g @openai/codex` actually
|
||||
// lands when the host uses fnm). Without these, ejclaw running under
|
||||
// bun/systemd cannot find the `codex` binary even though the user has
|
||||
// it installed via fnm's default node.
|
||||
...getFnmCodexBinDirs(),
|
||||
];
|
||||
if (process.versions.bun || path.basename(process.execPath) === 'bun') {
|
||||
entries.push(path.join(os.homedir(), '.hermes', 'node', 'bin'));
|
||||
@@ -42,6 +152,18 @@ function getPreferredCodexPathEntries(): string[] {
|
||||
return [...new Set(entries)];
|
||||
}
|
||||
|
||||
function findCodexBinary(): string {
|
||||
const candidates = [
|
||||
path.join(os.homedir(), '.npm-global', 'bin', 'codex'),
|
||||
...getFnmCodexBinDirs().map((dir) => path.join(dir, 'codex')),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
// Last resort: rely on PATH (we extend it via getPreferredCodexPathEntries).
|
||||
return 'codex';
|
||||
}
|
||||
|
||||
function getCodexHomeForAccount(accountIndex?: number): string | null {
|
||||
const authPath = getCodexAuthPath(accountIndex);
|
||||
if (!authPath || !fs.existsSync(authPath)) return null;
|
||||
@@ -51,8 +173,7 @@ function getCodexHomeForAccount(accountIndex?: number): string | null {
|
||||
export async function fetchCodexUsage(
|
||||
codexHomeOverride?: string,
|
||||
): Promise<CodexRateLimit[] | null> {
|
||||
const npmGlobalBin = path.join(os.homedir(), '.npm-global', 'bin', 'codex');
|
||||
const codexBin = fs.existsSync(npmGlobalBin) ? npmGlobalBin : 'codex';
|
||||
const codexBin = findCodexBinary();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
@@ -197,20 +318,19 @@ export function applyCodexUsageToAccount(
|
||||
|
||||
const pct = Math.round(effective.primary.usedPercent);
|
||||
const d7Pct = Math.round(effective.secondary.usedPercent);
|
||||
const resetStr = effective.primary.resetsAt
|
||||
? formatResetRemaining(effective.primary.resetsAt)
|
||||
: undefined;
|
||||
const resetD7Str = effective.secondary.resetsAt
|
||||
? formatResetRemaining(effective.secondary.resetsAt)
|
||||
: undefined;
|
||||
updateCodexAccountUsage(pct, resetStr, accountIndex, d7Pct, resetD7Str);
|
||||
// Store raw ISO timestamps (not pre-formatted strings) so the exhaustion
|
||||
// gate can compute "minutes until reset" later. The dashboard render path
|
||||
// formats these at display time via `formatResetRemaining`.
|
||||
const resetIso = toIsoMaybe(effective.primary.resetsAt);
|
||||
const resetD7Iso = toIsoMaybe(effective.secondary.resetsAt);
|
||||
updateCodexAccountUsage(pct, resetIso, accountIndex, d7Pct, resetD7Iso);
|
||||
logger.info(
|
||||
{
|
||||
account: accountIndex + 1,
|
||||
bucket: effective.limitId,
|
||||
h5: pct,
|
||||
d7: d7Pct,
|
||||
reset: resetStr,
|
||||
reset: resetIso,
|
||||
},
|
||||
`Codex account #${accountIndex + 1} usage: 5h=${pct}% 7d=${d7Pct}%`,
|
||||
);
|
||||
@@ -233,9 +353,9 @@ export function buildCodexUsageRowsFromState(): UsageRow[] {
|
||||
return {
|
||||
name: label,
|
||||
h5pct: acct.cachedUsagePct != null ? acct.cachedUsagePct : -1,
|
||||
h5reset: acct.resetAt || '',
|
||||
h5reset: acct.resetAt ? formatResetRemaining(acct.resetAt) : '',
|
||||
d7pct: acct.cachedUsageD7Pct != null ? acct.cachedUsageD7Pct : -1,
|
||||
d7reset: acct.resetD7At || '',
|
||||
d7reset: acct.resetD7At ? formatResetRemaining(acct.resetD7At) : '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user