Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ runs helper features around provider requests.
| `codexAutoStart?` | `boolean` | `true` | Let the Codex shim run `ocx ensure` before launching Codex. False makes ensure a no-op. |
| `codexShimAutoRestore?` | `boolean` | `true` | Restore an installed shim after a completed external Codex update replaces it. Environment opt-out: `OPENCODEX_CODEX_SHIM_AUTO_RESTORE=0`. |
| `codexDesktopAuthless?` | `boolean` | `false` | Opt-in authless Codex Desktop routing on a loopback bind: inject the dedicated `opencodex` provider with `requires_openai_auth = false` so Desktop opens without a ChatGPT login. Ignored on non-loopback binds. `ocx system settings --desktop-authless on`. See [Codex integration](/guides/codex-integration/#authless-codex-desktop-opt-in). |
| `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Logs carry a hashed account key only. |
| `resetCreditAutoRedeem?` | `{ enabled?: boolean; leadTimeMinutes?: number }` | off | Opt-in: redeem the main Codex account's soonest-expiring reset credit `leadTimeMinutes` (1–60, default 10) before it expires. Every attempt re-reads the upstream credit list first and skips when the credit is gone (for example, redeemed by hand); the `redeem_request_id` is journaled in `$OPENCODEX_HOME/reset-credit-auto-redeem.json` before the call so a crash replays the same idempotent request instead of spending a second credit. Servers sharing this configuration directory coordinate reservations and settlements so one process does not replace another's request record. Logs carry a hashed account key only. |
| `syncResumeHistory?` | `boolean` | `true` | Reversible Codex App history compatibility. Original metadata is backed up and restored by `ocx stop` / `ocx restore`. |
| `shadowCallIntercept?` | `{ enabled?: boolean; model?: string; sourceModels?: string[] }` | off | Redirect recognized Codex helper/shadow calls to a chosen model while preserving the request's configured reasoning effort. The default source prefix is `gpt-5.6-luna`; older clients through 0.144.x used `gpt-5.4-mini`, which `sourceModels` can restore. |
| `webSearchSidecar?` | `OcxWebSearchSidecarConfig` | on when usable | Web-search sidecar options. |
Expand Down
67 changes: 54 additions & 13 deletions src/codex/reset-credit-auto-redeem.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createHash, randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { ConfigMutationLockError, withConfigMutationLockSync } from "../config";
import { atomicWriteFile } from "../config/atomic-write";
import { getConfigDir } from "../config/paths";
import { registerOptionalShutdownHook } from "../lib/optional-shutdown-hooks";
Expand Down Expand Up @@ -91,9 +92,9 @@ function readJournal(path: string): Journal {
}
}

function writeJournal(path: string, journal: Journal): void {
function writeJournal(path: string, journal: Journal, now: number): void {
// Keep only entries whose credit could still matter: settled ones older than a week are noise.
const cutoff = Date.now() - 7 * 24 * 60 * 60_000;
const cutoff = now - 7 * 24 * 60 * 60_000;
journal.entries = journal.entries.filter(e => e.state !== "settled" || e.updatedAt > cutoff);
atomicWriteFile(path, JSON.stringify(journal, null, 2));
}
Expand All @@ -112,6 +113,7 @@ export interface AutoRedeemDeps {
now?: () => number;
setTimer?: (fn: () => void, ms: number) => unknown;
clearTimer?: (handle: unknown) => void;
/** Callers sharing an overridden journal must also share the OPENCODEX_HOME mutation coordinator. */
journalFile?: string;
log?: (line: string) => void;
/** Upper bound on one sleep so a laptop sleep or clock jump re-checks rather than trusting a stale plan. */
Expand Down Expand Up @@ -155,15 +157,38 @@ export function createResetCreditAutoRedeemer(deps: AutoRedeemDeps): ResetCredit
handle = setTimer(() => { handle = null; void tick(); }, Math.max(0, Math.min(ms, maxSleepMs)));
};

const retryJournal = (error: unknown): void => {
const cause = error instanceof ConfigMutationLockError ? error.cause : error;
const code = cause && typeof cause === "object" && "code" in cause ? String(cause.code) : "";
const busy = code === "SQLITE_BUSY" || code === "SQLITE_LOCKED"
|| (cause instanceof Error && /database (?:is|table is) locked/i.test(cause.message));
schedule(busy ? 1_000 : idleRecheckMs);
};

const dispatch = async (plan: AutoRedeemPlan): Promise<AutoRedeemOutcome> => {
const journal = readJournal(path);
let entry = journal.entries.find(e => e.accountKey === accountKey && e.grantedAt === plan.grantedAt && e.expiresAt === plan.expiresAt);
if (entry?.state === "settled") return { kind: "skipped", reason: "credit-gone" };
if (!entry) {
entry = { accountKey, grantedAt: plan.grantedAt, expiresAt: plan.expiresAt, redeemRequestId: randomUUID(), state: "dispatched", updatedAt: now() };
journal.entries.push(entry);
// Journal BEFORE the network call: a crash after this line replays the same request id.
writeJournal(path, journal);
// Reserve under the shared config-mutation lock. `inFlight` only serializes ticks inside
// ONE process; two servers on the same config dir would otherwise both read a journal with
// no entry, each mint a different `redeem_request_id`, and spend two credits for one plan.
let entry: JournalEntry;
try {
entry = withConfigMutationLockSync(() => {
const journal = readJournal(path);
const existing = journal.entries.find(e => e.accountKey === accountKey && e.grantedAt === plan.grantedAt && e.expiresAt === plan.expiresAt);
if (existing) return existing;
const created: JournalEntry = { accountKey, grantedAt: plan.grantedAt, expiresAt: plan.expiresAt, redeemRequestId: randomUUID(), state: "dispatched", updatedAt: now() };
journal.entries.push(created);
// Journal BEFORE the network call: a crash after this line replays the same request id.
writeJournal(path, journal, created.updatedAt);
return created;
});
} catch (error) {
// Only contention gets a short retry; persistent storage failures must not spin.
retryJournal(error);
return { kind: "error", message: error instanceof Error ? error.message : "journal reservation failed" };
}
if (entry.state === "settled") {
schedule(idleRecheckMs);
return { kind: "skipped", reason: "credit-gone" };
}
log(`[opencodex] reset-credit auto-redeem: dispatching for account ${accountKey} (credit expires ${plan.expiresAt})`);
let result: { code: string };
Expand All @@ -174,9 +199,25 @@ export function createResetCreditAutoRedeemer(deps: AutoRedeemDeps): ResetCredit
schedule(60_000);
return { kind: "ambiguous", redeemRequestId: entry.redeemRequestId };
}
entry.state = "settled";
entry.updatedAt = now();
writeJournal(path, journal);
// Re-read under the lock: a peer may have appended its own entries since the reservation,
// and writing a stale in-memory journal would drop them.
try {
withConfigMutationLockSync(() => {
const journal = readJournal(path);
const current = journal.entries.find(e => e.accountKey === accountKey && e.grantedAt === plan.grantedAt && e.expiresAt === plan.expiresAt);
if (!current || current.redeemRequestId !== entry.redeemRequestId) {
throw new Error("auto-redeem journal reservation changed before settlement");
}
current.state = "settled";
current.updatedAt = now();
writeJournal(path, journal, current.updatedAt);
});
} catch (error) {
// Upstream answered, but settlement could not be committed. Preserve any reservation;
// a later dispatch must reuse its request id. A vanished credit may never dispatch again.
retryJournal(error);
return { kind: "error", message: error instanceof Error ? error.message : "journal settlement failed" };
}
log(`[opencodex] reset-credit auto-redeem: upstream answered ${result.code} for account ${accountKey}`);
schedule(idleRecheckMs);
return { kind: "dispatched", code: result.code, redeemRequestId: entry.redeemRequestId };
Expand Down
Loading
Loading