[Bug]: Pi: fork and revert fail permanently past a turn whose session entry was never captured#20530

opened6d agobyHhenrychu04

Area: apps/server (code in packages/provider-pi/src/server/adapter.ts and apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts)

Steps to reproduce

No API key is needed. Any OpenAI-compatible endpoint works, including a local mock.

  1. Configure Pi (tested with @earendil-works/pi-coding-agent 1.3.0) to use an OpenAI-compatible endpoint.
  2. Open a Pi thread and run one turn to completion.
  3. Send a second prompt. While Pi waits on the model, SIGKILL the Pi process.
  4. After T3 settles the thread, fork from the first turn or revert to it.
  5. Retry step 4 as many times as you like.

Expected behavior: Fork and revert to the first turn succeed. The resulting Pi session contains the first prompt and not the second. If T3 cannot determine the cut point, a retry or another route should still recover, rather than refusing forever.

Actual behavior: Every fork fails with Pi fork boundary has no captured session-tree entry. Every revert fails with Pi rollback target has no captured session-tree entry. Retrying never helps. Pi's session JSONL on disk has the second prompt as a user entry, but T3 never learned its entry id. The thread cannot be forked or reverted to any point before the dead turn.

Impact: Major degradation or frequent failure

Version or commit: main @ a3e8cf69de

Environment: Pi (@earendil-works/pi-coding-agent) 1.3.0, Node 24. The failure is server-side.

Logs or stack traces:

shell
1Pi fork boundary has no captured session-tree entry
2Pi rollback target has no captured session-tree entry

An RPC trace of the reproduction showed the second turn settled as failed/cancelled, with completedAt set and nativeTurnRef.strength === "weak". The Pi session file held both user prompts.

Analysis (references are to main @ a3e8cf69de)

A Pi turn is created with a synthetic weak nativeTurnRef (adapter.ts:2472). It gets its real session-tree user entry id only in finalizeTurn (adapter.ts:1474), through captureTurnTreeRefs (adapter.ts:1430), which reads get_entries and walks the leaf's parent chain. A turn that never completes a capture keeps the weak ref permanently.

Ways a turn ends without a strong ref:

  1. Pi or the server dies mid-turn. ProviderRuntimeRecoveryService (apps/server/src/orchestration-v2/ProviderRuntimeRecoveryService.ts:370-386) marks the turn cancelled by spreading the old payload, so the weak ref carries over.
  2. Stop before kill: the pre-terminate captureTurnTreeRefs(2_000) read (adapter.ts:2688) times out.
  3. get_entries fails, or the leaf cursor is stale at turn end (adapter.ts:1460-1468). captureTurnTreeRefs then returns nativeTurnRef: undefined and the synthetic ref stays.

piRollbackForkEntry (adapter.ts:3132) takes the first turn after the target with a non-null ref as the boundary. It returns undefined when that ref is not strong. Both callers fail closed: revert at adapter.ts:2863, fork at adapter.ts:2988. The weak ref is persisted thread state, so the refusal is permanent for that thread.

Related

  • #13507 (closed, accepted) covers a different problem in the same code: a Pi extension rewinding natively, leaving T3's timeline on an abandoned branch. This issue is about a turn that never got a ref. It is related, not a duplicate.
  • PR #20521 (open) proposes a repair. When the boundary ref is weak, it reads the session tree and takes the earliest user entry on the active branch stamped after the target turn's completedAt (or the boundary's startedAt when there is no target) and before the boundary's completedAt, above the target's entry. It fails closed when nothing fits. The strong-ref path adds no RPC, and replay transcripts are unchanged. In the reproduction, fork and revert both then resolved the dead turn's prompt and produced session files with the first prompt and not the second.

Design note: Two approaches are not exclusive. Prevention would capture the entry id when Pi persists the prompt, and read the tree before killing Pi on session close, so weak refs never form. Repair (#20521) resolves the entry at fork or revert time, which also covers threads already holding a weak ref. The PR is one option in that trade-off, not the final design. Maintainer direction on which side to land on would help.

Workaround: None known. A new thread avoids the problem but cannot branch from the affected thread's earlier turns. (Unverified: whether a later successful turn unblocks forks past the dead turn. From the code it should not, since the dead turn stays the first post-target turn with a non-null ref.)

Note

Grok responding on behalf of Julius.

Fixed by #20521, now merged to main: Pi fork and revert now recover a past turn whose session entry was never captured. Thanks for the detailed report and the fix!