#20766·ai

harness-pi: a session reattached in-process keeps the previous request's createPi settings, and there is no way to inject credentials or opt out of reattach

Author: orcunbalcilarCreated Sep 15, 2026Updated Sep 17, 2026
Labelstask-identify-issue-type-donetask-bug-reproduction-successfactory-activetask-bug-reproduction-v6.0-issue-invalidfactory-automaticharness/pitask-identify-harness-labels-done

Verified against @ai-sdk/[email protected] (npm latest) and main, with @ai-sdk/[email protected] and the pi packages harness-pi resolves: @earendil-works/[email protected], @earendil-works/[email protected].

Setup

A multi-replica server (Next.js route handlers behind a load balancer, any replica may serve any request). Every request builds a fresh HarnessAgent with createPi({ agentDir }), where agentDir is a temporary directory materialized for that request (auth.json with the user's api_key credential for a custom provider, models.json declaring that provider, settings.json), and deleted when the request ends. Lifecycle state is persisted in a database, so the next request can land on any replica.

Bug: resuming a paused turn in the same process fails, in a fresh process it works

  1. Request 1: the model calls a host tool with no execute (awaiting a tool result — a question for the user). The route calls session.suspendTurn() and persists the continue-turn state.
  2. Request 2 (the tool result) builds a new agent with a new agentDir and calls agent.createSession({ sessionId, continueFrom }) + agent.continueStream(...).
  • Request 2 on another process: completes normally.
  • Request 2 on the same process: the turn fails with Error: Provider is not configured: <custom provider id> (surfaced from harness-pi/dist/index.js:2777).

Reproduced deterministically on a two-replica setup, same prompt both times: same-process answer fails, cross-process answer succeeds.

Cause (source-verified): doSuspendTurn stores the live session in the module-level parkedPiSessions map, and createPiSession returns that parked session whenever isResume is set (if (parkedSession) { parkedPiSessions.delete(...); return { ...parkedSession, isResume: true } }). The reattached session keeps the ModelRuntime / ModelRegistry / auth storage built from request 1's createPi({ agentDir }); request 2's settings are never applied. With a per-request agentDir those paths no longer exist. (We have not pinned the exact call that empties the provider's credential afterwards; the observable is the error above, plus pi's file auth backend re-creating the deleted directory with an empty {} auth.json.)

Related: the same reattach also keeps request 1's sandbox session handle, which we already work around by handing the provider a delegator.

Bug: a parked session goes stale when the turn finishes in another process

  1. Request 1 on process A pauses (awaiting a tool result) — A parks the session.
  2. Request 2 on process B completes the turn and persists a resume-session state.
  3. Request 3 (a new user message) lands on process A: A reattaches its stale parked session and the turn fails with Error: Agent is already processing. Specify streamingBehavior ('steer' or 'followUp') to queue the message. (at AgentSession.promptharness-pi/dist/index.js:2774).

createPiSession does not check the parked session against the incoming lifecycle state, and process B's progress never reaches A's map, so A's entry stays stale until the process restarts.

Gaps

  1. Reattach ignores the new request's settings and state. A reattached session should apply the current createPi settings (at least agentDir / providers / credentials) and be validated against the incoming lifecycle state — or not be reattached. The lifecycle contract already says a host-resident adapter "cannot keep its turn alive … the in-flight tail is recomputed on continue" (harness/dist/index.d.ts, doSuspendTurn), which is the path that works in a fresh process.
  2. No opt-out of in-process reattach. For stateless multi-replica deployments the in-memory parked session is a per-process cache whose behaviour differs from the fresh-process path. session.destroy() is a no-op after suspendTurn() (the handle is already detached), and stop() / detach() on an unfinished turn route to the same suspension. A setting such as createPi({ reattachInProcess: false }) would let every replica take the same, persisted-state path.
  3. No credential injection. For non-environment auth, createPiSession calls createPiModelRuntime({ auth, authPath: path.join(nativeAgentDir ?? hostAgentDir, 'auth.json'), modelsPath }) (packages/harness-pi/src/pi-session.ts ~L439), which passes the path to ModelRuntime.create({ authPath, modelsPath }) (packages/harness-pi/src/pi-auth.ts ~L117-130). PiHarnessSettings exposes auth, providers, thinkingLevel, agentDir, mcpServers, extensionFactories — nothing reaches ModelRuntime.create({ credentials }), although pi-ai's CredentialStore is designed for app-injected persistent stores (refresh runs inside modify) and pi-coding-agent documents ModelRuntime.create({ credentials }). harness-pi already builds an internal credential store for environment auth (createIsolatedPiCredentialStore). Injecting a store would remove the per-request credential files entirely.

Proposal

  • createPi({ credentials?: CredentialStore }) forwarded to ModelRuntime.create (or accept a pre-built modelRuntime).
  • Apply the current request's settings and validate the lifecycle state on reattach, or offer reattachInProcess: false.

Related

Not the same as #18147 (custom provider dispatch with auth.customEnv).