Repository navigation
Conversation
…nsient clear Losing an SSH transport calls clearStatusEntriesForConnection, which deletes every status row stamped with that connection and persists the deletion. The status row is the only thing carrying a pane's providerSession, so the pane -> sessionId/transcriptPath binding went with it. That is a safe bet while the app keeps running, because a reconnect re-publishes the rows; it is not safe across a renderer restart, which is exactly the reported sequence. Terminals reattach because PTYs are owned elsewhere, but chat resolves sessionId=null and the conversation reads as gone even though the transcript is still on the host. Persist the binding in its own last-status section, mirroring how authorityCommitments already survives the same clear. The transient path keeps it; only real teardown (retirement, pane/tab removal) drops it, and a pane rebind moves it alongside the authority commitment. Existing on-disk files hydrate unchanged: the section is optional, the version is not bumped, and each hydrated entry's own providerSession seeds the map, so no migration is needed and an older build ignores the extra key. Retention is capped at 512 entries with least-recently-observed eviction plus the existing hydrate TTL. Fixes stablyai#11546 Co-authored-by: Orca <help@stably.ai>
Greptile SummaryThis PR fixes the disappearing native-chat history after an SSH reconnect by introducing a new
Confidence Score: 4/5Safe to merge; the change is well-scoped to the main-process state layer, renderer wiring is deliberately deferred to a follow-up PR, and both forward/backward file compatibility are verified by the new tests. The core fix is solid and the test suite is thorough. There are two narrow gaps: Files Needing Attention: The hydration merge comparison and the
|
| Filename | Overview |
|---|---|
| src/main/agent-hooks/server.ts | Adds a new retainedProviderSessionsByPaneKey Map and providerSessionIdentities disk section to preserve pane→session bindings across transient SSH clears; all teardown paths (retirePaneAuthority, clearPaneState, clearPaneKeyAliasesForPty) correctly delete from the map, but dropStatusEntriesByTabPrefix may miss pane keys that exist only in this map after the live status row was cleared. |
| src/main/agent-hooks/server.test.ts | Adds four new targeted tests: transient-clear-and-restart with a legacy on-disk file, explicit retirement, TTL expiry, and insertion-order eviction at the 512-entry cap; covers both hydration paths and uses a Windows transcript path on a Unix runner to pin cross-platform path verbatim behaviour. |
Comments Outside Diff (1)
-
src/main/agent-hooks/server.ts, line 2316-2385 (link)dropStatusEntriesByTabPrefixmay not collect retained-only pane keysdropStatusEntriesByTabPrefixbuildspaneKeysToClearby scanning several maps (e.g.lastStatusByPaneKey,runtimeObservedStatusPaneKeys,promptSentDedupeByPaneKey) but does not scanretainedProviderSessionsByPaneKeyitself. AfterclearStatusEntriesForConnectionhas run — removing the pane fromlastStatusByPaneKeyandruntimeObservedStatusPaneKeys, and conditionally frompromptSentDedupeByPaneKey(only when state was'done') — the pane key may exist exclusively inretainedProviderSessionsByPaneKey. In that case, a subsequent tab-close callingdropStatusEntriesByTabPrefixwill not add it topaneKeysToClear, so the delete on line 2385 is never reached and the stale binding persists until the 7-day TTL.The fix is simply to scan
retainedProviderSessionsByPaneKeyin the collection phase, mirroring howlastStatusByPaneKeyis already handled.
Reviews (1): Last reviewed commit: "fix(agent-hooks): keep the pane provider..." | Re-trigger Greptile
| // Why: a live row's own binding is newer evidence than the retained section for the same pane. | ||
| if (!seeded || seeded.observedAt <= identity.observedAt) { |
There was a problem hiding this comment.
The
seeded.observedAt <= identity.observedAt condition uses <=, so when the entry-seeded binding and the persisted section carry the exact same timestamp (the common case after a restart where the disk file was written from that entry), the persisted section silently wins. The comment above says "a live row's own binding is newer evidence," which describes the opposite intention. Using strict < would preserve the entry-derived binding on a tie, which is the semantically safer choice — the entry binding was validated through the full hydration path just moments earlier.
| // Why: a live row's own binding is newer evidence than the retained section for the same pane. | |
| if (!seeded || seeded.observedAt <= identity.observedAt) { | |
| // Why: a live row's own binding is newer evidence than the retained section for the same pane. | |
| if (!seeded || seeded.observedAt < identity.observedAt) { |
📝 WalkthroughWalkthroughThe server now retains provider-session identities separately from live status rows. It validates, bounds, hydrates, and serializes these identities with TTL filtering. Live status rows take precedence during lookup and notifications. Retained identities survive transient connection clears and pane transfers, and cleanup paths remove them when authority or status state is retired. Tests cover restart restoration, retirement, expiration, and capacity eviction. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (2)
src/main/agent-hooks/server.ts (1)
422-433: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExtract a shared helper for building
AgentHookProviderSessionIdentityfrom a live row.The same field-mapping logic (
sessionId, optionaltranscriptPath, optionalworktreeId) is duplicated intoProviderSessionIdentity, the live branch ofgetProviderSessionForPane(Lines 750-758), and the live branch ofbuildStatusChangeNotification(Lines 970-978). A single helper, for exampletoProviderSessionIdentityFromLive(paneKey, live), would remove the duplication and make it easier to keep the live/retained precedence rule consistent if it changes later.♻️ Proposed helper extraction
+function toLiveProviderSessionIdentity( + paneKey: string, + live: EnrichedAgentHookEventPayload & { providerSession: AgentProviderSessionMetadata } +): AgentHookProviderSessionIdentity { + return { + paneKey, + sessionId: live.providerSession.id, + ...(live.providerSession.transcriptPath + ? { transcriptPath: live.providerSession.transcriptPath } + : {}), + ...(live.worktreeId ? { worktreeId: live.worktreeId } : {}) + } +}Also applies to: 750-758, 970-978
src/main/agent-hooks/server.test.ts (1)
6784-6933: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider adding coverage for a live row without
providerSessionafter retention.These four tests cover restore-with-no-live-row, explicit retirement, TTL expiry, and capacity eviction. None cover the scenario flagged in
server.ts(getProviderSessionForPane, Lines 739-767): a retained binding exists, then a live status row withoutproviderSessionis accepted for the same pane (for example, viaingestTerminalStatus). Add a test asserting the expected outcome forgetProviderSessionForPanein that case, once the behavior inserver.tsis confirmed.Do you want me to draft this test once the intended behavior is confirmed?
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 1ebc88d7-0288-483f-8319-bfcea7a87f45
📒 Files selected for processing (2)
src/main/agent-hooks/server.test.tssrc/main/agent-hooks/server.ts
|
Moving this PR to draft because it has been untouched for more than 80 days (latest recorded PR activity or commit timestamp: 2026-08-02T06:47:04Z; checked on 2026-10-27). This keeps the review queue focused on active work while preserving the proposal. Please mark it ready for review when work resumes. |
Fixes #11546
ELI5
When you chat with an agent in a pane, Orca doesn't keep the conversation in the window — it keeps a little sticky note saying "this pane is conversation #sess-1, whose transcript file lives here". The chat is then read from that transcript file.
Every time an SSH connection dropped, Orca threw away all the sticky notes for that connection — and saved that deletion to disk — betting the remote machine would hand them back on reconnect. If the app restarted before that happened, the terminal came back fine, but the sticky note was gone. The chat had no idea which conversation it was and showed empty history, even though the transcript was still sitting on the remote machine.
The fix keeps the sticky notes in their own section of Orca's saved-state file, copying the same trick an existing "who owns this pane" record already uses to survive disconnects. A dropped connection no longer erases them; only actually closing or retiring the pane does. Old saved-state files still work, new files won't break an older version of Orca, and the note pile is capped and expires after a week.
Root cause
clearStatusEntriesForConnectiondeletes every status row stamped with the lostconnectionIdand callsscheduleStatusPersist().serializeStatusFile()rebuildsentriespurely from the live map, so the wipe reaches disk.hydrateLastStatusFromDisk()then finds no row for the pane after a restart.The status row is the only thing carrying a pane's
providerSession— thesessionId+transcriptPathbinding that native chat resolves a conversation from. Deleting it is a safe bet while the app keeps running, because reconnect re-publishes the rows. It is not safe across a renderer restart, which is exactly the reported sequence:SshRelaySession.detach()/reconnect()route throughteardownProviders('connection_lost')→clearStatusEntriesForConnection, then the app restarts before reconnect completes. PTYs reattach because ownership lives elsewhere; chat resolvessessionId=nulland the conversation reads as gone.authorityCommitmentsin the same file already solves exactly this problem for pane authority, so this follows that pattern rather than inventing a new one.Fix
providerSessionIdentitiessection onLastStatusFile, keyed by paneKey:{ agent?, sessionKey?, sessionId, transcriptPath?, connectionId, worktreeId?, observedAt }.retainedProviderSessionsByPaneKey, populated fromapplyNormalizedStatus— both the full-status andproviderSessionOnlybranches, so every accepted payload carrying aproviderSessionseeds it, including the relayingestRemotepath which funnels through the same place.clearStatusEntriesForConnectiongoes throughdeleteStatusEntry(..., { preserveAuthority: true }); the binding is only dropped on the non-preserveAuthoritybranch. Real teardown does drop it:retirePaneAuthority,dropStatusEntriesByTabPrefix,clearPaneState, andclearPaneKeyAliasesForPtywhen the dying PTY still owns the stable pane key.transferPaneAuthoritymoves the binding to the new owner key alongside the authority commitment, so it follows a pane rebind instead of being orphaned.providerSession— this is what makes an existing on-disk file restore the binding with no migration — then applies the file's own section through a sanitizer reusingnormalizeAgentProviderSession(same trust boundary as relay/persisted input) and the existingHYDRATE_MAX_AGE_MScutoff. The section only wins over an entry-seeded row when itsobservedAtis not older.getProviderSessionForPane(paneKey)returns the live row's session; elsenullif a live row exists without one (a live row is the current authority — never resurrect an older conversation over it); else the retained binding.boundPaneKeyAliases, plus the 7-day hydrate TTL.Deliberately not done: widening
isValidPiProviderSessionOnly, and surfacing hydrated identities asproviderSessionOnlystatus rows. Both would risk resurrecting stale status (a phantom "working" badge). Retention is also not gated on thelaunchTokenHashcommitment — agents that never emit a launch token would lose the binding entirely. Pane reuse is handled by the live-row-wins rule plus deletion on real teardown.Proof
Before — new tests against
server.tsas it exists onmain:After — this branch:
The headline test uses a legacy-shaped on-disk file (no
providerSessionIdentitiessection) to prove existing state hydrates without migration. Its fixture deliberately uses a Windows remote transcript path while the test runs on macOS/Linux, pinning that remote paths are never normalized with localpathhelpers.pnpm tcandpnpm lintpass on this branch.Release-readiness
LAST_STATUS_FILE_VERSION = 2; the section is optional. Old file → new code: hydrate readsfile.providerSessionIdentities ?? {}and seeds from each hydrated entry's ownproviderSession, so today's on-disk file restores conversations with no migration. New file → old code: an older build reads onlyversion/entries/authorityCommitmentsand ignores the extra key, dropping it on its next write — downgrade-safe. This PR reduces data loss; it introduces no new persisted-state failure mode.transferPaneAuthorityrather than being orphaned. Sanitized through the existingnormalizeAgentProviderSessiontrust boundary.<userData>/agent-hooks/last-status.json, path built with the existingjoin()usage.transcriptPathis stored and returned verbatim, never normalized — for SSH/relay panes it is a remote path in the remote host's syntax.detach()/reconnect()→teardownProviders('connection_lost')→clearStatusEntriesForConnectionnow preserves the binding; the shutdown path (clearPtyOwnershipForConnection→ pane teardown) still drops it.connectionIdis retained so a consumer can tell which host owns the session.worktreeIdis carried only when the payload had one; nothing assumes a git worktree. Provider-neutral.Residual risk
sessionIdfrom the agent-status IPC row (native-chat-pane-resolution.ts). This change makes the binding survive and be resolvable — via the newgetProviderSessionForPaneand the retained rows now included ingetProviderSessionIdentities()— but does not itself change what the renderer asks for. The end-user symptom closes fully once a consumer reads it. Flagging clearly so this isn't mistaken for an end-to-end fix.publishProviderSessionChanges→ mobile session-tabs invalidation. Net effect is fewer spurious invalidations (a transient clear no longer looks like "session disappeared"), but it is a behavior change on that channel.retirePaneAuthority/clearPaneState/dropStatusEntriesByTabPrefix— e.g. an Orca crash before teardown — could keep a stale binding until the 7-day TTL. The live-row-wins rule prevents it from overriding a real session.Made with Orca 🐋