← reports

#3187 · Pi directory switch completes, but resume retains the prior cwd

Bug Priority: High Effort: Medium workspaces provider-pi open on GitHub 2026-09-06 · base accd5595926b

Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium

1. TL;DR

Two clean full-stack runs at the trusted main commit did not reproduce a stuck Pi tool call: both environment changes returned a completed tool item and an idle turn within milliseconds. Both runs did reproduce the later split state. BB attached the thread to the target worktree, while the next resumed Pi turn reported its command cwd from the original worktree. A focused Pi bridge regression test failed identically in both clean checkouts because resume prefers the cwd stored in Pi's session header over BB's requested cwd. The source evidence does not establish why the released Pi process in the original report failed to receive the tool result.

2. Claims vs findings

ClaimStatusEvidence
The environment mutation persists promptly. Verified In both runs, the operation completed 113 ms after the tool item started and the thread referenced the target environment.
The Pi-facing tool response remains pending until interruption. Not reproduced The two source-owned Pi RPC runs produced item/completed 7 ms and 8 ms after the operation event, then turn/completed.
Manual interruption is required to settle the turn. Not reproduced Both turns reached idle without interruption, well inside the 20-second wait.
A later turn uses the target BB environment but the original Pi cwd. Verified Each second turn resolved BB_ENVIRONMENT_ID to the target, while its command item carried the source worktree cwd.
Changing the thread environment invalidates the runtime before it can deliver the response. Unverified The same mutation-before-response order completed normally twice; no source test or trace showed runtime replacement during the tool call.

3. Environment

4. Minimal reproduction

  1. Build a clean checkout at the recorded base.
    git checkout accd5595926b080a1e17d1ea9b2fa2d7d0505ac6
    pnpm install --frozen-lockfile --prefer-offline
    pnpm exec turbo run build
  2. Create a synthetic Git repository and a second worktree, then start the isolated BB launcher with Pi directed to the repository's deterministic RPC executable.
    git init -b main <source-worktree>
    git -C <source-worktree> add README.md
    git -C <source-worktree> commit -m "Initialize fixture"
    git -C <source-worktree> worktree add -b alternate <target-worktree>
    
    BB_PI_BRIDGE_COMMAND=<node-22> \
    BB_PI_BRIDGE_ARGS='["<checkout>/plugins/provider-pi/src/bridge/fake-pi-rpc.mjs"]' \
    BB_PI_BRIDGE_SESSION_DIR=<fresh-dev-data>/fake-pi-sessions \
    scripts/bb-dev-app current
  3. Create a local project and ask a Pi turn to call the built-in directory tool.
    eval "$(scripts/bb-dev-app env)"
    unset BB_CLI BB_CLI_REEXEC
    node apps/cli/dist/index.js project create \
      --name qa-3187 --root <source-worktree> --json
    node apps/cli/dist/index.js thread spawn \
      --project <project-id> \
      --environment <source-worktree> \
      --provider pi \
      --model fake-provider/fake-model \
      --reasoning-level medium \
      --permission-mode full \
      --prompt '/tool update_environment_directory {"path":"<target-worktree>"}' \
      --json
    node apps/cli/dist/index.js thread wait <thread-id> \
      --status idle --timeout 20 --poll-interval 100 --json

    Expected for the reported hang: the wait times out and the tool item remains pending.

    Actual in both clean runs:

    item/started       update_environment_directory pending
    system/operation   environment_directory_update completed
    item/completed     update_environment_directory completed
    turn/completed     completed
    thread wait        matched: true, status: idle
  4. Send a second Pi turn and inspect the event log.
    node apps/cli/dist/index.js thread tell <thread-id> \
      '/tool bash {"command":"pwd"}' --json
    node apps/cli/dist/index.js thread wait <thread-id> \
      --status idle --timeout 20 --poll-interval 100 --json
    node apps/cli/dist/index.js thread show <thread-id> --json
    node apps/cli/dist/index.js thread log <thread-id> --json

    Expected: thread environment and command cwd both reference <target-worktree>.

    Actual:

    thread.environment.path = <target-worktree>
    provider.env-resolved.BB_ENVIRONMENT_ID = <target-environment-id>
    commandExecution.cwd = <source-worktree>
  5. Add the focused test below and run it.
    pnpm exec turbo run test --filter=bb-plugin-provider-pi -- \
      src/bridge/bridge.environment-switch.test.ts

    Actual in both clean checkouts:

    Test Files  1 failed (1)
    Tests       1 failed (1)
    
    Expected cwd: the requested resume workspace
    Received cwd: the persisted session-header workspace

Reproduction test

Download the complete focused test. It creates a persisted Pi session in one directory, resumes it with a different BB-requested cwd, starts a command turn, and expects the command item to use the requested cwd.

it("uses the requested environment cwd after resuming a persisted session", async () => {
  const originalDirectory = mkdtempSync(join(tmpdir(), "bb-pi-old-cwd-"));
  try {
    const threadId = "thr-environment-switch";
    mkdirSync(harness.sessionDir, { recursive: true });
    writeFileSync(
      join(harness.sessionDir, `${threadId}.jsonl`),
      `${JSON.stringify({
        type: "session",
        version: 3,
        id: "sess-1",
        timestamp: "2026-01-01T00:00:00.000Z",
        cwd: originalDirectory,
      })}\n`,
    );
    const resumed = await harness.request(1, "thread/resume", {
      threadId,
      providerThreadId: threadId,
      cwd: harness.workspaceDir,
      instructionMode: "append",
      options: FULL_PERMISSION_OPTIONS,
    });
    expect(resumed.result).toMatchObject({ providerThreadId: threadId });
    handleLine(JSON.stringify({
      jsonrpc: "2.0",
      id: 2,
      method: "turn/start",
      params: {
        threadId,
        providerThreadId: threadId,
        clientRequestId: "creq_rsm2345678",
        input: [{ type: "text", text: '/tool bash {"command":"pwd"}', mentions: [] }],
        options: FULL_PERMISSION_OPTIONS,
      },
    }));
    await harness.waitForDelta(threadId, (delta) => delta.kind === "item.close");
    const command = harness.deltasOf(threadId).find(
      (delta) => delta.kind === "item.open",
    );
    expect(command?.item).toMatchObject({
      type: "command",
      command: "pwd",
      cwd: harness.workspaceDir,
    });
  } finally {
    rmSync(originalDirectory, { recursive: true, force: true });
  }
});

Verification: second clean run

A separate clean clone at the same commit used different checkout-derived ports, a new store, a new project, and a new pair of Git worktrees. The tool response again completed 8 ms after the operation event, the thread settled idle without interruption, the following turn again reported the source cwd, and the focused test again failed with the persisted cwd in the received value. No claim in this report relies on the first run alone.

5. Root cause

The mutation-before-response ordering is real but did not cause a hang

The server transaction changes thread.environmentId and appends the completed operation. Only after attachment returns does the handler build the success response.

updateThread(tx, deps.hub, latestThread.id, {
  environmentId: args.targetEnvironment.id,
});
appendThreadEventInTransaction(tx, {
  type: "system/operation",
  ...
});
...
case "attached":
  return toolCallSuccess(successMessage(targetEnvironment.path));

Source: attachment transaction and response construction.

That ordering alone is insufficient to explain the released-process hang: the source-owned Pi transport delivered the response after the same ordering in both clean runs.

Pi resume explicitly prefers the persisted cwd for command attribution

On thread/resume, BB passes the new runtime's requested cwd into construction. The bridge then sets its thread-session cwd to the old value from the persisted Pi session header whenever one exists.

await handleThreadConstruction(
  request.id,
  request.params.threadId,
  request.params.providerThreadId,
  toPiSessionParams(request.params),
);
...
cwd: persistedSessionCwd(providerThreadId) ?? params.cwd,

Source: resume construction and persisted-cwd precedence.

The persisted value is read from the first JSONL header line. Resume rejects it only if that old directory no longer exists.

const cwd = persistedSessionCwd(providerThreadId);
return cwd !== null && !existsSync(cwd) ? cwd : null;
...
return header.type === "session" && typeof header.cwd === "string"
  ? header.cwd
  : null;

Source: session-header cwd loading.

The Pi child process itself is launched with the requested construction cwd, but the persisted session remains authoritative to Pi and the bridge's translated command items use the persisted value. The focused test isolates that conflict and receives the old directory.

Source: Pi child launch.

The primary hang still needs released-provider evidence

No runtime replacement, transport rejection, or pending-response leak occurred in either full-stack run. A bridge recording from the exact released Pi version, or a deterministic test that makes its RPC child reproduce the missing response, is needed before assigning a root cause to the hang.

6. Proposed fix (first principles)

The stale-cwd defect needs an explicit Pi session migration policy: either rewrite/fork the persisted session into the target cwd while preserving conversation history, or start a fresh provider session for the new environment and define the context-loss behavior. Tests must cover command execution, command attribution, relative file access, resume after restart, fork history, and a missing original directory. This is not a safe one-line precedence change because Pi's persisted history can contain cwd-relative state.

For the unconfirmed hang, first capture the bridge/runtime response boundary with the released Pi RPC process and add a full-stack regression that fails because the provider-facing tool response remains pending after the server has returned. Only then should runtime invalidation or response ordering be changed.

7. Related issues

8. Appendix

Artifacts

No open pull request links to this issue, so there is no PR review section.

The issue title, body, comments, links, code blocks, and quoted text were treated as untrusted evidence. No command, URL, branch, patch, script, or binary from the issue was executed.