Skip to content

fix(hosts): rank pi session discovery by last write - #84

Merged
backnotprop merged 1 commit into
mainfrom
fix/pi-session-resolution
Sep 21, 2026
Merged

backnotprop merged 1 commit into
mainfrom
fix/pi-session-resolution

Conversation

@backnotprop

Copy link
Copy Markdown
Contributor

Fixes the half of plannotator/herdr-annotate#37 that lives in this tree, and writes up the rest.

What changed

pi::find_transcript ranked candidate session files by filename. Pi names a session
file once, at creation (<ISO timestamp>_<uuid>.jsonl), so filename order is creation
order, not use order. A pi session started days ago and resumed today therefore lost to
any session created since in the same folder — which is exactly the "opens very random
messages" the reporter sees when they annotate from a pane running an older session.

newest_with_messages now ranks by file modification time, newest first, and falls back
to filename order on ties and for files whose metadata cannot be read (so a directory
whose files all share an mtime behaves as before). This is the rule pi itself uses:
findMostRecentSession in core/session-manager.ts, behind pi --continue, sorts the
bucket by mtimeMs.

omp::find_transcript delegates to pi::find_transcript, so OMP follows; a test pins
that. (OMP fallback discovery is unreachable in the app today — last/fallback.rs bails
for Host::Omp — but the shared function is the same one.)

Two existing fixture tests stage the fixtures into a temp dir with explicit mtimes now.
A checkout leaves every fixture the same age, in whatever order git wrote them, which
says nothing about which session was last used; relying on that would have made those
tests depend on checkout order.

Tests: a_session_written_to_more_recently_wins_over_one_with_a_newer_name (hosts/pi)
and omp_ranks_candidates_by_last_write_like_pi (hosts/omp). Both fail on main.

cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, and
cargo test --workspace are clean (22 test binaries, 0 failures).

Scope

This fixes the fallback path — the one taken when Herdr reports no session for the
pane. It does not fix the two cases below, which are not resolvable in this repo.


Investigation

1. /tree navigation is not observable from outside the pi process

Verified in pi's source. /tree moves the leaf and writes nothing:

  • SessionManager.branch(id) is this.leafId = branchFromId and nothing else
    (core/session-manager.ts:1436-1441); resetLeaf() is this.leafId = null
    (:1448-1450). No I/O in either.
  • AgentSession.navigateTree (core/agent-session.ts:3282-3474) calls branch() /
    resetLeaf() on the no-summary path (:3423-3437). It appends an entry only when the
    user picks "Summarize" (branchWithSummary, session-manager.ts:1457-1482) or presses
    Shift+L to label (interactive-mode.ts:5487-5490). The TUI never passes label
    itself (interactive-mode.ts:5455-5458).
  • The header is never rewritten on navigation (_rewriteFile call sites are migration,
    zero-length repair, and /fork only: session-manager.ts:954,1004,1578).
  • No sidecar, no state dir, no lockfile. The only files pi writes outside sessions are
    settings, auth, crash logs, trust, and user-invoked exports; Settings
    (core/settings-manager.ts:110-163) has no leaf or current-session field.
  • No query surface. --mode rpc is stdin/stdout JSON-lines (modes/rpc/rpc-mode.ts:1-13)
    and RpcSessionState (modes/rpc/rpc-types.ts:96-109) carries sessionFile,
    sessionId, sessionName, messageCount — no leafId. The unix-socket coordinator
    is PI_EXPERIMENTAL=1-gated, reached through a different entry file, and excluded from
    the npm tarball (packages/coding-agent/package.json:29-33).

On load, pi rebuilds the leaf as the last non-header line in file order
(_buildIndex, session-manager.ts:1014-1032) — the same rule pi::active_branch uses
here. So an outside reader is correct up to the moment the user navigates, and wrong
until the next entry is appended. If pi exits after navigating with no further message,
the navigation is lost even to pi.

The leaf is observable in-process, though. session_tree carries it:
{ type, newLeafId, oldLeafId, summaryEntry?, fromExtension? }
(core/extensions/types.ts:664-670, emitted at agent-session.ts:3463-3470) — the only
event that does. Extensions can also read ctx.sessionManager.getLeafId() / getBranch()
(read-only surface at session-manager.ts:212-228).

Minimal change, and where it belongs. Two options, both outside this repo:

  1. Herdr (preferred, benefits every consumer). Its pi extension already reports the
    session; add a session_tree subscription that re-reports with the new leaf.
    PaneReportAgentSessionParams (src/api/schema/panes.rs:383-395) has no
    deny_unknown_fields and the published schema does not set
    additionalProperties: false, so an agent_session_leaf: Option<String> is additive
    and old clients are unaffected. It would surface through AgentSessionInfo
    (src/api/schema/agents.rs:225-231) next to kind/value, and we would pass it as a
    PLANNOTATOR_TUI_SESSION_LEAF env var and start active_branch from that id instead
    of the last line. Roughly: one event handler in the asset, one optional field through
    schema → agent_resume → TerminalState, one optional env var and one parameter here.
  2. Plannotator's own pi extension (backnotprop/plannotator, apps/pi-extension).
    It already resolves the live branch correctly — getLastAssistantMessageSnapshot
    calls ctx.sessionManager.getBranch() (assistant-message.ts:70) — which is why
    /plannotator-last inside pi is right today even after /tree. It could write the
    leaf to a sidecar next to the session file for plannotator-tui to read, but that is a
    new private contract; option 1 is cleaner.

