Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getAppSettings,
getEnvironment,
getThread,
isSqliteUniqueConstraintOnColumns,
listStoredThreadProvisioningRowsByProvisioningId,
threads,
} from "@bb/db";
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 85 additions & 1 deletion apps/server/test/environments/environment-provisioning.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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, {
Expand Down
Loading