Reports · Issue #4446

Tasks project-default dispatch fails when the sole project source belongs to another host

2026-09-30 · Bug · Medium priority · Medium effort

REPRODUCED · High confidence in the Tasks-to-server host-selection failure. Remote workspace creation and model execution are outside the tested boundary.

Claim and exact observed result

Two synthetic connected hosts are seeded in the real server test database. One is primary; the project has a single default local-path source on the other. The actual Tasks delegation handler sends its project-default preset to the actual server thread-creation API through a test adapter. The server returns HTTP 404 with code invalid_request and message “Project has no local-path source for host”. Tasks keeps the task in todo with no attached thread.

This is a failure before successful dispatch, not evidence that work actually started on the wrong machine. With an explicit secondary-host worktree preset, the server returns 201 and records a provisioning intent naming that secondary host. A project whose source is on the primary host also succeeds with project-default. Explicitly choosing the wrong primary host for the secondary-only project fails with 409. Both clean runs produce the same results.

CaseServer resultTask / destination
Secondary-only source, project-default404, missing local-path source for hosttodo, zero attached threads; no successful destination
Secondary-only source, explicit secondary201in_progress, one attached thread; secondary provisioning intent
Primary source, project-default201in_progress, one attached thread; primary provisioning intent
Secondary-only source, explicit primary409, no project source configured for hosttodo, zero attached threads

Expected: project-default can select the project’s usable default source host when the primary host has no source, while existing primary-host projects keep their current destination. The regression asserts successful secondary-host selection for the failing case; both assertions fail in both runs.

Fixture boundaries

The fixture executes actual Tasks storage, preset selection, RPC delegation and task updates, plus the real server API, source resolution and thread-provisioning records. Both Tasks and server use real isolated SQLite databases. The SDK testing host routes its threads.spawn method into an in-process server request; this adapter forwards the project, environment and synthetic prompt under a test Codex provider. It does not exercise network transport, real plugin installation, provider-specific execution options or a Tasks CLI/browser.

Hosts and sessions are synthetic database entries, and Git inspection responses come from the repository host-RPC test harness. A fake git-worktree environment provider holds provisioning in a waiting state. Thus a 201 and destination intent prove selection and thread attachment, not worktree creation or actual execution on a remote machine. No real filesystem workspace, enrolled host, user runtime, external model or network listener is used. No UI claim or screenshot is needed for the reproduced 404 boundary.

Trusted base and environment

Fetched public origin/main: 36aacc040ec0a2b092785d65dee7869d02cec08e. Linux x86_64, Node 24.19.0, pinned pnpm 9.15.0, Vitest 4.1.1. Two clean worktrees at the same SHA, identical final test bytes, fresh harness/database state, separate node_modules and caches, shared dependency download store. Both frozen installs and server builds exit 0. Tests use --force. No added dependency or production fix.

Exact repeatable steps

git clone https://github.com/get-bb/bb.git bb-repro
cd bb-repro
git checkout --detach 36aacc040ec0a2b092785d65dee7869d02cec08e
corepack pnpm install --frozen-lockfile
corepack pnpm exec turbo run build --filter=@bb/server --force
# Save the inline test as apps/server/test/services/plugins/issue-4446.test.ts
corepack pnpm exec turbo run test --filter=@bb/server --force -- test/services/plugins/issue-4446.test.ts --silent=false

Ensure pnpm 9.15.0 is also used by subprocesses. This environment used a task-local Corepack shim and disposable writable caches. Repeat the identical test in a second clean checkout at the same SHA. Each final test exits 1 with one failing regression and two desired-behavior assertion failures. The explicit-secondary, primary-default, wrong-explicit and task-state controls pass before those assertions.

apps/server/test/services/plugins/issue-4446.test.ts

import { expect, it } from "vitest";
import { createFakePluginHost } from "@get-bb/plugin-sdk/testing";
import { createStore } from "../../../../../plugins/tasks/api/index.js";
import { registerDelegation } from "../../../../../plugins/tasks/delegate/index.js";
import { createTestAppHarness } from "../../helpers/test-app.js";
import { seedHostSession, seedPrimaryHost, seedProjectWithSource } from "../../helpers/seed.js";
import { installFakeGitWorktreeProvider } from "../../helpers/environment-provider.js";
import { getThreadProvisionContext } from "../../../src/services/threads/thread-startup-store.js";

