#3609 · Path claim contention during checkout attachment

Bug · Priority: Medium · Effort: Medium · workspaces

Issue · 2026-09-13 · trusted base cf51227e1135309a3c9c0baf5630be1ca7ba2714

REPRODUCED · Root-cause confidence: high

TL;DR

Overlapping requests to attach threads to one checkout can fail before either request changes a branch. The checkout provider takes an exclusive database path claim for every attachment and immediately fails when another request holds it. The first request retains its claim until the server finishes binding its thread. A deterministic server integration test reproduces the failure in two clean trusted-base checkouts using real migrated SQLite and the actual checkout provider.

Claims vs findings

ClaimFindingEvidence
Branchless attachments contendVerifiedSecond preparation reaches error while first holds the seeded ready environment; both contexts have empty inputs.
Failure occurs before host inspectionVerifiedProvider returns on a refused claim before calling attach.
Error refers to branch checkoutVerified in sourceRefused claim returns LIVE_THREAD_MESSAGE unconditionally.
Reported CLI frequency, provider versions and environment-ID workaroundUnverified liveNo real agent, CLI spawn loop, or Linux instance was used. This test isolates the underlying server race.

Environment

Darwin 25.6.0 arm64; Node v22.22.3; pnpm 9.15.0 through Corepack. Both worktrees started at the full commit above. Frozen installs and Turbo builds passed. Each test uses the repository server harness with a fresh migrated in-memory SQLite database. Host attach responses are synthetic; the database, provider create function, claim acquisition, path binding, and attachment release are real repository code. No development server, network-facing plugin, provider turn, or live runtime data was used.

Minimal reproduction

  1. Create a clean checkout of the trusted base commit.
  2. Install and build with the repository's pinned package manager.
  3. Save the inline test-only patch as regression.patch, apply it and run the focused test.
git checkout cf51227e1135309a3c9c0baf5630be1ca7ba2714
corepack pnpm install --frozen-lockfile --prefer-offline
corepack pnpm exec turbo run build
git apply regression.patch
corepack pnpm exec turbo run test --filter=@bb/server --force -- provider-orchestration.test.ts -t 'serializes concurrent branchless'

The test-only patch is included below. The test seeds one ready environment, starts an actual checkout-provider preparation, waits until its claim has moved to that environment, starts a competitor, waits for its real claim attempt, then completes the first attachment. Expected: the second preparation reaches ready on the same environment ID. Actual on trusted main:

AssertionError: expected { status: 'error', …(1) } to match object { status: 'ready' }
- "status": "ready"
+ "status": "error"

Reproduction test

diff --git a/apps/server/test/services/environments/provider-orchestration.test.ts b/apps/server/test/services/environments/provider-orchestration.test.ts
index 869a2c3bd..3fae0848c 100644
--- a/apps/server/test/services/environments/provider-orchestration.test.ts
+++ b/apps/server/test/services/environments/provider-orchestration.test.ts
@@ -1,3 +1,4 @@
+import checkoutPlugin from "../../../../../plugins/environment-project-checkout/server.js";
 import { appendThreadProvisioningEvent } from "../../../src/services/threads/thread-events.js";
 import { requestThreadStopForCurrentState } from "../../../src/services/threads/thread-lifecycle.js";
 import { prepareProviderEnvironment } from "../../../src/services/threads/thread-environment-placement.js";
@@ -1818,3 +1819,68 @@ it("keeps a shared workspace ready when its preparing owner cancels before attac
     expect(remove).not.toHaveBeenCalled();
   });
 });
+
+
+it("serializes concurrent branchless checkout attaches until the first thread is bound", async () =>
+  withTestHarness(async (harness) => {
+    const fake = createFakePluginHost({
+      pluginId: "environment-project-checkout",
+      experimental_callHostRpc: (call) => {
+        if (call.method !== "attach") throw new Error("Unexpected host call");
+        return { status: "attached", path: "/tmp/project", branchName: "main" };
+      },
+    });
+    try {
+      await checkoutPlugin(fake.bb);
+      const provider = fake.harness.registrations.environmentProviders.get("project-checkout");
+      if (!provider) throw new Error("Missing checkout provider");
+      let claimAttempts = 0;
+      let competingClaim = () => {};
+      const competing = new Promise<void>((resolve) => { competingClaim = resolve; });
+      const fixture = setup(harness, {
+        id: provider.id,
+        create: (context) => provider.create({
+          ...context,
+          experimental_claimPath: async (path) => {
+            const claimed = await context.experimental_claimPath(path);
+            if (++claimAttempts === 2) competingClaim();
+            return claimed;
+          },
+        }),
+        remove: provider.remove,
+        requires: { projectCheckout: true },
+      });
+      fixture.context.projectCheckout = { path: "/tmp/project", experimental_ownsPath: false };
+      fixture.context.inputs = {};
+      const existing = createEnvironment(harness.db, harness.hub, {
+        projectId: fixture.context.project.id,
+        hostId: fixture.host.id,
+        path: "/tmp/project",
+        status: "ready",
+        providerOwnsPath: false,
+      });
+      fixture.ask();
+      await fixture.settled();
+      expect(fixture.row().id).toBe(existing.id);
+      expect(fixture.row().claimPath).toBe("/tmp/project");
+      const competitor = seedThread(harness.deps, {
+        projectId: fixture.context.project.id,
+        status: "starting",
+      });
+      prepareProviderEnvironment(harness.deps, fixture.record, {
+        ...fixture.context,
+        thread: toThreadResponseFromThread(harness.deps, { thread: competitor }),
+      });
+      await competing;
+      fixture.attach();
+      await expect.poll(() => {
+        const row = getPreparingEnvironment(harness.db, competitor.id);
+        return { status: row?.status, message: row?.statusMessage };
+      }).toMatchObject({ status: "ready" });
+      const second = getPreparingEnvironment(harness.db, competitor.id);
+      expect(second?.id).toBe(existing.id);
+      markProviderEnvironmentAttached(harness.db, competitor.id, existing.id);
+    } finally {
+      await fake.harness.lifecycle.dispose();
+    }
+  }));

Root cause

Checkout provider create reads an optional branch but claims the path unconditionally. A refused claim immediately returns failed with the branch-checkout error. The later live-thread branch check is conditional; this earlier path check is not.

if (!(await context.experimental_claimPath(path))) {
  return { status: "failed", message: LIVE_THREAD_MESSAGE };
}

claimEnvironmentPath checks the current preparation identity and rejects any competing non-destroyed path claim inside an immediate transaction. bindEnvironmentPath transfers the claim and owner to an existing ready environment. markProviderEnvironmentAttached clears that claim only after attachment. These checks are individually consistent, but the provider interprets transient contention as a permanent failure.

Simply skipping the provider claim would be insufficient: the engine claims again before binding the produced path. Preserving serialization avoids a second conflict and prevents overlap with branch mutations.

Proposed fix

Retry refused claims for branchless attachment, preserving the exclusive claim through binding. Bound the wait by the existing 15-minute attachment timeout and honor cancellation. Continue refusing a competing branch mutation immediately. Use a preparation-contention message for refused claims. A local implementation changes 171 text lines across three files, with no dependencies, stored data, protocols, or public API changes.

Verification

The same agent repeated the reproduction in a second clean detached worktree at cf51227e1135309a3c9c0baf5630be1ca7ba2714, with a separate frozen install, build, and fresh harness database. Turbo caching was explicitly disabled for the second test run. Both runs fail on the same expected-ready/actual-error assertion. This is a deterministic server-level reproduction, not a measurement of live CLI failure rates. No report corrections were required after the second run.

After the fix, 65 environment orchestration tests and 34 checkout-plugin tests pass. Server and plugin typechecks pass. Added tests cover blocked branch requests, timeout, and cancellation without invoking host attach. The existing branch-mutation exclusion test remains in the suite, with its expected contention message updated.

Related issues

No open pull request linked to this issue was found through cross-reference metadata or an open-PR search for its numeric ID. Similar workspace issues do not replace this deterministic contention evidence.

Appendix

Issue content was treated only as untrusted claims. Its suggested commands and implementation instructions were not executed. The reproduction was written from trusted repository code.

Before fix, both clean checkouts: 1 failed, 64 skipped.
After fix, environment orchestration: 65 passed.
After fix, checkout plugin: 34 passed.
Server typecheck: passed. Plugin typecheck: passed.