@bitkyc08/opencodex 2.7.42 → 2.7.43

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.
Files changed (143) hide show
  1. package/README.md +8 -1
  2. package/bin/ocx.mjs +13 -14
  3. package/gui/dist/assets/index-Czw-jpTU.css +1 -0
  4. package/gui/dist/assets/index-cmds12BG.js +67 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/AGENTS.md +28 -0
  8. package/src/adapters/anthropic.ts +15 -6
  9. package/src/adapters/cursor/native-exec-shell.ts +18 -6
  10. package/src/adapters/cursor/protobuf-events.ts +24 -2
  11. package/src/adapters/cursor/protobuf-request.ts +1 -2
  12. package/src/adapters/cursor/tool-definitions.ts +68 -29
  13. package/src/adapters/google-wire-compiler.ts +4 -0
  14. package/src/adapters/google.ts +128 -2
  15. package/src/adapters/identity.ts +12 -2
  16. package/src/adapters/kiro.ts +49 -6
  17. package/src/adapters/mimo-free.ts +2 -0
  18. package/src/adapters/openai-responses.ts +246 -59
  19. package/src/claude/agents-inject.ts +5 -0
  20. package/src/claude/inbound.ts +26 -9
  21. package/src/cli/account-auth.ts +1 -1
  22. package/src/cli/agent-driven.ts +37 -0
  23. package/src/cli/claude.ts +3 -3
  24. package/src/cli/doctor.ts +23 -18
  25. package/src/cli/help.ts +42 -6
  26. package/src/cli/index.ts +88 -19
  27. package/src/cli/interactive-confirm.ts +129 -0
  28. package/src/cli/opencode.ts +701 -0
  29. package/src/cli/provider-runtime.ts +3 -0
  30. package/src/cli/provider.ts +31 -10
  31. package/src/cli/star-prompt.ts +55 -16
  32. package/src/cli/status.ts +47 -13
  33. package/src/codex/account-id.ts +34 -0
  34. package/src/codex/account-lifecycle.ts +4 -1
  35. package/src/codex/account-namespace-match.ts +63 -0
  36. package/src/codex/account-namespaces.ts +149 -0
  37. package/src/codex/account-pause.ts +20 -0
  38. package/src/codex/account-usability.ts +6 -1
  39. package/src/codex/app-server-processes.ts +511 -0
  40. package/src/codex/auth-api.ts +293 -34
  41. package/src/codex/auth-collision.ts +2 -1
  42. package/src/codex/auth-context.ts +60 -17
  43. package/src/codex/catalog/parsing.ts +16 -1
  44. package/src/codex/catalog/provider-fetch.ts +48 -12
  45. package/src/codex/catalog/sync.ts +18 -3
  46. package/src/codex/catalog.ts +1 -1
  47. package/src/codex/history-provider.ts +145 -1
  48. package/src/codex/inject.ts +114 -14
  49. package/src/codex/main-account.ts +2 -8
  50. package/src/codex/pool-rotation.ts +186 -0
  51. package/src/codex/quota.ts +15 -0
  52. package/src/codex/routing.ts +695 -106
  53. package/src/codex/shim.ts +4 -1
  54. package/src/codex/subagent-defaults.ts +550 -0
  55. package/src/codex/subagent-model-fallback.ts +2 -0
  56. package/src/codex/sync.ts +3 -0
  57. package/src/config.ts +566 -25
  58. package/src/images/artifacts.ts +516 -0
  59. package/src/images/fulfill-video.ts +163 -0
  60. package/src/images/fulfill.ts +111 -0
  61. package/src/images/index.ts +4 -0
  62. package/src/images/loop.ts +789 -0
  63. package/src/images/plan.ts +133 -0
  64. package/src/images/synthetic-tool.ts +133 -0
  65. package/src/images/types.ts +41 -0
  66. package/src/images/xai-client.ts +141 -0
  67. package/src/images/xai-video-client.ts +163 -0
  68. package/src/lib/admin-secrets.ts +25 -0
  69. package/src/lib/config-ownership.ts +327 -0
  70. package/src/lib/crash-guard.ts +2 -0
  71. package/src/lib/destination-policy.ts +122 -4
  72. package/src/lib/pinned-http.ts +151 -0
  73. package/src/lib/process-control.ts +2 -2
  74. package/src/lib/provider-outbound.ts +164 -0
  75. package/src/lib/provider-url.ts +14 -0
  76. package/src/lib/proxy-env.ts +18 -0
  77. package/src/lib/windows-elevation.ts +81 -3
  78. package/src/lib/windows-secret-acl.ts +189 -12
  79. package/src/lib/winsw.ts +2 -0
  80. package/src/oauth/anthropic-routing.ts +570 -0
  81. package/src/oauth/health.ts +6 -0
  82. package/src/oauth/index.ts +283 -72
  83. package/src/oauth/key-providers.ts +17 -6
  84. package/src/oauth/kimi.ts +2 -0
  85. package/src/oauth/kiro-credentials.ts +322 -10
  86. package/src/oauth/kiro.ts +423 -43
  87. package/src/oauth/login-cli.ts +32 -5
  88. package/src/oauth/store.ts +54 -4
  89. package/src/oauth/types.ts +11 -0
  90. package/src/providers/alibaba-region-migration.ts +16 -3
  91. package/src/providers/antigravity-models.ts +3 -0
  92. package/src/providers/api-keys.ts +13 -6
  93. package/src/providers/derive.ts +6 -0
  94. package/src/providers/key-failover.ts +24 -4
  95. package/src/providers/quota.ts +233 -29
  96. package/src/providers/registry.ts +11 -3
  97. package/src/responses/parser.ts +11 -0
  98. package/src/responses/state.ts +22 -8
  99. package/src/responses/tool-groups.ts +19 -0
  100. package/src/router.ts +14 -4
  101. package/src/server/auth-cors.ts +110 -22
  102. package/src/server/claude-messages.ts +8 -1
  103. package/src/server/gui-static.ts +30 -6
  104. package/src/server/images.ts +303 -9
  105. package/src/server/index.ts +77 -9
  106. package/src/server/lifecycle.ts +25 -1
  107. package/src/server/management/agent-settings-routes.ts +28 -4
  108. package/src/server/management/combo-routes.ts +7 -0
  109. package/src/server/management/config-routes.ts +5 -2
  110. package/src/server/management/logs-usage-routes.ts +167 -3
  111. package/src/server/management/oauth-account-routes.ts +163 -17
  112. package/src/server/management/provider-routes.ts +35 -7
  113. package/src/server/management/system-restart.ts +172 -0
  114. package/src/server/management/system-routes.ts +33 -10
  115. package/src/server/management-api.ts +2 -2
  116. package/src/server/management-auth.ts +216 -0
  117. package/src/server/proxy-liveness.ts +14 -3
  118. package/src/server/responses/compact.ts +21 -13
  119. package/src/server/responses/core.ts +583 -152
  120. package/src/server/responses-image-gen-repair.ts +118 -0
  121. package/src/server/responses-item-id-repair.ts +10 -85
  122. package/src/server/sse-payload-rewrite.ts +116 -0
  123. package/src/server/system-env.ts +28 -10
  124. package/src/service.ts +49 -2
  125. package/src/storage/cleanup-job.ts +57 -0
  126. package/src/storage/cleanup.ts +1504 -28
  127. package/src/storage/policy-job.ts +366 -0
  128. package/src/storage/policy-scheduler.ts +40 -0
  129. package/src/storage/policy-worker.ts +53 -0
  130. package/src/storage/policy.ts +522 -0
  131. package/src/storage/restore-job.ts +242 -0
  132. package/src/storage/restore-worker.ts +52 -0
  133. package/src/storage/storage-mutation-coordinator.ts +109 -0
  134. package/src/tray/windows.ts +2 -0
  135. package/src/types.ts +96 -1
  136. package/src/update/index.ts +36 -18
  137. package/src/update/job.ts +103 -12
  138. package/src/update/npm-invocation.d.mts +23 -0
  139. package/src/update/npm-invocation.mjs +94 -0
  140. package/src/usage/debug.ts +2 -0
  141. package/src/usage/log.ts +4 -0
  142. package/gui/dist/assets/index-Bl_VBGoI.js +0 -65
  143. package/gui/dist/assets/index-DfVGuN88.css +0 -1
@@ -12,21 +12,29 @@
12
12
  * satellite rows before staged files. Success never carries soft `dbWarning` /
13
13
  * `failedPaths`.
14
14
  */
15
- import { createHash } from "node:crypto";
15
+ import { createHash, randomUUID } from "node:crypto";
16
16
  import {
17
+ closeSync,
17
18
  existsSync,
19
+ fsyncSync,
20
+ linkSync,
18
21
  mkdirSync,
22
+ openSync,
19
23
  readdirSync,
24
+ readFileSync,
20
25
  renameSync,
21
26
  rmSync,
22
27
  statSync,
23
28
  unlinkSync,
24
29
  writeFileSync,
30
+ writeSync,
25
31
  chmodSync,
26
32
  } from "node:fs";
27
33
  import { basename, isAbsolute, join, relative, resolve, sep } from "node:path";
28
34
  import { Database } from "bun:sqlite";
29
35
  import { resolveCodexHomeDir } from "../codex/home";
36
+ import { readThreadFieldsFromRollout } from "../codex/history-provider";
37
+ import { renameAtomicFile } from "../config";
30
38
 
31
39
  export const ARCHIVED_SESSIONS_DIR = "archived_sessions";
32
40
  export const TRASH_DIR = ".trash";
@@ -39,9 +47,11 @@ export type CleanupErrorCode =
39
47
  | "invalid_digest"
40
48
  | "stale_preview"
41
49
  | "codex_busy"
50
+ | "storage_mutation_busy"
42
51
  | "fs_failed"
43
52
  | "db_reconcile_failed"
44
53
  | "referenced_history"
54
+ | "restore_pending_overlap"
45
55
  | "cleanup_failed";
46
56
 