it("4446 routes project-default Tasks dispatch to the project source host", async () => {
  const server = await createTestAppHarness();
  const observations: unknown[] = [];
  const plugin = createFakePluginHost({ pluginId: "tasks", sdk: {
    system: { config: async () => ({ primaryHostId: "host-synthetic-primary" }) },
    threads: {
      spawn: async args => {
        const response = await server.app.request("/api/v1/threads", { method: "POST", headers: { "content-type": "application/json" },
          body: JSON.stringify({ projectId: args.projectId, providerId: "codex", origin: "plugin", originPluginId: "tasks",
            input: [{ type: "text", text: args.prompt }], environment: args.environment }) });
        const body = await response.json();
        if (response.status !== 201) {
          observations.push({ environment: args.environment, status: response.status, error: body });
          throw Object.assign(new Error(body.message), { status: response.status, code: body.code });
        }
        const intent = getThreadProvisionContext(server.db, body.id)?.request.environmentIntent;
        observations.push({ environment: args.environment, status: response.status, intent });
        return { id: body.id };
      },
      get: async args => {
        const response = await server.app.request(`/api/v1/threads/${args.threadId}`);
        return response.json();
      },
    },
  } });
  try {
    installFakeGitWorktreeProvider();
    const primary = seedHostSession(server.deps, { id: "host-synthetic-primary" });
    const secondary = seedHostSession(server.deps, { id: "host-synthetic-secondary" });
    seedPrimaryHost(server.deps, primary.host.id);
    const onSecondary = seedProjectWithSource(server.deps, { hostId: secondary.host.id, name: "Secondary fixture", path: "/synthetic/secondary-project" });
    const onPrimary = seedProjectWithSource(server.deps, { hostId: primary.host.id, name: "Primary fixture", path: "/synthetic/primary-project" });
    expect(onSecondary.source.isDefault).toBe(true);
    const store = createStore(plugin.bb);
    registerDelegation(plugin.bb, store);
    const preset = (kind: "project-default" | "new-worktree", machineId: string | null) => store.tasks.createPreset({
      name: `Synthetic ${kind} ${machineId ?? "default"}`, providerId: "codex", modelId: "gpt-5", reasoningLevel: "high", serviceTier: null,
      permissionMode: "full", environmentKind: kind, baseBranch: null, machineId, instructions: "", builtin: false,
    });
    const defaults = preset("project-default", null);
    const explicitSecondary = preset("new-worktree", secondary.host.id);
    const explicitPrimary = preset("new-worktree", primary.host.id);
    const dispatch = async (linkedBbProjectId: string, presetId: string, name: string) => {
      const project = store.tasks.createProject({ name, prefix: name.slice(0, 10), color: "blue", linkedBbProjectId });
      const task = store.tasks.createTask({ projectId: project.id, title: "Synthetic routing check", status: "todo" });
      let succeeded = false; let message: string | null = null;
      try { await plugin.harness.callRpc("delegate", { taskId: task.id, presetId }); succeeded = true; }
      catch (error) { message = error instanceof Error ? error.message : String(error); }
      return { name, succeeded, message, taskStatus: store.tasks.getTask(task.id)?.status,
        attachedThreads: store.tasks.listTaskThreads(task.id).length, dispatch: observations.at(-1) };
    };
    const missingPrimarySource = await dispatch(onSecondary.project.id, defaults.id, "REMOTEDEFAULT");
    const secondaryControl = await dispatch(onSecondary.project.id, explicitSecondary.id, "REMOTEEXPLICIT");
    const primaryControl = await dispatch(onPrimary.project.id, defaults.id, "PRIMARYDEFAULT");
    const wrongExplicitControl = await dispatch(onSecondary.project.id, explicitPrimary.id, "WRONGEXPLICIT");
    console.log(JSON.stringify({ phase: "tasks-host-routing", primary: primary.host.id, soleDefaultSourceHost: onSecondary.source.hostId,
      missingPrimarySource, secondaryControl, primaryControl, wrongExplicitControl }));
    expect(secondaryControl.succeeded).toBe(true);
    expect(secondaryControl.dispatch).toMatchObject({ status: 201, intent: { machine: { hostId: secondary.host.id } } });
    expect(primaryControl.succeeded).toBe(true);
    expect(primaryControl.dispatch).toMatchObject({ status: 201, intent: { machine: { hostId: primary.host.id } } });
    expect(wrongExplicitControl.succeeded).toBe(false);
    expect(missingPrimarySource.taskStatus).toBe("todo");
    expect(missingPrimarySource.attachedThreads).toBe(0);
    expect.soft(missingPrimarySource.succeeded).toBe(true);
    expect.soft(missingPrimarySource.dispatch).toMatchObject({ status: 201, intent: { machine: { hostId: secondary.host.id } } });
  } finally { await plugin.harness.dispose(); await server.cleanup(); }
}, 60000);

Actual evidence and same-agent second clean reproduction

First clean checkout

{
  "phase": "tasks-host-routing",
  "primary": "host-synthetic-primary",
  "soleDefaultSourceHost": "host-synthetic-secondary",
  "missingPrimarySource": {
    "name": "REMOTEDEFAULT",
    "succeeded": false,
    "message": "Project has no local-path source for host",
    "taskStatus": "todo",
    "attachedThreads": 0,
    "dispatch": {
      "environment": {
        "type": "project-default"
      },
      "status": 404,
      "error": {
        "code": "invalid_request",
        "message": "Project has no local-path source for host"
      }
    }
  },
  "secondaryControl": {
    "name": "REMOTEEXPLICIT",
    "succeeded": true,
    "message": null,
    "taskStatus": "in_progress",
    "attachedThreads": 1,
    "dispatch": {
      "environment": {
        "type": "host",
        "hostId": "host-synthetic-secondary",
        "workspace": {
          "type": "managed-worktree",
          "baseBranch": {
            "kind": "default"
          }
        }
      },
      "status": 201,
      "intent": {
        "type": "provider",
        "environmentProviderId": "git-worktree",
        "machine": {
          "type": "existing",
          "hostId": "host-synthetic-secondary"
        },
        "inputs": {
          "branch": {
            "kind": "default"
          }
        },
        "selectionResolved": true
      }
    }
  },
  "primaryControl": {
    "name": "PRIMARYDEFAULT",
    "succeeded": true,
    "message": null,
    "taskStatus": "in_progress",
    "attachedThreads": 1,
    "dispatch": {
      "environment": {
        "type": "project-default"
      },
      "status": 201,
      "intent": {
        "type": "provider",
        "environmentProviderId": "git-worktree",
        "machine": {
          "type": "existing",
          "hostId": "host-synthetic-primary"
        },
        "inputs": {
          "branch": {
            "kind": "named",
            "name": "origin/main"
          }
        },
        "selectionResolved": true
      }
    }
  },
  "wrongExplicitControl": {
    "name": "WRONGEXPLICIT",
    "succeeded": false,
    "message": "Could not create a worktree on host-synthetic-primary from the default branch: No project source configured for this host",
    "taskStatus": "todo",
    "attachedThreads": 0,
    "dispatch": {
      "environment": {
        "type": "host",
        "hostId": "host-synthetic-primary",
        "workspace": {
          "type": "managed-worktree",
          "baseBranch": {
            "kind": "default"
          }
        }
      },
      "status": 409,
      "error": {
        "code": "invalid_request",
        "message": "No project source configured for this host"
      }
    }
  }
}
AssertionError: expected false to be true // Object.is equality
AssertionError: expected { …(3) } to match object { status: 201, intent: { …(1) } }
 Test Files  1 failed (1)
      Tests  1 failed (1)
   Duration  6.31s (transform 3.81s, setup 1.25s, import 4.11s, tests 794ms, environment 0ms)

Second clean checkout

{
  "phase": "tasks-host-routing",
  "primary": "host-synthetic-primary",
  "soleDefaultSourceHost": "host-synthetic-secondary",
  "missingPrimarySource": {
    "name": "REMOTEDEFAULT",
    "succeeded": false,
    "message": "Project has no local-path source for host",
    "taskStatus": "todo",
    "attachedThreads": 0,
    "dispatch": {
      "environment": {
        "type": "project-default"
      },
      "status": 404,
      "error": {
        "code": "invalid_request",
        "message": "Project has no local-path source for host"
      }
    }
  },
  "secondaryControl": {
    "name": "REMOTEEXPLICIT",
    "succeeded": true,
    "message": null,
    "taskStatus": "in_progress",
    "attachedThreads": 1,
    "dispatch": {
      "environment": {
        "type": "host",
        "hostId": "host-synthetic-secondary",
        "workspace": {
          "type": "managed-worktree",
          "baseBranch": {
            "kind": "default"
          }
        }
      },
      "status": 201,
      "intent": {
        "type": "provider",
        "environmentProviderId": "git-worktree",
        "machine": {
          "type": "existing",
          "hostId": "host-synthetic-secondary"
        },
        "inputs": {
          "branch": {
            "kind": "default"
          }
        },
        "selectionResolved": true
      }
    }
  },
  "primaryControl": {
    "name": "PRIMARYDEFAULT",
    "succeeded": true,
    "message": null,
    "taskStatus": "in_progress",
    "attachedThreads": 1,
    "dispatch": {
      "environment": {
        "type": "project-default"
      },
      "status": 201,
      "intent": {
        "type": "provider",
        "environmentProviderId": "git-worktree",
        "machine": {
          "type": "existing",
          "hostId": "host-synthetic-primary"
        },
        "inputs": {
          "branch": {
            "kind": "named",
            "name": "origin/main"
          }
        },
        "selectionResolved": true
      }
    }
  },
  "wrongExplicitControl": {
    "name": "WRONGEXPLICIT",
    "succeeded": false,
    "message": "Could not create a worktree on host-synthetic-primary from the default branch: No project source configured for this host",
    "taskStatus": "todo",
    "attachedThreads": 0,
    "dispatch": {
      "environment": {
        "type": "host",
        "hostId": "host-synthetic-primary",
        "workspace": {
          "type": "managed-worktree",
          "baseBranch": {
            "kind": "default"
          }
        }
      },
      "status": 409,
      "error": {
        "code": "invalid_request",
        "message": "No project source configured for this host"
      }
    }
  }
}
AssertionError: expected false to be true // Object.is equality
AssertionError: expected { …(3) } to match object { status: 201, intent: { …(1) } }
 Test Files  1 failed (1)
      Tests  1 failed (1)
   Duration  5.39s (transform 3.21s, setup 1.14s, import 3.38s, tests 728ms, environment 0ms)

The same agent personally executed both final tests in separate clean checkouts; this is not independent verification. Their evidence is identical. An initial first-checkout setup attempt reused a preset name and hit the Tasks database uniqueness constraint before dispatch. The fixture was corrected to unique preset names and valid project-prefix lengths; that setup attempt is not counted as bug evidence. The final fixture is unchanged across both reported runs.

Root cause and source permalinks

Fix proposal and next test

Resolve a suitable project source and its connected host before choosing the environment: preserve the primary host when it has a usable source, otherwise consider the project’s usable default source with an explicit deterministic policy. Preserve explicit-host requests and clear errors for offline or ambiguous sources. Keep the personal/projectless policy separate. No production fix was applied.

Add regression cases for primary-source preference, secondary default-source fallback, disconnected sources and multiple eligible sources. After a policy change, use two isolated real host daemons to verify worktree provisioning and execution reaches the selected host. Do not treat the fixture’s 201 as proof of that remote execution.

Trust and publication checks

Issue instructions, commands, proposed patches and environment descriptions were treated as untrusted claims. None of its commands, code, branches, external URLs or real runtime were used. The fixture is derived from trusted Tasks/server implementation and existing test helpers. All comments were read (zero before publication); current report and linked PR metadata were checked. Public visibility, source permalinks, evidence consistency and privacy were validated. Test code and output are inline; raw artifacts remain local. The referenced publisher script is absent, so direct HTML/reference/privacy checks were used without editing the generated index.