Warn instead of silently dropping agent sessions on pre-#5232 upgrade (#5356)

The cold-restore resume from PR #5240 resumes agent sessions from the
persisted sleepingAgentSessionsByPaneKey map, written at quit with origin
'quit'. Builds predating the #5232 fix never wrote that field, so on the
first launch after upgrading from such a version there is nothing to
resume: every live agent terminal cold-restores as a blank shell, silently
and unrecoverably.

The lost session ids genuinely cannot be conjured, so surface a one-time,
non-destructive notice instead of swapping in blank shells with no
explanation. Detection keys on a new per-session capability stamp
(agentSessionCaptureVersion): its absence marks a pre-fix writer. To avoid
false-alarming users upgrading from a working post-#5232 build (whose
sessions ARE resumed) on the first launch of this build — when no session
has the brand-new stamp yet — the notice is suppressed whenever quit/live
capture records exist, and only fires when the restored session actually
carried a resumable agent tab (launchAgent, which pre-fix builds persisted).

Main stamps every full session write so the stamp survives the quit flush;
the renderer consumes the signal at hydration and patches the stamp so the
notice never re-fires.
This commit is contained in:
Brennan Benson
2026-07-14 23:08:31 -07:00
parent 2519e51b8e
commit 919a9f7991
15 changed files with 410 additions and 10 deletions
+4 -2
View File
@@ -6608,7 +6608,7 @@ describe('Store', () => {
// ── Workspace Session ──────────────────────────────────────────────
it('get/set workspace session round-trips', async () => {
it('get/set workspace session round-trips (and stamps the capture version)', async () => {
const store = await createStore()
const session = {
activeRepoId: 'r1',
@@ -6618,7 +6618,9 @@ describe('Store', () => {
terminalLayoutsByTabId: {}
}
store.setWorkspaceSession(session)
expect(store.getWorkspaceSession()).toEqual(session)
// Why: main stamps every full write so the next launch knows this build
// could preserve agent sessions across a restart (#5356).
expect(store.getWorkspaceSession()).toEqual({ ...session, agentSessionCaptureVersion: 1 })
})
it('patches workspace session without replacing unchanged slices', async () => {
+12 -2
View File
@@ -104,6 +104,7 @@ import {
ONBOARDING_FINAL_STEP
} from '../shared/constants'
import { parseWorkspaceSession } from '../shared/workspace-session-schema'
import { AGENT_SESSION_CAPTURE_VERSION } from '../shared/agent-session-upgrade-notice'
import { normalizeUsagePercentageDisplay } from '../shared/usage-percentage-display'
import { isExistingPersistedProfile } from '../shared/project-order-manual-default-notice'
import { resolveUsagePercentageDisplayChangeNoticeDismissed } from '../shared/usage-percentage-display-change-notice'
@@ -5653,12 +5654,21 @@ export class Store {
}
setWorkspaceSession(session: PersistedState['workspaceSession'], hostId?: string | null): void {
// Why: main is the authority for what lands on disk, so stamp the
// capture-format version on every full write here. The renderer quit
// payload (buildWorkspaceSessionPayload) does not carry it, and a full
// write replaces the session — without this, the quit flush would drop the
// stamp and re-arm the pre-#5232 upgrade notice (#5356).
const stamped: WorkspaceSessionState = {
...session,
agentSessionCaptureVersion: AGENT_SESSION_CAPTURE_VERSION
}
const resolved = this.resolveHostId(hostId)
if (resolved === LOCAL_EXECUTION_HOST_ID) {
this.setLocalWorkspaceSession(session)
this.setLocalWorkspaceSession(stamped)
return
}
this.setHostWorkspaceSession(resolved, session)
this.setHostWorkspaceSession(resolved, stamped)
}
/** Persist a non-'local' host partition. The PTY-binding race protections in
+6
View File
@@ -119,6 +119,7 @@ import {
persistWorkspaceSessionByHostSync
} from './lib/workspace-session-host-persistence'
import { collectFolderWorkspaceKeysFromSession } from './lib/workspace-session-hydration-keys'
import { notifyPreUpgradeAgentSessionLossIfNeeded } from './lib/pre-upgrade-agent-session-loss-notice'
import {
getStartupErrorFallbackUI,
hydratePersistedUIAfterStartupRead
@@ -975,6 +976,11 @@ function App(): React.JSX.Element {
actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions)
actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions)
})
// Why: a session written by a build predating quit-time agent capture
// (#5232) cannot restore its live agent terminals — they cold-restore
// as blank shells. Warn once, non-destructively, off the raw loaded
// session rather than letting them vanish silently (#5356).
notifyPreUpgradeAgentSessionLossIfNeeded(sessionRead.session)
// Why: prune lastVisitedAtByWorktreeId entries whose worktrees
// no longer exist. Must run AFTER hydration — before this point,
// async repo loads may not have populated worktreesByRepo yet and
+3 -1
View File
@@ -126,7 +126,9 @@
"fe21e8f6f5": "Go back ({{value0}})",
"064bd07810": "Go back",
"ce37cf5279": "Toggle sidebar ({{value0}})",
"e4b9e7dff7": "Toggle sidebar"
"e4b9e7dff7": "Toggle sidebar",
"preUpgradeAgentSessionLossTitle": "Agent sessions could not be restored",
"preUpgradeAgentSessionLossBody": "After updating, agent terminals (Claude, Codex, and others) from your previous session started as fresh shells. Older versions could not save agent sessions across an update, so they could not be resumed. This will not happen again."
},
"web": {
"WebConnect": {
+3 -1
View File
@@ -126,7 +126,9 @@
"fe21e8f6f5": "Volver ({{value0}})",
"064bd07810": "Volver",
"ce37cf5279": "Alternar barra lateral ({{value0}})",
"e4b9e7dff7": "Alternar barra lateral"
"e4b9e7dff7": "Alternar barra lateral",
"preUpgradeAgentSessionLossTitle": "Agent sessions could not be restored",
"preUpgradeAgentSessionLossBody": "After updating, agent terminals (Claude, Codex, and others) from your previous session started as fresh shells. Older versions could not save agent sessions across an update, so they could not be resumed. This will not happen again."
},
"web": {
"WebConnect": {
+3 -1
View File
@@ -126,7 +126,9 @@
"fe21e8f6f5": "戻る ({{value0}})",
"064bd07810": "戻る",
"ce37cf5279": "サイドバーの切り替え ({{value0}})",
"e4b9e7dff7": "サイドバーの切り替え"
"e4b9e7dff7": "サイドバーの切り替え",
"preUpgradeAgentSessionLossTitle": "Agent sessions could not be restored",
"preUpgradeAgentSessionLossBody": "After updating, agent terminals (Claude, Codex, and others) from your previous session started as fresh shells. Older versions could not save agent sessions across an update, so they could not be resumed. This will not happen again."
},
"web": {
"WebConnect": {
+3 -1
View File
@@ -126,7 +126,9 @@
"fe21e8f6f5": "돌아가기({{value0}})",
"064bd07810": "돌아가기",
"ce37cf5279": "사이드바 표시/숨기기({{value0}})",
"e4b9e7dff7": "사이드바 표시/숨기기"
"e4b9e7dff7": "사이드바 표시/숨기기",
"preUpgradeAgentSessionLossTitle": "Agent sessions could not be restored",
"preUpgradeAgentSessionLossBody": "After updating, agent terminals (Claude, Codex, and others) from your previous session started as fresh shells. Older versions could not save agent sessions across an update, so they could not be resumed. This will not happen again."
},
"web": {
"WebConnect": {
+3 -1
View File
@@ -126,7 +126,9 @@
"fe21e8f6f5": "返回 ({{value0}})",
"064bd07810": "返回",
"ce37cf5279": "切换侧边栏 ({{value0}})",
"e4b9e7dff7": "切换侧边栏"
"e4b9e7dff7": "切换侧边栏",
"preUpgradeAgentSessionLossTitle": "Agent sessions could not be restored",
"preUpgradeAgentSessionLossBody": "After updating, agent terminals (Claude, Codex, and others) from your previous session started as fresh shells. Older versions could not save agent sessions across an update, so they could not be resumed. This will not happen again."
},
"web": {
"WebConnect": {
@@ -0,0 +1,62 @@
import { toast } from 'sonner'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import type { WorkspaceSessionState } from '../../../shared/types'
import {
AGENT_SESSION_CAPTURE_VERSION,
shouldNotifyPreUpgradeAgentSessionLoss
} from '../../../shared/agent-session-upgrade-notice'
import { patchWorkspaceSessionByHost } from './workspace-session-host-persistence'
/**
* On the first launch after upgrading from a build predating quit-time agent
* capture (#5232), a live agent terminal cold-restores as a blank shell because
* its session id was never persisted — and, before this notice, silently
* (#5356). The lost session cannot be conjured, so tell the user once,
* non-destructively, then stamp the session so it never re-fires.
*/
export function notifyPreUpgradeAgentSessionLossIfNeeded(session: WorkspaceSessionState): void {
// Fast path: a capture-capable build already stamped this session — no notice
// to show and nothing to reconcile, so skip the extra disk write too.
if ((session.agentSessionCaptureVersion ?? 0) >= AGENT_SESSION_CAPTURE_VERSION) {
return
}
// Why: this runs inline in the startup hydration path, so a fault here must
// never block the app from mounting — degrade to "no notice" instead.
try {
if (shouldNotifyPreUpgradeAgentSessionLoss(session)) {
toast(
<span data-testid="pre-upgrade-agent-session-loss-notice">
{translate(
'auto.App.preUpgradeAgentSessionLossTitle',
'Agent sessions could not be restored'
)}
</span>,
{
description: translate(
'auto.App.preUpgradeAgentSessionLossBody',
'After updating, agent terminals (Claude, Codex, and others) from your previous session started as fresh shells. Older versions could not save agent sessions across an update, so they could not be resumed. This will not happen again.'
),
// Sticky + dismissible: important enough to be seen, but purely
// informational — it blocks nothing and deletes nothing.
duration: Infinity,
dismissible: true
}
)
}
// Consume the pre-fix signal immediately (not only at the next graceful
// quit) so a crash before quit can't replay the notice.
void patchWorkspaceSessionByHost(
window.api.session,
{ agentSessionCaptureVersion: AGENT_SESSION_CAPTURE_VERSION },
useAppStore.getState()
).catch(() => {
// Best-effort: the quit-time full write re-stamps, so a failed patch just
// means the notice may re-evaluate next launch, never a crash.
})
} catch {
// Non-fatal: never let the upgrade notice interfere with startup.
}
}
@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest'
import type { SleepingAgentSessionRecord } from './agent-session-resume'
import type { TerminalTab, WorkspaceSessionState } from './types'
import {
AGENT_SESSION_CAPTURE_VERSION,
shouldNotifyPreUpgradeAgentSessionLoss
} from './agent-session-upgrade-notice'
function agentTab(launchAgent: string | undefined): TerminalTab {
return { launchAgent } as unknown as TerminalTab
}
function record(origin: SleepingAgentSessionRecord['origin']): SleepingAgentSessionRecord {
return { origin } as unknown as SleepingAgentSessionRecord
}
function session(
overrides: Partial<
Pick<
WorkspaceSessionState,
'agentSessionCaptureVersion' | 'tabsByWorktree' | 'sleepingAgentSessionsByPaneKey'
>
>
): Pick<
WorkspaceSessionState,
'agentSessionCaptureVersion' | 'tabsByWorktree' | 'sleepingAgentSessionsByPaneKey'
> {
return { tabsByWorktree: {}, ...overrides }
}
describe('shouldNotifyPreUpgradeAgentSessionLoss', () => {
it('warns for a pre-fix session: agent tab, no records, no stamp (#5356)', () => {
expect(
shouldNotifyPreUpgradeAgentSessionLoss(
session({ tabsByWorktree: { wt: [agentTab('codex')] } })
)
).toBe(true)
})
it('does not warn once the capture stamp is present', () => {
expect(
shouldNotifyPreUpgradeAgentSessionLoss(
session({
agentSessionCaptureVersion: AGENT_SESSION_CAPTURE_VERSION,
tabsByWorktree: { wt: [agentTab('codex')] }
})
)
).toBe(false)
})
it('does not warn when a quit/live capture record exists (working post-#5232 upgrade)', () => {
// The critical false-positive guard: on the first launch of the fixed build
// NO session has the brand-new stamp yet, so a working post-#5232 build must
// be distinguished by its resume records — those sessions ARE being restored.
expect(
shouldNotifyPreUpgradeAgentSessionLoss(
session({
tabsByWorktree: { wt: [agentTab('codex')] },
sleepingAgentSessionsByPaneKey: { 'tab:0': record('quit') }
})
)
).toBe(false)
expect(
shouldNotifyPreUpgradeAgentSessionLoss(
session({
tabsByWorktree: { wt: [agentTab('codex')] },
sleepingAgentSessionsByPaneKey: { 'tab:0': record('live') }
})
)
).toBe(false)
})
it('still warns when only a worktree-sleep record exists (does not resume cold-restored panes)', () => {
expect(
shouldNotifyPreUpgradeAgentSessionLoss(
session({
tabsByWorktree: { wt: [agentTab('codex')] },
sleepingAgentSessionsByPaneKey: { 'tab:0': record('worktree-sleep') }
})
)
).toBe(true)
})
it('does not warn for plain terminals with no agent tab', () => {
expect(
shouldNotifyPreUpgradeAgentSessionLoss(
session({ tabsByWorktree: { wt: [agentTab(undefined)] } })
)
).toBe(false)
})
it('does not warn for an unknown/non-resumable launchAgent', () => {
expect(
shouldNotifyPreUpgradeAgentSessionLoss(session({ tabsByWorktree: { wt: [agentTab('pi')] } }))
).toBe(false)
})
it('does not warn for a fresh session with no tabs', () => {
expect(shouldNotifyPreUpgradeAgentSessionLoss(session({ tabsByWorktree: {} }))).toBe(false)
expect(shouldNotifyPreUpgradeAgentSessionLoss(session({ tabsByWorktree: undefined }))).toBe(
false
)
})
})
@@ -0,0 +1,73 @@
import type { WorkspaceSessionState } from './types'
import { isResumableTuiAgent } from './agent-session-resume'
/**
* Bumped whenever the quit-time agent-session capture format changes. A session
* persisted by a build that captures live agents at quit (#5232/#5240) carries
* this stamp; builds predating that fix never wrote it. The *absence* of the
* stamp is the load-bearing signal that the previous writer could not preserve
* agent sessions across a restart — so it must NOT be added to
* getDefaultWorkspaceSession (a default would misclassify genuine pre-fix
* sessions as post-fix).
*/
export const AGENT_SESSION_CAPTURE_VERSION = 1
type PreUpgradeNoticeSession = Pick<
WorkspaceSessionState,
'agentSessionCaptureVersion' | 'tabsByWorktree' | 'sleepingAgentSessionsByPaneKey'
>
function hasRestorableAgentTab(
tabsByWorktree: WorkspaceSessionState['tabsByWorktree'] | undefined
): boolean {
if (!tabsByWorktree) {
return false
}
for (const tabs of Object.values(tabsByWorktree)) {
for (const tab of tabs) {
if (tab.launchAgent && isResumableTuiAgent(tab.launchAgent)) {
return true
}
}
}
return false
}
function hasQuitOrLiveCaptureRecord(
records: WorkspaceSessionState['sleepingAgentSessionsByPaneKey'] | undefined
): boolean {
if (!records) {
return false
}
for (const record of Object.values(records)) {
if (record.origin === 'quit' || record.origin === 'live') {
return true
}
}
return false
}
/**
* True when a restored session was written by a build predating quit-time agent
* capture (#5232) AND carried an agent terminal whose live session can no
* longer be resumed — the #5356 silent-loss case. The user gets a one-time,
* non-destructive notice instead of a blank shell appearing with no explanation.
*
* Deliberately conservative to avoid false alarms on the very first launch of
* the fixed build, when *every* prior session still lacks the brand-new stamp:
* - a present/current stamp means the writer captured sessions → never warn;
* - a quit/live capture record means the writer DID persist resumable agents
* (a working post-#5232 build) → those sessions are being restored, so
* never warn even though the stamp is not yet present;
* - only warn when the previous session actually had a resumable agent tab
* (`launchAgent`, which pre-fix builds already persisted) to lose.
*/
export function shouldNotifyPreUpgradeAgentSessionLoss(session: PreUpgradeNoticeSession): boolean {
if ((session.agentSessionCaptureVersion ?? 0) >= AGENT_SESSION_CAPTURE_VERSION) {
return false
}
if (hasQuitOrLiveCaptureRecord(session.sleepingAgentSessionsByPaneKey)) {
return false
}
return hasRestorableAgentTab(session.tabsByWorktree)
}
+6
View File
@@ -1106,6 +1106,12 @@ export type WorkspaceSessionState = {
defaultTerminalTabsAppliedByWorktreeId?: Record<string, true>
/** Provider-session resume records captured when workspaces sleep. */
sleepingAgentSessionsByPaneKey?: Record<string, SleepingAgentSessionRecord>
/** Capability stamp: the agent-session capture format version this session
* was last written with. Builds predating quit-time agent capture (#5232)
* never wrote it, so its absence flags a pre-fix session whose live agent
* sessions could not be preserved across the upgrade (#5356). Never seeded
* into defaults — absence is the signal. */
agentSessionCaptureVersion?: number
}
export type WorkspaceSessionPatch = Partial<WorkspaceSessionState>
@@ -425,4 +425,33 @@ describe('parseWorkspaceSession', () => {
expect(result.value.unifiedTabs?.wt[0].viewMode).toBe('terminal')
}
})
it('preserves the agentSessionCaptureVersion capability stamp (#5356)', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {},
agentSessionCaptureVersion: 1
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.agentSessionCaptureVersion).toBe(1)
}
})
it('leaves the stamp absent for a pre-fix session (absence is the signal)', () => {
const result = parseWorkspaceSession({
activeRepoId: null,
activeWorktreeId: null,
activeTabId: null,
tabsByWorktree: {},
terminalLayoutsByTabId: {}
})
expect(result.ok).toBe(true)
if (result.ok) {
expect(result.value.agentSessionCaptureVersion).toBeUndefined()
}
})
})
+7 -1
View File
@@ -295,7 +295,13 @@ export const workspaceSessionStateSchema: z.ZodType<WorkspaceSessionState> = z.o
)
.optional(),
defaultTerminalTabsAppliedByWorktreeId: z.record(z.string(), z.literal(true)).optional(),
sleepingAgentSessionsByPaneKey: sleepingAgentSessionsByPaneKeySchema
sleepingAgentSessionsByPaneKey: sleepingAgentSessionsByPaneKeySchema,
// Why: z.object strips unlisted keys, so the capability stamp must be declared
// here or it is dropped on every read and the pre-fix detection never
// terminates. `.catch(undefined)` degrades a corrupt value to "absent" (treat
// as pre-fix) rather than failing the whole-session parse. Never defaulted —
// its absence is the #5356 signal.
agentSessionCaptureVersion: z.number().int().nonnegative().optional().catch(undefined)
})
export type ParsedWorkspaceSession =
@@ -0,0 +1,92 @@
import { writeFileSync } from 'node:fs'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { getE2ECompletedOnboardingProfile } from './helpers/e2e-completed-onboarding-profile'
import { waitForSessionReady } from './helpers/store'
import { createRestartSession } from './helpers/orca-restart'
// #5356: the first launch after upgrading FROM a pre-#5232 version silently
// replaces every live floating-terminal agent session with a blank shell.
// Pre-fix builds never wrote `sleepingAgentSessionsByPaneKey` at quit, so the
// pane-level cold-restore finds nothing to resume and spawns a fresh shell —
// with no warning at all.
//
// This seeds orca-data.json with exactly what such a pre-fix quit left on disk:
// an agent terminal tab (`launchAgent`, which pre-fix builds DID persist), no
// resume records, and no post-fix capability stamp. On the first launch of the
// fixed build the session is unrecoverable (it genuinely cannot be conjured),
// so the app must NON-DESTRUCTIVELY tell the user instead of silently swapping
// in a blank shell.
/** A workspace session shaped exactly like a pre-#5232 quit payload. */
function preFixWorkspaceSession(): Record<string, unknown> {
return {
activeRepoId: null,
activeWorktreeId: 'wt-5356',
activeTabId: 'tab5356',
tabsByWorktree: {
'wt-5356': [
{
id: 'tab5356',
ptyId: null,
worktreeId: 'wt-5356',
title: 'Codex',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1,
// Pre-fix builds already persisted launchAgent, so a live agent tab
// is identifiable on disk even though its session id is gone.
launchAgent: 'codex'
}
]
},
terminalLayoutsByTabId: {}
// No sleepingAgentSessionsByPaneKey, no agentSessionCaptureVersion — that
// absence is precisely the pre-#5232 state.
}
}
function seedPreFixProfile(userDataDir: string): void {
const profile = {
...getE2ECompletedOnboardingProfile(),
workspaceSession: preFixWorkspaceSession()
}
writeFileSync(path.join(userDataDir, 'orca-data.json'), `${JSON.stringify(profile, null, 2)}\n`)
}
test('#5356 warns instead of silently dropping agent sessions when upgrading from a pre-#5232 version', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
const session = createRestartSession(testInfo)
try {
// The first launch after the update reads a pre-#5232 session off disk.
seedPreFixProfile(session.userDataDir)
const { app, page } = await session.launch()
await waitForSessionReady(page)
// Capture the loaded-pre-fix-session state either way: pre-fix builds show
// no notice here (the silent loss), the fixed build shows the warning.
await page.waitForTimeout(3_000)
await page.screenshot({
path: testInfo.outputPath('after-upgrade-relaunch.png'),
fullPage: true
})
// The lost agent session cannot be conjured, so the user must be told
// NON-DESTRUCTIVELY that agent state from the previous version could not be
// recovered. Pre-fix this notice is absent — that silent loss is the bug.
const notice = page.getByTestId('pre-upgrade-agent-session-loss-notice')
await expect(notice).toBeVisible({ timeout: 20_000 })
await page.screenshot({
path: testInfo.outputPath('upgrade-loss-notice.png'),
fullPage: true
})
await session.close(app)
} finally {
await session.dispose()
}
})