diff --git a/apps/server/src/services/environments/environment-provisioning-internal.ts b/apps/server/src/services/environments/environment-provisioning-internal.ts index 89f4ded675..50fac9e1d4 100644 --- a/apps/server/src/services/environments/environment-provisioning-internal.ts +++ b/apps/server/src/services/environments/environment-provisioning-internal.ts @@ -8,6 +8,7 @@ import { getAppSettings, getEnvironment, getThread, + isSqliteUniqueConstraintOnColumns, listStoredThreadProvisioningRowsByProvisioningId, threads, } from "@bb/db"; @@ -586,19 +587,37 @@ export function settleEnvironmentProvisionCommandResult( .all(); if (args.report.ok) { - recordProvisionedEnvironmentWorkspace( - args.deps.db, - args.deps.hub, - args.command.environmentId, - { - path: args.report.result.path, - isGitRepo: args.report.result.isGitRepo, - isWorktree: args.report.result.isWorktree, - branchName: args.report.result.branchName, - defaultBranch: args.report.result.defaultBranch, - ...resolveProvisionedEnvironmentBranchMetadata(args.command), - }, - ); + try { + recordProvisionedEnvironmentWorkspace( + args.deps.db, + args.deps.hub, + args.command.environmentId, + { + path: args.report.result.path, + isGitRepo: args.report.result.isGitRepo, + isWorktree: args.report.result.isWorktree, + branchName: args.report.result.branchName, + defaultBranch: args.report.result.defaultBranch, + ...resolveProvisionedEnvironmentBranchMetadata(args.command), + }, + ); + } catch (error) { + if ( + error instanceof Error && + isSqliteUniqueConstraintOnColumns(error, { + columnNames: ["project_id", "host_id", "path"], + indexName: "environments_project_host_path_idx", + tableName: "environments", + }) + ) { + throw new ApiError( + 409, + "invalid_request", + "Workspace path is already attached to another environment", + ); + } + throw error; + } const provisionedOutcome = applyLoggedEnvironmentLifecycleEventInTransaction(args.deps, { environmentId: args.command.environmentId, diff --git a/apps/server/test/environments/environment-provisioning.test.ts b/apps/server/test/environments/environment-provisioning.test.ts index 56cc82b46d..7731ed420d 100644 --- a/apps/server/test/environments/environment-provisioning.test.ts +++ b/apps/server/test/environments/environment-provisioning.test.ts @@ -5,7 +5,10 @@ import { listEvents, threads, } from "@bb/db"; -import { systemThreadProvisioningEventDataSchema } from "@bb/domain"; +import { + systemErrorEventDataSchema, + systemThreadProvisioningEventDataSchema, +} from "@bb/domain"; import { describe, expect, it, vi } from "vitest"; import { ApiError } from "../../src/errors.js"; import { @@ -334,6 +337,87 @@ describe("environment reprovisioning", () => { }); }); + it("reports a stable error when concurrent unmanaged provisions resolve to the same path", async () => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host-concurrent-unmanaged-path", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/concurrent-unmanaged-project", + }); + const createThread = (input: string) => + createThreadFromRequest(harness.deps, { + startedOnBehalfOf: null, + environment: { + type: "host", + hostId: host.id, + workspace: { + type: "unmanaged", + path: "/tmp/concurrent-unmanaged-workspace", + }, + }, + input: textInput(input), + origin: "cli", + projectId: project.id, + providerId: "codex", + }); + + const firstThread = await createThread("first concurrent provision"); + const secondThread = await createThread("second concurrent provision"); + const firstProvision = await waitForQueuedCommand( + harness, + ({ command }) => + command.type === "environment.provision" && + command.initiator?.threadId === firstThread.id, + ); + const secondProvision = await waitForQueuedCommand( + harness, + ({ command }) => + command.type === "environment.provision" && + command.initiator?.threadId === secondThread.id, + ); + if ( + firstProvision.command.type !== "environment.provision" || + secondProvision.command.type !== "environment.provision" + ) { + throw new Error("Expected environment provision commands"); + } + expect(firstProvision.command.environmentId).not.toBe( + secondProvision.command.environmentId, + ); + expect( + getEnvironment(harness.db, firstProvision.command.environmentId)?.path, + ).toBeNull(); + expect( + getEnvironment(harness.db, secondProvision.command.environmentId)?.path, + ).toBeNull(); + + const result = { + path: "/tmp/concurrent-unmanaged-workspace", + isGitRepo: true, + isWorktree: false, + branchName: "main", + defaultBranch: "main", + transcript: [], + }; + await reportQueuedCommandSuccess(harness, firstProvision, result); + await reportQueuedCommandSuccess(harness, secondProvision, result); + + const secondError = listEvents(harness.db, { + threadId: secondThread.id, + }).find((event) => event.type === "system/error"); + expect(secondError).toBeDefined(); + expect( + systemErrorEventDataSchema.parse(JSON.parse(secondError?.data ?? "{}")), + ).toMatchObject({ + code: "thread_provisioning_failed", + message: "Provisioning thread failed", + detail: "Workspace path is already attached to another environment", + }); + }); + }); + it("finalizes a tombstoned thread instead of activating it when provisioning succeeds late", async () => { await withTestHarness(async (harness) => { const { host } = seedHostSession(harness.deps, {