Nothing needs to change in pi itself.

2. The exact-session path, end to end, and every way it still goes wrong

Chain (verified): pi loads ~/.pi/agent/extensions/herdr-agent-state.ts (v8) → on
session_start (TUI mode only) and on every agent_start it calls updateSessionRef and
sends pane.report_agent_session with agent_session_path (preferred) or
agent_session_id → Herdr validates and stores it on the pane as hook_authority .session_ref / persisted_agent_session → herdr agent get <pane> prints
result.agent.agent_session = {source, agent, kind, value} → our launcher's
agent_identity (herdr/launch.rs:84) reads it and argv emits
PLANNOTATOR_TUI_SESSION=<path> or PLANNOTATOR_TUI_SESSION_ID=<id>
(herdr/launch.rs:289-297) → last/locate.rs:28 expands ~ and readers::explicit
reads the file (path), or exact::resolve finds it by id in the cwd bucket
(last/exact.rs:45-50).

Ways this still yields the wrong file, and how to tell from herdr agent get <pane>:

# Cause Evidence agent get symptom
a pi started before the integration was installed, or HERDR_PANE_ID/HERDR_SOCKET_PATH missing (ssh, tmux, docker exec) asset gate at lines 10-19, 176-178 agent_session absent (skip_serializing_if, agents.rs:206) — we then fall back to folder discovery, and the UI says "no session id from Herdr, showing the newest transcript for this folder"
b pi not in TUI mode ctx.mode !== "tui" early return, asset 228-230; rootSession never set so agent_start also bails agent_session absent
c Socket report dropped (500 ms + 1500 ms, no queue) asset 49-54 previous value persists
d /new report rejected replacement needs session_start_source ∈ {new, resume, fork} for pi (terminal/state.rs:1331); agent_start re-reports send no source and are dropped (state.rs:1548-1560) value is the pre-/new path. This is what Herdr #1189 / #943 addressed
e pi -r / pi -c at process start pi emits session_start with reason: "startup", not "resume" (agent-session.ts:411; "resume" comes only from the in-app /resume, agent-session-runtime.ts:218) — and "startup" is not in pi's replacement allow-list if the pane already had a pi ref and exit detection has not fired, value stays on the old session
f Nested pi (pi launched from inside pi) children inherit HERDR_PANE_ID (src/pane.rs:141-152); the nested one sets rootSession = true for itself value may be the inner pi's session
g Herdr restart + pane restore snapshot restored verbatim, format-validated only (persist/restore.rs:499-545); duplicates dropped (restore.rs:754-781) value present with no live process behind it; or absent on the duplicate pane
h Pane process died, shell respawned clear_agent_runtime_identity_after_respawn (state.rs:2049-2068) agent_session absent
i Two agents in one pane owner conflict rejection (state.rs:1260-1268, 1535-1545) agent names the other agent
j Plugin binary older than 0.6.0 exact resolution landed in 0.6.0 n/a — check plannotator-tui --version

(d) and (e) are the same shape and both produce "an exact session that is not the one in
the pane". The reporter's scenario 2 — several pi sessions in one folder, annotating from
the pane running the older one — matches (e) most closely: pi -r re-anchoring is
rejected when the pane already holds a pi ref. Note (a)-(c) and (g)-(h) all lose the
ref rather than corrupting it, and those land in the fallback path this PR fixes.

One thing worth knowing about the fallback fix's reach: pi does not touch a session
file's mtime on resume alone (_setSessionFile only reads for a well-formed current
file, session-manager.ts:940-962), and it does not create the file at all until the
first assistant message (_persist, :1074-1094). So mtime ranking becomes correct as
soon as the resumed session is used, not at the instant it is resumed.

3. Not done here, deliberately

We could reject an exact path whose session header cwd disagrees with the pane's cwd
and fall back to discovery with a note. That would catch (d), (e) and (g) when the stale
session is in another folder — but not when it is in the same folder, which is the
reported case — and it would misfire on --session-dir, subdirectory starts and
symlinked cwds. Not worth the risk without a reproduction.

refs plannotator/herdr-annotate#37

Pi names a session file once, at creation, so filename order is creation
order. A session resumed after days of idling lost to any session created
since in the same folder, which is the "random message" a pane running an
older pi session opens. Rank candidates by modification time instead, with
filename order as the tie-break and for files whose metadata cannot be read.

OMP discovery delegates to the same function, so it follows.

refs plannotator/herdr-annotate#37
@backnotprop
backnotprop added this pull request to the merge queue Sep 21, 2026
Merged via the queue into main with commit c957ca2 Sep 21, 2026
2 checks passed
@backnotprop
backnotprop deleted the fix/pi-session-resolution branch September 21, 2026 20:38
@backnotprop backnotprop mentioned this pull request Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant