Skip to content

Host reconnect retry can race environment finalization and surface raw UNIQUE constraint error #3233

Description

@guanbear

Summary

After a local unmanaged thread failed provisioning while the Host was disconnected, retrying the same project/source path and then reconnecting the Host left one environment ready while the other thread failed with a raw SQLite error:

UNIQUE constraint failed: environments.project_id, environments.host_id, environments.path

Observed behavior and source inspection suggest that two initially pathless provisioning environments can race when their successful results later record the same concrete workspace path. The later completion should reuse/attach to the existing environment, or fail with a stable domain error, rather than surface a database constraint message.

Versions and environment

  • bb 0.42.1, macOS desktop app
  • macOS 26.6 (25G72), Apple Silicon
  • Local Host, unmanaged workspace, standard project with a local-path source
  • Source inspected at current main: 06aeaa994942ae7527dc49d2268c1f801e8542a0
  • Provider choice is not relevant; the failure occurs during environment provisioning, before agent work starts

Steps to reproduce

This was reproduced once with the following timing-dependent sequence on 0.42.1:

  1. Create a standard project whose source is an existing local directory.
  2. Start a thread in that unmanaged directory while the local Host connection is unavailable or drops during provisioning.
  3. Observe the first thread fail with Host is not connected.
  4. Before all provisioning work from that attempt has fully settled, retry by creating another thread for the same project, Host, and source directory.
  5. Reconnect/restart the Host so queued/in-flight provisioning results settle.
  6. Observe one environment become ready; the other thread fails with the raw unique-constraint error.

I did not deliberately recreate the race against main, because doing so creates additional failed environment records. I inspected the current main completion path instead; it still writes the discovered path without handling this unique conflict.

Expected vs actual

Actual first failure event:
{"code":"thread_provisioning_failed","message":"Provisioning thread failed","detail":"Host is not connected"}

Actual retry failure event:
{"code":"thread_provisioning_failed","message":"Provisioning thread failed","detail":"UNIQUE constraint failed: environments.project_id, environments.host_id, environments.path"}

Expected:
- Provisioning completion is idempotent for a project/Host/path tuple, or
- the second completion attaches/reuses the already-ready environment when safe, or
- the thread receives a stable domain error such as environment_already_attached.

The raw SQLite constraint message should not be exposed as the user-facing provisioning failure.

Evidence

The affected project contained these environment states after the race (workspace path redacted):

environment       path                status      provision type
env_e72m2rg9fh    NULL                error       unmanaged
env_zw7f9huykk    NULL                error       unmanaged
env_8qtmiqs3sn    <workspace-path>    ready       unmanaged
env_yazb6h4p4a    NULL                destroyed   managed-worktree

Affected local thread IDs:

  • thr_ktsk6jq7vv: Host is not connected
  • thr_76e29ymjc6: unique constraint failure

Current main source observations:

  • Thread creation reuses an environment only when its concrete path is already known:
    function existingUnmanagedEnvironmentIntentByHostPath(
    deps: ThreadCreateDeps,
    args: ExistingUnmanagedEnvironmentIntentByHostPathArgs,
    ): ExistingUnmanagedEnvironmentIntentResult | null {
    const existing = findProjectEnvironmentByHostPath(
    deps.db,
    args.request.projectId,
    args.hostId,
    args.path,
    );
    if (!existing) {
    return null;
    }
    if (!args.branch) {
    if (existing.status === "ready" || existing.status === "provisioning") {
    return {
    environmentId: existing.id,
    intent: {
    type: "reuse",
    environmentId: existing.id,
    },
    };
  • The database intentionally enforces uniqueness for (project_id, host_id, path):
    (table) => [
    uniqueIndex("environments_project_host_path_idx").on(
    table.projectId,
    table.hostId,
    table.path,
    ),
  • A successful Host result records the discovered path directly, without translating or reconciling a unique conflict:
    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),
    },
    );
    const provisionedOutcome =
    applyLoggedEnvironmentLifecycleEventInTransaction(args.deps, {
    environmentId: args.command.environmentId,
    event: { type: "provision.succeeded" },
  • The underlying metadata update performs a direct update:
    export function recordProvisionedEnvironmentWorkspace(
    db: EnvironmentWriteConnection,
    notifier: DbNotifier,
    id: string,
    input: RecordProvisionedEnvironmentWorkspaceInput,
    ) {
    return updateEnvironmentMetadataRecord(db, notifier, id, {
    path: input.path,
    isGitRepo: input.isGitRepo,
    isWorktree: input.isWorktree,
    branchName: input.branchName,
    defaultBranch: input.defaultBranch,
    ...(input.baseBranch !== undefined ? { baseBranch: input.baseBranch } : {}),
    ...(input.mergeBaseBranch !== undefined
    ? { mergeBaseBranch: input.mergeBaseBranch }
    : {}),
    });

What I ruled out

  • Not database corruption: exactly one environment owns the concrete path and is ready; the failed records remain pathless.
  • Not an agent/provider failure: both errors occurred during thread/environment provisioning.
  • Not a failure of the later workflow run: a separate thread completed successfully after Host recovery.
  • No matching open or closed issue was found by searching for the exact constraint text and for environment provisioning/retry terms.
  • Not confirmed by a second runtime reproduction on main; only the still-unhandled source path was verified at 06aeaa9.

Suggested priority and effort

Medium — timing-dependent and recoverable by using the surviving ready environment, but retries after a transient Host disconnect can create failed threads and expose an internal database error. Likely medium effort because safe reuse must account for thread/environment lifecycle ownership, not merely catch and suppress the exception.

AGENT GENERATED

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmed-reproBug reproduced again from a clean trusted checkout; see linked reporthostHost daemon, process lifecycle, memory, event loopworkspacesWorktrees, environments, git, shells

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions