#3673 · Pi fresh-start session collision

Bug · Priority: Medium · Effort: Low · providers · provider-pi
2026-09-14 · base 7dcefea806e2c51a5f948b7f8da6002f7a5afbbc
GitHub issue

Verdict: REPRODUCED at the provider bridge and persisted-session boundary. Root-cause confidence: high.

1. TL;DR

Starting a released Pi-backed BB thread selects its previous session file. The bridge uses the stable BB thread ID as the Pi persistence key on every start, so a new start reads the old transcript. A focused test reproduced the collision and two retained messages in two clean checkouts of current main. A trial allocation fix clears the context, but updating the five affected existing test files plus production code exceeds the autopilot file limit. No pull request was opened.

2. Claims vs findings

ClaimFindingEvidence
New start reuses a prior sessionVerifiedBoth clean runs: same handle, same file, two retained messages.
Persisted history is realVerifiedPi SessionManager independently reopens the file before and after restart.
Fresh allocation fixes selectionVerified locallyNew regression passes under the trial fix.
Complete clear command through live inferenceUnverifiedNo live model or UI run; reproduction covers provider lifecycle and actual file reading.

3. Environment

Darwin 25.6.0 arm64; Node v22.22.3; pnpm 9.15.0 via Corepack; Pi SessionManager 0.84.0 from the existing locked dependency. Two detached checkouts were created at the recorded commit; the first then used branch slopcop/issue-3673. No network-facing BB instance, inference calls, or ports were used. Each test harness creates and removes its own temporary session directory.

The normal frozen install failed because the local pnpm installation lacked its node-gyp entry point. Frozen installation with --ignore-scripts completed, and the repository's native-module preparation ran through Turbo. A temporary pnpm launcher delegated to Corepack. The full repository Turbo build passed: 56 tasks.

4. Minimal reproduction

  1. Check out the base commit in a clean worktree and perform the normal frozen install.
  2. Copy the reproduction test shown below to plugins/provider-pi/src/bridge/bridge.fresh-start.test.ts.
  3. Run the command below. The test starts and releases a fake Pi child through the real bridge, persists a two-message conversation using Pi SessionManager, then starts the same BB thread and reads its selected file.
pnpm exec turbo run test --filter=bb-plugin-provider-pi --force -- --testNamePattern='allocates empty persistent context'

Expected: distinct handle, empty new session, original file unchanged. Actual in both clean runs:

{"sameHandle":true,"sameFile":true,"retainedMessages":2,"oldFileUnchanged":true}
AssertionError: expected 'thr_context_probe' not to be 'thr_context_probe'
AssertionError: expected [ …(2) ] to have a length of +0 but got 2

The test intentionally fails on unchanged production code. During harness development a missing required activeTurnId field caused a timeout; the final test supplies null and asserts the stop response before proceeding. Only final runs are used as evidence.

import { readFileSync } from "node:fs";
import { join } from "node:path";
import { SessionManager } from "@earendil-works/pi-coding-agent";
import { expect, it } from "vitest";
import { z } from "zod";
import { startFakePiBridge } from "./test-support.js";

