From b4b63d755f277d16cf88dc529e4c0a999773357e Mon Sep 17 00:00:00 2001 From: Stefan van der Velden Date: Thu, 15 Oct 2026 09:30:03 +1100 Subject: [PATCH 1/2] fix(server): old isolated threads from the pre-v2 fork get v2's workspace file The fork kept an isolated run's .code-workspace inside the container, while V2 opens the one written beside it. Threads moved over by the fork worktree repair had none there, so "Open in" opened the bare container folder. A startup pass now writes V2's file from the project's workspace file for every container that holds the fork's own copy and has none beside it. The fork's copy stays so the pre-V2 build can still open it. Containers V2 created only link the project's file in, so they are left alone. Fork-only; not intended for upstream. Co-Authored-By: Claude Opus 5.5 --- .../repairMultiRepoForkWorktrees.test.ts | 131 +++++++++++++++++- .../legacy/repairMultiRepoForkWorktrees.ts | 82 +++++++++++ .../src/serverRuntimeStartup.autoPull.test.ts | 2 + apps/server/src/serverRuntimeStartup.ts | 18 ++- 4 files changed, 231 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts index 3e6e3b906ef3..fc3de9037e16 100644 --- a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts +++ b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts @@ -14,7 +14,10 @@ import * as GitVcsDriver from "../../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../../vcs/VcsProcess.ts"; import * as WorkspaceRepositories from "../../workspace/WorkspaceRepositories.ts"; import * as ThreadManagement from "../ThreadManagementService.ts"; -import { repairMultiRepoForkWorktrees } from "./repairMultiRepoForkWorktrees.ts"; +import { + repairMultiRepoForkWorktrees, + writeMultiRepoForkWorkspaceFiles, +} from "./repairMultiRepoForkWorktrees.ts"; const git = (cwd: string, args: ReadonlyArray) => Effect.flatMap(VcsProcess.VcsProcess, (process) => @@ -187,3 +190,129 @@ it.effect("does nothing on databases that never ran the fork", () => ); }).pipe(Effect.provide(NodeServices.layer)), ); + +const workspaceFileLayer = NodeSqliteClient.layer({ filename: ":memory:" }).pipe( + Layer.merge(WorkspaceRepositories.layer), + Layer.provideMerge(NodeServices.layer), +); + +const forkWorkspace = JSON.stringify({ + folders: [ + { path: "./api", name: "api" }, + { path: "./web", name: "web" }, + ], +}); + +/** A project with `api` and `web`, and a thread's container recorded in V2 at `container`. */ +const makeForkThread = Effect.fn("makeForkThread")(function* (input: { + readonly threadId: string; + readonly projectRoot: string; + readonly container: string; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + for (const name of ["api", "web"]) { + yield* fileSystem.makeDirectory(path.join(input.projectRoot, name, ".git"), { + recursive: true, + }); + yield* fileSystem.makeDirectory(path.join(input.container, name), { recursive: true }); + } + yield* fileSystem.writeFileString( + path.join(input.projectRoot, "team.code-workspace"), + forkWorkspace, + ); + yield* fileSystem.writeFileString( + WorkspaceRepositories.isolatedRunLayoutPath(input.container), + JSON.stringify({ + repositories: ["api", "web"].map((name) => ({ + relativePath: name, + name, + source: path.join(input.projectRoot, name), + })), + }), + ); + yield* sql` + INSERT INTO orchestration_v2_projection_threads (thread_id, project_id, deleted_at, payload_json) + VALUES ( + ${input.threadId}, + ${input.threadId}, + NULL, + ${JSON.stringify({ worktreePath: input.container })} + ) + `; + yield* sql` + INSERT INTO projection_projects (project_id, workspace_root) + VALUES (${input.threadId}, ${input.projectRoot}) + `; +}); + +const createWorkspaceFileTables = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE orchestration_v2_projection_threads ( + thread_id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + deleted_at TEXT, + payload_json TEXT NOT NULL + ) + `; + yield* sql`CREATE TABLE projection_projects (project_id TEXT PRIMARY KEY, workspace_root TEXT)`; +}); + +it.effect("writes V2's workspace file for fork threads and keeps the fork's copy", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-fork-workspace-" }); + const projectRoot = path.join(baseDir, "team"); + const container = path.join(baseDir, "worktrees", "project-1", "thread-1"); + yield* createWorkspaceFileTables; + yield* makeForkThread({ threadId: "thread-1", projectRoot, container }); + // The fork's copy inside the container, pointed at the worktrees. + const forkCopy = path.join(container, "team.code-workspace"); + yield* fileSystem.writeFileString(forkCopy, forkWorkspace); + + assert.strictEqual(yield* writeMultiRepoForkWorkspaceFiles(), 1); + + const written = JSON.parse( + yield* fileSystem.readFileString( + WorkspaceRepositories.isolatedRunWorkspaceFilePath(container), + ), + ); + assert.deepStrictEqual(written.folders, [ + { path: path.join(container, "api"), name: "api" }, + { path: path.join(container, "web"), name: "web" }, + ]); + // The pre-V2 build opens the fork's copy, so it stays as it was. + assert.strictEqual(yield* fileSystem.readFileString(forkCopy), forkWorkspace); + const service = yield* WorkspaceRepositories.WorkspaceRepositories; + assert.strictEqual( + yield* service.workspaceFile(container), + WorkspaceRepositories.isolatedRunWorkspaceFilePath(container), + ); + + assert.strictEqual(yield* writeMultiRepoForkWorkspaceFiles(), 0); + }).pipe(Effect.provide(workspaceFileLayer)), +); + +it.effect("leaves V2 isolated runs that link the project's workspace file alone", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-fork-workspace-" }); + const projectRoot = path.join(baseDir, "team"); + const container = path.join(baseDir, "worktrees", "project-1", "thread-1"); + yield* createWorkspaceFileTables; + yield* makeForkThread({ threadId: "thread-1", projectRoot, container }); + yield* fileSystem.symlink( + path.join(projectRoot, "team.code-workspace"), + path.join(container, "team.code-workspace"), + ); + + assert.strictEqual(yield* writeMultiRepoForkWorkspaceFiles(), 0); + assert.isFalse( + yield* fileSystem.exists(WorkspaceRepositories.isolatedRunWorkspaceFilePath(container)), + ); + }).pipe(Effect.provide(workspaceFileLayer)), +); diff --git a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts index 0a9f15b88042..fde7ddd8aa68 100644 --- a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts +++ b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts @@ -19,6 +19,7 @@ import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; import * as ServerConfig from "../../config.ts"; +import { writeIsolatedRunWorkspaceFile } from "../../git/isolatedRunWorkspaceFile.ts"; import * as WorkspaceRepositories from "../../workspace/WorkspaceRepositories.ts"; import * as ThreadManagement from "../ThreadManagementService.ts"; @@ -108,3 +109,84 @@ export const repairMultiRepoForkWorktrees = Effect.fn("repairMultiRepoForkWorktr } return repaired; }); + +/** + * Gives repaired fork threads the workspace file V2 writes beside a new isolated run. + * + * The fork kept its copy inside the container, so V2's "Open in" fell back to the bare folder. + * This writes V2's own file from the project's workspace file. The fork's copy stays, because + * the pre-V2 build opens it and a database can still go back to that build. Threads that + * already have the file are skipped, so this runs once per thread. + */ +export const writeMultiRepoForkWorkspaceFiles = Effect.fn("writeMultiRepoForkWorkspaceFiles")( + function* () { + const sql = yield* SqlClient.SqlClient; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspaceRepositories = yield* WorkspaceRepositories.WorkspaceRepositories; + + const rows = yield* sql<{ + readonly thread_id: string; + readonly worktree_path: string; + readonly workspace_root: string; + }>` + SELECT + thread.thread_id, + json_extract(thread.payload_json, '$.worktreePath') AS worktree_path, + project.workspace_root + FROM orchestration_v2_projection_threads AS thread + INNER JOIN projection_projects AS project ON project.project_id = thread.project_id + WHERE thread.deleted_at IS NULL + AND json_extract(thread.payload_json, '$.worktreePath') IS NOT NULL + `; + + // Only the fork left a real workspace file in a container; V2 links the project's in. + const holdsForkCopy = (container: string) => + fileSystem.readDirectory(container).pipe( + Effect.flatMap((names) => { + const held = names.filter((name) => + name.endsWith(WorkspaceRepositories.CODE_WORKSPACE_EXTENSION), + ); + if (held.length !== 1) return Effect.succeed(false); + return fileSystem.readLink(path.join(container, held[0]!)).pipe( + Effect.as(false), + Effect.orElseSucceed(() => true), + ); + }), + Effect.orElseSucceed(() => false), + ); + + let written = 0; + for (const row of rows) { + const container = row.worktree_path; + const target = WorkspaceRepositories.isolatedRunWorkspaceFilePath(container); + if (yield* fileSystem.exists(target).pipe(Effect.orElseSucceed(() => true))) continue; + const layout = yield* workspaceRepositories.readIsolatedRun(container); + if (layout === null || !(yield* holdsForkCopy(container))) continue; + const projectWorkspaceFile = yield* workspaceRepositories.workspaceFile(row.workspace_root); + if (projectWorkspaceFile === null) continue; + + yield* writeIsolatedRunWorkspaceFile({ + projectWorkspaceFile, + container, + repositories: layout.repositories.map((repository) => ({ + source: repository.source, + worktree: path.resolve(container, repository.relativePath), + })), + }).pipe( + Effect.tap(() => + Effect.sync(() => { + written += 1; + }), + ), + Effect.catch((cause) => + Effect.logWarning("Could not write a multi-repo fork thread's workspace file", { + threadId: row.thread_id, + cause, + }), + ), + ); + } + return written; + }, +); diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 67bff5023436..3ec169a7a15f 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -34,6 +34,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as WorkspaceRepositories from "./workspace/WorkspaceRepositories.ts"; it.effect("parks automatic pull until activation without delaying command readiness", () => Effect.scoped( @@ -140,6 +141,7 @@ it.effect("parks automatic pull until activation without delaying command readin listShells: () => Effect.succeed(snapshot.projects), }), Layer.mock(ProjectService.ProjectService)({ snapshot: Effect.succeed(snapshot) }), + Layer.mock(WorkspaceRepositories.WorkspaceRepositories)({}), Layer.mock(ThreadManagement.ThreadManagementService)({ getShellSnapshot: () => Effect.succeed({ diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 5ae83d8cdfdd..cd34a1234181 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -32,7 +32,10 @@ import * as Keybindings from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import * as EffectWorker from "./orchestration-v2/EffectWorker.ts"; import * as LegacyV1ThreadImporter from "./orchestration-v2/legacy/LegacyV1ThreadImporter.ts"; -import { repairMultiRepoForkWorktrees } from "./orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts"; +import { + repairMultiRepoForkWorktrees, + writeMultiRepoForkWorkspaceFiles, +} from "./orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts"; import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; import * as ProviderRuntimeRecovery from "./orchestration-v2/ProviderRuntimeRecoveryService.ts"; import * as ProviderSessionManager from "./orchestration-v2/ProviderSessionManager.ts"; @@ -553,6 +556,19 @@ const make = (options?: StartupOptions) => ), ), ); + yield* runStartupPhase( + "orchestration-v2.legacy-v1.multi-repo-fork-workspace-files", + writeMultiRepoForkWorkspaceFiles().pipe( + Effect.tap((written) => + written === 0 + ? Effect.void + : Effect.logInfo("Wrote workspace files for multi-repo fork threads", { written }), + ), + Effect.catch((cause) => + Effect.logWarning("Failed to write multi-repo fork thread workspace files", { cause }), + ), + ), + ); // Runs after activation: the status check fetches every enabled project's // remote, and awaiting it here held command readiness for that long. yield* runStartupPhase( From 4792bfc40ab0403b7779a663119976a657730824 Mon Sep 17 00:00:00 2001 From: Stefan van der Velden Date: Thu, 15 Oct 2026 13:42:03 +1100 Subject: [PATCH 2/2] fix(server): only fork multi-repo threads get the workspace file, written atomically The startup step now only considers threads the pre-v2 fork recorded with more than one worktree, so a database that never ran the fork touches no files. The workspace file is written via a temp file and rename, so an existing file is always complete and safe to skip. Co-Authored-By: Claude Opus 5.5 --- .../src/git/isolatedRunWorkspaceFile.ts | 4 ++- .../repairMultiRepoForkWorktrees.test.ts | 35 +++++++++++++++++++ .../legacy/repairMultiRepoForkWorktrees.ts | 8 +++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/apps/server/src/git/isolatedRunWorkspaceFile.ts b/apps/server/src/git/isolatedRunWorkspaceFile.ts index 807fb13d0b9a..3a82a6c28e1c 100644 --- a/apps/server/src/git/isolatedRunWorkspaceFile.ts +++ b/apps/server/src/git/isolatedRunWorkspaceFile.ts @@ -9,6 +9,7 @@ * * @module isolatedRunWorkspaceFile */ +import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; @@ -62,7 +63,8 @@ export const writeIsolatedRunWorkspaceFile = Effect.fn("writeIsolatedRunWorkspac ); const target = WorkspaceRepositories.isolatedRunWorkspaceFilePath(input.container); const contents = yield* encodeDocument({ ...document, folders }); - yield* fileSystem.writeFileString(target, `${contents}\n`); + // Atomic, so a file that exists is a complete one and callers can skip it. + yield* writeFileStringAtomically({ filePath: target, contents: `${contents}\n` }); return target; }, ); diff --git a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts index fc3de9037e16..045f04ca9f72 100644 --- a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts +++ b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.test.ts @@ -208,6 +208,8 @@ const makeForkThread = Effect.fn("makeForkThread")(function* (input: { readonly threadId: string; readonly projectRoot: string; readonly container: string; + /** How many worktrees the fork recorded for the thread; 2 unless set. */ + readonly forkWorktrees?: number; }) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -245,6 +247,14 @@ const makeForkThread = Effect.fn("makeForkThread")(function* (input: { INSERT INTO projection_projects (project_id, workspace_root) VALUES (${input.threadId}, ${input.projectRoot}) `; + const forkWorktrees = ["api", "web"].slice(0, input.forkWorktrees ?? 2).map((name) => ({ + repoRoot: path.join(input.projectRoot, name), + worktreePath: path.join(input.container, name), + })); + yield* sql` + INSERT INTO projection_threads (thread_id, worktrees_json) + VALUES (${input.threadId}, ${JSON.stringify(forkWorktrees)}) + `; }); const createWorkspaceFileTables = Effect.gen(function* () { @@ -258,6 +268,12 @@ const createWorkspaceFileTables = Effect.gen(function* () { ) `; yield* sql`CREATE TABLE projection_projects (project_id TEXT PRIMARY KEY, workspace_root TEXT)`; + yield* sql` + CREATE TABLE projection_threads ( + thread_id TEXT PRIMARY KEY, + worktrees_json TEXT NOT NULL DEFAULT '[]' + ) + `; }); it.effect("writes V2's workspace file for fork threads and keeps the fork's copy", () => @@ -316,3 +332,22 @@ it.effect("leaves V2 isolated runs that link the project's workspace file alone" ); }).pipe(Effect.provide(workspaceFileLayer)), ); + +it.effect("leaves threads the fork gave a single worktree alone", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-fork-workspace-" }); + const projectRoot = path.join(baseDir, "team"); + const container = path.join(baseDir, "worktrees", "project-1", "thread-1"); + yield* createWorkspaceFileTables; + yield* makeForkThread({ threadId: "thread-1", projectRoot, container, forkWorktrees: 1 }); + // A real file in the container, as a root repository that commits one would leave. + yield* fileSystem.writeFileString(path.join(container, "team.code-workspace"), forkWorkspace); + + assert.strictEqual(yield* writeMultiRepoForkWorkspaceFiles(), 0); + assert.isFalse( + yield* fileSystem.exists(WorkspaceRepositories.isolatedRunWorkspaceFilePath(container)), + ); + }).pipe(Effect.provide(workspaceFileLayer)), +); diff --git a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts index fde7ddd8aa68..e661e8c571db 100644 --- a/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts +++ b/apps/server/src/orchestration-v2/legacy/repairMultiRepoForkWorktrees.ts @@ -125,6 +125,11 @@ export const writeMultiRepoForkWorkspaceFiles = Effect.fn("writeMultiRepoForkWor const path = yield* Path.Path; const workspaceRepositories = yield* WorkspaceRepositories.WorkspaceRepositories; + const columns = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; + if (!columns.some((column) => column.name === "worktrees_json")) return 0; + + // Only threads the fork gave several worktrees can hold its copy, so a database that + // never ran the fork touches no files. const rows = yield* sql<{ readonly thread_id: string; readonly worktree_path: string; @@ -136,8 +141,11 @@ export const writeMultiRepoForkWorkspaceFiles = Effect.fn("writeMultiRepoForkWor project.workspace_root FROM orchestration_v2_projection_threads AS thread INNER JOIN projection_projects AS project ON project.project_id = thread.project_id + INNER JOIN projection_threads AS fork ON fork.thread_id = thread.thread_id WHERE thread.deleted_at IS NULL AND json_extract(thread.payload_json, '$.worktreePath') IS NOT NULL + AND CASE WHEN json_valid(fork.worktrees_json) + THEN json_array_length(fork.worktrees_json) ELSE 0 END > 1 `; // Only the fork left a real workspace file in a container; V2 links the project's in.