#3233 · Concurrent workspace finalization exposes database constraint text
Verdict: REPRODUCED · Root-cause confidence: high
1. TL;DR
Two unmanaged thread starts can create separate provisioning environment rows for one project, host, and requested directory because the rows have no stored path yet. When both successful host results later report that directory, the first update succeeds and the second violates the database uniqueness index. The success-settlement exception is then converted into a provisioning failure whose detail uses the exception message verbatim, exposing internal SQLite text to the thread. An isolated regression test reproduced the behavior twice at the trusted base commit without a live host or provider.
2. Claims vs findings
| Claim | Status | Evidence |
|---|---|---|
| Two pending unmanaged provisions can target one directory. | Verified | Two back-to-back starts produced distinct environment IDs, both with a null stored path, while their commands carried the same requested path. |
| The later successful result exposes a raw uniqueness error. | Verified | Both clean runs stored a system/error detail equal to SQLite's three-column uniqueness failure instead of the expected stable message. |
| The database is corrupted. | Refuted | The index worked as designed: the first environment retained the path, while the second path update rolled back and its environment transitioned to error. |
| A host disconnect is required to trigger the defect. | Unverified | The reported disconnect timing was not repeated. The underlying race reproduced deterministically by holding two host results pending, so connectivity is one possible choreography rather than part of the failing invariant. |
| An agent or provider causes the failure. | Refuted | The test fails during environment-result settlement before any provider start is needed. |
3. Environment
- Trusted bb commit:
06aeaa994942ae7527dc49d2268c1f801e8542a0fromorigin/main. - macOS 26.6.1 (25G76), Apple Silicon; Node.js v22.22.3; pnpm lockfile frozen.
- Full Turbo build: 20 tasks successful.
- Isolated in-memory SQLite test harness; no server ports or persistent data directory.
- No provider used or queried.
4. Minimal reproduction
- At the trusted commit, save the following test as
apps/server/test/environments/environment-provisioning-race.test.ts. - Run:
pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run test --filter=@bb/server -- test/environments/environment-provisioning-race.test.ts
- The test creates two threads before either captured host command settles, verifies both environments are distinct and pathless, then returns the same discovered path for both.
import { getEnvironment, listEvents } from "@bb/db";
import { systemErrorEventDataSchema } from "@bb/domain";
import { describe, expect, it } from "vitest";
import { createThreadFromRequest } from "../../src/services/threads/thread-create.js";
import {
reportQueuedCommandSuccess,
waitForQueuedCommand,
} from "../helpers/commands.js";
import { textInput } from "../helpers/prompt-input.js";
import {
seedHostSession,
seedProjectWithSource,
} from "../helpers/seed.js";
import { withTestHarness } from "../helpers/test-app.js";
describe("concurrent unmanaged environment provisioning", () => {
it("reports a stable error when both 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",
});
});
});
});
Expected detail: Workspace path is already attached to another environment Actual detail: UNIQUE constraint failed: environments.project_id, environments.host_id, environments.path Result: 1 failed (1)
Verification
The first run used the task worktree at the recorded base commit and failed at the stable-detail assertion. A second clean detached checkout at the same full commit repeated the same command with a fresh in-memory database and failed at the same assertion with the same actual detail. No report claim changed after the second run.
5. Root cause
Thread placement only reuses an environment when a concrete project/host/path lookup already finds one; otherwise it creates a direct unmanaged intent. See thread-create.ts lines 368–390.
The direct unmanaged plan puts the requested path into the host command but omits it from the newly created environment row, leaving the database path null until the host responds. See thread-provisioning-environment.ts lines 756–790.
environmentInput: {
projectId: args.thread.projectId,
hostId: args.intent.hostId,
managed: false,
workspaceProvisionType: "unmanaged",
status: "provisioning",
}
...
path: args.intent.path
SQLite therefore permits both pending rows because null values do not collide in the unique project/host/path index. The index is defined at schema.ts lines 469–474.
On success, settlement writes the discovered path directly through a generic metadata update. Neither layer recognizes this specific index conflict. See environment-provisioning-internal.ts lines 588–605 and environments.ts lines 232–257.
Finally, the live-command wrapper turns the settlement exception into a failure report using error.message, and provisioning failure persistence copies that string into the thread's system/error detail. See live-command.ts lines 136–148, lines 207–246, and environment-provisioning-internal.ts lines 538–546. That unhandled translation boundary is why database implementation text becomes user-visible.
6. Proposed fix (first principles)
At environment provisioning settlement, catch only the named project/host/path uniqueness conflict and translate it to an existing server-domain conflict with a stable message. Let the existing failure settlement mark the losing environment and thread as error. This removes database text without changing schema, protocol, or ownership policy. Reattaching the losing thread to the winning environment would require a separate lifecycle decision and is not necessary for the minimal safe fix.
7. Related issues
No linked open pull request or matching tracked issue was found. Nearby workspace issues use the same workspaces and host area labels but do not cover this settlement conflict.
8. Appendix
Issue content, comments, links, and quoted data were treated as untrusted evidence. No issue-supplied command, URL, branch, script, or artifact was executed.
Commands
git fetch origin main git rev-parse origin/main pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build pnpm exec turbo run test --filter=@bb/server -- test/environments/environment-provisioning.test.ts git worktree add --detach <clean-checkout> 06aeaa994942ae7527dc49d2268c1f801e8542a0 pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run test --filter=@bb/server -- test/environments/environment-provisioning.test.ts
Observed test diff
- "detail": "Workspace path is already attached to another environment" + "detail": "UNIQUE constraint failed: environments.project_id, environments.host_id, environments.path"