From ae4ba58aa841f9f76725d7f1254b466004a0c84c Mon Sep 17 00:00:00 2001 From: chief-archivist Date: Sun, 25 Oct 2026 17:06:09 +0100 Subject: [PATCH 1/4] fix(server): removing a failed clone no longer leaves a stuck banner Deleting a project committed before its in-memory clone record was discarded, so a client that disconnected in between left a "Failed to clone" banner that Remove could no longer clear. The delete and discard now run uninterruptibly, and the HTTP project API discards too. Co-Authored-By: Claude Opus 5.5 --- .../src/project/ProjectMutation.test.ts | 60 +++++++++++++++++++ apps/server/src/project/ProjectMutation.ts | 20 +++++++ apps/server/src/project/http.ts | 6 +- apps/server/src/ws.ts | 7 +-- 4 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/project/ProjectMutation.test.ts diff --git a/apps/server/src/project/ProjectMutation.test.ts b/apps/server/src/project/ProjectMutation.test.ts new file mode 100644 index 0000000000000..ef855cacbc0e8 --- /dev/null +++ b/apps/server/src/project/ProjectMutation.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "@effect/vitest"; +import { CommandId, type Project, ProjectId } from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; + +import { mutateProjectWithClone } from "./ProjectMutation.ts"; + +const projectId = ProjectId.make("project-1"); +const deletedProject: Project = { + id: projectId, + title: "kh-frontend", + workspaceRoot: "/workspace/kh-frontend", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + projectIcon: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: "2026-01-01T00:00:00.000Z", +}; + +describe("mutateProjectWithClone", () => { + it.effect("discards the clone record even when the caller disconnects mid-delete", () => + Effect.gen(function* () { + const committed = yield* Deferred.make(); + const finishDelete = yield* Deferred.make(); + const discarded: Array = []; + const projects = { + create: () => Effect.die("unused"), + update: () => Effect.die("unused"), + // The delete event is already durable while its post-commit cleanup runs. + delete: () => + Deferred.succeed(committed, undefined).pipe( + Effect.andThen(Deferred.await(finishDelete)), + Effect.as(deletedProject), + ), + }; + const clones = { + discard: (id: ProjectId) => Effect.sync(() => void discarded.push(id)), + }; + + const request = yield* mutateProjectWithClone(projects, clones, { + type: "project.delete", + commandId: CommandId.make("command-1"), + projectId, + }).pipe(Effect.forkChild); + yield* Deferred.await(committed); + // Delivered synchronously so it lands before the delete can finish. + request.interruptUnsafe(); + yield* Deferred.succeed(finishDelete, undefined); + yield* Fiber.await(request); + + expect(discarded).toEqual([projectId]); + }), + ); +}); diff --git a/apps/server/src/project/ProjectMutation.ts b/apps/server/src/project/ProjectMutation.ts index 6f5747ee7f5e6..bffda7689deec 100644 --- a/apps/server/src/project/ProjectMutation.ts +++ b/apps/server/src/project/ProjectMutation.ts @@ -1,6 +1,7 @@ import { type ProjectMutation } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import { type ProjectCloneTracker } from "./ProjectCloneTracker.ts"; import { type ProjectService } from "./ProjectService.ts"; type ProjectMutations = Pick; @@ -51,3 +52,22 @@ export const projectMutationOperation = Effect.fn("projectMutationOperation")(fu }); } }); + +/** + * A deleted project's clone record goes with it. The record lives only in + * memory and nothing else removes it, so a client that disconnects after the + * delete commits must not skip the discard: the stale "Failed to clone" banner + * would outlive the project, and Remove could no longer clear it. + */ +export const mutateProjectWithClone = Effect.fn("mutateProjectWithClone")(function* ( + projects: ProjectMutations, + clones: Pick, + mutation: ProjectMutation, +) { + if (mutation.type !== "project.delete") + return yield* projectMutationOperation(projects, mutation); + return yield* projectMutationOperation(projects, mutation).pipe( + Effect.tap(() => clones.discard(mutation.projectId)), + Effect.uninterruptible, + ); +}); diff --git a/apps/server/src/project/http.ts b/apps/server/src/project/http.ts index 825e6c1cc8f43..0c4c7a44a5f25 100644 --- a/apps/server/src/project/http.ts +++ b/apps/server/src/project/http.ts @@ -14,8 +14,9 @@ import { } from "../auth/http.ts"; import { traceLocalHandlerWork } from "../cloud/traceRelayRequest.ts"; import * as ServerRuntimeStartup from "../serverRuntimeStartup.ts"; +import * as ProjectCloneTracker from "./ProjectCloneTracker.ts"; import * as ProjectService from "./ProjectService.ts"; -import { projectMutationOperation } from "./ProjectMutation.ts"; +import { mutateProjectWithClone } from "./ProjectMutation.ts"; const failProjectMutation = Effect.fn("environment.projects.failMutation")(function* ( cause: ProjectService.ProjectServiceError | ServerRuntimeStartup.ServerRuntimeStartupError, @@ -36,6 +37,7 @@ export const layer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const projects = yield* ProjectService.ProjectService; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; + const projectClones = yield* ProjectCloneTracker.ProjectCloneTracker; return handlers .handle( @@ -56,7 +58,7 @@ export const layer = HttpApiBuilder.group( for (const scope of requiredScopesForProjectMutation(args.payload)) { yield* requireEnvironmentScope(scope); } - const operation = projectMutationOperation(projects, args.payload); + const operation = mutateProjectWithClone(projects, projectClones, args.payload); return yield* startup .enqueueCommand(operation) .pipe(traceLocalHandlerWork, Effect.catch(failProjectMutation)); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a7ef5dedf24fb..b2e05041d326d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -214,7 +214,7 @@ import * as ProjectService from "./project/ProjectService.ts"; import { threadWorkspaceRoot } from "./project/threadWorkspaceRoot.ts"; import * as ManagedProjectFolders from "./project/ManagedProjectFolders.ts"; import * as ScratchThreadPromotion from "./project/ScratchThreadPromotion.ts"; -import { projectMutationOperation } from "./project/ProjectMutation.ts"; +import { mutateProjectWithClone } from "./project/ProjectMutation.ts"; import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; @@ -1856,10 +1856,7 @@ export const layerWsRpc = ( ); const mutateProject = Effect.fn("ws.projects.mutate")(function* (mutation: ProjectMutation) { - const result = yield* projectMutationOperation(projectService, mutation); - if (mutation.type === "project.delete") - yield* projectCloneTracker.discard(mutation.projectId); - return result; + return yield* mutateProjectWithClone(projectService, projectCloneTracker, mutation); }); const handlers = ServerWsRpcGroup.of({ From 7e6d7158afcc861d06abb3c6aa3e3853b874b4a8 Mon Sep 17 00:00:00 2001 From: chief-archivist Date: Sun, 25 Oct 2026 17:18:56 +0100 Subject: [PATCH 2/4] fix(server): deleting a project through MCP also clears its failed clone t3_project_delete called ProjectService.delete directly, so an agent removing a project left its clone record behind and the stale banner stayed. Route it through mutateProjectWithClone like the WebSocket and HTTP transports. Co-Authored-By: Claude Opus 5.5 --- .../src/mcp/toolkits/project/handlers.test.ts | 45 +++++++++++++++++++ .../src/mcp/toolkits/project/handlers.ts | 11 +++-- apps/server/src/mcp/toolkits/project/tools.ts | 2 + 3 files changed, 55 insertions(+), 3 deletions(-) diff --git a/apps/server/src/mcp/toolkits/project/handlers.test.ts b/apps/server/src/mcp/toolkits/project/handlers.test.ts index 03b97c32af6db..4f3aa5a5bb864 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.test.ts @@ -28,6 +28,7 @@ import * as ThreadManagement from "../../../orchestration-v2/ThreadManagementSer import * as ServerConfig from "../../../config.ts"; import * as ServerSettings from "../../../serverSettings.ts"; import * as Project from "../../../project/ProjectService.ts"; +import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as PeerForwarding from "../../../peer/PeerForwarding.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; @@ -531,3 +532,47 @@ it.effect("a client launches with the fallback for an exhausted default", () => expect(launched[0]?.modelSelection).toEqual(fallbackModel); }), ); + +it.effect("deleting a project through MCP also discards its clone record", () => + Effect.gen(function* () { + const discarded: Array = []; + const harness = clientLaunchHarness({ runtimeModeCeiling: "full-access", launched: [] }); + const dependencies = Layer.merge( + harness.dependencies, + Layer.mergeAll( + Layer.mock(Project.ProjectService)({ + delete: (input) => + Effect.succeed({ + id: input.projectId, + title: "client-target", + workspaceRoot: "/projects/client-target", + repositoryIdentity: null, + faviconPath: null, + defaultModelSelection: null, + defaultThreadEnvMode: null, + autoPull: false, + projectIcon: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: "2026-01-01T00:00:00.000Z", + }), + }), + Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ + discard: (projectId) => Effect.sync(() => void discarded.push(projectId)), + }), + ), + ); + const toolkit = yield* ProjectToolkit.pipe( + Effect.provide( + McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), + ), + ); + + yield* toolkit + .handle("t3_project_delete", { projectId: harness.projectId }) + .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); + + expect(discarded).toEqual([harness.projectId]); + }), +); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 014b109448f34..68fb4381cbc27 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -14,6 +14,8 @@ import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts" import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; import * as Project from "../../../project/ProjectService.ts"; +import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts"; +import { mutateProjectWithClone } from "../../../project/ProjectMutation.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as Repositories from "../../../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../../../vcs/GitVcsDriver.ts"; @@ -343,9 +345,12 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { t3_project_delete: McpToolAccess.writesEnvironment((input) => Effect.gen(function* () { const projects = yield* Project.ProjectService; - return yield* projects - .delete({ ...input, commandId: yield* newCommandId() }) - .pipe(Effect.mapError(projectFailure)); + const clones = yield* ProjectCloneTracker.ProjectCloneTracker; + return yield* mutateProjectWithClone(projects, clones, { + ...input, + type: "project.delete", + commandId: yield* newCommandId(), + }).pipe(Effect.mapError(projectFailure)); }), ), t3_project_clone: McpToolAccess.writesEnvironment( diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 3ea5a8ea9e48f..bd7c986f4c48a 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -30,6 +30,7 @@ import * as Crypto from "effect/Crypto"; import * as Schema from "effect/Schema"; import { Tool, Toolkit } from "effect/ai"; import * as ProjectService from "../../../project/ProjectService.ts"; +import * as ProjectCloneTracker from "../../../project/ProjectCloneTracker.ts"; import * as ManagedProjectFolders from "../../../project/ManagedProjectFolders.ts"; import * as ThreadManagementService from "../../../orchestration-v2/ThreadManagementService.ts"; import * as SourceControlRepositoryService from "../../../sourceControl/SourceControlRepositoryService.ts"; @@ -101,6 +102,7 @@ const ProjectDeleteTool = Tool.make("t3_project_delete", { projectId: ProjectId, force: Schema.optionalKey(Schema.Boolean), }), + dependencies: [...shared.dependencies, ProjectCloneTracker.ProjectCloneTracker], }).annotate(Tool.Destructive, true); const ProjectCloneTool = Tool.make("t3_project_clone", { ...shared, From e25558b601029ce8440ed37e8d9b4d273c4612d8 Mon Sep 17 00:00:00 2001 From: chief-archivist Date: Thu, 29 Oct 2026 23:13:22 +0100 Subject: [PATCH 3/4] test(server): MCP test layers provide the clone tracker the project delete tool now needs Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/linkOrigin.test.ts | 2 ++ apps/server/src/peer/PeerForwarding.test.ts | 3 +++ apps/server/src/peer/RemoteDelegation.test.ts | 3 +++ apps/server/src/provider/AgentContextBudget.test.ts | 2 ++ 4 files changed, 10 insertions(+) diff --git a/apps/server/src/mcp/linkOrigin.test.ts b/apps/server/src/mcp/linkOrigin.test.ts index c17f4ba579dd6..253905e1d6373 100644 --- a/apps/server/src/mcp/linkOrigin.test.ts +++ b/apps/server/src/mcp/linkOrigin.test.ts @@ -31,6 +31,7 @@ import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.t import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; @@ -157,6 +158,7 @@ const layerTools = Layer.mergeAll( }), }), ), + Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})), diff --git a/apps/server/src/peer/PeerForwarding.test.ts b/apps/server/src/peer/PeerForwarding.test.ts index 50be40682066b..d653e80754b3d 100644 --- a/apps/server/src/peer/PeerForwarding.test.ts +++ b/apps/server/src/peer/PeerForwarding.test.ts @@ -27,6 +27,7 @@ import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts"; @@ -227,6 +228,7 @@ const serveBox = (seen: Ref.Ref, linkSession: Ref.Ref) => Layer.provide(Layer.mock(ProviderRegistry.ProviderRegistry)({})), Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), + Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(ServerSettings.layerTest()), Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})), @@ -307,6 +309,7 @@ const makeLaptop = Effect.gen(function* () { Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}), Layer.mock(ScheduledTaskService.ScheduledTaskService)({}), Layer.mock(ProjectService.ProjectService)({}), + Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({}), Layer.mock(SecretRequests.SecretRequests)({}), ServerSettings.layerTest(), Layer.mock(RemoteDelegation.RemoteDelegation)({}), diff --git a/apps/server/src/peer/RemoteDelegation.test.ts b/apps/server/src/peer/RemoteDelegation.test.ts index a344f63c523ab..7672251288d8c 100644 --- a/apps/server/src/peer/RemoteDelegation.test.ts +++ b/apps/server/src/peer/RemoteDelegation.test.ts @@ -49,6 +49,7 @@ import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.t import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts"; +import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; import * as ProjectService from "../project/ProjectService.ts"; import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; import * as ProviderRegistry from "../provider/ProviderRegistry.ts"; @@ -495,6 +496,7 @@ const boxToolkitLayer = (thread: BoxThread) => { }), }), ), + Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(ServerSettings.layerTest()), Layer.provide(NodeServices.layer), @@ -594,6 +596,7 @@ const makeLaptop = ( Layer.provide(Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({})), Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})), Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})), Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})), Layer.provide(Layer.mock(ThreadLaunch.ThreadLaunchService)({})), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts index 9a26b53bff4e6..885053c7d0f24 100644 --- a/apps/server/src/provider/AgentContextBudget.test.ts +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -29,6 +29,7 @@ import { RemoteDelegation } from "../peer/RemoteDelegation.ts"; import { PreviewManager } from "../preview/Manager.ts"; import { PreviewBrowser } from "../preview/PreviewBrowser.ts"; import { ManagedProjectFolders } from "../project/ManagedProjectFolders.ts"; +import { ProjectCloneTracker } from "../project/ProjectCloneTracker.ts"; import { ProjectService } from "../project/ProjectService.ts"; import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; import { PullRequestReviewGuideService } from "../pullRequest/PullRequestReviewGuideService.ts"; @@ -58,6 +59,7 @@ const toolHandlerServices = Layer.mergeAll( Layer.mock(PreviewAutomationBroker)({}), Layer.mock(PreviewBrowser)({}), Layer.mock(PreviewManager)({}), + Layer.mock(ProjectCloneTracker)({}), Layer.mock(ProjectService)({}), Layer.mock(ProjectSetupScriptRunner)({}), Layer.mock(ProviderAdapterRegistryV2)({}), From bbaead8e169c3857ea1aacce3e000b6664bb2989 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 30 Oct 2026 04:28:36 +0000 Subject: [PATCH 4/4] test(server): preserve clones when project deletion is rejected --- .../server/src/project/ProjectMutation.test.ts | 18 ++++++++++++++++++ apps/server/src/project/ProjectMutation.ts | 7 +------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/apps/server/src/project/ProjectMutation.test.ts b/apps/server/src/project/ProjectMutation.test.ts index ef855cacbc0e8..9e4e075f06ea0 100644 --- a/apps/server/src/project/ProjectMutation.test.ts +++ b/apps/server/src/project/ProjectMutation.test.ts @@ -4,6 +4,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import { ProjectNotEmptyError } from "./ProjectService.ts"; import { mutateProjectWithClone } from "./ProjectMutation.ts"; const projectId = ProjectId.make("project-1"); @@ -24,6 +25,23 @@ const deletedProject: Project = { }; describe("mutateProjectWithClone", () => { + it.effect("keeps the clone when deletion is rejected", () => + Effect.gen(function* () { + const rejected = new ProjectNotEmptyError({ projectId }); + const result = yield* mutateProjectWithClone( + { + create: () => Effect.die("unused"), + update: () => Effect.die("unused"), + delete: () => Effect.fail(rejected), + }, + { discard: () => Effect.die("must not discard a project that still exists") }, + { type: "project.delete", commandId: CommandId.make("rejected-delete"), projectId }, + ).pipe(Effect.flip); + + expect(result).toBe(rejected); + }), + ); + it.effect("discards the clone record even when the caller disconnects mid-delete", () => Effect.gen(function* () { const committed = yield* Deferred.make(); diff --git a/apps/server/src/project/ProjectMutation.ts b/apps/server/src/project/ProjectMutation.ts index bffda7689deec..8cf70f302b8f2 100644 --- a/apps/server/src/project/ProjectMutation.ts +++ b/apps/server/src/project/ProjectMutation.ts @@ -53,12 +53,7 @@ export const projectMutationOperation = Effect.fn("projectMutationOperation")(fu } }); -/** - * A deleted project's clone record goes with it. The record lives only in - * memory and nothing else removes it, so a client that disconnects after the - * delete commits must not skip the discard: the stale "Failed to clone" banner - * would outlive the project, and Remove could no longer clear it. - */ +// A disconnect after deletion commits must not leave the in-memory clone record behind. export const mutateProjectWithClone = Effect.fn("mutateProjectWithClone")(function* ( projects: ProjectMutations, clones: Pick,