From 7b1300a8efb6b1c55a0fba18de09470a79411261 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 8 Aug 2026 23:41:18 +0900 Subject: [PATCH 1/2] fix(codex): make history no-op detection atomic --- src/codex/history-job.ts | 31 +++- src/codex/history-migration-guardian.ts | 19 +-- src/codex/history-provider.ts | 179 +++++++++++++++++++- src/codex/history-transition.ts | 4 +- src/codex/history-worker.ts | 24 ++- tests/codex-history-provider.test.ts | 102 ++++++++++- tests/codex-history-worker-boundary.test.ts | 28 +++ tests/codex-history-worker.test.ts | 32 +++- tests/codex-transition-state.test.ts | 46 +++++ tests/history-migration-guardian.test.ts | 32 +++- 10 files changed, 466 insertions(+), 31 deletions(-) diff --git a/src/codex/history-job.ts b/src/codex/history-job.ts index 2d35498da..c83539130 100644 --- a/src/codex/history-job.ts +++ b/src/codex/history-job.ts @@ -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. */ @@ -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"; @@ -92,8 +92,9 @@ export function isPlausibleWorkerResultForTests( message: Record, requestId: string, jobId: string, + target?: Pick, ): boolean { - return isPlausibleWorkerResult(message, requestId, jobId); + return isPlausibleWorkerResult(message, requestId, jobId, target); } /** @@ -108,13 +109,29 @@ function isPlausibleWorkerResult( message: Record, requestId: string, jobId: string, + target?: Pick, ): 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; + return 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"; @@ -227,7 +244,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 } : {}) }; } /** @@ -303,7 +320,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"); diff --git a/src/codex/history-migration-guardian.ts b/src/codex/history-migration-guardian.ts index e25ea97bf..ed5acfd74 100644 --- a/src/codex/history-migration-guardian.ts +++ b/src/codex/history-migration-guardian.ts @@ -53,7 +53,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; @@ -73,10 +73,10 @@ 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; + try { + countFn(); + } catch { + // Advisory probe failures must not suppress the locked worker attempt. } // Locked probe or pending work: attempt one migration pass. const result = await migrateFn(); @@ -85,11 +85,10 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps 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; } diff --git a/src/codex/history-provider.ts b/src/codex/history-provider.ts index 9a2ced85f..5717e5710 100644 --- a/src/codex/history-provider.ts +++ b/src/codex/history-provider.ts @@ -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"; @@ -200,6 +200,49 @@ interface BackupManifest { entries: Record; } +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; @@ -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; + 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; + 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 { @@ -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; diff --git a/src/codex/history-transition.ts b/src/codex/history-transition.ts index 1d96e9382..1dd74b4c6 100644 --- a/src/codex/history-transition.ts +++ b/src/codex/history-transition.ts @@ -36,8 +36,8 @@ function classify(outcome: CodexHistoryJobOutcome, txId: string | null): CodexHi // 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, + pendingRows: outcome.proof?.pendingRows ?? null, + backupEntries: outcome.proof?.backupEntries ?? null, }; case "skipped": // The user opting out is a completed decision, not a failure — converged diff --git a/src/codex/history-worker.ts b/src/codex/history-worker.ts index 35bf9e5bf..c0ce2dcf1 100644 --- a/src/codex/history-worker.ts +++ b/src/codex/history-worker.ts @@ -31,7 +31,11 @@ import { writeLegacyOpenaiHistoryRecovery, type HistoryWriteTarget, } from "./internal/history-writer"; -import type { CodexHistoryFailureReason } from "./history-provider"; +import { + snapshotCodexHistoryNoop, + type CodexHistoryFailureReason, + type CodexHistoryVerifiedNoopProof, +} from "./history-provider"; /** * The durable operation, mirrored into the request for diagnostics only. @@ -65,7 +69,8 @@ export interface HistoryWorkerRunMessage { export type HistoryWorkerResult = | { readonly type: "done"; readonly requestId: string; readonly jobId: string; readonly outcome: "converged" | "skipped"; - readonly rows: number; readonly files: number } + readonly rows: number; readonly files: number; + readonly proof?: CodexHistoryVerifiedNoopProof } | { readonly type: "blocked"; readonly requestId: string; readonly jobId: string; readonly reason: "busy" | "database" | "unsafe-path" | "desired_disabled" | "desired_enabled" } | { readonly type: "error"; readonly requestId: string; readonly jobId: string; @@ -133,6 +138,10 @@ export function runHistoryUnitUnderLock( if (operation === "recover-legacy-openai") { return writeLegacyOpenaiHistoryRecovery(permit, target); } + if (operation === "migrate-openai") { + const proof = snapshotCodexHistoryNoop(message.canonicalStateDbPath, message.canonicalBackupPath); + if (proof.kind === "verified-noop") return { verifiedNoop: proof } as const; + } // apply-opencodex routes history to opencodex; migrate/restore return it to // native. The provider is derived from the operation, never from a caller. const provider = operation === "apply-opencodex" ? "opencodex" : "openai"; @@ -152,6 +161,17 @@ export function runHistoryUnitUnderLock( reason: message.expectedDesiredEnabled ? "desired_disabled" : "desired_enabled", }; } + if ("verifiedNoop" in result) { + return { + type: "done", + requestId, + jobId, + outcome: "converged", + rows: 0, + files: 0, + proof: result.verifiedNoop, + }; + } if (result.failed === true) { return { type: "error", diff --git a/tests/codex-history-provider.test.ts b/tests/codex-history-provider.test.ts index ab6679f9c..62e6c04ab 100644 --- a/tests/codex-history-provider.test.ts +++ b/tests/codex-history-provider.test.ts @@ -1,9 +1,9 @@ -import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, utimesSync, writeFileSync } from "node:fs"; +import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; import { describe, expect, setDefaultTimeout, test } from "bun:test"; -import { classifyRecoverableHistoryError, countPendingOpencodexHistory, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, setHistoryDbBusyTimeoutForTests, syncCodexHistoryProvider, withHistoryRetry } from "../src/codex/history-provider"; +import { classifyRecoverableHistoryError, countPendingOpencodexHistory, historyBackupPathFor, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, setAfterNoopPendingCountForTests, setHistoryDbBusyTimeoutForTests, snapshotCodexHistoryNoop, syncCodexHistoryProvider, withHistoryRetry } from "../src/codex/history-provider"; // Windows CI: a transient file lock can consume the full production 5s busy timeout, tripping // bun's 5s default per-test timeout by itself. Fail fast into withHistoryRetry instead. @@ -363,6 +363,104 @@ describe("history lock retry", () => { }); describe("Design B migration helpers", () => { + test("strict no-op snapshots distinguish absence from manifest uncertainty", () => { + const dir = join(tmpdir(), `ocx-history-noop-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "state_5.sqlite"); + const backupPath = historyBackupPathFor(dbPath); + expect(snapshotCodexHistoryNoop(dbPath, backupPath)).toMatchObject({ + kind: "unknown", reason: "database-absent", + stateDbPresent: false, backupPresent: false, + }); + writeFileSync(backupPath, JSON.stringify({ version: 1, stateDbPath: dbPath, entries: {} })); + expect(snapshotCodexHistoryNoop(dbPath, backupPath)).toMatchObject({ + kind: "unknown", reason: "database-absent", stateDbPresent: false, backupPresent: true, + }); + writeFileSync(backupPath, "{not-json"); + expect(snapshotCodexHistoryNoop(dbPath, backupPath)).toMatchObject({ kind: "unknown", reason: "manifest-read" }); + writeFileSync(backupPath, JSON.stringify({ version: 1, stateDbPath: join(dir, "other.sqlite"), entries: {} })); + expect(snapshotCodexHistoryNoop(dbPath, backupPath)).toMatchObject({ kind: "unknown", reason: "manifest-foreign" }); + writeFileSync(backupPath, JSON.stringify({ version: 1, entries: {} })); + expect(snapshotCodexHistoryNoop(dbPath, backupPath)).toMatchObject({ kind: "unknown", reason: "manifest-schema" }); + writeFileSync(backupPath, JSON.stringify({ + version: 1, + stateDbPath: dbPath, + entries: { "thread-1": { id: "wrong-id", rolloutPath: "r", modelProvider: "openai", source: "cli", hasUserEvent: 1 } }, + })); + expect(snapshotCodexHistoryNoop(dbPath, backupPath)).toMatchObject({ kind: "unknown", reason: "manifest-schema" }); + }); + + test("a missing database with a valid nonempty manifest remains pending", () => { + const dir = join(tmpdir(), `ocx-history-noop-pending-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "state_5.sqlite"); + const backupPath = historyBackupPathFor(dbPath); + const rolloutPath = join(dir, "rollout.jsonl"); + writeFileSync(backupPath, JSON.stringify({ + version: 1, + stateDbPath: dbPath, + entries: { + "thread-1": { id: "thread-1", rolloutPath, modelProvider: "openai", source: "cli", hasUserEvent: 1 }, + }, + })); + expect(snapshotCodexHistoryNoop(dbPath, backupPath)).toMatchObject({ + kind: "work-pending", pendingRows: 0, backupEntries: 1, + stateDbPresent: false, backupPresent: true, + }); + }); + + test("a WAL commit after the pending count invalidates a no-op snapshot", () => { + const dir = join(tmpdir(), `ocx-history-noop-wal-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(dir, { recursive: true }); + const dbPath = join(dir, "state_5.sqlite"); + const backupPath = historyBackupPathFor(dbPath); + const seed = new Database(dbPath); + try { + seed.exec(` + PRAGMA journal_mode = WAL; + CREATE TABLE threads ( + id TEXT PRIMARY KEY, + rollout_path TEXT, + model_provider TEXT, + source TEXT, + has_user_event INTEGER, + first_user_message TEXT + ); + `); + seed.run( + "INSERT INTO threads VALUES (?, ?, 'openai', 'cli', 1, 'seed')", + ["openai-row", join(dir, "openai-rollout.jsonl")], + ); + } finally { + seed.close(); + } + + setAfterNoopPendingCountForTests(() => { + const writer = new Database(dbPath); + try { + writer.exec("PRAGMA journal_mode = WAL"); + writer.run( + "INSERT INTO threads VALUES (?, ?, 'opencodex', 'cli', 1, 'raced')", + ["raced-opencodex-row", join(dir, "raced-rollout.jsonl")], + ); + } finally { + writer.close(); + } + }); + + try { + expect(snapshotCodexHistoryNoop(dbPath, backupPath)).toMatchObject({ + kind: "unknown", + reason: "snapshot-race", + stateDbPresent: true, + backupPresent: false, + }); + } finally { + setAfterNoopPendingCountForTests(undefined); + rmSync(dir, { recursive: true, force: true }); + } + }); + const busy = () => Object.assign(new Error("database is locked"), { code: "SQLITE_BUSY" }); test("withHistoryRetry honors a custom attempts budget", () => { diff --git a/tests/codex-history-worker-boundary.test.ts b/tests/codex-history-worker-boundary.test.ts index b86c18f5b..d1eccaf74 100644 --- a/tests/codex-history-worker-boundary.test.ts +++ b/tests/codex-history-worker-boundary.test.ts @@ -75,6 +75,34 @@ describe("the result validator", () => { expect(isPlausibleWorkerResultForTests( { requestId: "r", jobId: "OTHER", type: "done", outcome: "converged", rows: 3, files: 1 }, "r", "j", )).toBe(false); + const target = { + canonicalStateDbPath: "/state/state_5.sqlite", + canonicalBackupPath: "/state/state_5.sqlite.ocx-backup.json", + }; + const verifiedNoop = { + requestId: "r", jobId: "j", type: "done", outcome: "converged", rows: 0, files: 0, + proof: { + kind: "verified-noop", pendingRows: 0, backupEntries: 0, + canonicalStateDbPath: target.canonicalStateDbPath, stateDbPresent: true, + canonicalBackupPath: target.canonicalBackupPath, backupPresent: false, + }, + }; + expect(isPlausibleWorkerResultForTests(verifiedNoop, "r", "j", target)).toBe(true); + expect(isPlausibleWorkerResultForTests( + { ...verifiedNoop, proof: { ...verifiedNoop.proof, canonicalStateDbPath: "/other/state.sqlite" } }, + "r", "j", target, + )).toBe(false); + expect(isPlausibleWorkerResultForTests({ ...verifiedNoop, rows: 1 }, "r", "j", target)).toBe(false); + expect(isPlausibleWorkerResultForTests( + { ...verifiedNoop, proof: { ...verifiedNoop.proof, canonicalBackupPath: "/other/backup.json" } }, + "r", "j", target, + )).toBe(false); + expect(isPlausibleWorkerResultForTests( + { ...verifiedNoop, proof: { ...verifiedNoop.proof, pendingRows: 1 } }, + "r", "j", target, + )).toBe(false); + expect(isPlausibleWorkerResultForTests({ ...verifiedNoop, outcome: "skipped" }, "r", "j", target)).toBe(false); + expect(isPlausibleWorkerResultForTests(verifiedNoop, "r", "j")).toBe(false); }); test("blocked carries exactly its three reasons", async () => { diff --git a/tests/codex-history-worker.test.ts b/tests/codex-history-worker.test.ts index 283275dff..b6dd0cfc3 100644 --- a/tests/codex-history-worker.test.ts +++ b/tests/codex-history-worker.test.ts @@ -5,7 +5,7 @@ import { join, resolve } from "node:path"; import { Database } from "bun:sqlite"; -import { setHistoryDbBusyTimeoutForTests } from "../src/codex/history-provider"; +import { historyBackupPathFor, setHistoryDbBusyTimeoutForTests } from "../src/codex/history-provider"; import { isHistoryWorkerRunMessage, runHistoryUnitUnderLock, @@ -64,7 +64,7 @@ function makeFixture(prefix: string): Fixture { return { codexHome, stateDb, - backup: join(codexHome, "history-backup.json"), + backup: historyBackupPathFor(stateDb), rollout, env: { ...Object.fromEntries(Object.entries(process.env) @@ -148,6 +148,34 @@ test("the unit runs the real transition under H", () => { expect(row?.model_provider).toBe("openai"); }); +test("migrate-openai returns a verified no-op only after entering H", () => { + const fixture = makeFixture("ocx-history-worker-noop-"); + const db = new Database(fixture.stateDb); + db.run("UPDATE threads SET model_provider = 'openai', source = 'cli' WHERE id = 'thread-1'"); + db.close(); + const before = readFileSync(fixture.rollout, "utf8"); + + const result = runHistoryUnitUnderLock(runMessage(fixture, { operation: "migrate-openai" })); + + expect(result).toMatchObject({ + type: "done", + outcome: "converged", + rows: 0, + files: 0, + proof: { + kind: "verified-noop", + pendingRows: 0, + backupEntries: 0, + canonicalStateDbPath: fixture.stateDb, + stateDbPresent: true, + canonicalBackupPath: fixture.backup, + backupPresent: false, + }, + }); + expect(readFileSync(fixture.rollout, "utf8")).toBe(before); + expect(existsSync(fixture.backup)).toBe(false); +}); + /** * The reason the unit lives in a Worker at all: while another process holds H, * this one reports a typed block instead of stalling its own thread. diff --git a/tests/codex-transition-state.test.ts b/tests/codex-transition-state.test.ts index fea549e20..fc04817e0 100644 --- a/tests/codex-transition-state.test.ts +++ b/tests/codex-transition-state.test.ts @@ -11,6 +11,8 @@ import { readCodexTransitionState, updateCodexHistoryTransition, } from "../src/codex/transition-state"; +import { resolveCodexHistoryTransition } from "../src/codex/history-transition"; +import { historyBackupPathFor } from "../src/codex/history-provider"; import { resolveCodexCoordinatorDatabasePath, resolveEffectiveUserIdentity, @@ -56,6 +58,50 @@ function transition(txId: string) { }; } +test("resolve persists zero counts only for a verified-noop proof", () => { + const started = beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-proof"), + ); + expect(started.kind).toBe("updated"); + resolveCodexHistoryTransition( + { nativeGeneration: 1, currentTxId: "tx-proof" }, + { + kind: "converged", rows: 0, files: 0, + proof: { + kind: "verified-noop", pendingRows: 0, backupEntries: 0, + canonicalStateDbPath: join(codexHome, "state_5.sqlite"), stateDbPresent: true, + canonicalBackupPath: historyBackupPathFor(join(codexHome, "state_5.sqlite")), backupPresent: false, + }, + }, + ); + const after = readCodexTransitionState(); + expect(after.kind).toBe("ready"); + if (after.kind === "ready") { + expect(after.state.history).toMatchObject({ + status: "converged", txId: "tx-proof", pendingRows: 0, backupEntries: 0, + }); + } +}); + +test("ordinary zero-mutation convergence keeps history counts unknown", () => { + const started = beginCodexTransition( + { nativeGeneration: 0, currentTxId: null }, + transition("tx-ordinary"), + ); + expect(started.kind).toBe("updated"); + resolveCodexHistoryTransition( + { nativeGeneration: 1, currentTxId: "tx-ordinary" }, + { kind: "converged", rows: 0, files: 0 }, + ); + const after = readCodexTransitionState(); + expect(after.kind).toBe("ready"); + if (after.kind === "ready") { + expect(after.state.history.pendingRows).toBeNull(); + expect(after.state.history.backupEntries).toBeNull(); + } +}); + test("a missing database initializes only from clean integration and native state", () => { expect(readCodexTransitionState()).toEqual({ kind: "ready", diff --git a/tests/history-migration-guardian.test.ts b/tests/history-migration-guardian.test.ts index c5930e5ca..54ee95645 100644 --- a/tests/history-migration-guardian.test.ts +++ b/tests/history-migration-guardian.test.ts @@ -34,13 +34,13 @@ describe("history migration guardian", () => { let migrations = 0; startHistoryMigrationGuardian({ countFn: () => ({ pendingRows: 0, backupEntries: 0 }), - migrateFn: () => { migrations++; return { rows: 0, files: 0 }; }, + migrateFn: () => { migrations++; return { rows: 0, files: 0, verifiedNoop: true }; }, log: silent, scheduleFn: sched.scheduleFn, }); expect(await sched.runNext()).toBe(true); - expect(migrations).toBe(0); // no pending work — never touches the migrate path + expect(migrations).toBe(1); // the locked worker owns no-op authority expect(sched.size).toBe(0); // and never reschedules }); @@ -100,7 +100,7 @@ describe("history migration guardian", () => { expect(migrations).toBe(0); }); - test("a locked count probe still attempts migration and keeps ticking until a clean re-count", async () => { + test("a locked count probe still attempts migration and stops on a worker-verified no-op", async () => { const sched = manualScheduler(); let migrations = 0; let counts = 0; @@ -112,14 +112,36 @@ describe("history migration guardian", () => { ? { pendingRows: 0, backupEntries: 0, failed: true as const } : { pendingRows: 0, backupEntries: 0 }; }, - migrateFn: () => { migrations++; return { rows: 0, files: 0 }; }, + migrateFn: () => { migrations++; return { rows: 0, files: 0, verifiedNoop: true }; }, log: silent, scheduleFn: sched.scheduleFn, }); expect(await sched.runNext()).toBe(true); expect(migrations).toBe(1); - expect(sched.size).toBe(0); // migration succeeded and re-count is clean → stop + expect(sched.size).toBe(0); // worker proof, not the advisory count, stops it + }); + + test("an advisory-clean count cannot turn an unverified zero-row result into success", async () => { + const sched = manualScheduler(); + let attempts = 0; + startHistoryMigrationGuardian({ + countFn: () => ({ pendingRows: 0, backupEntries: 0 }), + migrateFn: () => { + attempts++; + return attempts === 1 + ? { rows: 0, files: 0, verifiedNoop: false } + : { rows: 0, files: 0, verifiedNoop: true }; + }, + log: silent, + scheduleFn: sched.scheduleFn, + }); + expect(await sched.runNext()).toBe(true); + expect(attempts).toBe(1); + expect(sched.size).toBe(1); + expect(await sched.runNext()).toBe(true); + expect(attempts).toBe(2); + expect(sched.size).toBe(0); }); test("does not stop on a zero-row 'success' while backup entries remain (missing-DB race)", async () => { From cbd53046d0617ee4fed03c52d3bb56a5de3eb261 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sun, 9 Aug 2026 00:22:05 +0900 Subject: [PATCH 2/2] fix(codex): address atomic no-op review --- src/codex/history-job.ts | 7 ++--- src/codex/history-migration-guardian.ts | 14 +++------- src/codex/history-transition.ts | 6 ++--- tests/codex-history-provider.test.ts | 14 +++++++++- tests/codex-history-worker-boundary.test.ts | 4 +++ tests/codex-history-worker.test.ts | 15 ++++++++--- tests/history-migration-guardian.test.ts | 30 +++++++-------------- 7 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/codex/history-job.ts b/src/codex/history-job.ts index c83539130..64139c28f 100644 --- a/src/codex/history-job.ts +++ b/src/codex/history-job.ts @@ -92,7 +92,7 @@ export function isPlausibleWorkerResultForTests( message: Record, requestId: string, jobId: string, - target?: Pick, + target?: Pick, ): boolean { return isPlausibleWorkerResult(message, requestId, jobId, target); } @@ -109,7 +109,7 @@ function isPlausibleWorkerResult( message: Record, requestId: string, jobId: string, - target?: Pick, + target?: Pick, ): boolean { if (message.requestId !== requestId || message.jobId !== jobId) return false; switch (message.type) { @@ -121,7 +121,8 @@ function isPlausibleWorkerResult( if (!target || !message.proof || typeof message.proof !== "object" || Array.isArray(message.proof)) return false; { const proof = message.proof as Record; - return message.outcome === "converged" + return target.operation === "migrate-openai" + && message.outcome === "converged" && message.rows === 0 && message.files === 0 && proof.kind === "verified-noop" diff --git a/src/codex/history-migration-guardian.ts b/src/codex/history-migration-guardian.ts index ed5acfd74..68d52f837 100644 --- a/src/codex/history-migration-guardian.ts +++ b/src/codex/history-migration-guardian.ts @@ -1,4 +1,4 @@ -import { countPendingOpencodexHistory, migrateHistoryToOpenai } from "./history-provider"; +import { migrateHistoryToOpenai } from "./history-provider"; import { resolveCodexHistoryJobTarget, runCodexHistoryJob } from "./history-job"; /** @@ -23,7 +23,6 @@ export interface HistoryMigrationGuardianHandle { } export interface HistoryMigrationGuardianDeps { - countFn?: typeof countPendingOpencodexHistory; migrateFn?: () => ReturnType | Promise>; log?: Pick; @@ -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. @@ -73,12 +71,8 @@ export function startHistoryMigrationGuardian(deps: HistoryMigrationGuardianDeps if (stopped) return; ticks++; try { - try { - countFn(); - } catch { - // Advisory probe failures must not suppress the locked worker attempt. - } - // 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); @@ -98,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(); diff --git a/src/codex/history-transition.ts b/src/codex/history-transition.ts index 1dd74b4c6..2ff9c0cc5 100644 --- a/src/codex/history-transition.ts +++ b/src/codex/history-transition.ts @@ -33,9 +33,9 @@ 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. + // 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, }; diff --git a/tests/codex-history-provider.test.ts b/tests/codex-history-provider.test.ts index 62e6c04ab..485ac16f6 100644 --- a/tests/codex-history-provider.test.ts +++ b/tests/codex-history-provider.test.ts @@ -2,7 +2,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, import { tmpdir } from "node:os"; import { join } from "node:path"; import { Database } from "bun:sqlite"; -import { describe, expect, setDefaultTimeout, test } from "bun:test"; +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { classifyRecoverableHistoryError, countPendingOpencodexHistory, historyBackupPathFor, isRecoverableHistoryError, migrateHistoryToOpenai, restoreLegacyOpenaiHistory, setAfterNoopPendingCountForTests, setHistoryDbBusyTimeoutForTests, snapshotCodexHistoryNoop, syncCodexHistoryProvider, withHistoryRetry } from "../src/codex/history-provider"; // Windows CI: a transient file lock can consume the full production 5s busy timeout, tripping @@ -12,6 +12,12 @@ setHistoryDbBusyTimeoutForTests(250); // file measure 5-7s there (vs <100ms locally), straddling bun's 5s default. Explicit headroom. setDefaultTimeout(30_000); +const noopSnapshotArtifacts = new Set(); +afterEach(() => { + for (const path of noopSnapshotArtifacts) rmSync(path, { recursive: true, force: true }); + noopSnapshotArtifacts.clear(); +}); + /** Read the LAST session_meta payload, mirroring the app's last-writer-wins fold over rollout lines. */ function latestSessionMetaPayload(path: string): Record { const lines = readFileSync(path, "utf8").split("\n"); @@ -368,6 +374,8 @@ describe("Design B migration helpers", () => { mkdirSync(dir, { recursive: true }); const dbPath = join(dir, "state_5.sqlite"); const backupPath = historyBackupPathFor(dbPath); + noopSnapshotArtifacts.add(backupPath); + noopSnapshotArtifacts.add(dir); expect(snapshotCodexHistoryNoop(dbPath, backupPath)).toMatchObject({ kind: "unknown", reason: "database-absent", stateDbPresent: false, backupPresent: false, @@ -395,6 +403,8 @@ describe("Design B migration helpers", () => { mkdirSync(dir, { recursive: true }); const dbPath = join(dir, "state_5.sqlite"); const backupPath = historyBackupPathFor(dbPath); + noopSnapshotArtifacts.add(backupPath); + noopSnapshotArtifacts.add(dir); const rolloutPath = join(dir, "rollout.jsonl"); writeFileSync(backupPath, JSON.stringify({ version: 1, @@ -414,6 +424,8 @@ describe("Design B migration helpers", () => { mkdirSync(dir, { recursive: true }); const dbPath = join(dir, "state_5.sqlite"); const backupPath = historyBackupPathFor(dbPath); + noopSnapshotArtifacts.add(backupPath); + noopSnapshotArtifacts.add(dir); const seed = new Database(dbPath); try { seed.exec(` diff --git a/tests/codex-history-worker-boundary.test.ts b/tests/codex-history-worker-boundary.test.ts index d1eccaf74..b29c345ef 100644 --- a/tests/codex-history-worker-boundary.test.ts +++ b/tests/codex-history-worker-boundary.test.ts @@ -78,6 +78,7 @@ describe("the result validator", () => { const target = { canonicalStateDbPath: "/state/state_5.sqlite", canonicalBackupPath: "/state/state_5.sqlite.ocx-backup.json", + operation: "migrate-openai" as const, }; const verifiedNoop = { requestId: "r", jobId: "j", type: "done", outcome: "converged", rows: 0, files: 0, @@ -103,6 +104,9 @@ describe("the result validator", () => { )).toBe(false); expect(isPlausibleWorkerResultForTests({ ...verifiedNoop, outcome: "skipped" }, "r", "j", target)).toBe(false); expect(isPlausibleWorkerResultForTests(verifiedNoop, "r", "j")).toBe(false); + expect(isPlausibleWorkerResultForTests( + verifiedNoop, "r", "j", { ...target, operation: "apply-opencodex" }, + )).toBe(false); }); test("blocked carries exactly its three reasons", async () => { diff --git a/tests/codex-history-worker.test.ts b/tests/codex-history-worker.test.ts index b6dd0cfc3..4c09c7299 100644 --- a/tests/codex-history-worker.test.ts +++ b/tests/codex-history-worker.test.ts @@ -19,9 +19,11 @@ setDefaultTimeout(30_000); const repoRoot = resolve(import.meta.dir, ".."); const sandboxes: string[] = []; +const backupArtifacts: string[] = []; afterEach(() => { for (const root of sandboxes.splice(0)) rmSync(root, { recursive: true, force: true }); + for (const path of backupArtifacts.splice(0)) rmSync(path, { force: true }); }); interface Fixture { @@ -64,7 +66,7 @@ function makeFixture(prefix: string): Fixture { return { codexHome, stateDb, - backup: historyBackupPathFor(stateDb), + backup: join(codexHome, "history-backup.json"), rollout, env: { ...Object.fromEntries(Object.entries(process.env) @@ -150,12 +152,17 @@ test("the unit runs the real transition under H", () => { test("migrate-openai returns a verified no-op only after entering H", () => { const fixture = makeFixture("ocx-history-worker-noop-"); + const backup = historyBackupPathFor(fixture.stateDb); + backupArtifacts.push(backup); const db = new Database(fixture.stateDb); db.run("UPDATE threads SET model_provider = 'openai', source = 'cli' WHERE id = 'thread-1'"); db.close(); const before = readFileSync(fixture.rollout, "utf8"); - const result = runHistoryUnitUnderLock(runMessage(fixture, { operation: "migrate-openai" })); + const result = runHistoryUnitUnderLock(runMessage(fixture, { + operation: "migrate-openai", + canonicalBackupPath: backup, + })); expect(result).toMatchObject({ type: "done", @@ -168,12 +175,12 @@ test("migrate-openai returns a verified no-op only after entering H", () => { backupEntries: 0, canonicalStateDbPath: fixture.stateDb, stateDbPresent: true, - canonicalBackupPath: fixture.backup, + canonicalBackupPath: backup, backupPresent: false, }, }); expect(readFileSync(fixture.rollout, "utf8")).toBe(before); - expect(existsSync(fixture.backup)).toBe(false); + expect(existsSync(backup)).toBe(false); }); /** diff --git a/tests/history-migration-guardian.test.ts b/tests/history-migration-guardian.test.ts index 54ee95645..d54ee015e 100644 --- a/tests/history-migration-guardian.test.ts +++ b/tests/history-migration-guardian.test.ts @@ -29,11 +29,10 @@ function manualScheduler() { const silent = { log: () => {} }; describe("history migration guardian", () => { - test("stops silently when nothing is pending", async () => { + test("stops silently on a worker-verified no-op", async () => { const sched = manualScheduler(); let migrations = 0; startHistoryMigrationGuardian({ - countFn: () => ({ pendingRows: 0, backupEntries: 0 }), migrateFn: () => { migrations++; return { rows: 0, files: 0, verifiedNoop: true }; }, log: silent, scheduleFn: sched.scheduleFn, @@ -49,7 +48,6 @@ describe("history migration guardian", () => { const logs: string[] = []; let attempts = 0; startHistoryMigrationGuardian({ - countFn: () => ({ pendingRows: 3, backupEntries: 1 }), migrateFn: () => { attempts++; return attempts < 3 @@ -72,7 +70,6 @@ describe("history migration guardian", () => { const sched = manualScheduler(); const logs: string[] = []; startHistoryMigrationGuardian({ - countFn: () => ({ pendingRows: 1, backupEntries: 0 }), migrateFn: () => ({ rows: 0, files: 0, failed: true as const }), log: { log: (msg: string) => logs.push(msg) }, scheduleFn: sched.scheduleFn, @@ -82,14 +79,14 @@ describe("history migration guardian", () => { expect(await sched.runNext()).toBe(true); expect(await sched.runNext()).toBe(true); expect(sched.size).toBe(0); // budget exhausted — no reschedule - expect(logs.some(l => l.includes("stayed locked"))).toBe(true); + expect(logs.some(l => l.includes("Could not verify"))).toBe(true); + expect(logs.some(l => l.includes("stayed locked"))).toBe(false); }); test("stop() cancels the pending tick", async () => { const sched = manualScheduler(); let migrations = 0; const handle = startHistoryMigrationGuardian({ - countFn: () => ({ pendingRows: 1, backupEntries: 0 }), migrateFn: () => { migrations++; return { rows: 0, files: 0, failed: true as const }; }, log: silent, scheduleFn: sched.scheduleFn, @@ -100,18 +97,10 @@ describe("history migration guardian", () => { expect(migrations).toBe(0); }); - test("a locked count probe still attempts migration and stops on a worker-verified no-op", async () => { + test("stops on a worker-verified no-op without a pre-lock probe", async () => { const sched = manualScheduler(); let migrations = 0; - let counts = 0; startHistoryMigrationGuardian({ - countFn: () => { - counts++; - // First probe (pre-migrate) locked; re-count after migration comes back clean. - return counts === 1 - ? { pendingRows: 0, backupEntries: 0, failed: true as const } - : { pendingRows: 0, backupEntries: 0 }; - }, migrateFn: () => { migrations++; return { rows: 0, files: 0, verifiedNoop: true }; }, log: silent, scheduleFn: sched.scheduleFn, @@ -122,11 +111,10 @@ describe("history migration guardian", () => { expect(sched.size).toBe(0); // worker proof, not the advisory count, stops it }); - test("an advisory-clean count cannot turn an unverified zero-row result into success", async () => { + test("an unverified zero-row result cannot turn into success", async () => { const sched = manualScheduler(); let attempts = 0; startHistoryMigrationGuardian({ - countFn: () => ({ pendingRows: 0, backupEntries: 0 }), migrateFn: () => { attempts++; return attempts === 1 @@ -147,11 +135,11 @@ describe("history migration guardian", () => { test("does not stop on a zero-row 'success' while backup entries remain (missing-DB race)", async () => { const sched = manualScheduler(); let migrations = 0; - // DB missing: count sees only the backup manifest; migrate 'succeeds' with 0 rows. + const logs: string[] = []; + // DB missing: the locked migration returns zero rows without a proof. startHistoryMigrationGuardian({ - countFn: () => ({ pendingRows: 0, backupEntries: 2 }), migrateFn: () => { migrations++; return { rows: 0, files: 0 }; }, - log: silent, + log: { log: (msg: string) => logs.push(msg) }, scheduleFn: sched.scheduleFn, maxTicks: 3, }); @@ -162,5 +150,7 @@ describe("history migration guardian", () => { expect(await sched.runNext()).toBe(true); expect(await sched.runNext()).toBe(true); // budget exhausted on tick 3 expect(sched.size).toBe(0); + expect(logs.some(l => l.includes("Could not verify"))).toBe(true); + expect(logs.some(l => l.includes("stayed locked"))).toBe(false); }); });