diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 093777e67db5d..2bbcea20faa91 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -121,8 +121,10 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { '{"models":["shared"]}\n', ); yield* writeTextFile(path.join(sharedHome, "auth.json"), '{"shared":true}\n'); + yield* writeTextFile(path.join(sharedHome, "auth.lock"), ""); yield* fileSystem.makeDirectory(shadowHome, { recursive: true }); yield* writeTextFile(path.join(shadowHome, "auth.json"), '{"shadow":true}\n'); + yield* writeTextFile(path.join(shadowHome, "auth.lock"), ""); yield* fileSystem.symlink( path.join(sharedHome, "models_cache.json"), path.join(shadowHome, "models_cache.json"), @@ -149,6 +151,9 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { .readLink(path.join(shadowHome, "auth.json")) .pipe(Effect.result); const authContents = yield* fileSystem.readFileString(path.join(shadowHome, "auth.json")); + const authLockLinkResult = yield* fileSystem + .readLink(path.join(shadowHome, "auth.lock")) + .pipe(Effect.result); expect(sessionsTarget).toBe(path.join(sharedHome, "sessions")); expect(configTarget).toBe(path.join(sharedHome, "config.toml")); @@ -156,6 +161,7 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { expect(modelsCacheExists).toBe(false); expect(authLinkResult._tag).toBe("Failure"); expect(authContents).toContain("shadow"); + expect(authLockLinkResult._tag).toBe("Failure"); }), ); @@ -221,6 +227,40 @@ it.layer(NodeServices.layer)("CodexHomeLayout", (it) => { }), ); + it.effect.skipIf(!symlinksSupported)( + "shares local thread writer locks once no writer still holds one", + () => + 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"); + const shadowLocks = path.join(shadowHome, "thread-writer-locks"); + const threadLock = path.join(shadowLocks, "019a0000-0000-7000-8000-000000000000.lock"); + + yield* writeTextFile(path.join(shadowLocks, ".coordination.lock"), ""); + yield* writeTextFile(path.join(shadowLocks, ".DS_Store"), ""); + yield* writeTextFile(threadLock, ""); + + const layout = yield* resolveCodexHomeLayout( + decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }), + ); + + yield* materializeCodexShadowHome(layout); + expect(yield* fileSystem.exists(threadLock)).toBe(true); + + yield* fileSystem.remove(threadLock); + yield* materializeCodexShadowHome(layout); + expect(yield* fileSystem.readLink(shadowLocks)).toBe( + path.join(sharedHome, "thread-writer-locks"), + ); + }), + ); + it.effect.skipIf(!symlinksSupported)( "accepts Codex-created shadow-local runtime directories", () => diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index f175a0fdd00a9..f6d2ba9d44cf5 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -26,13 +26,19 @@ const KNOWN_SHARED_DIRECTORIES = [ "cache", "logs", "mcp-oauth-locks", + "thread-writer-locks", ] as const; -const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "models_cache.json"]); +// auth.lock guards the shadow home's private auth.json. +const PRIVATE_ENTRY_NAMES = new Set(["auth.json", "auth.lock", "models_cache.json"]); // Finder maintains metadata separately for each directory, including shadow homes. const SHADOW_LOCAL_ENTRY_NAMES = new Set(["log", "memories", "tmp", ".DS_Store"]); // 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", ".sqlite-maintenance.lock"]); +const REPLACEABLE_SHARED_RUNTIME_ENTRIES = new Set([ + "mcp-oauth-locks", + "thread-writer-locks", + ".sqlite-maintenance.lock", +]); function resolveHomePath(path: Path.Path, home: string, value: string | undefined): string { const expanded = @@ -253,6 +259,30 @@ const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (inpu }); } + if (input.entryName === "thread-writer-locks") { + const lockNames = yield* input.fileSystem.readDirectory(link).pipe( + Effect.catchTags({ + PlatformError: (cause) => + new CodexShadowHomeFileSystemError({ + sharedHomePath: input.sharedHomePath, + effectiveHomePath: input.effectiveHomePath, + operation: "readDirectory", + path: link, + entryName: input.entryName, + cause, + }), + }), + ); + // Codex deletes a thread lock on release, so one that is left may still guard a live + // writer. Keep the local directory until a later setup finds none. + if (lockNames.some((name) => name.endsWith(".lock") && name !== ".coordination.lock")) { + return yield* Effect.logWarning( + "Codex shadow home still has thread writer locks; it will share them on a later setup.", + { path: link }, + ); + } + } + yield* input.fileSystem.remove(link, { recursive: true }).pipe( Effect.catchTags({ PlatformError: (cause) =>