Skip to content

Pi: all subagent runs share session ID session, so checkpoints capture the wrong transcript #1870

Description

@khaong

Summary

Every Pi subagent run resolves to the same session ID — the literal string
session — because the ID is parsed from the transcript's basename, and Pi names
every subagent transcript session.jsonl.

Because that ID keys both transcript paths, every subagent run writes over the
previous one, and at commit time the checkpoint reads whichever run happened to
write last:

  • Staged copy: captureTranscript writes <repo>/.entire/tmp/pi/<id>.json
    → one session.json for all runs.
  • Shadow-branch archive: SessionMetadataDirFromSessionID is
    .entire/<id>/ → one metadata dir for all runs.
  • Condensation prefers the staged copy over the shadow archive
    (strategy/manual_commit_condensation.go:1005-1018), so the wrong content wins
    even where an archive exists.

Net effect: a checkpoint's stored transcript can be a different subagent's
conversation. There is no uncontaminated copy to fall back to, because both paths
collapse on the same ID.

Reproduces with one Pi session and one subagent. No concurrency required.

What is not a problem

So this isn't mistaken for an attribution bug: the sessions attached to a commit
are the correct ones. A trail shows the Pi parent and the Pi subagent work, both
genuine participants, nothing unrelated. Sessions↔checkpoints is many-to-many and
the subagent session carries the same checkpoint set as its parent, so no
checkpoint linkage is lost.

The damage is to transcript content, not to which sessions or checkpoints appear.

Observed

$ entire session list
Pi · gpt-5.6-sol     · session 019fab4c-c147-7b3a-8cc9-9ebd7224663b   turns 3
Pi · claude-sonnet-5 · session session                                turns 9

That second row is nine runs across three distinct subagents:

~/.pi/agent/sessions/<cwd-slug>/2026-07-29T00-36-01-991Z_019fab4c-…/
  71e7c5e8/run-0/session.jsonl   71e7c5e8/run-1/session.jsonl
  716671d6/run-0/session.jsonl   716671d6/run-1/session.jsonl   716671d6/run-2/session.jsonl
  d93a3451/run-0/session.jsonl   d93a3451/run-1/session.jsonl   d93a3451/run-2/session.jsonl
  d93a3451/run-3/session.jsonl

…and a single staged capture for all of them:

$ ls -l .entire/tmp/pi/
2075829  019fab4c-c147-7b3a-8cc9-9ebd7224663b.json   ← parent, fine
 197526  session.json                                 ← all 9 runs, last writer wins

Two commits were made from this session, at 11:01 and 11:28. session.json has an
mtime of 11:26 — so by the time the 11:28 commit condensed, the staged file held
the 11:26 run's transcript, not the run that produced the commit.

Root cause

Pi writes parent and subagent transcripts in different shapes. The parent's ID is
its filename; a subagent's ID is its directory path, and the leaf is named by
role:

2026-07-29T00-36-01-991Z_019fab4c-….jsonl                          ← parent: <timestamp>_<uuid>.jsonl
2026-07-29T00-36-01-991Z_019fab4c-…/d93a3451/run-0/session.jsonl   ← subagent

Refs at 849421758:

The ID is derived from the basename. agent/pi/lifecycle.go:366-378:

base := filepath.Base(p)
base = strings.TrimSuffix(base, ".jsonl")
if i := strings.LastIndex(base, "_"); i >= 0 {
    return base[i+1:]
}
return base

…/d93a3451/run-0/session.jsonlsession.jsonlsession → no underscore →
returns session. The fallback behaves as documented; the problem is that
filepath.Base discards the three segments carrying the identity.

It runs on every event, not just as a fallback. lifecycle.go:156-159 only
parses the path when payload.SessionID is empty — but
agent/pi/entire_extension.ts sends session_file: at lines 87, 97 and 120 and
never sends session_id. So the parse always runs. The comment at
lifecycle.go:173 ("Pi emits before_agent_start with a fully-populated session
ID") doesn't hold for the extension the CLI ships.

Nothing rejects the result. The only gate is validation.ValidateSessionID
(validation/validators.go:20), called at the capture choke point
(lifecycle.go:335). It checks empty, leading dash, path separators, ./..,
:, and glob metacharacters — "is this safe in a path?", not "is this an
identity?". session is a safe path segment and passes. That validator is doing a
different job correctly and shouldn't be extended to cover identity.

Both transcript paths key on the ID. Staging at lifecycle.go:346:

dst := filepath.Join(dir, sessionID+".json")

and the shadow archive via paths.SessionMetadataDirFromSessionID:

return EntireMetadataDir + "/" + sessionID

And the staged copy is preferred at read time.
strategy/manual_commit_condensation.go:1005-1018, reached from
extractSessionData with liveTranscriptPath = state.TranscriptPath:

if liveTranscriptPath != "" {
    ...
    if liveData, readErr := os.ReadFile(liveTranscriptPath); readErr == nil && len(liveData) > 0 {
        fullTranscript = string(liveData)
    }
}
if fullTranscript == "" {
    // Fall back to shadow branch copy

The documented rationale is sound in general — "handles the case where SaveStep
was skipped (no code changes) but the transcript continued growing; the shadow
branch copy would be stale"
— i.e. the live file is assumed to be a newer version
of the same session. For Pi subagents that assumption inverts: the live file is a
different subagent's transcript, so the preference actively selects wrong content.

Pi subagents never flow through the subagent mechanism. lifecycle.go:1088
derives SubagentTranscriptPath from event.SubagentID, which is how Claude Code
keeps per-subagent transcripts distinct. Pi's lifecycle events
(SessionStart/TurnStart/TurnEnd/session_shutdown) never populate
SubagentID, so Pi subagent runs arrive as ordinary turns carrying a colliding
session ID rather than as subagent steps. That's the design-level gap underneath
the parse bug.

Suggested fix

The identity is already in payload.SessionFile — parent UUID plus
<subagent>/<run> segments; only filepath.Base throws them away. Renaming the
staged capture alone is not sufficient, since the shadow archive keys on the
same ID.

Two coherent shapes:

  1. Model Pi subagents as subagents. Populate event.SubagentID from the path
    segments so runs go through the existing SubagentTranscriptPath mechanism, and
    let the session ID roll up to the parent UUID. This matches Claude Code, keeps a
    trail's session list to the conversation that drove the work, and gets distinct
    per-run transcripts for free.
  2. Give each run its own session ID (019fab4c-…:d93a3451:run-0). Simpler, but
    it adds session rows that duplicate the parent's checkpoint set.

(1) looks right given the mechanism already exists.

Worth doing alongside either: have entire_extension.ts send session_id
explicitly so the path parse stops being load-bearing, and add an identity check
distinct from the path-safety one.

Why tests didn't catch it

agent/pi/lifecycle_test.go:176 TestExtractSessionIDFromPath:

"/tmp/2026-05-09T12-00-00-000Z_abc-123.jsonl": "abc-123",
"abc-123.jsonl":                              "abc-123",
"/tmp/no-underscore-here.jsonl":              "no-underscore-here",   // fallback asserted as correct
"/path/with/multiple_under_scores_id.jsonl":  "id",

Every case is a flat file in a sessions directory, and the third pins the fallback
as intended. The nested subagent shape isn't represented, so the model under test
is "Pi transcripts are flat <timestamp>_<uuid>.jsonl" — true for parents, false
for subagents.

Scope

Observed within a single repo. Sessions appear repo-scoped and I have no evidence
of one session ID spanning repos — the only other checkout with Pi subagent runs
on disk had no Pi session registered, so that case is untested rather than ruled
out.

Environment

  • Entire CLI 0.9.0 (darwin/arm64, go1.26.4)
  • Pi, parent model gpt-5.6-sol, subagent model claude-sonnet-5

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions