fix(layout): the quit checkpoint records only the quit capture's resume records in main (dark, layout-owner build only)

Main keeps its own resume records over a window save, so the quit capture's changes, which reach
main as async commands, could miss the quit. The capture now sends only what it changed with the
synchronous stage, and main writes them through the record-only sleep command before staging; a
wake main recorded that the window had not seen stays.
This commit is contained in:
Jinwoo-H
2026-10-18 03:25:01 -04:00
parent 36d46bdcc2
commit 2a0f433c34
7 changed files with 242 additions and 26 deletions
@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { closeTestStores, createStore, testState } from '../persistence-test-harness'
import {
addWorkspace,
emptySession,
GIT_KEY,
leaf
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
const { handlers } = vi.hoisted(() => ({
handlers: new Map<string, (event: { returnValue?: unknown }, args: unknown) => unknown>()
}))
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false },
ipcMain: {
handle: (
channel: string,
handler: (event: { returnValue?: unknown }, args: unknown) => unknown
) => {
handlers.set(channel, handler)
},
on: (
channel: string,
handler: (event: { returnValue?: unknown }, args: unknown) => unknown
) => {
handlers.set(channel, handler)
}
}
}))
vi.mock('../telemetry/client', () => ({ track: vi.fn() }))
vi.mock('../telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn().mockReturnValue({}) }))
vi.mock('../ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(),
sshConfigHostsToTargets: vi.fn()
}))
import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint'
const PANE_A = `tab-a:${leaf(1)}`
const PANE_B = `tab-b:${leaf(2)}`
function session(): WorkspaceSessionState {
return addWorkspace(emptySession(), GIT_KEY, [
{
id: 'group-1',
tabs: [
{ id: 'tab-a', leaves: [[leaf(1), 'pty-1']] },
{ id: 'tab-b', leaves: [[leaf(2), 'pty-2']] }
]
}
])
}
function record(paneKey: string, capturedAt: number): SleepingAgentSessionRecord {
return {
paneKey,
tabId: paneKey.split(':')[0],
worktreeId: GIT_KEY,
agent: 'claude',
providerSession: { key: 'session_id', id: `session-${capturedAt}` },
prompt: 'work',
state: 'working',
capturedAt,
updatedAt: capturedAt
}
}
function stage(state: WorkspaceSessionState, sleepRecords?: SleepingAgentSessionRecord[]) {
const event: { returnValue?: unknown } = {}
handlers.get('app:stage-before-unload-sync')!(event, {
sessions: [{ state, hostId: 'local' }],
ui: {},
...(sleepRecords ? { sleepRecords } : {})
})
return event.returnValue
}
describe('the quit checkpoint in a layout-owner build', () => {
let store: ReturnType<typeof createStore>
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
store = createStore()
registerRendererShutdownCheckpointHandler(store)
})
afterEach(async () => {
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
it('records the quit capture’s resume records in main before staging', () => {
store.setWorkspaceSession(session(), 'local')
const captured = record(PANE_A, 2)
expect(
stage({ ...session(), sleepingAgentSessionsByPaneKey: { [PANE_A]: captured } }, [captured])
).toEqual({ ok: true })
expect(store.getWorkspaceSession('local').sleepingAgentSessionsByPaneKey?.[PANE_A]).toEqual(
captured
)
})
it('keeps a wake main recorded that the window had not seen yet', () => {
// Main woke tab-b (a phone showed it) after the window last saw its record.
store.setWorkspaceSession(session(), 'local')
const captured = record(PANE_A, 2)
stage(
{
...session(),
sleepingAgentSessionsByPaneKey: { [PANE_A]: captured, [PANE_B]: record(PANE_B, 1) }
},
[captured]
)
const records = store.getWorkspaceSession('local').sleepingAgentSessionsByPaneKey ?? {}
expect(Object.keys(records)).toEqual([PANE_A])
})
})
+43 -2
View File
@@ -1,5 +1,11 @@
import { ipcMain } from 'electron'
import type { ExecutionHostId } from '../../shared/execution-host'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume'
import { applyCommandToSession } from '../persistence/terminal-topology/terminal-pane-admission'
import type { PersistedUIState } from '../../shared/persisted-ui-state-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import type { Store } from '../persistence'
@@ -11,6 +17,37 @@ import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
type StageBeforeUnloadSyncArgs = {
sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[]
ui: Partial<PersistedUIState>
/** Layout-owner builds: the resume records the quit capture changed. */
sleepRecords?: SleepingAgentSessionRecord[]
}
/**
* The partition with the quit capture's resume records written, each workspace's through the
* record-only sleep command a live capture sends: only what the capture changed, so a wake main
* recorded after the window last looked stays. Records of panes the partition lacks change nothing.
*/
function withQuitSleepRecords(
hostId: string | undefined,
session: WorkspaceSessionState,
records: readonly SleepingAgentSessionRecord[]
): WorkspaceSessionState {
const byWorkspace = Map.groupBy(records, (record) => record.worktreeId)
let current = session
for (const [workspace, workspaceRecords] of byWorkspace) {
const change = applyCommandToSession(
normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID,
current,
{
type: 'sleep',
workspace,
paneKeys: workspaceRecords.map((record) => record.paneKey),
records: workspaceRecords,
recordOnly: true
}
)
current = change.session ?? current
}
return current
}
export type ShutdownCheckpointResult = { ok: boolean }
@@ -56,7 +93,11 @@ export function registerRendererShutdownCheckpointHandler(store: Store): void {
if (!isFrozenOrcadSourceSessionPartition(store, hostId)) {
// Switch point: main keeps its own layout over the window's and takes its view and facts.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
const stored = store.getWorkspaceSession(hostId)
const stored = withQuitSleepRecords(
hostId,
store.getWorkspaceSession(hostId),
args.sleepRecords ?? []
)
reportWindowSaveDrift(stored, state, hostId, 'shutdown-checkpoint')
store.stageWorkspaceSessionBeforeUnload(
windowViewSaveForMain(stored, state, hostId),
+3
View File
@@ -1,4 +1,5 @@
import type { AppIdentity } from '../../shared/app-identity'
import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resume'
import type { E2EConfig } from '../../shared/e2e-config'
import type { ExecutionHostId } from '../../shared/execution-host'
import type {
@@ -38,6 +39,8 @@ export type AppApi = {
stageBeforeUnloadSync: (args: {
sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[]
ui: Partial<PersistedUIState>
/** Layout-owner builds: the resume records the quit capture changed. */
sleepRecords?: SleepingAgentSessionRecord[]
}) => void
/** Resolves once the last staged checkpoint is durably written; rejects if that
* write failed, so a reload/restart can abort instead of losing the snapshot. */
@@ -21,6 +21,19 @@ function makeDeps(
}
describe('createShutdownCheckpointPersist', () => {
it('stages the resume records the quit capture changed, for main to record', () => {
const records = [{ paneKey: 'tab-1:leaf-1' }] as never
const deps = makeDeps({ captureSleepingAgentSessions: () => records })
createShutdownCheckpointPersist(deps).run()
expect(deps.stageBeforeUnloadSync).toHaveBeenCalledWith({
sessions: [{ state: { activeTabId: 't1' } }],
ui: { activeView: 'workspace' },
sleepRecords: records
})
})
it('does not fail the checkpoint when the sleeping-agent quit capture throws (STA-5505)', () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
const deps = makeDeps({
@@ -1,4 +1,5 @@
import type { PersistedUIState } from '../../../shared/persisted-ui-state-types'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { formatShutdownCheckpointFailureReason } from '../../../shared/renderer-shutdown-events'
import type { WorkspaceSessionHostSnapshot } from '../lib/workspace-session-host-persistence'
import { recordRendererCrashBreadcrumb } from '../lib/crash-breadcrumb-recorder'
@@ -6,13 +7,16 @@ import { recordRendererCrashBreadcrumb } from '../lib/crash-breadcrumb-recorder'
export type ShutdownCheckpointStageArgs = {
sessions: WorkspaceSessionHostSnapshot[]
ui: Partial<PersistedUIState>
/** Layout-owner builds: the resume records the quit capture changed, which main records. */
sleepRecords?: SleepingAgentSessionRecord[]
}
export type ShutdownCheckpointPersistDeps = {
shouldCaptureSession: () => boolean
/** Per-pane failures are already swallowed inside the capture loop. */
captureTerminalBuffers: () => void
captureSleepingAgentSessions: () => void
/** Returns the records it changed when main records them (a layout-owner build). */
captureSleepingAgentSessions: () => SleepingAgentSessionRecord[] | void
buildSessionSnapshots: () => WorkspaceSessionHostSnapshot[]
buildUiPatch: () => Partial<PersistedUIState>
hasDirtyOpenFiles: () => boolean
@@ -41,10 +45,11 @@ export function createShutdownCheckpointPersist(
let fullStagingFailedOnPriorAttempt = false
const run = (): void => {
const shouldCaptureSession = deps.shouldCaptureSession()
let sleepRecords: SleepingAgentSessionRecord[] | void = undefined
if (shouldCaptureSession) {
deps.captureTerminalBuffers()
try {
deps.captureSleepingAgentSessions()
sleepRecords = deps.captureSleepingAgentSessions()
} catch (error) {
// Why: blocking the shutdown here is what stranded STA-5505. The cost of
// continuing is real but bounded — done panes keep their weaker live-origin
@@ -74,7 +79,8 @@ export function createShutdownCheckpointPersist(
try {
deps.stageBeforeUnloadSync({
sessions: degraded ? [] : sessionSnapshots,
ui: deps.buildUiPatch()
ui: deps.buildUiPatch(),
...(sleepRecords && sleepRecords.length > 0 && !degraded ? { sleepRecords } : {})
})
} catch (error) {
// A durable-only stage has nothing safer left to fall back to.
@@ -1,5 +1,6 @@
import { useEffect } from 'react'
import { useAppStore } from '../store'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { createSessionWriteSubscriber } from '../lib/session-write-subscriber'
import { buildActiveViewUnloadPatch } from '../lib/active-view-persist'
import {
@@ -19,6 +20,8 @@ import {
createShutdownCheckpointGuard
} from '../lib/shutdown-checkpoint-guard'
import { createShutdownCheckpointPersist } from './shutdown-checkpoint-persist'
import { changedSleepingRecords } from '../store/terminals/terminal-sleep-layout-owner'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume'
import { shutdownBufferCaptures } from '../components/terminal-pane/shutdown-buffer-captures'
import {
dispatchWindowCloseRequest,
@@ -81,8 +84,17 @@ export function useAppSessionPersistence(): void {
// which is in-memory. Capture them into the persisted sleeping-session
// map so a daemon/session death while the app is closed can still
// cold-restore via the agent's resume command (#5232).
captureSleepingAgentSessions: () =>
useAppStore.getState().captureAllSleepingAgentSessions('quit'),
captureSleepingAgentSessions: (): SleepingAgentSessionRecord[] | undefined => {
const previous = useAppStore.getState().sleepingAgentSessionsByPaneKey
useAppStore.getState().captureAllSleepingAgentSessions('quit')
// Switch point: the stage writes only the capture's changes into main's records.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
const state = useAppStore.getState()
return changedSleepingRecords(state, previous, state.sleepingAgentSessionsByPaneKey)
.recorded
}
return undefined
},
// Why: re-read state after capture() calls populated scrollback buffers
// into the store via Zustand setters. The shouldCaptureSession read is
// only for the gating flags and would miss those updates.
@@ -40,35 +40,49 @@ export function recordPaneSleepThroughLayoutOwner(
}
}
type HeldRecordsState = Pick<AppState, 'tabsByWorktree'>
/**
* The resume records `next` sets or replaces since `previous`, and the ones it drops, of panes in
* tabs the window holds: a closed tab's or removed workspace's records went with it in main, and a
* capture from a stale agent status names a tab main no longer has.
*/
export function changedSleepingRecords(
state: HeldRecordsState,
previous: SleepingRecords,
next: SleepingRecords
): { recorded: SleepingAgentSessionRecord[]; dropped: [string, SleepingAgentSessionRecord][] } {
const held = ([paneKey, record]: [string, SleepingAgentSessionRecord]): boolean =>
state.tabsByWorktree[record.worktreeId]?.some(
(tab) => tab.id === parsePaneKey(paneKey)?.tabId
) ?? false
return {
recorded: Object.entries(next)
.filter((entry) => previous[entry[0]] !== entry[1] && held(entry))
.map(([, record]) => record),
dropped: Object.entries(previous).filter((entry) => !(entry[0] in next) && held(entry))
}
}
/**
* Every change of the window's resume records, as record-only commands: live and quit captures of
* running agents, records a resume or close dropped, and the rest no sleep or wake command sends.
* Without these main learned the records only from the window's session save.
*/
export function recordSleepingRecordChangesThroughLayoutOwner(
state: WorktreeRuntimeOwnerState & Pick<AppState, 'tabsByWorktree'>,
state: WorktreeRuntimeOwnerState & HeldRecordsState,
previous: SleepingRecords,
next: SleepingRecords
): void {
// Only panes of tabs the window holds: a closed tab's or removed workspace's records went with it
// in main, and a capture from a stale agent status names a tab main no longer has.
const held = ([paneKey, record]: [string, SleepingAgentSessionRecord]): boolean =>
state.tabsByWorktree[record.worktreeId]?.some(
(tab) => tab.id === parsePaneKey(paneKey)?.tabId
) ?? false
const changedByWorktree = new Map<string, Record<string, SleepingAgentSessionRecord>>()
for (const [paneKey, record] of Object.entries(next)) {
if (previous[paneKey] !== record && held([paneKey, record])) {
changedByWorktree.set(record.worktreeId, {
...changedByWorktree.get(record.worktreeId),
[paneKey]: record
})
}
const { recorded, dropped } = changedSleepingRecords(state, previous, next)
for (const [worktreeId, records] of Map.groupBy(recorded, (record) => record.worktreeId)) {
recordPaneSleepThroughLayoutOwner(
state,
worktreeId,
Object.fromEntries(records.map((record) => [record.paneKey, record])),
true
)
}
for (const [worktreeId, records] of changedByWorktree) {
recordPaneSleepThroughLayoutOwner(state, worktreeId, records, true)
}
const dropped = Object.entries(previous).filter((entry) => !(entry[0] in next) && held(entry))
for (const [tabId, entries] of byTab(dropped, ([paneKey]) => paneKey)) {
recordLayoutApply(state, entries[0]![1].worktreeId, {
type: 'wake',