Skip to content
Closed
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
32 changes: 25 additions & 7 deletions src/codex/history-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import type {
HistoryWorkerResult,
} from "./history-worker";
import { historyBackupPathFor } from "./history-provider";
import type { CodexHistoryFailureReason } from "./history-provider";
import type { CodexHistoryFailureReason, CodexHistoryVerifiedNoopProof } from "./history-provider";
import { getCodexHome } from "./paths";

/** Where Codex keeps its resume history, and the manifest that shadows it. */
Expand Down Expand Up @@ -69,7 +69,7 @@ export interface CodexHistoryJobRequest {
}

export type CodexHistoryJobOutcome =
| { readonly kind: "converged"; readonly rows: number; readonly files: number }
| { readonly kind: "converged"; readonly rows: number; readonly files: number; readonly proof?: CodexHistoryVerifiedNoopProof }
| { readonly kind: "skipped" }
| { readonly kind: "blocked"; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" }
| { readonly kind: "failed"; readonly reason: "worker-error" | "worker-died" | "timeout";
Expand All @@ -92,8 +92,9 @@ export function isPlausibleWorkerResultForTests(
message: Record<string, unknown>,
requestId: string,
jobId: string,
target?: Pick<CodexHistoryJobRequest, "canonicalStateDbPath" | "canonicalBackupPath" | "operation">,
): boolean {
return isPlausibleWorkerResult(message, requestId, jobId);
return isPlausibleWorkerResult(message, requestId, jobId, target);
}

/**
Expand All @@ -108,13 +109,30 @@ function isPlausibleWorkerResult(
message: Record<string, unknown>,
requestId: string,
jobId: string,
target?: Pick<CodexHistoryJobRequest, "canonicalStateDbPath" | "canonicalBackupPath" | "operation">,
): boolean {
if (message.requestId !== requestId || message.jobId !== jobId) return false;
switch (message.type) {
case "done":
return (message.outcome === "converged" || message.outcome === "skipped")
if (!((message.outcome === "converged" || message.outcome === "skipped")
&& typeof message.rows === "number"
&& typeof message.files === "number";
&& typeof message.files === "number")) return false;
if (message.proof === undefined) return true;
if (!target || !message.proof || typeof message.proof !== "object" || Array.isArray(message.proof)) return false;
{
const proof = message.proof as Record<string, unknown>;
return target.operation === "migrate-openai"
&& message.outcome === "converged"
&& message.rows === 0
&& message.files === 0
&& proof.kind === "verified-noop"
&& proof.pendingRows === 0
&& proof.backupEntries === 0
&& proof.canonicalStateDbPath === target.canonicalStateDbPath
&& proof.stateDbPresent === true
&& proof.canonicalBackupPath === target.canonicalBackupPath
&& typeof proof.backupPresent === "boolean";
}
case "blocked":
return message.reason === "busy" || message.reason === "database" || message.reason === "unsafe-path"
|| message.reason === "desired_disabled" || message.reason === "desired_enabled";
Expand Down Expand Up @@ -227,7 +245,7 @@ function classifyWorkerResult(result: HistoryWorkerResult): CodexHistoryJobOutco
}
return result.outcome === "skipped"
? { kind: "skipped" }
: { kind: "converged", rows: result.rows, files: result.files };
: { kind: "converged", rows: result.rows, files: result.files, ...(result.proof ? { proof: result.proof } : {}) };
}

/**
Expand Down Expand Up @@ -303,7 +321,7 @@ export async function runCodexHistoryJob(
died("history_worker_unknown_message_type");
return;
}
if (!isPlausibleWorkerResult(message, requestId, jobId)) {
if (!isPlausibleWorkerResult(message, requestId, jobId, request)) {
// A recognized type with a missing payload read as `converged` with
// undefined fields once — success for work that may not have happened.
died("history_worker_malformed_payload");
Expand Down
25 changes: 9 additions & 16 deletions src/codex/history-migration-guardian.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { countPendingOpencodexHistory, migrateHistoryToOpenai } from "./history-provider";
import { migrateHistoryToOpenai } from "./history-provider";
import { resolveCodexHistoryJobTarget, runCodexHistoryJob } from "./history-job";

/**
Expand All @@ -23,7 +23,6 @@ export interface HistoryMigrationGuardianHandle {
}

export interface HistoryMigrationGuardianDeps {
countFn?: typeof countPendingOpencodexHistory;
migrateFn?: () => ReturnType<typeof migrateHistoryToOpenai>
| Promise<ReturnType<typeof migrateHistoryToOpenai>>;
log?: Pick<Console, "log">;
Expand All @@ -43,7 +42,6 @@ function defaultSchedule(fn: () => void, ms: number): { cancel(): void } {
}

export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps = {}): HistoryMigrationGuardianHandle {
const countFn = deps.countFn ?? countPendingOpencodexHistory;
// The default migration goes through the history job, so the guardian's timer
// thread never performs the transition itself. A background repair that races
// an apply or a restore is exactly what H exists to order.
Expand All @@ -53,7 +51,7 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps
operation: "migrate-openai",
});
return outcome.kind === "converged"
? { rows: outcome.rows, files: outcome.files }
? { rows: outcome.rows, files: outcome.files, verifiedNoop: outcome.proof?.kind === "verified-noop" }
: { rows: 0, files: 0, failed: true as const };
});
const log = deps.log ?? console;
Expand All @@ -73,23 +71,18 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps
if (stopped) return;
ticks++;
try {
const count = countFn();
if (!count.failed && count.pendingRows === 0 && count.backupEntries === 0) {
stopped = true; // nothing left to migrate — normal steady state, no log noise
return;
}
// Locked probe or pending work: attempt one migration pass.
// The worker re-derives state under H on every pass; no pre-lock probe is
// allowed to stop or suppress this attempt.
const result = await migrateFn();
if (!result.failed) {
const moved = result.rows + ((result as { ejectedRows?: number }).ejectedRows ?? 0);
if (moved > 0) {
log.log(`🩹 history-migration: ${moved} legacy opencodex thread(s) migrated back to openai.`);
}
// A "successful" zero-row migration can also mean the DB does not exist YET while a
// backup manifest still holds restore work (fresh reinstall race). Only stop when a
// re-count proves nothing is pending; otherwise keep ticking within the budget.
const after = countFn();
if (moved > 0 || (!after.failed && after.pendingRows === 0 && after.backupEntries === 0)) {
// Zero mutations are authoritative only when the worker verified the
// exact DB/manifest state while H was held.
const verifiedNoop = (result as { verifiedNoop?: boolean }).verifiedNoop === true;
if (moved > 0 || verifiedNoop) {
stopped = true;
return;
}
Expand All @@ -99,7 +92,7 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps
}
if (ticks >= maxTicks) {
stopped = true;
log.log("⚠️ history-migration: Codex history DB stayed locked; legacy threads not yet migrated. Close the Codex app and run 'ocx sync' (or check 'ocx doctor').");
log.log("⚠️ history-migration: Could not verify that legacy threads were migrated; the history database may be busy, unavailable, or not yet ready. Run 'ocx sync' (or check 'ocx doctor').");
return;
}
schedule();
Expand Down
179 changes: 178 additions & 1 deletion src/codex/history-provider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, unlinkSync, writeSync } from "node:fs";
import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, statSync, unlinkSync, writeSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { zstdDecompressSync } from "node:zlib";
import { Database } from "bun:sqlite";
Expand Down Expand Up @@ -200,6 +200,49 @@ interface BackupManifest {
entries: Record<string, BackupEntry>;
}

export interface CodexHistoryVerifiedNoopProof {
readonly kind: "verified-noop";
readonly pendingRows: 0;
readonly backupEntries: 0;
readonly canonicalStateDbPath: string;
readonly stateDbPresent: true;
readonly canonicalBackupPath: string;
readonly backupPresent: boolean;
}

export type CodexHistoryNoopSnapshot =
| CodexHistoryVerifiedNoopProof
| {
readonly kind: "work-pending";
readonly pendingRows: number;
readonly backupEntries: number;
readonly canonicalStateDbPath: string;
readonly stateDbPresent: boolean;
readonly canonicalBackupPath: string;
readonly backupPresent: boolean;
}
| {
readonly kind: "unknown";
readonly pendingRows: null;
readonly backupEntries: null;
readonly canonicalStateDbPath: string;
readonly stateDbPresent: boolean;
readonly canonicalBackupPath: string;
readonly backupPresent: boolean;
readonly reason: "backup-path" | "database-absent" | "manifest-read" | "manifest-schema" | "manifest-foreign" | "database-query" | "snapshot-race";
};

type StrictBackupInspection =
| { readonly kind: "known"; readonly present: boolean; readonly entries: number; readonly fingerprint: string }
| { readonly kind: "unknown"; readonly present: boolean; readonly reason: "manifest-read" | "manifest-schema" | "manifest-foreign" };

let afterNoopPendingCountForTests: (() => void) | undefined;

/** Test seam: runs after the pending count and before stability validation. */
export function setAfterNoopPendingCountForTests(hook: (() => void) | undefined): void {
afterNoopPendingCountForTests = hook;
}

interface NativeRestoreTarget {
modelProvider: string;
source: string;
Expand All @@ -212,6 +255,65 @@ function samePath(a: string, b: string): boolean {
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
}

function inspectBackupForNoop(path: string, stateDbPath: string): StrictBackupInspection {
if (!existsSync(path)) return { kind: "known", present: false, entries: 0, fingerprint: "absent" };
let parsed: unknown;
let raw: string;
try {
raw = readFileSync(path, "utf8");
parsed = JSON.parse(raw);
} catch {
return { kind: "unknown", present: true, reason: "manifest-read" };
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return { kind: "unknown", present: true, reason: "manifest-schema" };
}
const manifest = parsed as Partial<BackupManifest>;
if (manifest.version !== 1 || typeof manifest.stateDbPath !== "string") {
return { kind: "unknown", present: true, reason: "manifest-schema" };
}
if (!samePath(manifest.stateDbPath, stateDbPath)) {
return { kind: "unknown", present: true, reason: "manifest-foreign" };
}
if (!manifest.entries || typeof manifest.entries !== "object" || Array.isArray(manifest.entries)) {
return { kind: "unknown", present: true, reason: "manifest-schema" };
}
for (const [id, value] of Object.entries(manifest.entries)) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return { kind: "unknown", present: true, reason: "manifest-schema" };
}
const entry = value as Partial<BackupEntry>;
if (entry.id !== id
|| typeof entry.rolloutPath !== "string"
|| typeof entry.modelProvider !== "string"
|| typeof entry.source !== "string"
|| typeof entry.hasUserEvent !== "number") {
return { kind: "unknown", present: true, reason: "manifest-schema" };
}
}
return {
kind: "known",
present: true,
entries: Object.keys(manifest.entries).length,
fingerprint: createHash("sha256").update(raw).digest("hex"),
};
}

function historyFileIdentity(path: string): string | null {
try {
const stat = statSync(path);
return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
} catch {
return null;
}
}

function readHistoryDataVersion(db: Database): number | null {
const row = db.query<{ data_version: number }, []>("PRAGMA data_version").get();
const value = row?.data_version;
return typeof value === "number" && Number.isSafeInteger(value) ? value : null;
}

function readBackup(path: string, stateDbPath?: string): BackupManifest {
if (!existsSync(path)) return { version: 1, stateDbPath, entries: {} };
try {
Expand Down Expand Up @@ -756,6 +858,81 @@ export function migrateHistoryToOpenai(
return retried.ok ? retried.value : { rows: 0, files: 0, failed: true, failureReason: retried.reason };
}

/**
* Captures no-op evidence while the caller holds the history serialization
* lock H. This function does not acquire H itself. Unknown or foreign backup
* state is never collapsed into an empty manifest.
*/
export function snapshotCodexHistoryNoop(
stateDbPath: string,
backupPath: string,
): CodexHistoryNoopSnapshot {
const canonicalStateDbPath = resolve(stateDbPath);
const canonicalBackupPath = resolve(backupPath);
const stateDbPresent = existsSync(stateDbPath);
const backupPresent = existsSync(backupPath);
const base = { canonicalStateDbPath, stateDbPresent, canonicalBackupPath, backupPresent };
if (!samePath(backupPath, historyBackupPathFor(stateDbPath))) {
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "backup-path" };
}
const backup = inspectBackupForNoop(backupPath, stateDbPath);
if (backup.kind === "unknown") {
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: backup.reason };
}
if (!stateDbPresent) {
return backup.entries > 0
? { kind: "work-pending", pendingRows: 0, backupEntries: backup.entries, ...base }
: { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-absent" };
}
const stateDbIdentity = historyFileIdentity(stateDbPath);
if (stateDbIdentity === null) {
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "snapshot-race" };
}
let monitor: Database | undefined;
try {
monitor = new Database(stateDbPath, { readonly: true });
monitor.exec("PRAGMA busy_timeout = 100");
const dataVersionBefore = readHistoryDataVersion(monitor);
if (dataVersionBefore === null) {
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
}
const pending = countPendingOpencodexHistory(stateDbPath, backupPath);
if (pending.failed) {
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
}
afterNoopPendingCountForTests?.();
const backupAfter = inspectBackupForNoop(backupPath, stateDbPath);
if (backupAfter.kind === "unknown") {
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: backupAfter.reason };
}
const dataVersionAfter = readHistoryDataVersion(monitor);
if (dataVersionAfter === null) {
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
}
if (dataVersionAfter !== dataVersionBefore
|| pending.backupEntries !== backup.entries
|| backupAfter.entries !== backup.entries
|| backupAfter.present !== backup.present
|| backupAfter.fingerprint !== backup.fingerprint
|| historyFileIdentity(stateDbPath) !== stateDbIdentity
|| existsSync(stateDbPath) !== stateDbPresent
|| existsSync(backupPath) !== backupPresent) {
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "snapshot-race" };
}
return pending.pendingRows === 0 && backup.entries === 0
? { kind: "verified-noop", pendingRows: 0, backupEntries: 0, ...base, stateDbPresent: true }
: { kind: "work-pending", pendingRows: pending.pendingRows, backupEntries: backup.entries, ...base };
} catch {
return { kind: "unknown", pendingRows: null, backupEntries: null, ...base, reason: "database-query" };
} finally {
try {
monitor?.close();
} catch {
// Read-only monitor cleanup cannot make an uncertain snapshot authoritative.
}
}
}

export interface PendingHistoryCount {
/** Threads still tagged opencodex that the eject path WOULD move (mirrors its WHERE). */
pendingRows: number;
Expand Down
10 changes: 5 additions & 5 deletions src/codex/history-transition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ function classify(outcome: CodexHistoryJobOutcome, txId: string | null): CodexHi
attempts: 1,
nextRetryAt: null,
txId,
// Mutation counts, not probe counts. The durable counts come from the
// final probe, which this path does not run — null rather than a
// manufactured zero.
pendingRows: null,
backupEntries: null,
// A zero is durable only when the worker proved the exact DB/manifest
// state while H was held. Without proof, keep the counts unknown. Keep
// `??`: `||` would discard a verified zero.
pendingRows: outcome.proof?.pendingRows ?? null,
backupEntries: outcome.proof?.backupEntries ?? null,
};
case "skipped":
// The user opting out is a completed decision, not a failure — converged
Expand Down
Loading
Loading