From 3feb5ce2d6e10a9caac1324bfca341c002261005 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 6 Nov 2026 09:57:32 -0500 Subject: [PATCH 1/4] fix(server): new Codex home entries no longer disable shadow-home providers When Codex added a top-level entry after a shadow home was set up, Codex under the shadow home created a real copy there. The next setup rejected it with CodexShadowHomeEntryConflictError and disabled the provider instance. A conflicting real shadow entry that should be shared is now moved into .t3-shadow-conflicts/-/ inside the shadow home, the shared copy is linked, and a warning names the backup path. A SQLite base and its -wal, -shm and -journal files are resolved as one unit before any member is linked: every real copy moves aside and every member the shared home has is linked, or all of it is restored and the family stays local for this setup. A base only the shadow home has keeps its whole family local. Any failure is logged and setup continues with the remaining entries. Private, shadow-local and replaceable runtime entries, including the thread-writer-locks live-lock deferral, keep their existing handling. Fixes #5817 Co-Authored-By: Claude Opus 5.5 --- .../provider/Drivers/CodexHomeLayout.test.ts | 182 ++++++++++++++-- .../src/provider/Drivers/CodexHomeLayout.ts | 194 +++++++++++++++--- 2 files changed, 335 insertions(+), 41 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 2bbcea20faa91..7abf4dc9e8815 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -3,17 +3,18 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import { CodexSettings } from "@t3tools/contracts"; import { - CodexShadowHomeEntryConflictError, CodexShadowHomePathConflictError, materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings); +const CONFLICTS_DIRECTORY = ".t3-shadow-conflicts"; const decodeCodexSettings = (input: { readonly enabled?: boolean; @@ -38,6 +39,16 @@ const writeTextFile = Effect.fn("CodexHomeLayout.test.writeTextFile")(function* yield* fileSystem.writeFileString(filePath, contents); }); +const listConflictRuns = Effect.fn("CodexHomeLayout.test.listConflictRuns")(function* ( + shadowHome: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const conflictsPath = path.join(shadowHome, CONFLICTS_DIRECTORY); + if (!(yield* fileSystem.exists(conflictsPath))) return []; + return yield* fileSystem.readDirectory(conflictsPath); +}); + it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { describe("resolveCodexHomeLayout", () => { it.effect("uses the shared home for continuation and the shadow home for runtime", () => @@ -252,6 +263,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { yield* materializeCodexShadowHome(layout); expect(yield* fileSystem.exists(threadLock)).toBe(true); + expect(yield* listConflictRuns(shadowHome)).toEqual([]); yield* fileSystem.remove(threadLock); yield* materializeCodexShadowHome(layout); @@ -331,35 +343,169 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { ); it.effect.skipIf(!symlinksSupported)( - "rejects shared entries that already exist in the shadow home as real files", + "moves conflicting shadow files and directories aside, then links the shared entry", () => Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const sharedHome = yield* makeTempDir("t3code-codex-shared-"); const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); const shadowHome = path.join(shadowRoot, "shadow"); - yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); - yield* writeTextFile(path.join(shadowHome, "config.toml"), 'model = "local"\n'); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + yield* materializeCodexShadowHome(layout); + + // Codex adds new entries to both homes after the first setup. + yield* writeTextFile(path.join(sharedHome, "memories_v2.lock"), "shared lock"); + yield* writeTextFile(path.join(shadowHome, "memories_v2.lock"), "shadow lock"); + yield* writeTextFile(path.join(sharedHome, "memories_v2", ".git", "HEAD"), "shared"); + yield* writeTextFile(path.join(shadowHome, "memories_v2", ".git", "HEAD"), "shadow"); + + yield* materializeCodexShadowHome(layout); + yield* materializeCodexShadowHome(layout); + const runs = yield* listConflictRuns(shadowHome); + expect(runs).toHaveLength(1); + const backup = path.join(shadowHome, CONFLICTS_DIRECTORY, runs[0]!); + expect(yield* fileSystem.readDirectory(backup)).toEqual( + expect.arrayContaining(["memories_v2", "memories_v2.lock"]), + ); + expect(yield* fileSystem.readFileString(path.join(backup, "memories_v2.lock"))).toBe( + "shadow lock", + ); + expect( + yield* fileSystem.readFileString(path.join(backup, "memories_v2", ".git", "HEAD")), + ).toBe("shadow"); + for (const entryName of ["memories_v2.lock", "memories_v2"]) { + expect(yield* fileSystem.readLink(path.join(shadowHome, entryName))).toBe( + path.join(sharedHome, entryName), + ); + } + expect(yield* fileSystem.readFileString(path.join(sharedHome, "memories_v2.lock"))).toBe( + "shared lock", + ); + expect( + yield* fileSystem.readFileString(path.join(sharedHome, "memories_v2", ".git", "HEAD")), + ).toBe("shared"); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "moves a conflicting SQLite database aside together with its sidecars", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + yield* writeTextFile(path.join(sharedHome, "queue_1.sqlite"), "shared db"); + yield* writeTextFile(path.join(sharedHome, "queue_1.sqlite-wal"), "shared wal"); + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite"), "shadow db"); + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite-wal"), "shadow wal"); + // The shared home has no -shm, so nothing would link it, but it must not stay behind. + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite-shm"), "shadow shm"); + // Shadow-only databases have nothing to share and stay where they are. + yield* writeTextFile(path.join(shadowHome, "goals_1.sqlite"), "shadow goals"); + yield* writeTextFile(path.join(shadowHome, "goals_1.sqlite-wal"), "shadow goals wal"); const layout = yield* resolveCodexHomeLayout( - decodeCodexSettings({ - homePath: sharedHome, - shadowHomePath: shadowHome, - }), + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), ); - const error = yield* materializeCodexShadowHome(layout).pipe(Effect.flip); + yield* materializeCodexShadowHome(layout); + yield* materializeCodexShadowHome(layout); - expect(error).toBeInstanceOf(CodexShadowHomeEntryConflictError); - expect(error).toMatchObject({ - sharedHomePath: sharedHome, - effectiveHomePath: shadowHome, - entryName: "config.toml", - linkPath: path.join(shadowHome, "config.toml"), - targetPath: path.join(sharedHome, "config.toml"), + const runs = yield* listConflictRuns(shadowHome); + expect(runs).toHaveLength(1); + const backup = path.join(shadowHome, CONFLICTS_DIRECTORY, runs[0]!); + for (const [entryName, contents] of [ + ["queue_1.sqlite", "shadow db"], + ["queue_1.sqlite-wal", "shadow wal"], + ["queue_1.sqlite-shm", "shadow shm"], + ] as const) { + expect(yield* fileSystem.readFileString(path.join(backup, entryName))).toBe(contents); + } + for (const entryName of ["queue_1.sqlite", "queue_1.sqlite-wal"]) { + expect(yield* fileSystem.readLink(path.join(shadowHome, entryName))).toBe( + path.join(sharedHome, entryName), + ); + } + expect(yield* fileSystem.exists(path.join(shadowHome, "queue_1.sqlite-shm"))).toBe(false); + expect(yield* fileSystem.readFileString(path.join(sharedHome, "queue_1.sqlite"))).toBe( + "shared db", + ); + expect(yield* fileSystem.readFileString(path.join(shadowHome, "goals_1.sqlite"))).toBe( + "shadow goals", + ); + expect( + yield* fileSystem.readFileString(path.join(shadowHome, "goals_1.sqlite-wal")), + ).toBe("shadow goals wal"); + }), + ); + + it.effect.skipIf(!symlinksSupported)( + "restores a conflict group and links the rest when inspecting, moving, or linking fails", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + yield* writeTextFile(path.join(sharedHome, "config.toml"), 'model = "gpt-5-codex"\n'); + for (const entryName of ["queue_1.sqlite", "queue_1.sqlite-wal", "queue_1.sqlite-shm"]) { + yield* writeTextFile(path.join(sharedHome, entryName), `shared ${entryName}`); + } + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite"), "shadow db"); + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite-wal"), "shadow wal"); + yield* writeTextFile(path.join(sharedHome, "cache.json"), "shared"); + yield* writeTextFile(path.join(shadowHome, "cache.json"), "shadow"); + yield* writeTextFile(path.join(sharedHome, "logs_2.sqlite"), "shared logs"); + const failure = (method: string) => + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method, + description: "Forced failure.", + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readLink: (linkPath) => + linkPath === path.join(shadowHome, "logs_2.sqlite-shm") + ? Effect.fail(failure("readLink")) + : fileSystem.readLink(linkPath), + // The base fails after its WAL has moved, as a file held open on Windows would. + rename: (fromPath, toPath) => + fromPath === path.join(shadowHome, "queue_1.sqlite") + ? Effect.fail(failure("rename")) + : fileSystem.rename(fromPath, toPath), + symlink: (target, link) => + link === path.join(shadowHome, "cache.json") + ? Effect.fail(failure("symlink")) + : fileSystem.symlink(target, link), }); - expect(error.message).toBe( - `Cannot create Codex shadow home entry 'config.toml' because '${path.join(shadowHome, "config.toml")}' already exists and is not a symlink.`, + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + yield* materializeCodexShadowHome(layout).pipe( + Effect.provideService(FileSystem.FileSystem, failingFileSystem), + ); + + expect(yield* fileSystem.readFileString(path.join(shadowHome, "queue_1.sqlite"))).toBe( + "shadow db", + ); + expect( + yield* fileSystem.readFileString(path.join(shadowHome, "queue_1.sqlite-wal")), + ).toBe("shadow wal"); + expect(yield* fileSystem.exists(path.join(shadowHome, "queue_1.sqlite-shm"))).toBe(false); + expect(yield* fileSystem.readFileString(path.join(shadowHome, "cache.json"))).toBe( + "shadow", + ); + expect(yield* fileSystem.exists(path.join(shadowHome, "logs_2.sqlite"))).toBe(false); + expect(yield* fileSystem.readLink(path.join(shadowHome, "config.toml"))).toBe( + path.join(sharedHome, "config.toml"), ); }), ); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index f6d2ba9d44cf5..6203c2dfa65d4 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -1,4 +1,5 @@ import { ProviderDriverKind, type CodexSettings } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -31,8 +32,15 @@ const KNOWN_SHARED_DIRECTORIES = [ // auth.lock guards the shadow home's private auth.json. const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "auth.lock", "models_cache.json"]); +const SHADOW_CONFLICTS_DIRECTORY = ".t3-shadow-conflicts"; // Finder maintains metadata separately for each directory, including shadow homes. -const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"]); +const SHADOW_LOCAL_ENTRY_NAMES = new Set([ + "log", + "memories", + "tmp", + ".DS_Store", + SHADOW_CONFLICTS_DIRECTORY, +]); // Shadow homes may create local runtime locks first; replace them with shared links because they guard shared state. const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set([ "mcp-oauth-locks", @@ -102,20 +110,6 @@ export class CodexShadowHomePathConflictError extends Schema.TaggedError()( - "CodexShadowHomeEntryConflictError", - { - ...CodexShadowHomeContext, - entryName: Schema.String, - linkPath: Schema.String, - targetPath: Schema.String, - }, -) { - override get message(): string { - return `Cannot create Codex shadow home entry '${this.entryName}' because '${this.linkPath}' already exists and is not a symlink.`; - } -} - export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedError()( "CodexShadowHomePrivateEntrySymlinkError", { @@ -132,7 +126,6 @@ export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedError< export const CodexShadowHomeError = Schema.Union([ CodexShadowHomeFileSystemError, CodexShadowHomePathConflictError, - CodexShadowHomeEntryConflictError, CodexShadowHomePrivateEntrySymlinkError, ]); export type CodexShadowHomeError = typeof CodexShadowHomeError.Type; @@ -219,15 +212,147 @@ const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(f } }); +const SQLITE_SIDECAR_SUFFIXES = ["-wal", "-shm", "-journal"] as const; + +function sqliteBaseName(entryName: string): string | undefined { + const suffix = SQLITE_SIDECAR_SUFFIXES.find((candidate) => entryName.endsWith(candidate)); + const baseName = suffix === undefined ? entryName : entryName.slice(0, -suffix.length); + return baseName.endsWith(".sqlite") ? baseName : undefined; +} + +// Sidecars go first, so the base is the last thing to leave the shadow home. +function conflictGroupMembers(entryName: string): ReadonlyArray { + const baseName = sqliteBaseName(entryName); + if (baseName === undefined) return [entryName]; + return [...SQLITE_SIDECAR_SUFFIXES.map((suffix) => baseName + suffix), baseName]; +} + +type ResolveConflictGroup = (entryName: string) => Effect.Effect; + +// A SQLite base and its sidecars are one unit: move every real copy aside and link what the +// shared home has, or restore all of it, so a base never pairs with another database's journal. +const replaceConflictGroup = Effect.fn("CodexHomeLayout.replaceConflictGroup")(function* (input: { + readonly fileSystem: FileSystem.FileSystem; + readonly sharedHomePath: string; + readonly effectiveHomePath: string; + readonly entryName: string; + readonly sharedEntryNames: ReadonlySet; + readonly backupDirectory: Effect.Effect; +}): Effect.fn.Return { + const path = yield* Path.Path; + const inspected = yield* Effect.forEach(conflictGroupMembers(input.entryName), (name) => + readLinkState({ + ...input, + entryName: name, + linkPath: path.join(input.effectiveHomePath, name), + }).pipe(Effect.map((state) => ({ name, state }))), + ).pipe(Effect.result); + if (inspected._tag === "Failure") { + yield* Effect.logWarning( + `Could not inspect Codex shadow home entry '${input.entryName}'; it stays local until a later setup.`, + { entryName: input.entryName, cause: inspected.failure }, + ); + return false; + } + const members = inspected.success; + const conflicts = members.filter((member) => member.state._tag === "NotSymlink"); + if (conflicts.length === 0) return true; + const baseName = sqliteBaseName(input.entryName); + const baseIsLocalOnly = + baseName !== undefined && + !input.sharedEntryNames.has(baseName) && + conflicts.some((member) => member.name === baseName); + if (baseIsLocalOnly) return false; + + const moved: Array<{ + readonly name: string; + readonly path: string; + readonly backupPath: string; + }> = []; + const linked: Array = []; + const replace = Effect.gen(function* () { + for (const { name } of conflicts) { + const memberPath = path.join(input.effectiveHomePath, name); + const backupPath = path.join(yield* input.backupDirectory, name); + yield* input.fileSystem.rename(memberPath, backupPath); + moved.push({ name, path: memberPath, backupPath }); + } + for (const { name, state } of members) { + if (state._tag === "Symlink" || !input.sharedEntryNames.has(name)) continue; + const link = path.join(input.effectiveHomePath, name); + yield* input.fileSystem.symlink(path.join(input.sharedHomePath, name), link); + linked.push(link); + } + }); + const restore = Effect.gen(function* () { + for (const link of [...linked].reverse()) { + yield* input.fileSystem.remove(link).pipe( + Effect.catch((cause) => + Effect.logError(`Could not remove Codex shadow home link '${link}' while restoring.`, { + cause, + }), + ), + ); + } + for (const entry of [...moved].reverse()) { + yield* input.fileSystem + .rename(entry.backupPath, entry.path) + .pipe( + Effect.catch((cause) => + Effect.logError( + `Could not restore Codex shadow home entry '${entry.name}'; it is still at '${entry.backupPath}'.`, + { ...entry, cause }, + ), + ), + ); + } + }); + + return yield* replace.pipe( + Effect.andThen( + Effect.forEach( + moved, + (entry) => + Effect.logWarning( + `Moved Codex shadow home entry '${entry.name}' to '${entry.backupPath}' so the shared copy can be linked.`, + { entryName: entry.name, path: entry.path, backupPath: entry.backupPath }, + ), + { discard: true }, + ), + ), + Effect.as(true), + Effect.catch((cause) => + restore.pipe( + Effect.andThen( + Effect.logWarning( + `Could not replace Codex shadow home entry '${input.entryName}' with a shared link; it stays local until a later setup.`, + { + entryName: input.entryName, + path: path.join(input.effectiveHomePath, input.entryName), + cause, + }, + ), + ), + Effect.as(false), + ), + ), + ); +}); + const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { readonly fileSystem: FileSystem.FileSystem; readonly sharedHomePath: string; readonly effectiveHomePath: string; readonly entryName: string; + readonly resolveConflictGroup: ResolveConflictGroup; }): Effect.fn.Return { const path = yield* Path.Path; const target = path.join(input.sharedHomePath, input.entryName); const link = path.join(input.effectiveHomePath, input.entryName); + // Resolve a SQLite family before linking any member of it. + if (sqliteBaseName(input.entryName) !== undefined) { + if (!(yield* input.resolveConflictGroup(input.entryName))) return; + } const state = yield* readLinkState({ ...input, linkPath: link, @@ -250,13 +375,8 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu if (state._tag === "NotSymlink") { if (!REPLACEABLE_SHARED_RUNTIME_ENTRIES.has(input.entryName)) { - return yield* new CodexShadowHomeEntryConflictError({ - sharedHomePath: input.sharedHomePath, - effectiveHomePath: input.effectiveHomePath, - entryName: input.entryName, - linkPath: link, - targetPath: target, - }); + yield* input.resolveConflictGroup(input.entryName); + return; } if (input.entryName === "thread-writer-locks") { @@ -400,6 +520,33 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" }), }), ); + // Created only when a conflict needs it; the random suffix keeps runs from colliding. + const backupDirectory = yield* Effect.cached( + Effect.gen(function* () { + const conflictsPath = path.join(effectiveHomePath, SHADOW_CONFLICTS_DIRECTORY); + yield* fileSystem.makeDirectory(conflictsPath, { recursive: true }); + const timestamp = DateTime.formatIso(yield* DateTime.now).replace(/[:.]/g, "-"); + return yield* fileSystem.makeTempDirectory({ + directory: conflictsPath, + prefix: `${timestamp}-`, + }); + }), + ); + const sharedEntryNameSet = new Set(sharedEntryNames); + const conflictGroupOutcomes = new Map(); + const resolveConflictGroup: ResolveConflictGroup = (entryName) => { + const groupKey = sqliteBaseName(entryName) ?? entryName; + const outcome = conflictGroupOutcomes.get(groupKey); + if (outcome !== undefined) return Effect.succeed(outcome); + return replaceConflictGroup({ + fileSystem, + sharedHomePath: layout.sharedHomePath, + effectiveHomePath, + entryName, + sharedEntryNames: sharedEntryNameSet, + backupDirectory, + }).pipe(Effect.tap((result) => Effect.sync(() => conflictGroupOutcomes.set(groupKey, result)))); + }; const entries = new Set(KNOWN_SHARED_DIRECTORIES); for (const entryName of sharedEntryNames) { if (!PRIVATE_ENTRY_NAMES.has(entryName) && !SHADOW_LOCAL_ENTRY_NAMES.has(entryName)) { @@ -432,6 +579,7 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" sharedHomePath: layout.sharedHomePath, effectiveHomePath, entryName, + resolveConflictGroup, }); }, { discard: true }, From ee400bff2ff2b49290fbb1abeb0a38c671d95340 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 6 Nov 2026 10:05:37 -0500 Subject: [PATCH 2/4] fix(server): stop Codex shadow-home setup when a conflict cannot be restored If replacing a conflict group fails and putting it back fails too, the shadow home can hold a SQLite base without its WAL, or beside a shared one. Setup now fails with the restore error, which names where the entry is stranded, instead of starting Codex on that state. Co-Authored-By: Claude Opus 5.5 --- .../provider/Drivers/CodexHomeLayout.test.ts | 50 +++++++++++ .../src/provider/Drivers/CodexHomeLayout.ts | 88 +++++++++++++------ 2 files changed, 112 insertions(+), 26 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 7abf4dc9e8815..fdcadb992c72b 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -8,12 +8,14 @@ import * as Schema from "effect/Schema"; import { CodexSettings } from "@t3tools/contracts"; import { + CodexShadowHomeFileSystemError, CodexShadowHomePathConflictError, materializeCodexShadowHome, resolveCodexHomeLayout, } from "./CodexHomeLayout.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings); +const isCodexShadowHomeFileSystemError = Schema.is(CodexShadowHomeFileSystemError); const CONFLICTS_DIRECTORY = ".t3-shadow-conflicts"; const decodeCodexSettings = (input: { @@ -319,6 +321,54 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "fails setup instead of starting Codex on a SQLite family it could not restore", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + yield* writeTextFile(path.join(sharedHome, "queue_1.sqlite"), "shared db"); + yield* writeTextFile(path.join(sharedHome, "queue_1.sqlite-wal"), "shared wal"); + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite"), "shadow db"); + yield* writeTextFile(path.join(shadowHome, "queue_1.sqlite-wal"), "shadow wal"); + const failure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "rename", + description: "Forced failure.", + }); + // The base cannot move, and the WAL that already moved cannot come back. + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + rename: (fromPath, toPath) => + fromPath === path.join(shadowHome, "queue_1.sqlite") || + fromPath.includes(CONFLICTS_DIRECTORY) + ? Effect.fail(failure) + : fileSystem.rename(fromPath, toPath), + }); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + const error = yield* materializeCodexShadowHome(layout).pipe( + Effect.provideService(FileSystem.FileSystem, failingFileSystem), + Effect.flip, + ); + + expect(error).toBeInstanceOf(CodexShadowHomeFileSystemError); + expect(error).toMatchObject({ + operation: "rename", + entryName: "queue_1.sqlite-wal", + targetPath: path.join(shadowHome, "queue_1.sqlite-wal"), + }); + if (!isCodexShadowHomeFileSystemError(error)) return; + expect(yield* fileSystem.readFileString(error.path)).toBe("shadow wal"); + }), + ); + it.effect("rejects shadow homes that point at the shared home", () => Effect.gen(function* () { const sharedHome = yield* makeTempDir("t3code-codex-shared-"); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 6203c2dfa65d4..8a873bcfeba99 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -88,7 +88,14 @@ export class CodexShadowHomeFileSystemError extends Schema.TaggedError { return [...SQLITE_SIDECAR_SUFFIXES.map((suffix) => baseName + suffix), baseName]; } -type ResolveConflictGroup = (entryName: string) => Effect.Effect; +type ResolveConflictGroup = ( + entryName: string, +) => Effect.Effect; // A SQLite base and its sidecars are one unit: move every real copy aside and link what the // shared home has, or restore all of it, so a base never pairs with another database's journal. @@ -238,7 +247,7 @@ const replaceConflictGroup = Effect.fn("CodexHomeLayout.replaceConflictGroup")(f readonly entryName: string; readonly sharedEntryNames: ReadonlySet; readonly backupDirectory: Effect.Effect; -}): Effect.fn.Return { +}): Effect.fn.Return { const path = yield* Path.Path; const inspected = yield* Effect.forEach(conflictGroupMembers(input.entryName), (name) => readLinkState({ @@ -284,28 +293,47 @@ const replaceConflictGroup = Effect.fn("CodexHomeLayout.replaceConflictGroup")(f linked.push(link); } }); + const fileSystemError = ( + operation: "remove" | "rename", + failedPath: string, + cause: PlatformError.PlatformError, + entryName?: string, + targetPath?: string, + ) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation, + path: failedPath, + ...(targetPath === undefined ? {} : { targetPath }), + ...(entryName === undefined ? {} : { entryName }), + cause, + }); const restore = Effect.gen(function* () { + const failures: Array = []; for (const link of [...linked].reverse()) { - yield* input.fileSystem.remove(link).pipe( - Effect.catch((cause) => - Effect.logError(`Could not remove Codex shadow home link '${link}' while restoring.`, { - cause, - }), - ), - ); + yield* input.fileSystem + .remove(link) + .pipe( + Effect.catch((cause) => + Effect.sync(() => failures.push(fileSystemError("remove", link, cause))), + ), + ); } for (const entry of [...moved].reverse()) { yield* input.fileSystem .rename(entry.backupPath, entry.path) .pipe( Effect.catch((cause) => - Effect.logError( - `Could not restore Codex shadow home entry '${entry.name}'; it is still at '${entry.backupPath}'.`, - { ...entry, cause }, + Effect.sync(() => + failures.push( + fileSystemError("rename", entry.backupPath, cause, entry.name, entry.path), + ), ), ), ); } + return failures; }); return yield* replace.pipe( @@ -322,19 +350,27 @@ const replaceConflictGroup = Effect.fn("CodexHomeLayout.replaceConflictGroup")(f ), Effect.as(true), Effect.catch((cause) => - restore.pipe( - Effect.andThen( - Effect.logWarning( - `Could not replace Codex shadow home entry '${input.entryName}' with a shared link; it stays local until a later setup.`, - { - entryName: input.entryName, - path: path.join(input.effectiveHomePath, input.entryName), - cause, - }, - ), - ), - Effect.as(false), - ), + Effect.gen(function* () { + const restoreFailures = yield* restore; + const annotations = { + entryName: input.entryName, + path: path.join(input.effectiveHomePath, input.entryName), + cause, + }; + // A half-restored SQLite family is unsafe to open, so stop instead of starting Codex on it. + if (restoreFailures.length > 0) { + yield* Effect.logError( + `Could not restore Codex shadow home entry '${input.entryName}' after a failed replacement.`, + { ...annotations, restoreFailures }, + ); + return yield* restoreFailures[0]!; + } + yield* Effect.logWarning( + `Could not replace Codex shadow home entry '${input.entryName}' with a shared link; it stays local until a later setup.`, + annotations, + ); + return false; + }), ), ); }); From 95b84be92d397e8a0b64820991a765258523d4fe Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Fri, 6 Nov 2026 10:10:40 -0500 Subject: [PATCH 3/4] fix(server): finish Codex shadow-home conflict replacement when interrupted Interrupting setup between moves skipped the restore and could leave a SQLite family split between the shadow home and its backup. The replace and restore step now runs uninterruptibly; it is a handful of local renames and symlinks. Co-Authored-By: Claude Opus 5.5 --- .../provider/Drivers/CodexHomeLayout.test.ts | 49 +++++++++++++++++++ .../src/provider/Drivers/CodexHomeLayout.ts | 2 + 2 files changed, 51 insertions(+) diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index fdcadb992c72b..22fe3efc9e991 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; @@ -321,6 +323,53 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "finishes replacing a SQLite family when setup is interrupted mid-move", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const shadowRoot = yield* makeTempDir("t3code-codex-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + for (const home of [sharedHome, shadowHome]) { + yield* writeTextFile(path.join(home, "queue_1.sqlite"), "db"); + yield* writeTextFile(path.join(home, "queue_1.sqlite-wal"), "wal"); + } + const baseRenameStarted = yield* Deferred.make(); + const releaseBaseRename = yield* Deferred.make(); + // The WAL has already moved when the base rename blocks. + const blockingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + rename: (fromPath, toPath) => + fromPath === path.join(shadowHome, "queue_1.sqlite") + ? Deferred.succeed(baseRenameStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseBaseRename)), + Effect.andThen(fileSystem.rename(fromPath, toPath)), + ) + : fileSystem.rename(fromPath, toPath), + }); + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ homePath: sharedHome, shadowHomePath: shadowHome }), + ); + + const setup = yield* materializeCodexShadowHome(layout).pipe( + Effect.provideService(FileSystem.FileSystem, blockingFileSystem), + Effect.forkChild, + ); + yield* Deferred.await(baseRenameStarted); + setup.interruptUnsafe(); + yield* Deferred.succeed(releaseBaseRename, undefined); + yield* Fiber.await(setup); + + for (const entryName of ["queue_1.sqlite", "queue_1.sqlite-wal"]) { + expect(yield* fileSystem.readLink(path.join(shadowHome, entryName))).toBe( + path.join(sharedHome, entryName), + ); + } + }), + ); + it.effect.skipIf(!symlinksSupported)( "fails setup instead of starting Codex on a SQLite family it could not restore", () => diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index 8a873bcfeba99..529a9cbdb596c 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -336,6 +336,7 @@ const replaceConflictGroup = Effect.fn("CodexHomeLayout.replaceConflictGroup")(f return failures; }); + // Interrupting between moves would skip the restore and split the family. return yield* replace.pipe( Effect.andThen( Effect.forEach( @@ -372,6 +373,7 @@ const replaceConflictGroup = Effect.fn("CodexHomeLayout.replaceConflictGroup")(f return false; }), ), + Effect.uninterruptible, ); }); From fdf3451c0829c9f048f25ee4aaff2bb223fae8b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:31:27 +0000 Subject: [PATCH 4/4] docs: describe how Codex shadow homes resolve entry conflicts AI-assisted; verified with the tests and tools listed above. --- docs/user/providers-codex.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/user/providers-codex.md b/docs/user/providers-codex.md index 3384831aa87b9..e98199d8237fd 100644 --- a/docs/user/providers-codex.md +++ b/docs/user/providers-codex.md @@ -81,8 +81,9 @@ not move the conversation into a separate Codex home. If the account is missing from the picker, compare the home paths in provider settings. If two instances show the same unexpected account or models, check their reported accounts, refresh provider status, and confirm the second instance has -its own shadow path and login. A shadow-home conflict usually means the directory -contains a copied Codex setup. Use a fresh shadow directory and sign in again. +its own shadow path and login. When the shadow directory has its own copy of a +shared file, T3 Code moves that copy into the directory's `.t3-shadow-conflicts` +folder and links the shared one in its place. ## Answer questions while Codex works