47
57
  export interface ArchivedCandidate {
@@ -233,9 +243,8 @@ export function normalizeArchivedRolloutPath(rolloutPath: string, codexHome: str
233
243
  return logical;
234
244
  }
235
245
 
236
- /** Content digest of the exact previewed candidate set (paths + size + mtime). */
237
- export function computePreviewDigest(candidates: ArchivedCandidate[], percent: number): string {
238
- const lines = candidates
246
+ function candidateDigestLines(candidates: ArchivedCandidate[]): string[] {
247
+ return candidates
239
248
  .map(c => {
240
249
  const physical = [...c.physicalFiles]
241
250
  .sort((a, b) => a.relPath.localeCompare(b.relPath))
@@ -244,8 +253,22 @@ export function computePreviewDigest(candidates: ArchivedCandidate[], percent: n
244
253
  return `${c.relPath}|${c.bytes}|${Math.trunc(c.mtimeMs)}|${physical}`;
245
254
  })
246
255
  .sort();
256
+ }
257
+
258
+ /** Content digest of the exact previewed candidate set (paths + size + mtime). */
259
+ export function computePreviewDigest(candidates: ArchivedCandidate[], percent: number): string {
260
+ return createHash("sha256")
261
+ .update(`${clampPercent(percent)}\n${candidateDigestLines(candidates).join("\n")}`)
262
+ .digest("hex");
263
+ }
264
+
265
+ /**
266
+ * Digest bound to an explicit candidate list (not a percent selection).
267
+ * Used when reduceToBytes needs an exact count that percent rounding cannot represent.
268
+ */
269
+ export function computeExactPreviewDigest(candidates: ArchivedCandidate[]): string {
247
270
  return createHash("sha256")
248
- .update(`${clampPercent(percent)}\n${lines.join("\n")}`)
271
+ .update(`exact\n${candidateDigestLines(candidates).join("\n")}`)
249
272
  .digest("hex");
250
273
  }
251
274
 
@@ -314,29 +337,161 @@ export function selectOldestPercent(candidates: ArchivedCandidate[], percent: nu
314
337
  const pct = clampPercent(percent);
315
338
  if (pct <= 0 || candidates.length === 0) return [];
316
339
  if (pct >= 100) return [...candidates];
317
- const n = Math.max(1, Math.floor((candidates.length * pct) / 100));
340
+ const n = percentSelectionTargetCount(candidates.length, pct);
318
341
  return candidates.slice(0, n);
319
342
  }
320
343
 
344
+ /** Count implied by percent selection over the full candidate list. */
345
+ export function percentSelectionTargetCount(totalCount: number, percent: number): number {
346
+ const pct = clampPercent(percent);
347
+ if (pct <= 0 || totalCount === 0) return 0;
348
+ if (pct >= 100) return totalCount;
349
+ return Math.max(1, Math.floor((totalCount * pct) / 100));
350
+ }
351
+
352
+ function candidateOverlapsPendingRestore(
353
+ candidate: ArchivedCandidate,
354
+ pendingDestRels: ReadonlySet<string>,
355
+ ): boolean {
356
+ if (pendingDestRels.size === 0) return false;
357
+ for (const rel of candidate.physicalRelPaths) {
358
+ if (pendingDestRels.has(rel)) return true;
359
+ }
360
+ return pendingDestRels.has(candidate.relPath);
361
+ }
362
+
363
+ /** Drop cleanup candidates whose physical paths overlap an in-progress restore. */
364
+ export function filterCandidatesExcludingPendingRestore(
365
+ candidates: ArchivedCandidate[],
366
+ codexHome: string = resolveCodexHomeDir(),
367
+ ): ArchivedCandidate[] {
368
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
369
+ if (pendingDestRels.size === 0) return candidates;
370
+ return candidates.filter(c => !candidateOverlapsPendingRestore(c, pendingDestRels));
371
+ }
372
+
373
+ /**
374
+ * Oldest-first percent selection that skips pending-restore destinations without
375
+ * consuming the percent budget, backfilling with the next oldest safe candidates.
376
+ */
377
+ export function selectOldestPercentSkippingPendingRestore(
378
+ candidates: ArchivedCandidate[],
379
+ percent: number,
380
+ codexHome: string = resolveCodexHomeDir(),
381
+ ): ArchivedCandidate[] {
382
+ const target = percentSelectionTargetCount(candidates.length, percent);
383
+ if (target === 0) return [];
384
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
385
+ const out: ArchivedCandidate[] = [];
386
+ for (const c of candidates) {
387
+ if (candidateOverlapsPendingRestore(c, pendingDestRels)) continue;
388
+ out.push(c);
389
+ if (out.length >= target) break;
390
+ }
391
+ return out;
392
+ }
393
+
394
+ /**
395
+ * Reduce archived total toward `reduceToBytes` using oldest safe candidates only.
396
+ * Pending-restore destinations are skipped and do not count toward bytes freed.
397
+ */
398
+ export function selectReduceToBytesSkippingPendingRestore(
399
+ candidates: ArchivedCandidate[],
400
+ reduceToBytes: number,
401
+ codexHome: string = resolveCodexHomeDir(),
402
+ ): ArchivedCandidate[] {
403
+ if (!Number.isFinite(reduceToBytes) || reduceToBytes < 0) return [];
404
+ const total = candidates.reduce((sum, c) => sum + c.bytes, 0);
405
+ if (total <= reduceToBytes) return [];
406
+ const need = total - reduceToBytes;
407
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
408
+ const out: ArchivedCandidate[] = [];
409
+ let freed = 0;
410
+ for (const c of candidates) {
411
+ if (candidateOverlapsPendingRestore(c, pendingDestRels)) continue;
412
+ out.push(c);
413
+ freed += c.bytes;
414
+ if (freed >= need) break;
415
+ }
416
+ return out;
417
+ }
418
+
419
+ /** Accepted destination paths from every valid in-progress restore marker under `.trash`. */
420
+ export function collectRestorePendingAcceptedDestRels(codexHome: string): Set<string> {
421
+ const out = new Set<string>();
422
+ const trashRoot = join(codexHome, TRASH_DIR);
423
+ if (!existsSync(trashRoot)) return out;
424
+ for (const name of readdirSync(trashRoot)) {
425
+ if (!TRASH_EPOCH_DIR.test(name)) continue;
426
+ const read = readRestorePending(join(trashRoot, name));
427
+ if (read.status !== "valid") continue;
428
+ for (const rel of read.state.acceptedDestRels) out.add(rel);
429
+ }
430
+ return out;
431
+ }
432
+
321
433
  export function previewArchivedCleanup(
322
434
  percent: number,
323
435
  codexHome: string = resolveCodexHomeDir(),
324
436
  ): CleanupPreview {
325
437
  const all = listArchivedCandidates(codexHome);
326
- const selected = selectOldestPercent(all, percent);
438
+ const safe = selectOldestPercentSkippingPendingRestore(all, percent, codexHome);
327
439
  const pct = clampPercent(percent);
328
440
  return {
329
441
  codexHome,
330
442
  percent: pct,
331
- count: selected.length,
332
- bytes: selected.reduce((sum, c) => sum + c.bytes, 0),
333
- digest: computePreviewDigest(selected, pct),
334
- candidates: selected,
443
+ count: safe.length,
444
+ bytes: safe.reduce((sum, c) => sum + c.bytes, 0),
445
+ digest: computePreviewDigest(safe, pct),
446
+ candidates: safe,
335
447
  };
336
448
  }
337
449
 
450
+ /** Preview bound to an explicit candidate set (exact digest, percent left at 0). */
451
+ export function previewExactArchivedCleanup(
452
+ candidates: ArchivedCandidate[],
453
+ codexHome: string = resolveCodexHomeDir(),
454
+ ): CleanupPreview {
455
+ const safe = filterCandidatesExcludingPendingRestore(candidates, codexHome);
456
+ return {
457
+ codexHome,
458
+ percent: 0,
459
+ count: safe.length,
460
+ bytes: safe.reduce((sum, c) => sum + c.bytes, 0),
461
+ digest: computeExactPreviewDigest(safe),
462
+ candidates: safe,
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Resolve an exact candidate list from current archive state.
468
+ * Returns null when any requested path is missing or drifted (caller maps to stale_preview).
469
+ */
470
+ export function resolveExactArchivedCandidates(
471
+ candidateRelPaths: string[],
472
+ codexHome: string = resolveCodexHomeDir(),
473
+ ): ArchivedCandidate[] | null {
474
+ if (!Array.isArray(candidateRelPaths) || candidateRelPaths.length === 0) return [];
475
+ const all = listArchivedCandidates(codexHome);
476
+ const byRel = new Map(all.map(c => [c.relPath, c]));
477
+ const selected: ArchivedCandidate[] = [];
478
+ for (const rel of candidateRelPaths) {
479
+ const hit = byRel.get(rel);
480
+ if (!hit) return null;
481
+ selected.push(hit);
482
+ }
483
+ return selected;
484
+ }
485
+
338
486
  function openDbWritable(dbPath: string, busyTimeoutMs = 100): Database {
339
487
  const db = new Database(dbPath);
488
+ try {
489
+ // bun:sqlite exposes a binding-level timeout; set both so Windows lock waits
490
+ // honor the caller's budget (pragma alone has been flaky under CI contention).
491
+ (db as Database & { timeout?: number }).timeout = busyTimeoutMs;
492
+ } catch {
493
+ /* older bindings */
494
+ }
340
495
  try {
341
496
  db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
342
497
  } catch {
@@ -552,6 +707,10 @@ type SqlRow = Record<string, string | number | bigint | null | Uint8Array>;
552
707
 
553
708
  interface SatelliteBackup {
554
709
  threadIds: string[];
710
+ /** Full `threads` row images (SELECT *) captured under the state write lock. */
711
+ threads?: SqlRow[];
712
+ dynamicTools?: SqlRow[];
713
+ spawnEdges?: SqlRow[];
555
714
  logs?: { path: string; rows: SqlRow[] };
556
715
  memories?: {
557
716
  path: string;
@@ -569,6 +728,11 @@ interface SatelliteBackup {
569
728
  };
570
729
  }
571
730
 
731
+ type SatelliteBackupRead =
732
+ | { status: "missing" }
733
+ | { status: "ok"; backup: SatelliteBackup }
734
+ | { status: "invalid" };
735
+
572
736
  interface ReconcileTestHooks {
573
737
  failAfterLogsMutation?: boolean;
574
738
  failAfterMemoriesMutation?: boolean;
@@ -576,11 +740,42 @@ interface ReconcileTestHooks {
576
740
  failBeforeStateCommit?: boolean;
577
741
  failSatelliteRestore?: boolean;
578
742
  failSatelliteBackupWrite?: boolean;
743
+ /**
744
+ * Fail a satellite-backup.json *replacement* after the temp is durable but before
745
+ * rename — exercises crash-safety of the post-memories rewrite without truncating
746
+ * the last valid backup.
747
+ */
748
+ failSatelliteBackupReplace?: boolean;
579
749
  /** Runs after satellite deletes are committed, before state thread deletion. */
580
750
  afterSatelliteMutations?: () => void;
581
751
  }
582
752
 
583
753
  const SATELLITE_BACKUP_FILE = "satellite-backup.json";
754
+ /** Marks an incomplete restore so retries can accept dest files and resume metadata. */
755
+ const RESTORE_PENDING_FILE = "restore-pending.json";
756
+ let _satelliteBackupSeq = 0;
757
+
758
+ type StagedFile = { from: string; to: string; relPath: string };
759
+
760
+ interface RestorePendingSections {
761
+ state: boolean;
762
+ logs: boolean;
763
+ memories: boolean;
764
+ goals: boolean;
765
+ }
766
+
767
+ interface RestorePendingState {
768
+ version: 1;
769
+ filesRestored: true;
770
+ /**
771
+ * Planned CODEX_HOME-relative destinations for this restore attempt.
772
+ * Written before moves so a mid-loop failure can still accept placed dests
773
+ * on resume while finishing files that remain staged.
774
+ */
775
+ acceptedDestRels: string[];
776
+ /** Sections that still need reconciliation on retry. */
777
+ pending: RestorePendingSections;
778
+ }
584
779
 
585
780
  function quoteIdent(name: string): string {
586
781
  return `"${name.replaceAll('"', '""')}"`;
@@ -590,17 +785,134 @@ function selectRows(db: Database, sql: string, params: Array<string | number>):
590
785
  return db.query<SqlRow, Array<string | number>>(sql).all(...params) as SqlRow[];
591
786
  }
592
787
 
593
- function insertRowsConflictIgnore(db: Database, table: string, rows: SqlRow[]): void {
788
+ function tableColumnNames(db: Database, table: string): Set<string> {
789
+ if (!tableExists(db, table)) return new Set();
790
+ const rows = db.query<{ name: string }, []>(
791
+ `PRAGMA table_info("${table.replaceAll('"', '""')}")`,
792
+ ).all();
793
+ return new Set(rows.map(r => r.name));
794
+ }
795
+
796
+ /** Insert rows with ON CONFLICT DO NOTHING; returns only rows that were newly inserted. */
797
+ function insertRowsConflictIgnore(db: Database, table: string, rows: SqlRow[]): SqlRow[] {
798
+ const inserted: SqlRow[] = [];
799
+ if (rows.length === 0) return inserted;
800
+ const allowed = tableColumnNames(db, table);
594
801
  for (const row of rows) {
595
- const cols = Object.keys(row);
802
+ const cols = Object.keys(row).filter(c => allowed.has(c));
596
803
  if (cols.length === 0) continue;
597
- db.run(
804
+ const result = db.run(
598
805
  `INSERT INTO ${quoteIdent(table)} (${cols.map(quoteIdent).join(", ")}) VALUES (${cols.map(() => "?").join(", ")}) ON CONFLICT DO NOTHING`,
599
806
  cols.map(c => row[c] as string | number | bigint | null | Uint8Array),
600
807
  );
808
+ if (result.changes > 0) inserted.push(row);
809
+ }
810
+ return inserted;
811
+ }
812
+
813
+ /** Snapshot state-DB dependents that cleanup deletes with the thread rows. */
814
+ function snapshotStateDependents(
815
+ db: Database,
816
+ threadIds: string[],
817
+ ): Pick<SatelliteBackup, "threads" | "dynamicTools" | "spawnEdges"> {
818
+ const out: Pick<SatelliteBackup, "threads" | "dynamicTools" | "spawnEdges"> = {};
819
+ if (threadIds.length === 0 || !tableExists(db, "threads")) return out;
820
+
821
+ const threads: SqlRow[] = [];
822
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
823
+ const placeholders = chunk.map(() => "?").join(",");
824
+ threads.push(...selectRows(db, `SELECT * FROM threads WHERE id IN (${placeholders})`, chunk));
825
+ }
826
+ out.threads = threads;
827
+
828
+ if (tableExists(db, "thread_dynamic_tools")) {
829
+ const dynamicTools: SqlRow[] = [];
830
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
831
+ const placeholders = chunk.map(() => "?").join(",");
832
+ dynamicTools.push(...selectRows(
833
+ db,
834
+ `SELECT * FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`,
835
+ chunk,
836
+ ));
837
+ }
838
+ out.dynamicTools = dynamicTools;
839
+ }
840
+
841
+ if (tableExists(db, "thread_spawn_edges")) {
842
+ const spawnEdges: SqlRow[] = [];
843
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK)) {
844
+ const placeholders = chunk.map(() => "?").join(",");
845
+ spawnEdges.push(...selectRows(
846
+ db,
847
+ `SELECT * FROM thread_spawn_edges
848
+ WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
849
+ [...chunk, ...chunk],
850
+ ));
851
+ }
852
+ out.spawnEdges = spawnEdges;
853
+ }
854
+
855
+ return out;
856
+ }
857
+
858
+ /** Remap serialized absolute DB paths onto the newest DBs under the current Codex home. */
859
+ function remapSatelliteBackupPaths(
860
+ backup: SatelliteBackup,
861
+ paths: RuntimeDbPaths,
862
+ ): { ok: true; backup: SatelliteBackup } | { ok: false } {
863
+ const next: SatelliteBackup = {
864
+ threadIds: backup.threadIds,
865
+ ...(backup.threads ? { threads: backup.threads } : {}),
866
+ ...(backup.dynamicTools ? { dynamicTools: backup.dynamicTools } : {}),
867
+ ...(backup.spawnEdges ? { spawnEdges: backup.spawnEdges } : {}),
868
+ };
869
+ if (backup.logs) {
870
+ if (!paths.logs) return { ok: false };
871
+ next.logs = { ...backup.logs, path: paths.logs };
872
+ }
873
+ if (backup.memories) {
874
+ if (!paths.memories) return { ok: false };
875
+ next.memories = { ...backup.memories, path: paths.memories };
876
+ }
877
+ if (backup.goals) {
878
+ if (!paths.goals) return { ok: false };
879
+ next.goals = { ...backup.goals, path: paths.goals };
880
+ }
881
+ return { ok: true, backup: next };
882
+ }
883
+
884
+ /**
885
+ * Same-volume move that never replaces an existing destination.
886
+ *
887
+ * `existsSync` + `renameSync` is TOCTOU: a live file created between the check
888
+ * and rename can be overwritten (Windows rename replaces files). Hard-link then
889
+ * unlink fails with EEXIST if `to` appears, which is what trash → archived_sessions
890
+ * restore needs. Callers under the same `CODEX_HOME` volume should not hit EXDEV.
891
+ */
892
+ function renameNoReplace(from: string, to: string): void {
893
+ try {
894
+ linkSync(from, to);
895
+ } catch (error) {
896
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
897
+ // Hard links unavailable (rare FS) — refuse rather than clobber via rename.
898
+ if (code === "EXDEV" || code === "EPERM" || code === "ENOTSUP" || code === "EINVAL") {
899
+ throw Object.assign(new Error("rename_no_replace_unsupported"), { code, cause: error });
900
+ }
901
+ throw error;
902
+ }
903
+ try {
904
+ unlinkSync(from);
905
+ } catch (error) {
906
+ // Roll back the hard link so we do not leave the file at both paths.
907
+ try { unlinkSync(to); } catch { /* best-effort */ }
908
+ throw error;
601
909
  }
602
910
  }
603
911
 
912
+ function isExistError(error: unknown): boolean {
913
+ return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
914
+ }
915
+
604
916
  function updateRowFromSnapshot(
605
917
  db: Database,
606
918
  table: string,
@@ -667,8 +979,66 @@ function restoreConsolidateGlobalJob(
667
979
  }
668
980
  }
669
981
 
670
- function writeSatelliteBackup(stageDir: string, backup: SatelliteBackup): void {
671
- writePrivateFile(join(stageDir, SATELLITE_BACKUP_FILE), JSON.stringify(backup));
982
+ /**
983
+ * Best-effort directory fsync so a preceding rename is durable on crash.
984
+ * Unsupported on some Windows setups — never treat failure as fatal.
985
+ */
986
+ function fsyncDirectoryBestEffort(dirPath: string): void {
987
+ let fd: number | undefined;
988
+ try {
989
+ fd = openSync(dirPath, "r");
990
+ fsyncSync(fd);
991
+ } catch {
992
+ /* best-effort */
993
+ } finally {
994
+ if (fd !== undefined) {
995
+ try { closeSync(fd); } catch { /* */ }
996
+ }
997
+ }
998
+ }
999
+
1000
+ /**
1001
+ * Atomically replace satellite-backup.json: private temp in the stage, full write + fsync,
1002
+ * rename (with Windows sharing-violation retries), then best-effort directory fsync.
1003
+ * An interrupted update never truncates the last valid backup that was written before a
1004
+ * satellite DB commit.
1005
+ */
1006
+ function writeSatelliteBackup(
1007
+ stageDir: string,
1008
+ backup: SatelliteBackup,
1009
+ options?: { failWrite?: boolean; failReplaceBeforeRename?: boolean },
1010
+ ): void {
1011
+ if (options?.failWrite) throw new Error("test_fail_satellite_backup_write");
1012
+ const dest = join(stageDir, SATELLITE_BACKUP_FILE);
1013
+ const replacing = existsSync(dest);
1014
+ const tmp = join(stageDir, `${SATELLITE_BACKUP_FILE}.${process.pid}.${++_satelliteBackupSeq}.tmp`);
1015
+ const payload = Buffer.from(JSON.stringify(backup), "utf8");
1016
+ const fd = openSync(tmp, "w", 0o600);
1017
+ try {
1018
+ let offset = 0;
1019
+ while (offset < payload.length) {
1020
+ offset += writeSync(fd, payload, offset, payload.length - offset, null);
1021
+ }
1022
+ fsyncSync(fd);
1023
+ } catch (error) {
1024
+ try { closeSync(fd); } catch { /* */ }
1025
+ try { unlinkSync(tmp); } catch { /* */ }
1026
+ throw error;
1027
+ }
1028
+ closeSync(fd);
1029
+ chmodPrivatePath(tmp, 0o600);
1030
+ if (options?.failReplaceBeforeRename && replacing) {
1031
+ try { unlinkSync(tmp); } catch { /* */ }
1032
+ throw new Error("test_fail_satellite_backup_replace");
1033
+ }
1034
+ try {
1035
+ renameAtomicFile(tmp, dest);
1036
+ } catch (error) {
1037
+ try { unlinkSync(tmp); } catch { /* */ }
1038
+ throw error;
1039
+ }
1040
+ chmodPrivatePath(dest, 0o600);
1041
+ fsyncDirectoryBestEffort(stageDir);
672
1042
  }
673
1043
 
674
1044
  function clearSatelliteBackup(stageDir: string): void {
@@ -690,6 +1060,7 @@ interface SatelliteWriteLocks {
690
1060
  function beginSatelliteWriteLocks(
691
1061
  paths: RuntimeDbPaths,
692
1062
  busyTimeoutMs: number,
1063
+ only?: Partial<Record<"logs" | "memories" | "goals", boolean>>,
693
1064
  ): SatelliteWriteLocks {
694
1065
  const locks: SatelliteWriteLocks = {};
695
1066
  const order: Array<{ key: "logs" | "memories" | "goals"; path: string | null }> = [
@@ -699,6 +1070,7 @@ function beginSatelliteWriteLocks(
699
1070
  ];
700
1071
  try {
701
1072
  for (const { key, path } of order) {
1073
+ if (only && !only[key]) continue;
702
1074
  if (!path || !existsSync(path)) continue;
703
1075
  const db = openDbWritable(path, busyTimeoutMs);
704
1076
  try {
@@ -933,11 +1305,19 @@ function deleteAndCommitSatellites(
933
1305
  if (locks.memories && backup.memories) {
934
1306
  deleteMemoriesInTx(locks.memories.db, backup.memories);
935
1307
  if (backup.memories.consolidateTouched) {
1308
+ // Capture under the write lock, but persist only after COMMIT+close.
1309
+ // Holding BEGIN IMMEDIATE across a durable backup rewrite lets Windows CI
1310
+ // disk/AV latency stall the lock long enough for concurrent reopen hooks
1311
+ // (and bun's default 5s test timeout) to hang — see PR #558 windows-latest.
936
1312
  backup.memories.consolidatePostImage = readConsolidateGlobalJob(locks.memories.db);
937
- writeSatelliteBackup(stageDir, backup);
938
1313
  }
939
1314
  commitSatelliteLock(locks.memories);
940
1315
  locks.memories = undefined;
1316
+ if (backup.memories.consolidateTouched) {
1317
+ writeSatelliteBackup(stageDir, backup, {
1318
+ failReplaceBeforeRename: hooks?.failSatelliteBackupReplace,
1319
+ });
1320
+ }
941
1321
  if (hooks?.failAfterMemoriesMutation) throw new Error("test_fail_after_memories");
942
1322
  }
943
1323
  if (locks.goals && backup.goals) {
@@ -1103,11 +1483,14 @@ function reconcileDeletedThreads(
1103
1483
  satelliteLocks = beginSatelliteWriteLocks(paths, busyTimeoutMs);
1104
1484
  try {
1105
1485
  backup = snapshotSatelliteBackupInLocks(satelliteLocks, threadIds);
1486
+ const stateDeps = snapshotStateDependents(stateDb, threadIds);
1487
+ backup.threads = stateDeps.threads;
1488
+ backup.dynamicTools = stateDeps.dynamicTools;
1489
+ backup.spawnEdges = stateDeps.spawnEdges;
1106
1490
  try {
1107
- if (hooks?.failSatelliteBackupWrite) {
1108
- throw new Error("test_fail_satellite_backup_write");
1109
- }
1110
- writeSatelliteBackup(stageDir, backup);
1491
+ writeSatelliteBackup(stageDir, backup, {
1492
+ failWrite: hooks?.failSatelliteBackupWrite,
1493
+ });
1111
1494
  } catch {
1112
1495
  rollbackAllSatelliteLocks(satelliteLocks);
1113
1496
  stateDb.exec("ROLLBACK");
@@ -1134,7 +1517,7 @@ function reconcileDeletedThreads(
1134
1517
  deleteThreadsAndDependents(stateDb, threadIds);
1135
1518
  if (hooks?.failBeforeStateCommit) throw new Error("test_fail_before_state_commit");
1136
1519
  stateDb.exec("COMMIT");
1137
- clearSatelliteBackup(stageDir);
1520
+ // Keep satellite-backup.json for quarantine restore; permanent purge removes the stage.
1138
1521
  return { ok: true, threads };
1139
1522
  } catch (error) {
1140
1523
  if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
@@ -1148,8 +1531,6 @@ function reconcileDeletedThreads(
1148
1531
  }
1149
1532
  }
1150
1533
 
1151
- type StagedFile = { from: string; to: string; relPath: string };
1152
-
1153
1534
  function absFromRel(codexHome: string, relPath: string): string {
1154
1535
  if (relPath.includes("..") || isAbsolute(relPath) || /^[A-Za-z]:[\\/]/.test(relPath)) {
1155
1536
  throw new Error("invalid_rel_path");
@@ -1271,6 +1652,11 @@ export interface ExecuteCleanupOptions {
1271
1652
  mode: CleanupMode;
1272
1653
  /** Required digest from preview; rejects when the candidate set drifted. */
1273
1654
  digest: string;
1655
+ /**
1656
+ * Optional exact candidate set (logical relPaths). When set, selection bypasses
1657
+ * percent rounding and the digest must match `computeExactPreviewDigest`.
1658
+ */
1659
+ candidateRelPaths?: string[];
1274
1660
  codexHome?: string;
1275
1661
  /** Test-only: shrink busy_timeout so lock tests fail fast. */
1276
1662
  busyTimeoutMs?: number;
@@ -1287,6 +1673,7 @@ export interface ExecuteCleanupOptions {
1287
1673
  failBeforeStateCommit?: boolean;
1288
1674
  failSatelliteRestore?: boolean;
1289
1675
  failSatelliteBackupWrite?: boolean;
1676
+ failSatelliteBackupReplace?: boolean;
1290
1677
  afterSatelliteMutations?: () => void;
1291
1678
  };
1292
1679
  }
@@ -1316,6 +1703,7 @@ export function pickWireCleanupTestHooks(raw: unknown): CleanupWireTestHooks | u
1316
1703
  if (typeof o.failBeforeStateCommit === "boolean") out.failBeforeStateCommit = o.failBeforeStateCommit;
1317
1704
  if (typeof o.failSatelliteRestore === "boolean") out.failSatelliteRestore = o.failSatelliteRestore;
1318
1705
  if (typeof o.failSatelliteBackupWrite === "boolean") out.failSatelliteBackupWrite = o.failSatelliteBackupWrite;
1706
+ if (typeof o.failSatelliteBackupReplace === "boolean") out.failSatelliteBackupReplace = o.failSatelliteBackupReplace;
1319
1707
  return Object.keys(out).length > 0 ? out : undefined;
1320
1708
  }
1321
1709
 
@@ -1358,10 +1746,38 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
1358
1746
  return fail(mode, percent, "invalid_digest");
1359
1747
  }
1360
1748
 
1361
- const preview = previewArchivedCleanup(percent, codexHome);
1749
+ let preview: CleanupPreview;
1750
+ let unfilteredSelected: ArchivedCandidate[];
1751
+ if (options.candidateRelPaths !== undefined) {
1752
+ const selected = resolveExactArchivedCandidates(options.candidateRelPaths, codexHome);
1753
+ if (selected === null) {
1754
+ return fail(mode, percent, "stale_preview");
1755
+ }
1756
+ unfilteredSelected = selected;
1757
+ preview = previewExactArchivedCleanup(selected, codexHome);
1758
+ } else {
1759
+ const all = listArchivedCandidates(codexHome);
1760
+ unfilteredSelected = selectOldestPercent(all, percent);
1761
+ preview = previewArchivedCleanup(percent, codexHome);
1762
+ }
1362
1763
  if (preview.digest.toLowerCase() !== options.digest.toLowerCase()) {
1764
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
1765
+ const blocked = unfilteredSelected.filter(c => candidateOverlapsPendingRestore(c, pendingDestRels));
1766
+ const unfilteredDigest = options.candidateRelPaths !== undefined
1767
+ ? computeExactPreviewDigest(unfilteredSelected)
1768
+ : computePreviewDigest(unfilteredSelected, percent);
1769
+ if (
1770
+ unfilteredDigest.toLowerCase() === options.digest.toLowerCase()
1771
+ && blocked.length > 0
1772
+ ) {
1773
+ return fail(mode, percent, "restore_pending_overlap");
1774
+ }
1363
1775
  return fail(mode, percent, "stale_preview");
1364
1776
  }
1777
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
1778
+ if (preview.candidates.some(c => candidateOverlapsPendingRestore(c, pendingDestRels))) {
1779
+ return fail(mode, percent, "restore_pending_overlap");
1780
+ }
1365
1781
 
1366
1782
  if (preview.candidates.length === 0) {
1367
1783
  return {
@@ -1505,9 +1921,12 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
1505
1921
  digest: preview.digest,
1506
1922
  purgeIncomplete: true,
1507
1923
  purgedRelPaths: purge.purged.map(item => item.relPath),
1508
- entries: manifestEntries.filter(entry =>
1509
- entry.physicalRelPaths.some(rel => survivingRelPaths.has(rel)),
1510
- ),
1924
+ entries: manifestEntries
1925
+ .map(entry => ({
1926
+ ...entry,
1927
+ physicalRelPaths: entry.physicalRelPaths.filter(rel => survivingRelPaths.has(rel)),
1928
+ }))
1929
+ .filter(entry => entry.physicalRelPaths.length > 0),
1511
1930
  }, null, 2),
1512
1931
  );
1513
1932
  } catch { /* best-effort: the pre-commit manifest is still on disk */ }
@@ -1536,3 +1955,1060 @@ export function executeArchivedCleanup(options: ExecuteCleanupOptions): CleanupR
1536
1955
  removedPaths,
1537
1956
  };
1538
1957
  }
1958
+
1959
+ // ---------------------------------------------------------------------------
1960
+ // Phase 2.1 — quarantine list + restore
1961
+ // ---------------------------------------------------------------------------
1962
+
1963
+ export type RestoreErrorCode =
1964
+ | "invalid_trash"
1965
+ | "missing_trash"
1966
+ | "codex_busy"
1967
+ | "storage_mutation_busy"
1968
+ | "fs_failed"
1969
+ | "db_reconcile_failed"
1970
+ | "dest_exists"
1971
+ | "restore_failed"
1972
+ | "restore_worker_timeout"
1973
+ | "restore_worker_aborted"
1974
+ | "restore_worker_failed";
1975
+
1976
+ export interface TrashEntrySummary {
1977
+ /** CODEX_HOME-relative path, e.g. `.trash/1700000000000`. */
1978
+ id: string;
1979
+ /** Epoch directory name (may include collision suffix, e.g. `1700-1`). */
1980
+ epoch: string;
1981
+ fileCount: number;
1982
+ bytes: number;
1983
+ quarantinedAt?: number;
1984
+ mode?: CleanupMode;
1985
+ }
1986
+
1987
+ export interface RestoreResult {
1988
+ ok: boolean;
1989
+ trashDir?: string;
1990
+ count: number;
1991
+ bytes: number;
1992
+ restoredPaths: string[];
1993
+ error?: RestoreErrorCode;
1994
+ /** Optional operator-facing detail when the error code alone is insufficient. */
1995
+ message?: string;
1996
+ }
1997
+
1998
+ interface TrashManifest {
1999
+ quarantinedAt?: number;
2000
+ mode?: CleanupMode;
2001
+ entries?: CleanupManifestEntry[];
2002
+ }
2003
+
2004
+ /** Epoch dir names: digits, optionally `-N` from createExclusiveStageDir collision. */
2005
+ const TRASH_EPOCH_DIR = /^(\d+)(-\d+)?$/;
2006
+
2007
+ /**
2008
+ * Parse a trash `manifest.json` atomically.
2009
+ *
2010
+ * Any missing `entries` array, or any malformed entry / `physicalRelPaths` value /
2011
+ * required field, rejects the **entire** manifest (returns null). Individual bad
2012
+ * entries are never filtered out so a partial parse cannot silently drop evidence.
2013
+ */
2014
+ function parseTrashManifest(raw: string): TrashManifest | null {
2015
+ try {
2016
+ const parsed = JSON.parse(raw) as unknown;
2017
+ if (!parsed || typeof parsed !== "object") return null;
2018
+ const o = parsed as Record<string, unknown>;
2019
+ if (!Array.isArray(o.entries)) return null;
2020
+
2021
+ const entries: CleanupManifestEntry[] = [];
2022
+ for (const e of o.entries) {
2023
+ if (!e || typeof e !== "object" || Array.isArray(e)) return null;
2024
+ const entry = e as Record<string, unknown>;
2025
+ if (typeof entry.relPath !== "string" || entry.relPath.length === 0) return null;
2026
+ if (typeof entry.bytes !== "number" || !Number.isFinite(entry.bytes)) return null;
2027
+ if (typeof entry.mtimeMs !== "number" || !Number.isFinite(entry.mtimeMs)) return null;
2028
+ if (!Array.isArray(entry.physicalRelPaths) || entry.physicalRelPaths.length === 0) return null;
2029
+ const physical: string[] = [];
2030
+ for (const p of entry.physicalRelPaths) {
2031
+ // Do not strip bad elements — one malformed path invalidates the whole manifest.
2032
+ if (typeof p !== "string" || p.length === 0) return null;
2033
+ physical.push(p);
2034
+ }
2035
+ if ("threadId" in entry && typeof entry.threadId !== "string") return null;
2036
+ if ("rolloutPath" in entry && typeof entry.rolloutPath !== "string") return null;
2037
+ if (
2038
+ "archived" in entry
2039
+ && entry.archived !== null
2040
+ && typeof entry.archived !== "number"
2041
+ ) {
2042
+ return null;
2043
+ }
2044
+ entries.push({
2045
+ relPath: entry.relPath,
2046
+ bytes: entry.bytes,
2047
+ mtimeMs: entry.mtimeMs,
2048
+ physicalRelPaths: physical,
2049
+ ...(typeof entry.threadId === "string" ? { threadId: entry.threadId } : {}),
2050
+ ...(typeof entry.rolloutPath === "string" ? { rolloutPath: entry.rolloutPath } : {}),
2051
+ ...(entry.archived === null || typeof entry.archived === "number"
2052
+ ? { archived: entry.archived as number | null }
2053
+ : {}),
2054
+ });
2055
+ }
2056
+
2057
+ const out: TrashManifest = { entries };
2058
+ if (typeof o.quarantinedAt === "number" && Number.isFinite(o.quarantinedAt)) {
2059
+ out.quarantinedAt = o.quarantinedAt;
2060
+ }
2061
+ if (o.mode === "quarantine" || o.mode === "permanent") out.mode = o.mode;
2062
+ return out;
2063
+ } catch {
2064
+ return null;
2065
+ }
2066
+ }
2067
+
2068
+ /**
2069
+ * Validate a trash entry id as a single `.trash/<epoch>` segment under CODEX_HOME.
2070
+ * Returns the absolute stage directory, or null when the id is unsafe / missing.
2071
+ */
2072
+ export function resolveTrashStageDir(
2073
+ trashId: string,
2074
+ codexHome: string,
2075
+ ): { ok: true; stageDir: string; id: string } | { ok: false; error: RestoreErrorCode } {
2076
+ const normalized = toForwardSlash(trashId.trim()).replace(/\/+$/, "");
2077
+ if (!normalized.startsWith(`${TRASH_DIR}/`)) return { ok: false, error: "invalid_trash" };
2078
+ const rest = normalized.slice(TRASH_DIR.length + 1);
2079
+ if (!rest || rest.includes("/") || rest.includes("\\") || rest.includes("..")) {
2080
+ return { ok: false, error: "invalid_trash" };
2081
+ }
2082
+ if (!TRASH_EPOCH_DIR.test(rest)) return { ok: false, error: "invalid_trash" };
2083
+ let stageDir: string;
2084
+ try {
2085
+ stageDir = absFromRel(codexHome, `${TRASH_DIR}/${rest}`);
2086
+ } catch {
2087
+ return { ok: false, error: "invalid_trash" };
2088
+ }
2089
+ if (!existsSync(stageDir)) return { ok: false, error: "missing_trash" };
2090
+ try {
2091
+ if (!statSync(stageDir).isDirectory()) return { ok: false, error: "invalid_trash" };
2092
+ } catch {
2093
+ return { ok: false, error: "missing_trash" };
2094
+ }
2095
+ return { ok: true, stageDir, id: `${TRASH_DIR}/${rest}` };
2096
+ }
2097
+
2098
+ function sumTrashEntryBytes(stageDir: string, manifest: TrashManifest | null): {
2099
+ fileCount: number;
2100
+ bytes: number;
2101
+ } {
2102
+ let fileCount = 0;
2103
+ let bytes = 0;
2104
+ let names: string[] = [];
2105
+ try {
2106
+ names = readdirSync(stageDir);
2107
+ } catch {
2108
+ return { fileCount: 0, bytes: 0 };
2109
+ }
2110
+ for (const name of names) {
2111
+ if (
2112
+ name === "manifest.json"
2113
+ || name === SATELLITE_BACKUP_FILE
2114
+ || name === RESTORE_PENDING_FILE
2115
+ ) {
2116
+ continue;
2117
+ }
2118
+ if (!isRolloutFileName(name)) continue;
2119
+ try {
2120
+ const st = statSync(join(stageDir, name));
2121
+ if (!st.isFile()) continue;
2122
+ fileCount += 1;
2123
+ bytes += st.size;
2124
+ } catch { /* */ }
2125
+ }
2126
+ // Prefer live FS counts; fall back to manifest totals when the stage is empty of rollouts.
2127
+ if (fileCount === 0 && manifest?.entries?.length) {
2128
+ fileCount = manifest.entries.reduce((n, e) => n + Math.max(1, e.physicalRelPaths.length), 0);
2129
+ bytes = manifest.entries.reduce((n, e) => n + (e.bytes || 0), 0);
2130
+ }
2131
+ return { fileCount, bytes };
2132
+ }
2133
+
2134
+ /** List quarantine entries under `CODEX_HOME/.trash/` (relative ids only). */
2135
+ export function listTrashEntries(
2136
+ codexHome: string = resolveCodexHomeDir(),
2137
+ ): TrashEntrySummary[] {
2138
+ const trashRoot = join(codexHome, TRASH_DIR);
2139
+ let names: string[] = [];
2140
+ try {
2141
+ names = readdirSync(trashRoot);
2142
+ } catch {
2143
+ return [];
2144
+ }
2145
+ const out: TrashEntrySummary[] = [];
2146
+ for (const name of names) {
2147
+ if (!TRASH_EPOCH_DIR.test(name)) continue;
2148
+ const stageDir = join(trashRoot, name);
2149
+ try {
2150
+ if (!statSync(stageDir).isDirectory()) continue;
2151
+ } catch {
2152
+ continue;
2153
+ }
2154
+ let manifest: TrashManifest | null = null;
2155
+ try {
2156
+ manifest = parseTrashManifest(readFileSync(join(stageDir, "manifest.json"), "utf8"));
2157
+ } catch {
2158
+ manifest = null;
2159
+ }
2160
+ const { fileCount, bytes } = sumTrashEntryBytes(stageDir, manifest);
2161
+ // Skip empty collision placeholders left behind without a manifest or rollouts.
2162
+ if (fileCount === 0 && !manifest?.entries?.length) {
2163
+ try {
2164
+ if (!existsSync(join(stageDir, "manifest.json"))) continue;
2165
+ } catch {
2166
+ continue;
2167
+ }
2168
+ }
2169
+ out.push({
2170
+ id: `${TRASH_DIR}/${name}`,
2171
+ epoch: name,
2172
+ fileCount,
2173
+ bytes,
2174
+ ...(manifest?.quarantinedAt !== undefined ? { quarantinedAt: manifest.quarantinedAt } : {}),
2175
+ ...(manifest?.mode ? { mode: manifest.mode } : {}),
2176
+ });
2177
+ }
2178
+ out.sort((a, b) => {
2179
+ const aq = a.quarantinedAt ?? (Number(a.epoch.split("-")[0]) || 0);
2180
+ const bq = b.quarantinedAt ?? (Number(b.epoch.split("-")[0]) || 0);
2181
+ return bq - aq || b.epoch.localeCompare(a.epoch);
2182
+ });
2183
+ return out;
2184
+ }
2185
+
2186
+ function readSatelliteBackupFile(stageDir: string): SatelliteBackupRead {
2187
+ const path = join(stageDir, SATELLITE_BACKUP_FILE);
2188
+ if (!existsSync(path)) return { status: "missing" };
2189
+ try {
2190
+ const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
2191
+ if (!raw || typeof raw !== "object") return { status: "invalid" };
2192
+ const o = raw as SatelliteBackup;
2193
+ if (!Array.isArray(o.threadIds)) return { status: "invalid" };
2194
+ return { status: "ok", backup: o };
2195
+ } catch {
2196
+ // File exists but is truncated / malformed — distinct from a missing backup.
2197
+ return { status: "invalid" };
2198
+ }
2199
+ }
2200
+
2201
+ function isSqlRowArray(value: unknown): value is SqlRow[] {
2202
+ return Array.isArray(value) && value.every(row => row && typeof row === "object" && !Array.isArray(row));
2203
+ }
2204
+
2205
+ /** True when a snapshotted thread row covers every NOT NULL column on the live schema. */
2206
+ function threadSnapshotCoversRequiredColumns(row: SqlRow, requiredCols: string[]): boolean {
2207
+ for (const col of requiredCols) {
2208
+ if (!(col in row) || row[col] === undefined) return false;
2209
+ }
2210
+ return true;
2211
+ }
2212
+
2213
+ function requiredThreadColumnNames(db: Database): string[] {
2214
+ if (!tableExists(db, "threads")) return [];
2215
+ const rows = db.query<{ name: string; notnull: number }, []>(
2216
+ `PRAGMA table_info("threads")`,
2217
+ ).all();
2218
+ return rows.filter(r => r.notnull === 1).map(r => r.name);
2219
+ }
2220
+
2221
+ /**
2222
+ * Build a production-shaped thread row for schemas that predate full satellite snapshots.
2223
+ * Prefer `readThreadFieldsFromRollout` (canonical history/session_meta path); fall back to
2224
+ * the sparse manifest fields only when the live schema does not require model/source/message.
2225
+ */
2226
+ function reconstructThreadRowFromRollout(
2227
+ entry: CleanupManifestEntry,
2228
+ rolloutAbsPath: string,
2229
+ allowedCols: Set<string>,
2230
+ requiredCols: string[],
2231
+ ): SqlRow | null {
2232
+ if (typeof entry.threadId !== "string" || typeof entry.rolloutPath !== "string") return null;
2233
+
2234
+ const fields = readThreadFieldsFromRollout(rolloutAbsPath);
2235
+ const row: SqlRow = {
2236
+ id: entry.threadId,
2237
+ rollout_path: entry.rolloutPath,
2238
+ };
2239
+
2240
+ if (fields) {
2241
+ // Prefer manifest thread id (binding) but keep rollout-derived listing fields.
2242
+ if (allowedCols.has("model_provider")) row.model_provider = fields.modelProvider;
2243
+ if (allowedCols.has("source")) row.source = fields.source;
2244
+ if (allowedCols.has("first_user_message")) row.first_user_message = fields.firstUserMessage;
2245
+ if (allowedCols.has("has_user_event")) row.has_user_event = fields.hasUserEvent;
2246
+ if (allowedCols.has("cwd") && fields.cwd !== undefined) row.cwd = fields.cwd;
2247
+ if (allowedCols.has("history_mode") && fields.historyMode !== undefined) {
2248
+ row.history_mode = fields.historyMode;
2249
+ }
2250
+ if (allowedCols.has("cli_version") && fields.cliVersion !== undefined) {
2251
+ row.cli_version = fields.cliVersion;
2252
+ }
2253
+ }
2254
+
2255
+ if (allowedCols.has("archived")) {
2256
+ row.archived = entry.archived ?? 1;
2257
+ }
2258
+ if (allowedCols.has("archived_at")) {
2259
+ row.archived_at = null;
2260
+ }
2261
+
2262
+ // Fill remaining NOT NULL columns with safe empties when the rollout lacked them
2263
+ // (e.g. fixture rollouts without a user turn still need first_user_message = '').
2264
+ for (const col of requiredCols) {
2265
+ if (row[col] !== undefined) continue;
2266
+ if (col === "id" || col === "rollout_path") continue;
2267
+ if (col === "model_provider") row[col] = "openai";
2268
+ else if (col === "source") row[col] = "cli";
2269
+ else if (col === "first_user_message") row[col] = "";
2270
+ else if (col === "has_user_event") row[col] = 0;
2271
+ else if (col === "archived") row[col] = entry.archived ?? 1;
2272
+ else return null; // unknown required column we cannot invent
2273
+ }
2274
+
2275
+ // If the schema requires listing fields, refuse when the rollout was unreadable.
2276
+ const needsSessionMeta = requiredCols.some(
2277
+ c => c === "model_provider" || c === "source" || c === "first_user_message",
2278
+ );
2279
+ if (needsSessionMeta && !fields) return null;
2280
+
2281
+ return row;
2282
+ }
2283
+
2284
+ type RestorePendingRead =
2285
+ | { status: "missing" }
2286
+ | { status: "valid"; state: RestorePendingState }
2287
+ | { status: "invalid" };
2288
+
2289
+ let _restorePendingSeq = 0;
2290
+
2291
+ function parseRestorePendingState(raw: unknown): RestorePendingState | null {
2292
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
2293
+ const o = raw as Record<string, unknown>;
2294
+ if (o.version !== 1 || o.filesRestored !== true) return null;
2295
+ if (!Array.isArray(o.acceptedDestRels)) return null;
2296
+ const acceptedDestRels = o.acceptedDestRels.filter((r): r is string => typeof r === "string");
2297
+ if (acceptedDestRels.length !== o.acceptedDestRels.length) return null;
2298
+ const pendingRaw = o.pending;
2299
+ if (!pendingRaw || typeof pendingRaw !== "object" || Array.isArray(pendingRaw)) return null;
2300
+ const p = pendingRaw as Record<string, unknown>;
2301
+ if (
2302
+ typeof p.state !== "boolean"
2303
+ || typeof p.logs !== "boolean"
2304
+ || typeof p.memories !== "boolean"
2305
+ || typeof p.goals !== "boolean"
2306
+ ) {
2307
+ return null;
2308
+ }
2309
+ return {
2310
+ version: 1,
2311
+ filesRestored: true,
2312
+ acceptedDestRels,
2313
+ pending: {
2314
+ state: p.state,
2315
+ logs: p.logs,
2316
+ memories: p.memories,
2317
+ goals: p.goals,
2318
+ },
2319
+ };
2320
+ }
2321
+
2322
+ /**
2323
+ * Distinguish a missing marker from a present-but-malformed one. An invalid marker
2324
+ * must never be treated as a fresh restore (that would ignore already-moved files).
2325
+ */
2326
+ function readRestorePending(stageDir: string): RestorePendingRead {
2327
+ const path = join(stageDir, RESTORE_PENDING_FILE);
2328
+ if (!existsSync(path)) return { status: "missing" };
2329
+ try {
2330
+ const state = parseRestorePendingState(JSON.parse(readFileSync(path, "utf8")) as unknown);
2331
+ if (!state) return { status: "invalid" };
2332
+ return { status: "valid", state };
2333
+ } catch {
2334
+ return { status: "invalid" };
2335
+ }
2336
+ }
2337
+
2338
+ /**
2339
+ * Atomically replace restore-pending.json: private temp in the stage, fsync, then rename.
2340
+ * An interrupted update leaves the previous valid marker intact.
2341
+ */
2342
+ function writeRestorePending(
2343
+ stageDir: string,
2344
+ state: RestorePendingState,
2345
+ options?: { failBeforeRename?: boolean; failWrite?: boolean },
2346
+ ): void {
2347
+ if (options?.failWrite) throw new Error("test_fail_pending_write");
2348
+ const dest = join(stageDir, RESTORE_PENDING_FILE);
2349
+ const tmp = join(stageDir, `${RESTORE_PENDING_FILE}.${process.pid}.${++_restorePendingSeq}.tmp`);
2350
+ const payload = JSON.stringify(state);
2351
+ const fd = openSync(tmp, "w", 0o600);
2352
+ try {
2353
+ writeSync(fd, payload, null, "utf8");
2354
+ fsyncSync(fd);
2355
+ } catch (error) {
2356
+ try { closeSync(fd); } catch { /* */ }
2357
+ try { unlinkSync(tmp); } catch { /* */ }
2358
+ throw error;
2359
+ }
2360
+ closeSync(fd);
2361
+ chmodPrivatePath(tmp, 0o600);
2362
+ if (options?.failBeforeRename) {
2363
+ try { unlinkSync(tmp); } catch { /* */ }
2364
+ throw new Error("test_fail_pending_rename");
2365
+ }
2366
+ try {
2367
+ renameSync(tmp, dest);
2368
+ } catch (error) {
2369
+ try { unlinkSync(tmp); } catch { /* */ }
2370
+ throw error;
2371
+ }
2372
+ }
2373
+
2374
+ function restoreThreadsFromManifest(
2375
+ stateDbPath: string | null,
2376
+ entries: CleanupManifestEntry[],
2377
+ backup: SatelliteBackup | null,
2378
+ busyTimeoutMs: number,
2379
+ codexHome: string,
2380
+ ): { ok: true } | ReconcileErr {
2381
+ const manifestThreadIds = entries
2382
+ .map(e => e.threadId)
2383
+ .filter((id): id is string => typeof id === "string");
2384
+ const backupThreadIds = backup?.threadIds ?? [];
2385
+ const needsThreads = manifestThreadIds.length > 0
2386
+ || backupThreadIds.length > 0
2387
+ || Boolean(backup?.threads?.length);
2388
+
2389
+ if (needsThreads && (!stateDbPath || !existsSync(stateDbPath))) {
2390
+ return { ok: false, error: "db_reconcile_failed" };
2391
+ }
2392
+ if (!stateDbPath || !existsSync(stateDbPath)) {
2393
+ return { ok: true };
2394
+ }
2395
+
2396
+ const result = withWritableDb(stateDbPath, busyTimeoutMs, db => {
2397
+ if (!tableExists(db, "threads")) throw new Error("missing_threads_table");
2398
+
2399
+ const requiredCols = requiredThreadColumnNames(db);
2400
+ const allowedCols = tableColumnNames(db, "threads");
2401
+ const snapshotThreads = backup?.threads && isSqlRowArray(backup.threads)
2402
+ ? backup.threads
2403
+ : [];
2404
+ const completeSnapshots = snapshotThreads.filter(row =>
2405
+ threadSnapshotCoversRequiredColumns(row, requiredCols),
2406
+ );
2407
+ const coveredIds = new Set(
2408
+ completeSnapshots
2409
+ .map(r => r.id)
2410
+ .filter((id): id is string => typeof id === "string"),
2411
+ );
2412
+
2413
+ // Legacy Phase-2 quarantine (no / incomplete satellite thread snapshots): reconstruct
2414
+ // every required column from the restored rollout via the history-provider session path.
2415
+ const toReconstruct = entries.filter(
2416
+ e => typeof e.threadId === "string"
2417
+ && typeof e.rolloutPath === "string"
2418
+ && !coveredIds.has(e.threadId!),
2419
+ );
2420
+ const reconstructed: SqlRow[] = [];
2421
+ for (const entry of toReconstruct) {
2422
+ let abs: string | undefined;
2423
+ try {
2424
+ abs = absFromRel(codexHome, entry.rolloutPath!);
2425
+ } catch {
2426
+ abs = undefined;
2427
+ }
2428
+ // Legacy compressed-only quarantine: manifest rolloutPath is often the logical
2429
+ // `.jsonl` name while the only restored physical file is `.jsonl.zst`.
2430
+ if (!abs || !existsSync(abs)) {
2431
+ for (const rel of entry.physicalRelPaths) {
2432
+ try {
2433
+ const candidate = absFromRel(codexHome, rel);
2434
+ if (existsSync(candidate)) {
2435
+ abs = candidate;
2436
+ break;
2437
+ }
2438
+ } catch {
2439
+ /* try next physical path */
2440
+ }
2441
+ }
2442
+ }
2443
+ if (!abs) throw new Error("missing_rollout_for_thread");
2444
+ // Prefer a plain .jsonl sibling when present; otherwise readThreadFieldsFromRollout
2445
+ // decompresses a lone .jsonl.zst in memory (bounded) for legacy quarantine restores.
2446
+ if (abs.endsWith(ZST_SUFFIX)) {
2447
+ const plain = abs.slice(0, -".zst".length);
2448
+ if (existsSync(plain)) abs = plain;
2449
+ }
2450
+ const row = reconstructThreadRowFromRollout(entry, abs, allowedCols, requiredCols);
2451
+ if (!row) throw new Error("thread_reconstruct_failed");
2452
+ reconstructed.push(row);
2453
+ }
2454
+
2455
+ if (completeSnapshots.length > 0) {
2456
+ insertRowsConflictIgnore(db, "threads", completeSnapshots);
2457
+ }
2458
+ if (reconstructed.length > 0) {
2459
+ insertRowsConflictIgnore(db, "threads", reconstructed);
2460
+ }
2461
+
2462
+ if (backup?.dynamicTools && isSqlRowArray(backup.dynamicTools) && tableExists(db, "thread_dynamic_tools")) {
2463
+ insertRowsConflictIgnore(db, "thread_dynamic_tools", backup.dynamicTools);
2464
+ }
2465
+ if (backup?.spawnEdges && isSqlRowArray(backup.spawnEdges) && tableExists(db, "thread_spawn_edges")) {
2466
+ insertRowsConflictIgnore(db, "thread_spawn_edges", backup.spawnEdges);
2467
+ }
2468
+ });
2469
+ if (!result.ok) return result;
2470
+ return { ok: true };
2471
+ }
2472
+
2473
+ function isSafeArchivedPhysicalRel(rel: string): boolean {
2474
+ const normalized = toForwardSlash(rel);
2475
+ if (!normalized.startsWith(`${ARCHIVED_SESSIONS_DIR}/`)) return false;
2476
+ if (normalized.includes("..")) return false;
2477
+ const rest = normalized.slice(ARCHIVED_SESSIONS_DIR.length + 1);
2478
+ if (!rest || rest.includes("/")) return false;
2479
+ return isRolloutFileName(rest);
2480
+ }
2481
+
2482
+ /** Test-only failure injection for restore atomicity regressions. */
2483
+ export interface RestoreTestHooks {
2484
+ /** After state threads/dependents commit, before satellite commits. */
2485
+ failAfterStateCommit?: boolean;
2486
+ /** After the first satellite DB commit (logs → memories → goals). */
2487
+ failAfterFirstSatelliteCommit?: boolean;
2488
+ /** When the leftover staged-rollout completeness gate runs. */
2489
+ failAtLeftoverStageGate?: boolean;
2490
+ /** Fail the initial restore-pending.json write (before any file moves). */
2491
+ failInitialPendingWrite?: boolean;
2492
+ /** Fail a later pending update after the temp is written but before rename. */
2493
+ failPendingWriteBeforeRename?: boolean;
2494
+ /** Crash immediately after file moves (marker already durable). */
2495
+ failAfterFileMoves?: boolean;
2496
+ /**
2497
+ * After this many successful rollout moves in the current attempt, throw.
2498
+ * Exercises mid-loop failure with some dests placed and others still staged.
2499
+ */
2500
+ failAfterMoveCount?: number;
2501
+ /** Fail renaming the completed stage to a non-listable tombstone dir. */
2502
+ failStageTombstoneRename?: boolean;
2503
+ /** After tombstone rename, skip best-effort tombstone delete (orphan is OK). */
2504
+ failTombstoneDelete?: boolean;
2505
+ /**
2506
+ * Test-only: spin-wait this many ms after rollout file moves, before DB
2507
+ * reconcile, so cleanup can race an in-flight restore.
2508
+ */
2509
+ holdAfterFileMovesMs?: number;
2510
+ }
2511
+
2512
+ /**
2513
+ * Resume must not clear owed satellite work when the matching backup section is
2514
+ * absent — fail closed per section instead.
2515
+ */
2516
+ function failClosedSatelliteResume(
2517
+ priorPending: RestorePendingState,
2518
+ satelliteBackup: SatelliteBackup | null,
2519
+ ): RestoreErrorCode | null {
2520
+ const owed = priorPending.pending;
2521
+ if (!owed.logs && !owed.memories && !owed.goals) return null;
2522
+ if (!satelliteBackup) return "db_reconcile_failed";
2523
+ if (owed.logs && !satelliteBackup.logs) return "db_reconcile_failed";
2524
+ if (owed.memories && !satelliteBackup.memories) return "db_reconcile_failed";
2525
+ if (owed.goals && !satelliteBackup.goals) return "db_reconcile_failed";
2526
+ return null;
2527
+ }
2528
+
2529
+ /**
2530
+ * Successful restore finalization: rename the stage to a tombstone name that
2531
+ * `listTrashEntries` ignores, then delete the tombstone best-effort. A failed
2532
+ * rename leaves the original stage (and all evidence) intact for retry.
2533
+ */
2534
+ function finalizeRestoredStage(
2535
+ stageDir: string,
2536
+ codexHome: string,
2537
+ hooks?: Pick<RestoreTestHooks, "failStageTombstoneRename" | "failTombstoneDelete">,
2538
+ ): boolean {
2539
+ const trashRoot = join(codexHome, TRASH_DIR);
2540
+ const epoch = basename(stageDir);
2541
+ const tombstoneName = `.tombstone-${epoch}-${randomUUID()}`;
2542
+ const tombstonePath = join(trashRoot, tombstoneName);
2543
+ try {
2544
+ if (hooks?.failStageTombstoneRename) throw new Error("test_fail_stage_tombstone_rename");
2545
+ renameSync(stageDir, tombstonePath);
2546
+ } catch {
2547
+ return false;
2548
+ }
2549
+ if (!hooks?.failTombstoneDelete) {
2550
+ try { rmSync(tombstonePath, { recursive: true, force: true }); } catch { /* best-effort */ }
2551
+ }
2552
+ return true;
2553
+ }
2554
+
2555
+ /**
2556
+ * Restore one quarantine entry: move JSONL back, re-insert threads (+ satellites
2557
+ * when satellite-backup.json is present), then remove the trash directory.
2558
+ *
2559
+ * Late failures after files have moved never compensate metadata or restage.
2560
+ * Instead they persist `restore-pending.json` (accepted dest paths + which
2561
+ * state/logs/memories/goals sections still need work) atomically *before* any
2562
+ * rollout move, then update it after each section so a retry can accept existing
2563
+ * destinations and resume only missing metadata.
2564
+ */
2565
+ export function restoreTrashEntry(
2566
+ trashId: string,
2567
+ options?: {
2568
+ codexHome?: string;
2569
+ busyTimeoutMs?: number;
2570
+ _test?: RestoreTestHooks;
2571
+ },
2572
+ ): RestoreResult {
2573
+ const codexHome = options?.codexHome ?? resolveCodexHomeDir();
2574
+ const busyTimeoutMs = options?.busyTimeoutMs ?? 100;
2575
+ const hooks = options?._test;
2576
+
2577
+ const resolved = resolveTrashStageDir(trashId, codexHome);
2578
+ if (!resolved.ok) {
2579
+ return { ok: false, count: 0, bytes: 0, restoredPaths: [], error: resolved.error };
2580
+ }
2581
+ const { stageDir, id } = resolved;
2582
+
2583
+ let manifestRaw: string;
2584
+ try {
2585
+ manifestRaw = readFileSync(join(stageDir, "manifest.json"), "utf8");
2586
+ } catch {
2587
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
2588
+ }
2589
+ const manifest = parseTrashManifest(manifestRaw);
2590
+ if (!manifest?.entries?.length) {
2591
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
2592
+ }
2593
+
2594
+ const pendingRead = readRestorePending(stageDir);
2595
+ if (pendingRead.status === "invalid") {
2596
+ // Malformed marker means an incomplete restore may already have moved files;
2597
+ // never treat it as a fresh restore.
2598
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" };
2599
+ }
2600
+ const priorPending = pendingRead.status === "valid" ? pendingRead.state : null;
2601
+ const acceptedDest = new Set(priorPending?.acceptedDestRels ?? []);
2602
+
2603
+ // Partial permanent purges may leave only a subset of physical files on disk —
2604
+ // trim to survivors rather than failing the whole entry for a purged twin.
2605
+ // Resume also treats already-restored accepted destinations as survivors.
2606
+ const entries: CleanupManifestEntry[] = [];
2607
+ for (const entry of manifest.entries) {
2608
+ if (!entry.physicalRelPaths.every(isSafeArchivedPhysicalRel)) {
2609
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "invalid_trash" };
2610
+ }
2611
+ const surviving = entry.physicalRelPaths.filter(rel => {
2612
+ if (existsSync(join(stageDir, basename(rel)))) return true;
2613
+ if (!acceptedDest.has(rel)) return false;
2614
+ try {
2615
+ return existsSync(absFromRel(codexHome, rel));
2616
+ } catch {
2617
+ return false;
2618
+ }
2619
+ });
2620
+ if (surviving.length === 0) {
2621
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error: "fs_failed" };
2622
+ }
2623
+ entries.push({ ...entry, physicalRelPaths: surviving });
2624
+ }
2625
+
2626
+ const paths = discoverRuntimeDbPaths(codexHome);
2627
+ const backupRead = readSatelliteBackupFile(stageDir);
2628
+ if (backupRead.status === "invalid") {
2629
+ return {
2630
+ ok: false,
2631
+ trashDir: id,
2632
+ count: 0,
2633
+ bytes: 0,
2634
+ restoredPaths: [],
2635
+ error: "db_reconcile_failed",
2636
+ };
2637
+ }
2638
+
2639
+ let satelliteBackup: SatelliteBackup | null = null;
2640
+ if (backupRead.status === "ok") {
2641
+ const remapped = remapSatelliteBackupPaths(backupRead.backup, paths);
2642
+ if (!remapped.ok) {
2643
+ return {
2644
+ ok: false,
2645
+ trashDir: id,
2646
+ count: 0,
2647
+ bytes: 0,
2648
+ restoredPaths: [],
2649
+ error: "db_reconcile_failed",
2650
+ };
2651
+ }
2652
+ satelliteBackup = remapped.backup;
2653
+ }
2654
+
2655
+ if (priorPending) {
2656
+ const resumeErr = failClosedSatelliteResume(priorPending, satelliteBackup);
2657
+ if (resumeErr) {
2658
+ return {
2659
+ ok: false,
2660
+ trashDir: id,
2661
+ count: 0,
2662
+ bytes: 0,
2663
+ restoredPaths: [],
2664
+ error: resumeErr,
2665
+ };
2666
+ }
2667
+ }
2668
+
2669
+ const pendingSections: RestorePendingSections = {
2670
+ state: priorPending ? priorPending.pending.state : true,
2671
+ logs: priorPending ? priorPending.pending.logs : Boolean(satelliteBackup?.logs),
2672
+ memories: priorPending ? priorPending.pending.memories : Boolean(satelliteBackup?.memories),
2673
+ goals: priorPending ? priorPending.pending.goals : Boolean(satelliteBackup?.goals),
2674
+ };
2675
+
2676
+ const needAnySatellite = pendingSections.logs || pendingSections.memories || pendingSections.goals;
2677
+ if (pendingSections.state) {
2678
+ const needsThreads = entries.some(e => typeof e.threadId === "string")
2679
+ || Boolean(satelliteBackup?.threadIds?.length)
2680
+ || Boolean(satelliteBackup?.threads?.length);
2681
+ if (needsThreads && (!paths.state || !existsSync(paths.state))) {
2682
+ return {
2683
+ ok: false,
2684
+ trashDir: id,
2685
+ count: 0,
2686
+ bytes: 0,
2687
+ restoredPaths: [],
2688
+ error: "db_reconcile_failed",
2689
+ };
2690
+ }
2691
+ const probe = probeStateDbWritable(codexHome, busyTimeoutMs);
2692
+ if (!probe.ok) {
2693
+ return {
2694
+ ok: false,
2695
+ trashDir: id,
2696
+ count: 0,
2697
+ bytes: 0,
2698
+ restoredPaths: [],
2699
+ error: probe.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
2700
+ };
2701
+ }
2702
+ }
2703
+
2704
+ // Acquire only the satellite locks still needed so a busy DB for an already-
2705
+ // finished section cannot block resume. Locks happen before moves on a fresh
2706
+ // attempt so failure stays retryable (nothing has left the stage yet).
2707
+ let satelliteLocks: SatelliteWriteLocks | undefined;
2708
+ if (needAnySatellite) {
2709
+ try {
2710
+ satelliteLocks = beginSatelliteWriteLocks(paths, busyTimeoutMs, {
2711
+ logs: pendingSections.logs,
2712
+ memories: pendingSections.memories,
2713
+ goals: pendingSections.goals,
2714
+ });
2715
+ } catch (error) {
2716
+ return {
2717
+ ok: false,
2718
+ trashDir: id,
2719
+ count: 0,
2720
+ bytes: 0,
2721
+ restoredPaths: [],
2722
+ error: mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
2723
+ };
2724
+ }
2725
+ }
2726
+
2727
+ const failBeforeMoves = (error: RestoreErrorCode): RestoreResult => {
2728
+ if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
2729
+ return { ok: false, trashDir: id, count: 0, bytes: 0, restoredPaths: [], error };
2730
+ };
2731
+
2732
+ // Plan renames: staged basename → original archived_sessions path.
2733
+ // Resume accepts destinations already restored by this incomplete attempt.
2734
+ const alreadyMoved: StagedFile[] = [];
2735
+ const toMove: StagedFile[] = [];
2736
+ for (const entry of entries) {
2737
+ for (const rel of entry.physicalRelPaths) {
2738
+ const base = basename(rel);
2739
+ const from = join(stageDir, base);
2740
+ let to: string;
2741
+ try {
2742
+ to = absFromRel(codexHome, rel);
2743
+ } catch {
2744
+ return failBeforeMoves("invalid_trash");
2745
+ }
2746
+ const fromExists = existsSync(from);
2747
+ const toExists = existsSync(to);
2748
+ if (toExists && acceptedDest.has(rel) && !fromExists) {
2749
+ alreadyMoved.push({ from, to, relPath: rel });
2750
+ continue;
2751
+ }
2752
+ if (toExists) {
2753
+ return failBeforeMoves("dest_exists");
2754
+ }
2755
+ if (!fromExists) {
2756
+ return failBeforeMoves("fs_failed");
2757
+ }
2758
+ toMove.push({ from, to, relPath: rel });
2759
+ }
2760
+ }
2761
+
2762
+ const planned = [...alreadyMoved, ...toMove];
2763
+ const restoredPaths = [...new Set(entries.map(e => e.relPath))];
2764
+ const bytes = entries.reduce((sum, e) => sum + (e.bytes || 0), 0);
2765
+ const partialCounts = { count: restoredPaths.length, bytes, restoredPaths };
2766
+
2767
+ let pendingWriteCount = 0;
2768
+ const persistPending = (): void => {
2769
+ pendingWriteCount += 1;
2770
+ const isInitial = pendingWriteCount === 1;
2771
+ writeRestorePending(
2772
+ stageDir,
2773
+ {
2774
+ version: 1,
2775
+ filesRestored: true,
2776
+ acceptedDestRels: planned.map(m => m.relPath),
2777
+ pending: { ...pendingSections },
2778
+ },
2779
+ {
2780
+ failWrite: Boolean(isInitial && hooks?.failInitialPendingWrite),
2781
+ failBeforeRename: Boolean(!isInitial && hooks?.failPendingWriteBeforeRename),
2782
+ },
2783
+ );
2784
+ };
2785
+
2786
+ // Durable resume marker before any rollout leaves the stage. Crash after a
2787
+ // later move can still accept destinations from this marker.
2788
+ try {
2789
+ persistPending();
2790
+ } catch {
2791
+ return failBeforeMoves("fs_failed");
2792
+ }
2793
+
2794
+ const newlyMoved: StagedFile[] = [];
2795
+ try {
2796
+ mkdirSync(join(codexHome, ARCHIVED_SESSIONS_DIR), { recursive: true });
2797
+ for (const item of toMove) {
2798
+ // Atomic no-replace (.trash ↔ archived_sessions). Mid-loop failure keeps
2799
+ // already-placed dests and the durable planned acceptedDestRels marker.
2800
+ renameNoReplace(item.from, item.to);
2801
+ newlyMoved.push(item);
2802
+ if (
2803
+ hooks?.failAfterMoveCount !== undefined
2804
+ && newlyMoved.length >= hooks.failAfterMoveCount
2805
+ ) {
2806
+ throw new Error("test_fail_after_move_count");
2807
+ }
2808
+ }
2809
+ } catch (error) {
2810
+ // Marker was written before any move. Never reverse successful renames or
2811
+ // drop/narrow acceptedDestRels — resume must accept placed dests and finish
2812
+ // the remaining staged files.
2813
+ if (satelliteLocks) rollbackAllSatelliteLocks(satelliteLocks);
2814
+ const placed = [...alreadyMoved, ...newlyMoved];
2815
+ const placedPhysical = new Set(placed.map(m => m.relPath));
2816
+ const partialEntries = entries.filter(e =>
2817
+ e.physicalRelPaths.every(rel => placedPhysical.has(rel)),
2818
+ );
2819
+ const midMoveRestored = [...new Set(partialEntries.map(e => e.relPath))];
2820
+ return {
2821
+ ok: false,
2822
+ trashDir: id,
2823
+ count: midMoveRestored.length,
2824
+ bytes: partialEntries.reduce((sum, e) => sum + (e.bytes || 0), 0),
2825
+ restoredPaths: midMoveRestored,
2826
+ error: isExistError(error) ? "dest_exists" : "fs_failed",
2827
+ };
2828
+ }
2829
+
2830
+ const moved = [...alreadyMoved, ...newlyMoved];
2831
+
2832
+ /**
2833
+ * Never compensate DBs or restage files after moves. Keep restored files,
2834
+ * persist which sections remain, and return accurate partial counts.
2835
+ */
2836
+ const abortAfterMoves = (error: RestoreErrorCode): RestoreResult => {
2837
+ if (satelliteLocks) {
2838
+ rollbackAllSatelliteLocks(satelliteLocks);
2839
+ satelliteLocks = undefined;
2840
+ }
2841
+ try {
2842
+ persistPending();
2843
+ } catch {
2844
+ /* best-effort — files already restored; prior atomic marker remains */
2845
+ }
2846
+ return { ok: false, trashDir: id, ...partialCounts, error };
2847
+ };
2848
+
2849
+ if (hooks?.holdAfterFileMovesMs !== undefined) {
2850
+ const holdMs = Math.max(0, Math.floor(hooks.holdAfterFileMovesMs));
2851
+ if (holdMs > 0) {
2852
+ const deadline = Date.now() + holdMs;
2853
+ while (Date.now() < deadline) { /* test-only spin wait */ }
2854
+ }
2855
+ }
2856
+
2857
+ if (hooks?.failAfterFileMoves) {
2858
+ return abortAfterMoves("fs_failed");
2859
+ }
2860
+
2861
+ if (pendingSections.state) {
2862
+ const threadsRestored = restoreThreadsFromManifest(
2863
+ paths.state,
2864
+ entries,
2865
+ satelliteBackup,
2866
+ busyTimeoutMs,
2867
+ codexHome,
2868
+ );
2869
+ if (!threadsRestored.ok) {
2870
+ return abortAfterMoves(
2871
+ threadsRestored.error === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
2872
+ );
2873
+ }
2874
+ pendingSections.state = false;
2875
+ try {
2876
+ persistPending();
2877
+ } catch {
2878
+ return abortAfterMoves("fs_failed");
2879
+ }
2880
+ }
2881
+
2882
+ if (hooks?.failAfterStateCommit) {
2883
+ return abortAfterMoves("db_reconcile_failed");
2884
+ }
2885
+
2886
+ if (satelliteLocks && satelliteBackup) {
2887
+ const locks = satelliteLocks;
2888
+ try {
2889
+ // Commit one satellite DB at a time; uncommitted txs roll back via
2890
+ // rollbackAllSatelliteLocks. Completed sections are cleared in pending.
2891
+ if (pendingSections.logs && satelliteBackup.logs) {
2892
+ if (!locks.logs) throw new Error("missing_logs_lock");
2893
+ if (!tableExists(locks.logs.db, "logs")) throw new Error("missing_logs_table");
2894
+ insertRowsConflictIgnore(locks.logs.db, "logs", satelliteBackup.logs.rows);
2895
+ commitSatelliteLock(locks.logs);
2896
+ locks.logs = undefined;
2897
+ pendingSections.logs = false;
2898
+ persistPending();
2899
+ if (hooks?.failAfterFirstSatelliteCommit) {
2900
+ throw new Error("test_fail_after_first_satellite");
2901
+ }
2902
+ }
2903
+ if (pendingSections.memories && satelliteBackup.memories) {
2904
+ if (!locks.memories) throw new Error("missing_memories_lock");
2905
+ const mem = satelliteBackup.memories;
2906
+ if (!tableExists(locks.memories.db, "stage1_outputs")) {
2907
+ throw new Error("missing_stage1_outputs_table");
2908
+ }
2909
+ insertRowsConflictIgnore(locks.memories.db, "stage1_outputs", mem.stage1);
2910
+ if (tableExists(locks.memories.db, "jobs")) {
2911
+ insertRowsConflictIgnore(locks.memories.db, "jobs", mem.stage1Jobs);
2912
+ if (mem.consolidateTouched) {
2913
+ restoreConsolidateGlobalJob(
2914
+ locks.memories.db,
2915
+ mem.consolidateJob,
2916
+ mem.consolidatePostImage,
2917
+ );
2918
+ }
2919
+ }
2920
+ commitSatelliteLock(locks.memories);
2921
+ locks.memories = undefined;
2922
+ pendingSections.memories = false;
2923
+ persistPending();
2924
+ if (hooks?.failAfterFirstSatelliteCommit && !satelliteBackup.logs) {
2925
+ throw new Error("test_fail_after_first_satellite");
2926
+ }
2927
+ }
2928
+ if (pendingSections.goals && satelliteBackup.goals) {
2929
+ if (!locks.goals) throw new Error("missing_goals_lock");
2930
+ const g = satelliteBackup.goals;
2931
+ if (!tableExists(locks.goals.db, "thread_goals")) {
2932
+ throw new Error("missing_thread_goals_table");
2933
+ }
2934
+ insertRowsConflictIgnore(locks.goals.db, "thread_goals", g.goals);
2935
+ if (tableExists(locks.goals.db, "thread_goal_continuation_deferrals")) {
2936
+ insertRowsConflictIgnore(
2937
+ locks.goals.db,
2938
+ "thread_goal_continuation_deferrals",
2939
+ g.deferrals,
2940
+ );
2941
+ }
2942
+ commitSatelliteLock(locks.goals);
2943
+ locks.goals = undefined;
2944
+ pendingSections.goals = false;
2945
+ persistPending();
2946
+ if (
2947
+ hooks?.failAfterFirstSatelliteCommit
2948
+ && !satelliteBackup.logs
2949
+ && !satelliteBackup.memories
2950
+ ) {
2951
+ throw new Error("test_fail_after_first_satellite");
2952
+ }
2953
+ }
2954
+ // Close any locks acquired for DBs that had no pending work / backup rows.
2955
+ rollbackAllSatelliteLocks(locks);
2956
+ satelliteLocks = undefined;
2957
+ } catch (error) {
2958
+ return abortAfterMoves(
2959
+ mapDbError(error) === "codex_busy" ? "codex_busy" : "db_reconcile_failed",
2960
+ );
2961
+ }
2962
+ }
2963
+
2964
+ if (
2965
+ pendingSections.state
2966
+ || pendingSections.logs
2967
+ || pendingSections.memories
2968
+ || pendingSections.goals
2969
+ ) {
2970
+ return abortAfterMoves("db_reconcile_failed");
2971
+ }
2972
+
2973
+ // Completeness gate: every planned file must sit at its restored path, and the stage
2974
+ // must hold no leftover rollout files, before we destroy the quarantine evidence.
2975
+ for (const item of moved) {
2976
+ if (!existsSync(item.to) || existsSync(item.from)) {
2977
+ return abortAfterMoves("fs_failed");
2978
+ }
2979
+ }
2980
+ try {
2981
+ if (hooks?.failAtLeftoverStageGate) {
2982
+ return abortAfterMoves("fs_failed");
2983
+ }
2984
+ for (const name of readdirSync(stageDir)) {
2985
+ if (
2986
+ name === "manifest.json"
2987
+ || name === SATELLITE_BACKUP_FILE
2988
+ || name === RESTORE_PENDING_FILE
2989
+ ) {
2990
+ continue;
2991
+ }
2992
+ if (!isRolloutFileName(name)) continue;
2993
+ return abortAfterMoves("fs_failed");
2994
+ }
2995
+ } catch {
2996
+ return abortAfterMoves("fs_failed");
2997
+ }
2998
+
2999
+ if (!finalizeRestoredStage(stageDir, codexHome, hooks)) {
3000
+ return {
3001
+ ok: false,
3002
+ trashDir: id,
3003
+ ...partialCounts,
3004
+ error: "fs_failed",
3005
+ };
3006
+ }
3007
+ removeEmptyTrashRoot(codexHome);
3008
+
3009
+ return {
3010
+ ok: true,
3011
+ trashDir: id,
3012
+ ...partialCounts,
3013
+ };
3014
+ }