From 40ec48ca8fb23d9957ed56272624fd4a053bb1a4 Mon Sep 17 00:00:00 2001 From: Michael Appiah Date: Tue, 10 Nov 2026 16:18:20 +0000 Subject: [PATCH 1/2] fix(server): archive projects whose folder no longer exists A project whose folder was deleted, moved, or lives on a disconnected drive stayed in every project list. On startup, the server now archives each active project whose workspace root is gone. Archiving keeps its threads, and Unarchive brings it back. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/serverRuntimeStartup.test.ts | 34 ++++++++++++++++++ apps/server/src/serverRuntimeStartup.ts | 38 ++++++++++++++++++++ docs/user/project-settings.md | 3 ++ 3 files changed, 75 insertions(+) diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index e91a0ac1ac65..29506862eb87 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -364,3 +364,37 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che assert.deepStrictEqual(pulled, ["/inherited"]); }), ); + +it.effect("startup archives active projects whose folder is gone", () => + Effect.gen(function* () { + const archived: string[] = []; + const projects = { + update: (input: { projectId: string; archived?: boolean }) => + Effect.sync(() => { + if (input.archived) archived.push(input.projectId); + return {} as never; + }), + } as unknown as ProjectService.ProjectService["Service"]; + const fileSystem = { + exists: (path: string) => + path === "/unreadable" + ? Effect.fail(new Error("EACCES") as never) + : Effect.succeed(path === "/present"), + } as unknown as FileSystem.FileSystem; + const project = (workspaceRoot: string, archivedAt: string | null = null) => + ({ id: ProjectId.make(workspaceRoot), workspaceRoot, archivedAt }) as never; + + yield* ServerRuntimeStartup.archiveMissingProjects([ + project("/present"), + project("/gone"), + project("/already-archived", "2026-01-01T00:00:00.000Z"), + project("/unreadable"), + ]).pipe( + Effect.provideService(ProjectService.ProjectService, projects), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provide(NodeCrypto.layer), + ); + + assert.deepStrictEqual(archived, ["/gone"]); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 26f91f59affc..0400767f0edc 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -5,6 +5,7 @@ import { DEFAULT_SERVER_SETTINGS, isListedThreadLineage, type ModelSelection, + type OrchestrationProjectShell, type Project, ProjectId, ProviderInstanceId, @@ -20,6 +21,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Queue from "effect/Queue"; @@ -249,6 +251,39 @@ export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( ); }); +// Archive, not delete: a missing folder is often an unplugged drive, and archiving keeps threads. +export const archiveMissingProjects = Effect.fn("archiveMissingProjects")(function* ( + projects: ReadonlyArray>, +) { + const fileSystem = yield* FileSystem.FileSystem; + const projectService = yield* ProjectService.ProjectService; + const random = yield* SecureRandom.SecureRandom; + yield* Effect.forEach( + projects.filter((project) => !project.archivedAt), + (project) => + Effect.gen(function* () { + if (yield* fileSystem.exists(project.workspaceRoot)) return; + yield* projectService.update({ + commandId: CommandId.make(yield* random.uuidV4()), + projectId: project.id, + archived: true, + }); + yield* Effect.logInfo("Archived project whose folder is missing", { + projectId: project.id, + workspaceRoot: project.workspaceRoot, + }); + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to check project folder", { + projectId: project.id, + cause, + }), + ), + ), + { concurrency: 8, discard: true }, + ); +}); + const resolveWelcomeBase = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const segments = serverConfig.cwd.split(/[/\\]/).filter(Boolean); @@ -572,6 +607,9 @@ const make = Effect.fn("serverRuntimeStartup.make")(function* (options?: Startup "projects.auto-pull", Effect.gen(function* () { const projects = yield* (yield* ProjectStore.ProjectStoreV2).listShells(); + yield* archiveMissingProjects(projects).pipe( + Effect.provideService(SecureRandom.SecureRandom, random), + ); const settings = yield* serverSettings.getSettings; yield* autoPullProjects(projects, settings); }), diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 8880dcb3e64c..1165b98a961a 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -60,6 +60,9 @@ Its threads, settings, and files stay as they are, and archived projects still s Settings project list, marked **Archived**. Choose one there and select **Unarchive** to bring it back. On mobile, both are in **Settings → Projects → Organize**. +When T3 Code starts, it archives any project whose folder no longer exists, such as one you +deleted or moved, or one on a drive that isn't connected. Unarchive it once the folder is back. + A project on several machines must have every machine connected to pin or archive it. ## Worktree branch names From 4c9c7309f145793a7c46f8f6e50bb49331197213 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 10 Nov 2026 18:11:26 +0000 Subject: [PATCH 2/2] test(server): keep the auto-pull startup project folder present --- apps/server/src/serverRuntimeStartup.autoPull.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/serverRuntimeStartup.autoPull.test.ts b/apps/server/src/serverRuntimeStartup.autoPull.test.ts index 549aae79e8be..e7a3a07d7bb8 100644 --- a/apps/server/src/serverRuntimeStartup.autoPull.test.ts +++ b/apps/server/src/serverRuntimeStartup.autoPull.test.ts @@ -160,7 +160,7 @@ it.effect("parks automatic pull until activation without delaying command readin }), Layer.mock(AnalyticsService.AnalyticsService)({ record: () => Effect.void }), // No provider logins or Connect link, so startup records no owners. - FileSystem.layerNoop({}), + FileSystem.layerNoop({ exists: () => Effect.succeed(true) }), Layer.mock(ServerSecretStore.ServerSecretStore)({ get: () => Effect.succeedNone }), NodeCrypto.layer, Layer.mock(EnvironmentAuth.EnvironmentAuth)({}),