Skip to content

[Bug]: A Pi session rewound natively by an extension leaves T3's timeline and turn refs on the abandoned branch #13507

Closed

Description

@v1b3coder

Note on the branch: Pi support is not in main yet; it lives on the V2 preview line (apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts). The repro below is on T3 Code desktop 0.0.44 (V2 preview) with Pi 0.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

  1. 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 });
        },
      });
    }
  2. Start a Pi thread in T3 Code.

  3. Send prompt A and let the turn complete.

  4. Send prompt B and let the turn complete.

  5. Send /rewind in 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 parentId ancestry no longer contains A or B).
  • T3's timeline still shows A, B, and the /rewind turn. The /rewind turn has no assistant reply (extension commands do not call the model), so it reads as a no-op.
  • T3's provider thread nativeConversationHeadRef is updated to the post-rewind leaf, but no run above it is marked rolled_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. nativeTurnRef is the rollback target for a run (contextSourcePointForRun in Orchestrator.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.captureTurnTreeRefs treats get_entries as a single-turn append window: it fetches entries since: lastKnownLeaf, takes the first user entry in the window as the turn-start ref, and stores the returned leafId as the head. Pi's get_entries is 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.
  • leafCursorStale only covers a failed get_entries; a successful call after a rewind is not detected, so the cursor is treated as trustworthy.
  • ProviderEventIngestor.normalize maps provider_thread.updated straight to a provider-thread.updated domain event. Nothing reconciles runs above a head that moved backwards; CheckpointRollbackService is the only path that marks runs rolled_back / checkpoints stale, 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

  1. juliusmarminge commented on Sep 24, 2026

    @juliusmarminge
    Member

    Triage: #13507 Pi native rewind leaves T3 on the abandoned branch

    Verdict: Valid. Still present on t3code/codex-turn-mapping at 393d7b346f. Pi lives on this V2 line (apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts); origin/main does 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 /clone stay 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 them completed, and keeps lastRunOrdinal on the newest T3 run.

    nativeConversationHeadRef does 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 the nativeTurnRef captured on the next turn.

    The next turn’s nativeTurnRef is the first user entry in an append-ordered get_entries window that now includes the abandoned branch. piRollbackForkEntry uses that ref as the fork entry id, so T3 Revert can re-root on an abandoned entry.

    Diagnosis

    captureTurnTreeRefs treats get_entries since: lastKnownLeaf as one forward turn. It stores leafId as the head and, unless the cursor was already marked stale, takes the first user entry in that window as the turn-start ref.

          /**
           * Locate this turn's first user entry and the new leaf in pi's session
           * tree. The user-entry id becomes the provider turn's native ref (the
           * point `fork` rolls back to); the leaf becomes the conversation head.
           * Pure bookkeeping: failures degrade to the synthetic refs.
           */
          const captureTurnTreeRefs = Effect.fnUntraced(function* () {
            const cursorWasStale = leafCursorStale;
            const cursor = cursorWasStale ? null : lastKnownLeaf;
            const data = yield* request({
              type: "get_entries",
              ...(cursor === null ? {} : { since: cursor }),
            }).pipe(Effect.orElseSucceed(() => undefined));
            // ...
            leafCursorStale = false;
            const firstUserEntryId = cursorWasStale
              ? undefined
              : Array.isArray(entries)
                ? entries
                    .filter(
                      (entry) =>
                        recordField(entry, "type") === "message" &&
                        recordString(recordField(entry, "message"), "role") === "user",
                    )
                    // ...

    leafCursorStale is set only when get_entries fails (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 knows get_entries is the whole tree, abandoned branches included; readThreadSnapshot uses get_messages for that reason.

    since is append order. After navigateTree, the current leaf is an older JSONL id. The next since that 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 up 6570076c and f057ff12. A slash command that never appends a user entry leaves the rewind turn on its initial weak ref, matching turn 5.

    finalizeTurn publishes the new leaf and the (possibly wrong) turn ref. ProviderEventIngestor maps provider_thread.updated straight through. Nothing walks runs above a head that moved backward.

    lastRunOrdinal only moves forward here, on turn start:

                  yield* updateProviderThread(state, {
                    status: "active",
                    firstRunOrdinal: state.providerThread.firstRunOrdinal ?? turnInput.runOrdinal,
                    lastRunOrdinal: turnInput.runOrdinal,
                  });

    The only writer that marks runs rolled_back, checkpoints stale, and lowers lastRunOrdinal is CheckpointRollbackService, and it runs only for T3 Revert. That path calls Pi fork, which replaces the session file. navigateTree does not, so the two paths never meet.

    Clients already hide rolled_back runs. isOrchestrationV2TurnItemVisible in packages/shared/src/orchestrationV2Timeline.ts is 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 returned leafId is not in the since window and is not the cursor. Then reconcile in orchestration, where the projection’s provider turns and run ordinals already live. Reuse the events CheckpointRollbackService writes (run.updated / node.updated → rolled_back, checkpoint.captured → stale, lastRunOrdinal lowered to the last run whose strong nativeTurnRef is still on the leaf’s parentId chain).

    Assigning the next nativeTurnRef needs the same check. Re-basing lastKnownLeaf onto 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_entries the way PiAdapterV2.test.ts already queues entries: a rewind response whose leafId is 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 main bug and not a duplicate.

  2. added
    bugSomething is broken or behaving incorrectly.
    acceptedfeature request accepted
    via-triageFiled through npx t3 triage
    on Sep 24, 2026
  3. added a commit that references this issue on Sep 30, 2026
    acf4884
  4. juliusmarminge commented on Oct 9, 2026

    @juliusmarminge
    Member

    Note

    Grok responding on behalf of Julius.

    Closing this since #13839 just landed on main. It reconciles in-session Pi rewinds (like the ctx.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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    acceptedfeature request acceptedbugSomething is broken or behaving incorrectly.via-triageFiled through npx t3 triage

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions