Repository navigation
Description
Note on the branch: Pi support is not in
mainyet; it lives on the V2 preview line (apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts). The repro below is on T3 Code desktop0.0.44(V2 preview) with Pi0.87.1. Filing it because the design issue is in the adapter's native-session model, not in anything Pi-specific.
Area
apps/server
Steps to reproduce
-
Register a minimal Pi extension that rewinds the session one user prompt:
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; export default function (pi: ExtensionAPI) { pi.registerCommand("rewind", { description: "Rewind one user prompt", handler: async (_args, ctx) => { const branch = ctx.sessionManager.getBranch(); const lastUser = branch.findLastIndex( (entry) => entry.type === "message" && entry.message.role === "user", ); if (lastUser <= 0) return; await ctx.navigateTree(branch[lastUser - 1].id, { summarize: false }); }, }); }
-
Start a Pi thread in T3 Code.
-
Send prompt A and let the turn complete.
-
Send prompt B and let the turn complete.
-
Send
/rewindin the composer.
The command name is arbitrary. Nothing about it is special: any Pi extension can call ctx.navigateTree() from a command handler, and Pi's own /tree, /fork, and /clone flows rewind the same way. The bug is the class "a native session rewound outside T3 is invisible to T3".
Expected behavior
T3 Code follows the native session: the runs above the new native head are marked rolled_back, the provider thread's lastRunOrdinal / nativeConversationHeadRef move back to the new head, and the timeline stops showing the abandoned messages. That is exactly what T3 Code already does for its own Revert (CheckpointRollbackService), so the state is representable today — it is just not reached when the rewind happens on the provider side.
Actual behavior
- Pi's session leaf moves back to before A (verified in the session JSONL: the current leaf's
parentIdancestry no longer contains A or B). - T3's timeline still shows A, B, and the
/rewindturn. The/rewindturn has no assistant reply (extension commands do not call the model), so it reads as a no-op. - T3's provider thread
nativeConversationHeadRefis updated to the post-rewind leaf, but no run above it is markedrolled_back. - The turns after the rewind get wrong
nativeTurnRefs: the turn after the rewind was assigned the abandoned branch's first user entry, and the rewind turn itself the entry before it.nativeTurnRefis the rollback target for a run (contextSourcePointForRuninOrchestrator.ts), so a later Revert can roll back to the wrong point.
Evidence from the repro thread (ids abbreviated):
Native branch after the rewind (walking parentId from the current leaf):
c9974523 (17:37:43) → 4cf0d969 (17:36:55) → 9f0102c9 (17:27:17) → …
f057ff12 ("prompt A") and 6570076c ("prompt B") are NOT on the branch.
T3 provider turns (ordinal → nativeTurnRef):
3 → f057ff12 (prompt A, completed)
4 → weak (prompt B, interrupted)
5 → weak (/rewind #1)
6 → 6570076c (prompt B again — the abandoned branch's first user entry)
7 → f057ff12 (prompt A again — the abandoned branch's first user entry)
T3 provider thread nativeConversationHeadRef: c9974523 (the correct post-rewind leaf)
Impact
Major degradation or frequent failure.
- The timeline shows a conversation the model cannot see. For anyone who manages context deliberately (context pressure, staying out of the degradation zone), the UI actively lies.
- T3's own Revert becomes unreliable after a native rewind, because it rolls back to
providerTurn.nativeTurnRef. - Everything that reads
nativeConversationHeadRef/lastRunOrdinal(context usage, compaction, resume) is computed from the wrong head.
Version or commit
feat/queued-message-navigation @ 100472324 (V2 preview line, Pi support not in main), T3 Code desktop 0.0.44.
Environment
Linux, T3 Code desktop 0.0.44 (V2 orchestration preview build), Pi provider (Early Access), Pi 0.87.1, thread running in a git worktree.
Logs or stack traces
Pi session JSONL excerpt (leaf and parent ids):
{"type":"message","id":"6570076c","parentId":"f5bebee1","timestamp":"2026-09-24T17:35:11.106Z","message":{"role":"user","content":[{"type":"text","text":"prompt B"}]}}
{"type":"message","id":"59920991","parentId":"6570076c","timestamp":"2026-09-24T17:35:31.026Z","message":{"role":"assistant","content":[]}}
{"type":"message","id":"4cf0d969","parentId":"9f0102c9","timestamp":"2026-09-24T17:36:55.828Z","message":{"role":"user","content":[{"type":"text","text":"prompt C"}]}}4cf0d969's parent is 9f0102c9, not 59920991: the leaf left the branch that contains A and B, while T3's provider_turn.updated events still carry nativeTurnRef values from that abandoned branch.
Analysis
PiAdapterV2.captureTurnTreeRefstreatsget_entriesas a single-turn append window: it fetches entriessince: lastKnownLeaf, takes the first user entry in the window as the turn-start ref, and stores the returnedleafIdas the head. Pi'sget_entriesis append-ordered and, per Pi's docs, "includes pre-compaction history and abandoned branches" — so after a native rewind the window spans the abandoned branch and the "first user entry" is not this turn's first user entry.leafCursorStaleonly covers a failedget_entries; a successful call after a rewind is not detected, so the cursor is treated as trustworthy.ProviderEventIngestor.normalizemapsprovider_thread.updatedstraight to aprovider-thread.updateddomain event. Nothing reconciles runs above a head that moved backwards;CheckpointRollbackServiceis the only path that marks runsrolled_back/ checkpointsstale, and it is only reachable through T3's own Revert.
Workaround
None that keeps T3's view honest. After a native rewind, the native session and T3's Revert disagree, so the thread should be treated as unreliable and restarted.
Suggested fix
Reconcile instead of refusing. The adapter already reads get_entries at the end of every turn and already knows lastKnownLeaf; the leaf's parentId chain tells it whether the leaf still descends from the cursor. When it does not, report the run ordinals whose turn-start ref is no longer on the branch, and reuse the exact events CheckpointRollbackService writes (run.updated → rolled_back, node.updated → rolled_back, checkpoint.captured → stale, lowered lastRunOrdinal). The client already hides rolled_back runs, so no client change is needed. Happy to open a PR against the V2 line if the direction sounds right.
Activity
juliusmarminge commentedon Sep 24, 2026
Triage: #13507 Pi native rewind leaves T3 on the abandoned branch
Verdict: Valid. Still present on
t3code/codex-turn-mappingat393d7b346f. Pi lives on this V2 line (apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts);origin/maindoes not contain that file. No other issue matches.Severity: High on Pi threads that rewind outside T3. The timeline keeps prompts the model can no longer see, and a later T3 Revert forks at the wrong session-tree entry. Pi is Early Access. Threads that never call
navigateTree,/tree,/fork, or/clonestay consistent.What happened
A Pi extension (or Pi’s own tree commands) moves the session leaf with
navigateTree. Pi’s JSONL leaf no longer descends through the abandoned prompts. T3 still shows those runs, leaves themcompleted, and keepslastRunOrdinalon the newest T3 run.nativeConversationHeadRefdoes move to the post-rewind leaf. That part of the report is right. What stays wrong is everything that still treats later runs as live: the timeline,lastRunOrdinal, and thenativeTurnRefcaptured on the next turn.The next turn’s
nativeTurnRefis the first user entry in an append-orderedget_entrieswindow that now includes the abandoned branch.piRollbackForkEntryuses that ref as theforkentry id, so T3 Revert can re-root on an abandoned entry.Diagnosis
captureTurnTreeRefstreatsget_entriessince: lastKnownLeafas one forward turn. It storesleafIdas the head and, unless the cursor was already marked stale, takes the first user entry in that window as the turn-start ref.leafCursorStaleis set only whenget_entriesfails (and cleared on the next success). A successful call whose leaf jumped to an older entry is treated as a normal append. The adapter already knowsget_entriesis the whole tree, abandoned branches included;readThreadSnapshotusesget_messagesfor that reason.sinceis append order. AfternavigateTree, the current leaf is an older JSONL id. The nextsincethat id returns every entry written after it, including the abandoned branch and the new prompt. The first user entry in that window is an abandoned prompt, which is how turns 6 and 7 in the report picked up6570076candf057ff12. A slash command that never appends a user entry leaves the rewind turn on its initial weak ref, matching turn 5.finalizeTurnpublishes the new leaf and the (possibly wrong) turn ref.ProviderEventIngestormapsprovider_thread.updatedstraight through. Nothing walks runs above a head that moved backward.lastRunOrdinalonly moves forward here, on turn start:The only writer that marks runs
rolled_back, checkpointsstale, and lowerslastRunOrdinalisCheckpointRollbackService, and it runs only for T3 Revert. That path calls Pifork, which replaces the session file.navigateTreedoes not, so the two paths never meet.Clients already hide
rolled_backruns.isOrchestrationV2TurnItemVisibleinpackages/shared/src/orchestrationV2Timeline.tsis what web and mobile projections use.Context usage is a smaller piece of this. The next settle reads Pi’s live
get_stats, so the meter can catch up to the rewound session. The timeline and Revert target do not.Fix
Detect the discontinuity in
captureTurnTreeRefs: the returnedleafIdis not in thesincewindow and is not the cursor. Then reconcile in orchestration, where the projection’s provider turns and run ordinals already live. Reuse the eventsCheckpointRollbackServicewrites (run.updated/node.updated→rolled_back,checkpoint.captured→stale,lastRunOrdinallowered to the last run whose strongnativeTurnRefis still on the leaf’sparentIdchain).Assigning the next
nativeTurnRefneeds the same check. Re-basinglastKnownLeafonto the rewound leaf still leaves abandoned entries later in the file, so “first user entry since the cursor” stays wrong for the first prompt after each rewind. The turn-start id has to be a user entry that descends from the new leaf.No client change. A test can fake
get_entriesthe wayPiAdapterV2.test.tsalready queues entries: a rewind response whoseleafIdis an older id, then a following turn whose window lists the abandoned user entry first.Workaround
Start a new thread after a native rewind. T3 Revert on the same thread forks at
nativeTurnRef, which may be an entry on the abandoned branch.Disposition
Keep #13507 open. Fix it on the V2 line (the open orchestrator PR is #2829). It is not a
mainbug and not a duplicate.fix(server): Pi threads follow session rewinds made outside T3
juliusmarminge commentedon Oct 9, 2026
Note
Grok responding on behalf of Julius.
Closing this since #13839 just landed on
main. It reconciles in-session Pi rewinds (like thectx.navigateTree()repro here): T3 now walks the active leaf's ancestry, rolls back the abandoned runs and checkpoints, and skips turns that left no native entry when reverting.The PR lists a few known limits: detached rewinds rebaseline, extension forks aren't tracked, returning to an abandoned branch doesn't restore retired runs, and branch summaries can block rollback. If you hit one of those, please open a new issue with the repro.