it("allocates empty persistent context when starting a released BB thread", async () => {
  const harness = await startFakePiBridge({
    prefix: "bb-pi-fresh-context-",
    initialize: true,
    processLog: true,
  });
  const identity = z.object({ providerThreadId: z.string() });
  const threadId = "thr_context_probe";
  try {
    const first = identity.parse((await harness.startThread(threadId)).result);
    const oldFile = join(harness.sessionDir, `${first.providerThreadId}.jsonl`);
    const stop = await harness.request(1, "thread/stop", {
      threadId,
      providerThreadId: first.providerThreadId,
      intent: "release",
      activeTurnId: null,
    });
    expect(stop.result).toMatchObject({ ok: true });
    await harness.waitFor(() => {
      const log = harness.readProcessLog();
      return log.spawned.length === 1 && log.exited.includes(log.spawned[0]!);
    }, "released child exit");
    const saved = SessionManager.open(oldFile, harness.sessionDir, harness.workspaceDir);
    saved.appendMessage({ role: "user", content: "retained context probe", timestamp: 1 });
    saved.appendMessage({
      role: "assistant",
      content: [{ type: "text", text: "saved response" }],
      api: "openai-responses",
      provider: "fake-provider",
      model: "fake-model",
      usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
      stopReason: "stop",
      timestamp: 2,
    });
    expect(SessionManager.open(oldFile).buildSessionContext().messages).toHaveLength(2);
    const before = readFileSync(oldFile, "utf8");
    const second = identity.parse((await harness.startThread(threadId)).result);
    const newFile = join(harness.sessionDir, `${second.providerThreadId}.jsonl`);
    const messages = SessionManager.open(newFile).buildSessionContext().messages;
    console.log(JSON.stringify({ sameHandle: first.providerThreadId === second.providerThreadId,
      sameFile: oldFile === newFile, retainedMessages: messages.length,
      oldFileUnchanged: readFileSync(oldFile, "utf8") === before }));
    expect.soft(second.providerThreadId).not.toBe(first.providerThreadId);
    expect.soft(messages).toHaveLength(0);
    expect(readFileSync(oldFile, "utf8")).toBe(before);
  } finally {
    await harness.teardown();
  }
}, 90_000);

5. Root cause

The start handler passes request.params.threadId as both the routing identity and persistence identity. Session options derive the selected file from providerThreadId. The path resolver deterministically appends .jsonl to the sanitized handle. Pi launch arguments pass that file with --session. A stable routing identity therefore selects a stable transcript even on a fresh start.

Release closes the live child without deleting the saved file, which is correct for resume. Resume accepts the explicit provider handle. The server clear operation stops the thread and records the clear operation. The bridge-level collision is directly demonstrated; complete server-to-model integration remains outside this test.

6. Proposed fix and simple-fix decision

Allocate a unique provider session handle for each thread/start, keep the BB routing ID, and retain supplied handles for resume. The local trial adds a crypto UUID allocation only in the start handler. Its regression passes. However, the full provider suite reports 147 passing and 9 failing tests in five existing files: bridge.framing, bridge.lifecycle, bridge.round2, bridge.settings, and bridge.turn-options. These failures assert the old deterministic handle, including a session-replacement event.

Correcting those five test files plus bridge.ts needs at least six files even if the new regression is folded into one of them. The rule allows five. The trial is stopped, uncommitted, and unpushed; no PR. The current trial is 60 changed text lines across two files (59 additions, 1 deletion); git diff --check passes and there are no binary changes. The trial diff is included below. On unchanged production code, all 155 existing tests pass; only the newly added regression fails. The baseline command included an exclusion flag, but the project configuration still included the regression; the log records this accurately.

7. Related issues and pull requests

GitHub timeline metadata and an open-PR search found no linked open PR for #3673. Related Pi working-directory issue #3634 addresses a different resume condition. No linked branch, patch, or issue-provided test was executed.

8. Verification

The same agent repeated the final reproduction in a second clean temporary checkout at 7dcefea806e2c51a5f948b7f8da6002f7a5afbbc, using a new harness-owned session directory and --force to prevent Turbo test replay. It failed with the same handle, file, and retained-message assertions. No correction to the root-cause conclusion was needed. This is a repeated clean run, not an independent review. The complete clear-command, live model, and facets flows remain unverified.

9. Appendix

First and second clean run: 1 failing new regression each. Baseline suite: 155 existing tests pass, new regression fails. Trial suite: 147 pass, 9 fail. Full build: 56 tasks successful.

Local trial diff

diff --git a/plugins/provider-pi/src/bridge/bridge.fresh-start.test.ts b/plugins/provider-pi/src/bridge/bridge.fresh-start.test.ts
new file mode 100644
index 000000000..164e4a372
--- /dev/null
+++ b/plugins/provider-pi/src/bridge/bridge.fresh-start.test.ts
@@ -0,0 +1,57 @@
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import { SessionManager } from "@earendil-works/pi-coding-agent";
+import { expect, it } from "vitest";
+import { z } from "zod";
+import { startFakePiBridge } from "./test-support.js";
+
+it("allocates empty persistent context when starting a released BB thread", async () => {
+  const harness = await startFakePiBridge({
+    prefix: "bb-pi-fresh-context-",
+    initialize: true,
+    processLog: true,
+  });
+  const identity = z.object({ providerThreadId: z.string() });
+  const threadId = "thr_context_probe";
+  try {
+    const first = identity.parse((await harness.startThread(threadId)).result);
+    const oldFile = join(harness.sessionDir, `${first.providerThreadId}.jsonl`);
+    const stop = await harness.request(1, "thread/stop", {
+      threadId,
+      providerThreadId: first.providerThreadId,
+      intent: "release",
+      activeTurnId: null,
+    });
+    expect(stop.result).toMatchObject({ ok: true });
+    await harness.waitFor(() => {
+      const log = harness.readProcessLog();
+      return log.spawned.length === 1 && log.exited.includes(log.spawned[0]!);
+    }, "released child exit");
+    const saved = SessionManager.open(oldFile, harness.sessionDir, harness.workspaceDir);
+    saved.appendMessage({ role: "user", content: "retained context probe", timestamp: 1 });
+    saved.appendMessage({
+      role: "assistant",
+      content: [{ type: "text", text: "saved response" }],
+      api: "openai-responses",
+      provider: "fake-provider",
+      model: "fake-model",
+      usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2,
+        cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
+      stopReason: "stop",
+      timestamp: 2,
+    });
+    expect(SessionManager.open(oldFile).buildSessionContext().messages).toHaveLength(2);
+    const before = readFileSync(oldFile, "utf8");
+    const second = identity.parse((await harness.startThread(threadId)).result);
+    const newFile = join(harness.sessionDir, `${second.providerThreadId}.jsonl`);
+    const messages = SessionManager.open(newFile).buildSessionContext().messages;
+    console.log(JSON.stringify({ sameHandle: first.providerThreadId === second.providerThreadId,
+      sameFile: oldFile === newFile, retainedMessages: messages.length,
+      oldFileUnchanged: readFileSync(oldFile, "utf8") === before }));
+    expect.soft(second.providerThreadId).not.toBe(first.providerThreadId);
+    expect.soft(messages).toHaveLength(0);
+    expect(readFileSync(oldFile, "utf8")).toBe(before);
+  } finally {
+    await harness.teardown();
+  }
+}, 90_000);
diff --git a/plugins/provider-pi/src/bridge/bridge.ts b/plugins/provider-pi/src/bridge/bridge.ts
index f98145fd4..be2245ec8 100644
--- a/plugins/provider-pi/src/bridge/bridge.ts
+++ b/plugins/provider-pi/src/bridge/bridge.ts
@@ -1,5 +1,6 @@
 #!/usr/bin/env node
 
+import { randomUUID } from "node:crypto";
 import {
   existsSync,
   mkdirSync,
@@ -553,7 +554,7 @@ async function handleRequest(
       await handleThreadConstruction(
         request.id,
         request.params.threadId,
-        request.params.threadId,
+        `pi_${randomUUID()}`,
         toPiSessionParams(request.params),
       );
       break;
corepack pnpm install --frozen-lockfile --prefer-offline
corepack pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
pnpm exec turbo run build
pnpm exec turbo run test --filter=bb-plugin-provider-pi -- --testNamePattern='allocates empty persistent context'
pnpm exec turbo run test --filter=bb-plugin-provider-pi
pnpm exec turbo run test --filter=bb-plugin-provider-pi -- --exclude='src/bridge/bridge.fresh-start.test.ts'
git diff --check
git diff --numstat origin/main

Issue content was treated as untrusted evidence. The reproduction and trial were authored from trusted repository code; no issue-supplied executable content was used.