@scotthuang/agent-knock-knock 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.1 - 2026-08-07
4
+
5
+ ### Fixed
6
+
7
+ - Harden Codex resumable-thread discovery across transient SQLite WAL/SHM creation, replacement, and checkpoint windows with one read transaction, identity-checked bounded `SQLITE_CANTOPEN` recovery, and a query-only sidecar-materialization fallback that does not use `immutable=1`.
8
+ - Forward configured `codexHome` to Codex lifecycle discovery, new, and resume paths in OpenClaw tools and `/akk` commands.
9
+
3
10
  ## 0.11.0 - 2026-08-07
4
11
 
5
12
  ### Added
@@ -7,11 +7,30 @@ export { parseLsofCwdMap, parsePsProcessSnapshots, type ProcessCommandResult as
7
7
  export interface CodexStoreAdapterOptions {
8
8
  codexHome?: string;
9
9
  runCommand?: (command: string, args: string[]) => ProcessCommandResult;
10
+ runSqliteThreadQuery?: CodexSqliteThreadQueryRunner;
11
+ sqliteCantOpenRetryDelaysMs?: readonly number[];
12
+ sleep?: (milliseconds: number) => Promise<void>;
10
13
  maxSessions?: number;
11
14
  }
15
+ export type CodexSqliteOpenMode = "readonly" | "query_only";
16
+ export interface CodexSqliteThreadQueryRequest {
17
+ dbPath: string;
18
+ openMode: CodexSqliteOpenMode;
19
+ maxSessions: number;
20
+ nativeThreadId?: string;
21
+ afterSchema?: (columns: readonly string[]) => void | Promise<void>;
22
+ }
23
+ export interface CodexSqliteThreadQueryResult {
24
+ columns: string[];
25
+ rows: CodexThreadRow[];
26
+ }
27
+ export type CodexSqliteThreadQueryRunner = (request: CodexSqliteThreadQueryRequest) => Promise<CodexSqliteThreadQueryResult>;
12
28
  export declare class CodexStoreAdapter implements CodexLocalSessionAdapter, TerminalThreadLifecycleCandidateProvider {
13
29
  private readonly codexHome;
14
30
  private readonly runCommand;
31
+ private readonly runSqliteThreadQuery;
32
+ private readonly sqliteCantOpenRetryDelaysMs;
33
+ private readonly sleep;
15
34
  private readonly maxSessions;
16
35
  constructor(options?: CodexStoreAdapterOptions);
17
36
  listThreadRows(): Promise<CodexThreadRow[]>;
@@ -21,8 +40,9 @@ export declare class CodexStoreAdapter implements CodexLocalSessionAdapter, Term
21
40
  readRollout(rolloutPath: string): Promise<string | undefined>;
22
41
  listProcessSnapshots(): Promise<CodexProcessSnapshot[]>;
23
42
  resolveActiveSessionIdentityForPid(pid: number, cwd?: string, preferredSessionId?: string, allowedCompanionIdentity?: ActiveAgentSessionIdentity, allowedAdditionalIdentities?: readonly ActiveAgentSessionIdentity[]): Promise<ActiveAgentSessionIdentity | undefined>;
24
- private queryJson;
43
+ private queryThreadRows;
25
44
  }
45
+ export declare function runCodexSqliteThreadQuery(request: CodexSqliteThreadQueryRequest): Promise<CodexSqliteThreadQueryResult>;
26
46
  export declare function latestStateDbPath(codexHome: string): string | undefined;
27
47
  export interface LsofOpenFileRecord {
28
48
  fd?: string;
@@ -1,36 +1,42 @@
1
- import { createHash } from "node:crypto";
1
+ import { spawn } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
2
3
  import fs from "node:fs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
6
+ import { pathToFileURL } from "node:url";
5
7
  import { codexLifecycleBehaviorProfile, supportedCodexLifecycleVersions } from "./codex-lifecycle-compatibility.js";
6
8
  import { discoverCodexProcesses } from "./codex-session-provider.js";
7
9
  import { SystemTerminalProcessSource, runProcessCommand } from "./terminal-process-source.js";
8
10
  export { parseLsofCwdMap, parsePsProcessSnapshots } from "./terminal-process-source.js";
9
11
  const NATIVE_THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
10
12
  const MAX_CODEX_SESSION_META_BYTES = 1024 * 1024;
13
+ const MAX_SQLITE_QUERY_OUTPUT_BYTES = 10 * 1024 * 1024;
14
+ const MAX_SQLITE_ERROR_OUTPUT_BYTES = 1024 * 1024;
15
+ const SQLITE_QUERY_TIMEOUT_MS = 10_000;
16
+ const DEFAULT_SQLITE_CANTOPEN_RETRY_DELAYS_MS = [25, 75, 150];
11
17
  const NO_FOLLOW_FLAG = typeof fs.constants.O_NOFOLLOW === "number"
12
18
  ? fs.constants.O_NOFOLLOW
13
19
  : 0;
14
20
  export class CodexStoreAdapter {
15
21
  codexHome;
16
22
  runCommand;
23
+ runSqliteThreadQuery;
24
+ sqliteCantOpenRetryDelaysMs;
25
+ sleep;
17
26
  maxSessions;
18
27
  constructor(options = {}) {
19
28
  this.codexHome = options.codexHome ?? path.join(os.homedir(), ".codex");
20
29
  this.runCommand = options.runCommand ?? runProcessCommand;
30
+ this.runSqliteThreadQuery = options.runSqliteThreadQuery ??
31
+ runCodexSqliteThreadQuery;
32
+ this.sqliteCantOpenRetryDelaysMs =
33
+ options.sqliteCantOpenRetryDelaysMs ??
34
+ DEFAULT_SQLITE_CANTOPEN_RETRY_DELAYS_MS;
35
+ this.sleep = options.sleep ?? waitForMilliseconds;
21
36
  this.maxSessions = options.maxSessions ?? 100;
22
37
  }
23
38
  async listThreadRows() {
24
- const dbPath = latestStateDbPath(this.codexHome);
25
- if (!dbPath) {
26
- throw new Error("no Codex state sqlite database found");
27
- }
28
- const columns = this.queryJson(dbPath, "pragma table_info(threads)")
29
- .map((column) => column.name);
30
- if (!columns.includes("id") || !columns.includes("cwd")) {
31
- throw new Error("Codex threads table is missing required id or cwd columns");
32
- }
33
- return this.queryJson(dbPath, buildThreadSelect(columns, this.maxSessions));
39
+ return this.queryThreadRows({ maxSessions: this.maxSessions });
34
40
  }
35
41
  async listThreadLifecycleCandidates(request) {
36
42
  assertCodexLifecycleCandidateRequest(request);
@@ -112,15 +118,10 @@ export class CodexStoreAdapter {
112
118
  if (!NATIVE_THREAD_ID_PATTERN.test(nativeThreadId)) {
113
119
  throw new Error("Codex thread lookup requires an exact UUID");
114
120
  }
115
- const dbPath = latestStateDbPath(this.codexHome);
116
- if (!dbPath) {
117
- return undefined;
118
- }
119
- const columns = this.queryJson(dbPath, "pragma table_info(threads)").map((column) => column.name);
120
- if (!columns.includes("id") || !columns.includes("cwd")) {
121
- throw new Error("Codex threads table is missing required id or cwd columns");
122
- }
123
- return this.queryJson(dbPath, buildThreadByIdSelect(columns, nativeThreadId))[0];
121
+ return (await this.queryThreadRows({
122
+ maxSessions: 1,
123
+ nativeThreadId
124
+ }))[0];
124
125
  }
125
126
  async readRollout(rolloutPath) {
126
127
  if (!fs.existsSync(rolloutPath)) {
@@ -163,13 +164,424 @@ export class CodexStoreAdapter {
163
164
  lsofOutput: result.stdout
164
165
  });
165
166
  }
166
- queryJson(dbPath, sql) {
167
- const result = this.runCommand("sqlite3", ["-readonly", "-json", dbPath, sql]);
168
- if (result.status !== 0) {
169
- throw new Error(result.stderr || result.error?.message || "sqlite3 query failed");
167
+ async queryThreadRows({ maxSessions, nativeThreadId }) {
168
+ const dbPath = latestStateDbPath(this.codexHome);
169
+ if (!dbPath) {
170
+ throw new Error("no Codex state sqlite database found");
171
+ }
172
+ const baseline = inspectCodexSqliteFiles(dbPath);
173
+ assertStableCodexSqliteMain({
174
+ baseline,
175
+ current: baseline,
176
+ stage: "initial"
177
+ });
178
+ let lastFailure;
179
+ const attempts = this.sqliteCantOpenRetryDelaysMs.length + 1;
180
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
181
+ if (attempt > 0) {
182
+ await this.sleep(this.sqliteCantOpenRetryDelaysMs[attempt - 1]);
183
+ }
184
+ const currentPath = latestStateDbPath(this.codexHome);
185
+ const before = inspectCodexSqliteFiles(currentPath ?? dbPath);
186
+ assertStableCodexSqliteMain({
187
+ baseline,
188
+ current: before,
189
+ stage: `readonly_attempt_${attempt + 1}`,
190
+ selectedPath: currentPath
191
+ });
192
+ let result;
193
+ try {
194
+ result = await this.runSqliteThreadQuery({
195
+ dbPath,
196
+ openMode: "readonly",
197
+ maxSessions,
198
+ nativeThreadId
199
+ });
200
+ }
201
+ catch (error) {
202
+ const failedPath = latestStateDbPath(this.codexHome);
203
+ const failedFiles = inspectCodexSqliteFiles(failedPath ?? dbPath);
204
+ assertStableCodexSqliteMain({
205
+ baseline,
206
+ current: failedFiles,
207
+ stage: `readonly_attempt_${attempt + 1}_failed`,
208
+ selectedPath: failedPath
209
+ });
210
+ const failure = codexSqliteQueryFailure(error, {
211
+ dbPath,
212
+ stage: `readonly_attempt_${attempt + 1}`,
213
+ files: failedFiles
214
+ });
215
+ if (!isSqliteCantOpen(failure)) {
216
+ throw codexSqliteQueryDiagnosticError(failure);
217
+ }
218
+ lastFailure = failure;
219
+ continue;
220
+ }
221
+ const completedPath = latestStateDbPath(this.codexHome);
222
+ assertStableCodexSqliteMain({
223
+ baseline,
224
+ current: inspectCodexSqliteFiles(completedPath ?? dbPath),
225
+ stage: `readonly_attempt_${attempt + 1}_complete`,
226
+ selectedPath: completedPath
227
+ });
228
+ return validateCodexThreadQueryResult(result);
229
+ }
230
+ const currentPath = latestStateDbPath(this.codexHome);
231
+ const beforeMaterialization = inspectCodexSqliteFiles(currentPath ?? dbPath);
232
+ assertStableCodexSqliteMain({
233
+ baseline,
234
+ current: beforeMaterialization,
235
+ stage: "query_only_materialization",
236
+ selectedPath: currentPath
237
+ });
238
+ let materializedResult;
239
+ try {
240
+ materializedResult = await this.runSqliteThreadQuery({
241
+ dbPath,
242
+ openMode: "query_only",
243
+ maxSessions,
244
+ nativeThreadId
245
+ });
170
246
  }
171
- return JSON.parse(result.stdout || "[]");
247
+ catch (error) {
248
+ const failedPath = latestStateDbPath(this.codexHome);
249
+ const failedFiles = inspectCodexSqliteFiles(failedPath ?? dbPath);
250
+ assertStableCodexSqliteMain({
251
+ baseline,
252
+ current: failedFiles,
253
+ stage: "query_only_materialization_failed",
254
+ selectedPath: failedPath
255
+ });
256
+ throw codexSqliteQueryDiagnosticError(codexSqliteQueryFailure(error, {
257
+ dbPath,
258
+ stage: "query_only_materialization",
259
+ files: failedFiles,
260
+ previousFailure: lastFailure
261
+ }));
262
+ }
263
+ const completedPath = latestStateDbPath(this.codexHome);
264
+ assertStableCodexSqliteMain({
265
+ baseline,
266
+ current: inspectCodexSqliteFiles(completedPath ?? dbPath),
267
+ stage: "query_only_materialization_complete",
268
+ selectedPath: completedPath
269
+ });
270
+ return validateCodexThreadQueryResult(materializedResult);
271
+ }
272
+ }
273
+ class CodexSqliteSessionError extends Error {
274
+ status;
275
+ stage;
276
+ constructor({ message, status, stage }) {
277
+ super(message);
278
+ this.name = "CodexSqliteSessionError";
279
+ this.status = status;
280
+ this.stage = stage;
281
+ }
282
+ }
283
+ export async function runCodexSqliteThreadQuery(request) {
284
+ if (request.nativeThreadId &&
285
+ !NATIVE_THREAD_ID_PATTERN.test(request.nativeThreadId)) {
286
+ throw new Error("Codex thread lookup requires an exact UUID");
287
+ }
288
+ const nonce = randomUUID();
289
+ const controlColumn = "__akk_sqlite_control";
290
+ const schemaControl = `schema:${nonce}`;
291
+ const rowsControl = `rows:${nonce}`;
292
+ const schemaMarker = JSON.stringify([{ [controlColumn]: schemaControl }]);
293
+ const rowsMarker = JSON.stringify([{ [controlColumn]: rowsControl }]);
294
+ const databaseArgument = request.openMode === "query_only"
295
+ ? sqliteReadWriteUri(request.dbPath)
296
+ : request.dbPath;
297
+ const args = ["-batch", "-bail", "-json"];
298
+ if (request.openMode === "readonly") {
299
+ args.push("-readonly");
300
+ }
301
+ else {
302
+ // This is the first SQL statement for the mode=rw connection. It lets
303
+ // SQLite materialize WAL/SHM bookkeeping while forbidding business SQL
304
+ // writes for the entire AKK session.
305
+ args.push("-cmd", "PRAGMA query_only=ON");
306
+ }
307
+ args.push(databaseArgument);
308
+ return new Promise((resolve, reject) => {
309
+ const child = spawn("sqlite3", args, {
310
+ stdio: ["pipe", "pipe", "pipe"]
311
+ });
312
+ let phase = "schema";
313
+ let output = "";
314
+ let stderr = "";
315
+ let result;
316
+ let authoritativeColumns = [];
317
+ let terminalError;
318
+ let settled = false;
319
+ const timeout = setTimeout(() => {
320
+ if (!terminalError) {
321
+ terminalError = new CodexSqliteSessionError({
322
+ message: `sqlite3 thread query timed out after ${SQLITE_QUERY_TIMEOUT_MS}ms`,
323
+ status: null,
324
+ stage: phase
325
+ });
326
+ }
327
+ child.kill("SIGKILL");
328
+ }, SQLITE_QUERY_TIMEOUT_MS);
329
+ const stopWithError = (error) => {
330
+ if (!terminalError) {
331
+ terminalError = error instanceof CodexSqliteSessionError
332
+ ? error
333
+ : new CodexSqliteSessionError({
334
+ message: error.message,
335
+ status: null,
336
+ stage: phase
337
+ });
338
+ }
339
+ child.kill("SIGKILL");
340
+ };
341
+ const appendOutput = (current, chunk, limit) => {
342
+ const next = current + chunk;
343
+ if (Buffer.byteLength(next, "utf8") > limit) {
344
+ throw new Error(`sqlite3 ${phase} output exceeded ${limit} bytes`);
345
+ }
346
+ return next;
347
+ };
348
+ const parseArray = (text, label) => {
349
+ if (!text.trim()) {
350
+ return [];
351
+ }
352
+ const parsed = JSON.parse(text);
353
+ if (!Array.isArray(parsed)) {
354
+ throw new Error(`sqlite3 ${label} output was not a JSON array`);
355
+ }
356
+ return parsed;
357
+ };
358
+ const writeRowsQuery = (columns) => {
359
+ validateCodexThreadColumns(columns);
360
+ authoritativeColumns = columns;
361
+ const sql = request.nativeThreadId
362
+ ? buildThreadByIdSelect(columns, request.nativeThreadId)
363
+ : buildThreadSelect(columns, request.maxSessions);
364
+ phase = "rows";
365
+ child.stdin.write(`${sql};\nselect '${rowsControl}' as "${controlColumn}";\n`);
366
+ };
367
+ const consumeOutput = () => {
368
+ if (phase === "schema") {
369
+ const markerIndex = output.indexOf(schemaMarker);
370
+ if (markerIndex < 0) {
371
+ return;
372
+ }
373
+ const schema = parseArray(output.slice(0, markerIndex).trim(), "schema");
374
+ const columns = schema
375
+ .map((column) => typeof column.name === "string" ? column.name : "")
376
+ .filter(Boolean);
377
+ output = output.slice(markerIndex + schemaMarker.length).trimStart();
378
+ phase = "schema_hook";
379
+ void Promise.resolve(request.afterSchema?.(columns))
380
+ .then(() => writeRowsQuery(columns))
381
+ .catch((error) => stopWithError(error instanceof Error ? error : new Error(String(error))));
382
+ }
383
+ if (phase === "rows") {
384
+ const markerIndex = output.indexOf(rowsMarker);
385
+ if (markerIndex < 0) {
386
+ return;
387
+ }
388
+ const rows = parseArray(output.slice(0, markerIndex).trim(), "rows");
389
+ result = {
390
+ columns: authoritativeColumns,
391
+ rows
392
+ };
393
+ phase = "complete";
394
+ child.stdin.end("COMMIT;\n.quit\n");
395
+ }
396
+ };
397
+ child.stdout.setEncoding("utf8");
398
+ child.stderr.setEncoding("utf8");
399
+ child.stdout.on("data", (chunk) => {
400
+ try {
401
+ output = appendOutput(output, chunk, MAX_SQLITE_QUERY_OUTPUT_BYTES);
402
+ consumeOutput();
403
+ }
404
+ catch (error) {
405
+ stopWithError(error instanceof Error ? error : new Error(String(error)));
406
+ }
407
+ });
408
+ child.stderr.on("data", (chunk) => {
409
+ try {
410
+ stderr = appendOutput(stderr, chunk, MAX_SQLITE_ERROR_OUTPUT_BYTES);
411
+ }
412
+ catch (error) {
413
+ stopWithError(error instanceof Error ? error : new Error(String(error)));
414
+ }
415
+ });
416
+ child.on("error", (error) => {
417
+ if (!terminalError) {
418
+ terminalError = new CodexSqliteSessionError({
419
+ message: error.message,
420
+ status: null,
421
+ stage: phase
422
+ });
423
+ }
424
+ });
425
+ child.stdin.on("error", (error) => {
426
+ if (!terminalError && phase !== "complete") {
427
+ terminalError = error;
428
+ }
429
+ });
430
+ child.on("close", (status) => {
431
+ clearTimeout(timeout);
432
+ if (settled) {
433
+ return;
434
+ }
435
+ settled = true;
436
+ if (terminalError) {
437
+ reject(terminalError);
438
+ return;
439
+ }
440
+ if (status !== 0) {
441
+ reject(new CodexSqliteSessionError({
442
+ message: stderr.trim() || `sqlite3 exited with status ${status ?? "unknown"}`,
443
+ status,
444
+ stage: phase
445
+ }));
446
+ return;
447
+ }
448
+ if (!result || phase !== "complete") {
449
+ reject(new CodexSqliteSessionError({
450
+ message: "sqlite3 exited before the thread query protocol completed",
451
+ status,
452
+ stage: phase
453
+ }));
454
+ return;
455
+ }
456
+ resolve(result);
457
+ });
458
+ child.stdin.write(`BEGIN;\npragma table_info(threads);\n` +
459
+ `select '${schemaControl}' as "${controlColumn}";\n`);
460
+ });
461
+ }
462
+ function validateCodexThreadQueryResult(result) {
463
+ validateCodexThreadColumns(result.columns);
464
+ return result.rows;
465
+ }
466
+ function validateCodexThreadColumns(columns) {
467
+ if (!columns.includes("id") || !columns.includes("cwd")) {
468
+ throw new Error("Codex threads table is missing required id or cwd columns");
469
+ }
470
+ }
471
+ function sqliteReadWriteUri(dbPath) {
472
+ const uri = pathToFileURL(path.resolve(dbPath));
473
+ uri.searchParams.set("mode", "rw");
474
+ return uri.href;
475
+ }
476
+ function waitForMilliseconds(milliseconds) {
477
+ return new Promise((resolve) => setTimeout(resolve, Math.max(0, milliseconds)));
478
+ }
479
+ function inspectCodexSqliteFiles(dbPath) {
480
+ return {
481
+ dbPath: path.resolve(dbPath),
482
+ main: inspectCodexSqliteFile(dbPath),
483
+ wal: inspectCodexSqliteFile(`${dbPath}-wal`),
484
+ shm: inspectCodexSqliteFile(`${dbPath}-shm`)
485
+ };
486
+ }
487
+ function inspectCodexSqliteFile(filePath) {
488
+ try {
489
+ const stat = fs.statSync(filePath);
490
+ return {
491
+ path: path.resolve(filePath),
492
+ exists: true,
493
+ kind: stat.isFile()
494
+ ? "file"
495
+ : stat.isDirectory()
496
+ ? "directory"
497
+ : "other",
498
+ device: String(stat.dev),
499
+ inode: String(stat.ino),
500
+ size: stat.size,
501
+ mtimeMs: stat.mtimeMs
502
+ };
503
+ }
504
+ catch (error) {
505
+ const code = typeof error === "object" && error !== null && "code" in error
506
+ ? String(error.code)
507
+ : undefined;
508
+ return {
509
+ path: path.resolve(filePath),
510
+ exists: false,
511
+ ...(code ? { errorCode: code } : {})
512
+ };
513
+ }
514
+ }
515
+ function assertStableCodexSqliteMain({ baseline, current, stage, selectedPath = current.dbPath }) {
516
+ const samePath = Boolean(selectedPath &&
517
+ path.resolve(selectedPath) === baseline.dbPath &&
518
+ current.dbPath === baseline.dbPath);
519
+ const sameFile = Boolean(baseline.main.exists &&
520
+ baseline.main.kind === "file" &&
521
+ current.main.exists &&
522
+ current.main.kind === "file" &&
523
+ baseline.main.device === current.main.device &&
524
+ baseline.main.inode === current.main.inode);
525
+ if (samePath && sameFile) {
526
+ return;
172
527
  }
528
+ throw new Error(`Codex SQLite main database changed during ${stage}; refusing stale ` +
529
+ `thread discovery (selected_db=${selectedPath ?? "missing"}, ` +
530
+ `baseline_db=${baseline.dbPath}, current_db=${current.dbPath}; ` +
531
+ `${formatCodexSqliteFiles(current)})`);
532
+ }
533
+ function codexSqliteQueryFailure(error, context) {
534
+ const errorRecord = typeof error === "object" && error !== null
535
+ ? error
536
+ : undefined;
537
+ const reportedStatus = typeof errorRecord?.status === "number"
538
+ ? errorRecord.status
539
+ : null;
540
+ const reportedStage = typeof errorRecord?.stage === "string"
541
+ ? errorRecord.stage
542
+ : undefined;
543
+ return {
544
+ dbPath: path.resolve(context.dbPath),
545
+ stage: reportedStage
546
+ ? `${context.stage}:${reportedStage}`
547
+ : context.stage,
548
+ status: reportedStatus !== null && Number.isInteger(reportedStatus)
549
+ ? reportedStatus
550
+ : null,
551
+ detail: error instanceof Error ? error.message : String(error),
552
+ files: context.files,
553
+ previousFailure: context.previousFailure
554
+ };
555
+ }
556
+ function isSqliteCantOpen(failure) {
557
+ return failure.status === 14 ||
558
+ /(?:SQLITE_CANTOPEN|unable to open database file|\(14\))/iu.test(failure.detail);
559
+ }
560
+ function codexSqliteQueryDiagnosticError(failure) {
561
+ const prior = failure.previousFailure
562
+ ? `; previous=[stage=${failure.previousFailure.stage},status=` +
563
+ `${failure.previousFailure.status ?? "unknown"},` +
564
+ `${formatCodexSqliteFiles(failure.previousFailure.files)}]`
565
+ : "";
566
+ return new Error(`Codex SQLite thread query failed ` +
567
+ `(stage=${failure.stage}, db=${failure.dbPath}, ` +
568
+ `status=${failure.status ?? "unknown"}${prior}; ` +
569
+ `${formatCodexSqliteFiles(failure.files)}): ` +
570
+ failure.detail.replace(/\s+/gu, " ").trim());
571
+ }
572
+ function formatCodexSqliteFiles(snapshot) {
573
+ return [
574
+ ["main", snapshot.main],
575
+ ["wal", snapshot.wal],
576
+ ["shm", snapshot.shm]
577
+ ].map(([label, value]) => {
578
+ const file = value;
579
+ if (!file.exists) {
580
+ return `${label}=missing${file.errorCode ? `(${file.errorCode})` : ""}`;
581
+ }
582
+ return `${label}=${file.kind}(dev=${file.device},ino=${file.inode},` +
583
+ `size=${file.size},mtime_ms=${Math.trunc(file.mtimeMs ?? 0)})`;
584
+ }).join(" ");
173
585
  }
174
586
  export function latestStateDbPath(codexHome) {
175
587
  if (!fs.existsSync(codexHome)) {
@@ -178,7 +590,18 @@ export function latestStateDbPath(codexHome) {
178
590
  return fs.readdirSync(codexHome)
179
591
  .filter((entry) => /^state_\d+\.sqlite$/u.test(entry))
180
592
  .map((entry) => path.join(codexHome, entry))
181
- .sort((left, right) => fs.statSync(right).mtimeMs - fs.statSync(left).mtimeMs)[0];
593
+ .flatMap((filePath) => {
594
+ try {
595
+ const stat = fs.statSync(filePath);
596
+ return stat.isFile() ? [{ filePath, mtimeMs: stat.mtimeMs }] : [];
597
+ }
598
+ catch {
599
+ // Codex may rotate a versioned state database while discovery is
600
+ // enumerating it. The caller will re-resolve and validate identity.
601
+ return [];
602
+ }
603
+ })
604
+ .sort((left, right) => right.mtimeMs - left.mtimeMs)[0]?.filePath;
182
605
  }
183
606
  export function parseLsofOpenFiles(text) {
184
607
  const records = [];