From fc24ac37155f8729dbc94c8eaaa6becb4f35bcd7 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 18 Oct 2026 02:56:38 -0700 Subject: [PATCH] Window saves stop carrying tab and pane layout; main keeps its own (dark, layout-owner build only) (#30282) * fix(layout): an SSH pane's terminal binds in its own session partition (dark, layout-owner build only) Main keeps its own layout over a window save, so a binding only a save carries is lost. A window or runtime spawn of an SSH pane bound in the local partition; it now binds where the pane's layout lives. (cherry picked from commit beb2275b457abbe210a71a52ee992ed7f0c2dffa) * test(layout): the SSH binding test uses the shared spawn commit dependencies (cherry picked from commit 672b64189735f5cb4dcd2f7354863954b628dea1) * fix(layout): main moves a tab between a local and an SSH workspace itself (dark, layout-owner build only) moveTabToWorkspace across two partitions was refused as other_partition and left to the window's save. Main now reads both partitions, moves the tabs as within one, and writes both in one durable mutation. (cherry picked from commit ee27a0bb0749010af5b748e776fe681e7dc73114) * chore(layout): one binding write for the partition choice; shipped builds keep their exact replaced-binding read * fix(layout): window saves keep main's layout and give it only the window's view and facts (dark, layout-owner build only) windowViewSaveForMain/windowViewPatchForMain replace windowSaveForMain: main's own layout joined with the window's view, facts and carried fields, written only into the records that change. A paired server's partition passes through. A visited workspace main holds keeps its terminal row (D1). The P10 pending case is on. (cherry picked from commit 0c2fc1597436b1aa7980ce4761a9b06cf75379e4) * fix(layout): the save divergence detector becomes a report-only drift check behind its log (dark, layout-owner build only) It no longer wraps the write: it compares the window's raw save with main's layout model, and only when ORCA_LAYOUT_SAVE_DIVERGENCE_LOG is set. (cherry picked from commit 6bc1e5556f6f367f2d48c4006139200dd6d6b120) * fix(layout): tab reconcile and terminal reconnect stop writing tab structure (dark, layout-owner build only) Main's frames draw the tab bar and rows: reconcile still returns its counts but writes nothing, and reconnect still advertises restored terminals but leaves the rows to main. (cherry picked from commit ef8eb625ca8d4ee78cd57a841b42fd7a34feacd6) * fix(layout): a layout command main cannot apply is a logged refusal, not the window's own change (dark, layout-owner build only) other_partition, a thrown call, and host_not_owned for a workspace that is not a paired server's are refused and logged: a window save no longer carries the change, so drawing it would last only until the next frame. A twin main may route to a paired server keeps today's path, as does the first terminal's host_not_owned fallback. (cherry picked from commit 2c513b2d10d96b336cbf25f1c28f99f226846167) * fix(layout): the window's refusal code type keeps main's host refusals (cherry picked from commit 36d46bdcc206e3b2e8b10105771252a572434943) * 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. (cherry picked from commit 2a0f433c340b7cf8cfde062b1e7c3f881214d8dc) * test(layout): the quit capture test builds a typed resume record (cherry picked from commit d92d1246ee53564e1a0014f43b530297d9411aa8) * chore(layout): one layout write per save handler * chore(layout): shipped builds keep their exact quit capture * fix(layout): the drift check compares structure only, not records main writes itself (dark, layout-owner build only) Pane bindings and resume records, default-tab marks, closed-tab records and terminal row owners are main's own writes; a save that has not caught up with them asks main for nothing, so they are no longer reported. (cherry picked from commit 93e8b2627ca5a3001e23c50bfa21d270c3cc8475) * test(layout): relaunch first-terminal cases, and the side-by-side resume case moves its tab the way the window does (cherry picked from commit ab381332b11c580917a531ca580b041c781036e2) * test(layout): the relaunch case also checks a reopen that cleared read-only (cherry picked from commit bbf3d040c80f6eb433390a5f949839cf45d3e1d4) * docs(layout): the drift check names the record kinds it ignores * revert(layout): drop the SSH binding partition change; SSH terminal starts are refused before any binding is saved getProvider refuses every non-local host, so neither spawn path reaches persistPtyBinding for an SSH pane, with the switch on or off. * test: run A2's two real-SQLite session tests in the Node runtime project --- config/scripts/vitest-node-runtime-files.mjs | 2 + ...-shutdown-checkpoint-sleep-records.test.ts | 127 +++++++++ src/main/ipc/renderer-shutdown-checkpoint.ts | 84 ++++-- src/main/ipc/session-window-view-save.test.ts | 175 ++++++++++++ src/main/ipc/session.ts | 56 ++-- ...nce-invalid-workspace-layout-purge.test.ts | 13 +- .../window-save-divergence.test.ts | 252 ++++-------------- .../persistence/window-save-divergence.ts | 158 +++-------- .../window-save-pane-trees.test.ts | 196 -------------- .../persistence/window-save-pane-trees.ts | 45 ---- .../window-save-raw-session-divergence.ts | 70 +---- src/main/persistence/window-save-view.ts | 140 ++++++++++ src/preload/api/app-api.ts | 3 + .../shutdown-checkpoint-persist.test.ts | 25 ++ .../app-shell/shutdown-checkpoint-persist.ts | 12 +- .../app-shell/use-app-session-persistence.ts | 17 +- .../src/runtime/layout-apply-send.test.ts | 50 +++- src/renderer/src/runtime/layout-apply-send.ts | 44 ++- .../src/runtime/layout-owner-command.test.ts | 36 ++- .../layout-owner-shared-path-gates.test.ts | 69 +++++ .../store/slices/tabs/tabs-session-actions.ts | 8 +- .../terminals/terminal-sleep-layout-owner.ts | 52 ++-- ...erminal-tab-create-through-layout-owner.ts | 9 +- .../terminals/workspace-terminal-reconnect.ts | 6 +- tests/e2e/relaunch-first-terminal.spec.ts | 154 +++++++++++ ...nch-sleeping-agents-resume-on-view.spec.ts | 24 +- 26 files changed, 1063 insertions(+), 764 deletions(-) create mode 100644 src/main/ipc/renderer-shutdown-checkpoint-sleep-records.test.ts create mode 100644 src/main/ipc/session-window-view-save.test.ts delete mode 100644 src/main/persistence/window-save-pane-trees.test.ts delete mode 100644 src/main/persistence/window-save-pane-trees.ts create mode 100644 src/main/persistence/window-save-view.ts create mode 100644 src/renderer/src/store/slices/tabs/layout-owner-shared-path-gates.test.ts create mode 100644 tests/e2e/relaunch-first-terminal.spec.ts diff --git a/config/scripts/vitest-node-runtime-files.mjs b/config/scripts/vitest-node-runtime-files.mjs index 75b3e6d698a..77314a0658c 100644 --- a/config/scripts/vitest-node-runtime-files.mjs +++ b/config/scripts/vitest-node-runtime-files.mjs @@ -106,6 +106,8 @@ export const NODE_RUNTIME_INCLUDE = [ 'src/main/hermes/hermes-config-remote-adapter.test.ts', 'src/main/ipc/orca-profiles-switch-persistence.test.ts', 'src/main/ipc/settings-delayed-response.test.ts', + 'src/main/ipc/session-window-view-save.test.ts', + 'src/main/ipc/renderer-shutdown-checkpoint-sleep-records.test.ts', 'src/main/runtime/structured-agent-session-startup-tab-restore.test.ts', 'src/main/runtime/structured-agent-session-windowless-layout-host.test.ts', 'src/shared/remote-runtime-shared-control-connection.test.ts', diff --git a/src/main/ipc/renderer-shutdown-checkpoint-sleep-records.test.ts b/src/main/ipc/renderer-shutdown-checkpoint-sleep-records.test.ts new file mode 100644 index 00000000000..d5121cc5f83 --- /dev/null +++ b/src/main/ipc/renderer-shutdown-checkpoint-sleep-records.test.ts @@ -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 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 + 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]) + }) +}) diff --git a/src/main/ipc/renderer-shutdown-checkpoint.ts b/src/main/ipc/renderer-shutdown-checkpoint.ts index 809b62631fe..f867f69e036 100644 --- a/src/main/ipc/renderer-shutdown-checkpoint.ts +++ b/src/main/ipc/renderer-shutdown-checkpoint.ts @@ -1,16 +1,53 @@ 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' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' -import { windowSaveForMain } from '../persistence/window-save-pane-trees' -import { writeWindowSaveWatchingLayout } from '../persistence/window-save-divergence' +import { windowViewSaveForMain } from '../persistence/window-save-view' +import { reportWindowSaveDrift } from '../persistence/window-save-divergence' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' type StageBeforeUnloadSyncArgs = { sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[] ui: Partial + /** 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 } @@ -44,6 +81,21 @@ function flushStagedStateWithDeadline(store: Store): Promise - store.stageWorkspaceSessionBeforeUnload( - ORCA_LAYOUT_OWNER_IS_MAIN - ? windowSaveForMain(store.getWorkspaceSession(hostId), state, hostId) - : state, - hostId - ) - if (ORCA_LAYOUT_OWNER_IS_MAIN) { - writeWindowSaveWatchingLayout( - () => store.getWorkspaceSession(hostId), - hostId, - stage, - 'shutdown-checkpoint', - state.terminalLayoutsByTabId - ) - } else { - stage() - } + store.stageWorkspaceSessionBeforeUnload( + ORCA_LAYOUT_OWNER_IS_MAIN + ? stagedSessionForMain(store, state, hostId, args.sleepRecords) + : state, + hostId + ) } } store.updateUI(args.ui) diff --git a/src/main/ipc/session-window-view-save.test.ts b/src/main/ipc/session-window-view-save.test.ts new file mode 100644 index 00000000000..8508d7d5ba5 --- /dev/null +++ b/src/main/ipc/session-window-view-save.test.ts @@ -0,0 +1,175 @@ +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 { WorkspaceSessionState } from '../../shared/workspace-session-state-types' + +const { handlers } = vi.hoisted(() => ({ + handlers: new Map 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: unknown, ...args: unknown[]) => unknown) => { + handlers.set(channel, handler) + }, + on: (channel: string, handler: (event: 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 { registerSessionHandlers } from './session' + +const OTHER_KEY = 'repo-1::/Users/dev/other' + +/** Main's partition: one workspace with two terminal tabs, the first split in two panes. */ +function mainSession(): WorkspaceSessionState { + return addWorkspace(emptySession(), GIT_KEY, [ + { + id: 'group-1', + tabs: [ + { + id: 'tab-a', + leaves: [ + [leaf(1), 'pty-1'], + [leaf(2), 'pty-2'] + ] + }, + { id: 'tab-b', leaves: [[leaf(3), 'pty-3']] } + ] + } + ]) +} + +/** What a window that lags main saves: tab-b closed, a tab main never got, one pane, new titles. */ +function laggingWindowSave(): WorkspaceSessionState { + const save = addWorkspace(emptySession(), GIT_KEY, [ + { + id: 'group-1', + tabs: [ + { id: 'tab-a', title: 'vim', leaves: [[leaf(1), 'pty-1']] }, + { id: 'tab-new', leaves: [[leaf(5), 'pty-5']] } + ] + } + ]) + return { + ...save, + activeWorktreeId: GIT_KEY, + lastVisitedAtByWorktreeId: { [GIT_KEY]: 5, [OTHER_KEY]: 6 } + } +} + +function terminalTabs(session: WorkspaceSessionState, key = GIT_KEY): string[] { + return (session.tabsByWorktree[key] ?? []).map((row) => row.id) +} + +async function call(channel: string, ...args: unknown[]): Promise { + await handlers.get(channel)!({}, ...args) +} + +describe('a window save in a layout-owner build', () => { + let store: ReturnType + beforeEach(() => { + testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-')) + store = createStore() + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the save handlers never reach the runtime. + registerSessionHandlers(store, {} as never) + }) + afterEach(async () => { + await closeTestStores() + rmSync(testState.dir, { recursive: true, force: true }) + }) + + it('keeps main’s tabs and panes, and takes the window’s view and titles', async () => { + store.setWorkspaceSession(mainSession(), 'local') + + await call('session:set', laggingWindowSave(), 'local') + + const saved = store.getWorkspaceSession('local') + expect(terminalTabs(saved)).toEqual(['tab-a', 'tab-b']) + expect(saved.unifiedTabs?.[GIT_KEY]?.map((entry) => entry.id)).toEqual(['tab-a', 'tab-b']) + expect(saved.terminalLayoutsByTabId['tab-a']?.root).toEqual( + mainSession().terminalLayoutsByTabId['tab-a']!.root + ) + expect(saved.terminalLayoutsByTabId['tab-new']).toBeUndefined() + expect(saved.tabsByWorktree[GIT_KEY]?.[0]?.title).toBe('vim') + expect(saved.activeWorktreeId).toBe(GIT_KEY) + }) + + it('takes a scrollback patch into main’s panes and leaves main’s tree', async () => { + store.setWorkspaceSession(mainSession(), 'local') + const windowLayout = laggingWindowSave().terminalLayoutsByTabId['tab-a']! + + await call( + 'session:patch', + { + terminalLayoutsByTabId: { + 'tab-a': { ...windowLayout, buffersByLeafId: { [leaf(1)]: 'scrollback' } } + } + }, + 'local' + ) + + const layout = store.getWorkspaceSession('local').terminalLayoutsByTabId['tab-a'] + expect(layout?.root).toEqual(mainSession().terminalLayoutsByTabId['tab-a']!.root) + // The Store moves a saved buffer into a scrollback snapshot. + expect(Object.keys(layout?.scrollbackRefsByLeafId ?? {})).toEqual([leaf(1)]) + expect(terminalTabs(store.getWorkspaceSession('local'))).toEqual(['tab-a', 'tab-b']) + }) + + it('passes a scalar patch through', async () => { + store.setWorkspaceSession(mainSession(), 'local') + + await call('session:patch', { activeWorktreeId: OTHER_KEY }, 'local') + + expect(store.getWorkspaceSession('local').activeWorktreeId).toBe(OTHER_KEY) + expect(terminalTabs(store.getWorkspaceSession('local'))).toEqual(['tab-a', 'tab-b']) + }) + + it('gives a visited workspace main holds its terminal row, and one main lacks none', async () => { + store.setWorkspaceSession( + addWorkspace(emptySession(), GIT_KEY, [ + { id: 'group-1', tabs: [{ id: 'editor-1', kind: 'editor' }] } + ]), + 'local' + ) + const save = { + ...addWorkspace(emptySession(), GIT_KEY, [ + { id: 'group-1', tabs: [{ id: 'editor-1', kind: 'editor' }] } + ]), + tabsByWorktree: { [GIT_KEY]: [], [OTHER_KEY]: [] }, + lastVisitedAtByWorktreeId: { [GIT_KEY]: 5, [OTHER_KEY]: 6 } + } + + await call('session:set', save, 'local') + + const saved = store.getWorkspaceSession('local') + expect(Object.hasOwn(saved.tabsByWorktree, GIT_KEY)).toBe(true) + expect(Object.hasOwn(saved.tabsByWorktree, OTHER_KEY)).toBe(false) + }) + + it('writes a paired server’s partition as the window sent it', async () => { + store.setWorkspaceSession(mainSession(), 'runtime:env-1') + + await call('session:set', laggingWindowSave(), 'runtime:env-1') + + expect(terminalTabs(store.getWorkspaceSession('runtime:env-1'))).toEqual(['tab-a', 'tab-new']) + }) +}) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index a9e83b28106..c7c05f56597 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -4,8 +4,8 @@ import type { OrcaRuntimeService } from '../runtime/orca-runtime' import { parseTerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import { isFrozenOrcadSourceSessionPartition } from '../ssh/orcad-retained-source' import { markAgentLaunchesClosedByUser } from '../agent-launch/agent-launch-pane-attachment' -import { windowSaveForMain } from '../persistence/window-save-pane-trees' -import { writeWindowSaveWatchingLayout } from '../persistence/window-save-divergence' +import { windowViewPatchForMain, windowViewSaveForMain } from '../persistence/window-save-view' +import { reportWindowSaveDrift } from '../persistence/window-save-divergence' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' import type { WorkspaceSessionPatch, @@ -31,49 +31,33 @@ export function registerSessionHandlers(store: Store, runtime: OrcaRuntimeServic return store.getWorkspaceSessionHostIds() }) - // Switch point: main keeps its own pane trees over the window's, and reports any layout the - // save still changes. - const saveFromWindow = ( - hostId: string | null | undefined, - write: () => void, - source: string, - save: WorkspaceSessionState | WorkspaceSessionPatch - ) => - ORCA_LAYOUT_OWNER_IS_MAIN - ? writeWindowSaveWatchingLayout( - () => store.getWorkspaceSession(hostId), - hostId, - write, - source, - save.terminalLayoutsByTabId - ) - : write() - const forMain = ( - save: T, - hostId: string | null | undefined - ): T => - ORCA_LAYOUT_OWNER_IS_MAIN - ? windowSaveForMain(store.getWorkspaceSession(hostId), save, hostId) - : save + // Switch point: main keeps its own layout over the window's and takes the window's view and + // facts; the drift check reports where the save's layout still differs from main's. + const sessionForMain = (args: WorkspaceSessionState, hostId: string | null | undefined) => { + const stored = store.getWorkspaceSession(hostId) + reportWindowSaveDrift(stored, args, hostId, 'session:set') + return windowViewSaveForMain(stored, args, hostId) + } + const patchForMain = (args: WorkspaceSessionPatch, hostId: string | null | undefined) => { + const stored = store.getWorkspaceSession(hostId) + reportWindowSaveDrift(stored, { ...stored, ...args }, hostId, 'session:patch') + return windowViewPatchForMain(stored, args, hostId) + } ipcMain.handle('session:set', (_event, args: WorkspaceSessionState, hostId?: string | null) => { if (!isFenced(hostId)) { - saveFromWindow( - hostId, - () => store.setWorkspaceSession(forMain(args, hostId), hostId), - 'session:set', - args + store.setWorkspaceSession( + ORCA_LAYOUT_OWNER_IS_MAIN ? sessionForMain(args, hostId) : args, + hostId ) } }) ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch, hostId?: string | null) => { if (!isFenced(hostId)) { - saveFromWindow( - hostId, - () => store.patchWorkspaceSession(forMain(args, hostId), hostId), - 'session:patch', - args + store.patchWorkspaceSession( + ORCA_LAYOUT_OWNER_IS_MAIN ? patchForMain(args, hostId) : args, + hostId ) } }) diff --git a/src/main/persistence-invalid-workspace-layout-purge.test.ts b/src/main/persistence-invalid-workspace-layout-purge.test.ts index dbc62314021..9912753499a 100644 --- a/src/main/persistence-invalid-workspace-layout-purge.test.ts +++ b/src/main/persistence-invalid-workspace-layout-purge.test.ts @@ -21,6 +21,7 @@ import { } from './local-worktree-scan-generation' import { dropWorkspacesTheListingInvalidates } from './persistence/restoring-sessions/invalid-workspace-layout-purge' import { WorkspaceLayoutStream } from './runtime/workspace-layout-stream' +import { windowViewSaveForMain } from './persistence/window-save-view' vi.mock('../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) vi.mock('./ssh/ssh-config-parser', () => ({ @@ -222,8 +223,7 @@ describe('main drops the layout of workspaces the catalog no longer validates', }) }) - // Pending until Core-A: today a window save still carries structure, so it can restore the key. - it.skip('a later window save that still names a dropped workspace does not bring it back', () => { + it('a later window save that still names a dropped workspace does not bring it back', () => { const store = (() => { writeDataFile({ schemaVersion: 1, @@ -235,7 +235,14 @@ describe('main drops the layout of workspaces the catalog no longer validates', })() dropWorkspacesTheListingInvalidates(store, listing([KEPT])) - store.setWorkspaceSession(sessionWith(KEPT, DELETED), 'local') + store.setWorkspaceSession( + windowViewSaveForMain( + store.getWorkspaceSession('local'), + sessionWith(KEPT, DELETED), + 'local' + ), + 'local' + ) expect(storedKeys(store)).toEqual([KEPT]) }) diff --git a/src/main/persistence/window-save-divergence.test.ts b/src/main/persistence/window-save-divergence.test.ts index 5b8d58b7fa4..9dc7255f7a3 100644 --- a/src/main/persistence/window-save-divergence.test.ts +++ b/src/main/persistence/window-save-divergence.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { addWorkspace, @@ -9,8 +9,7 @@ import { GIT_KEY, leaf } from '../../shared/workspace-layout/workspace-layout-session.test-fixture' -import { writeWindowSaveWatchingLayout } from './window-save-divergence' -import { windowSaveForMain } from './window-save-pane-trees' +import { reportWindowSaveDrift } from './window-save-divergence' function session(tabs: { id: string; title?: string }[]): WorkspaceSessionState { return addWorkspace(emptySession(), GIT_KEY, [ @@ -24,24 +23,21 @@ function session(tabs: { id: string; title?: string }[]): WorkspaceSessionState ]) } -/** Runs a save that replaces the stored session with `next`, as the store would. */ +/** Checks a window save of `next` against main's `stored` partition. */ function save(stored: WorkspaceSessionState, next: WorkspaceSessionState, hostId = 'local') { - let current = stored - writeWindowSaveWatchingLayout( - () => current, - hostId, - () => { - current = next - }, - 'session:set' - ) - return current + reportWindowSaveDrift(stored, next, hostId, 'session:set') + return next } -describe('writeWindowSaveWatchingLayout', () => { +describe('reportWindowSaveDrift', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) let logDir: string | null = null + beforeEach(() => { + logDir = mkdtempSync(join(tmpdir(), 'orca-divergence-')) + vi.stubEnv('ORCA_LAYOUT_SAVE_DIVERGENCE_LOG', join(logDir, 'divergence.jsonl')) + }) + afterEach(() => { warn.mockClear() vi.unstubAllEnvs() @@ -58,10 +54,8 @@ describe('writeWindowSaveWatchingLayout', () => { expect(warn).not.toHaveBeenCalled() }) - it('reports a tab the save added, dropped, renamed or moved, and still writes it', () => { - logDir = mkdtempSync(join(tmpdir(), 'orca-divergence-')) - const logPath = join(logDir, 'divergence.jsonl') - vi.stubEnv('ORCA_LAYOUT_SAVE_DIVERGENCE_LOG', logPath) + it('reports a tab the save added, dropped, renamed or moved', () => { + const logPath = join(logDir!, 'divergence.jsonl') const next = session([{ id: 'tab-b', title: 'renamed' }, { id: 'tab-c' }]) expect(save(session([{ id: 'tab-a' }, { id: 'tab-b' }]), next)).toBe(next) @@ -91,7 +85,7 @@ describe('writeWindowSaveWatchingLayout', () => { ) }) - it('still writes the save when its log cannot be written', () => { + it('never throws when its log cannot be written', () => { vi.stubEnv( 'ORCA_LAYOUT_SAVE_DIVERGENCE_LOG', join(tmpdir(), 'orca-missing-dir', 'x', 'log.jsonl') @@ -122,33 +116,21 @@ describe('writeWindowSaveWatchingLayout', () => { ) }) - it('writes once when the check cannot read the stored copy, and lets a failed write throw', () => { - const write = vi.fn() - writeWindowSaveWatchingLayout( - () => { - throw new Error('unreadable') - }, - 'local', - write, - 'session:set' - ) - expect(write).toHaveBeenCalledOnce() + it('checks nothing without its log, and never throws when the check fails', () => { + vi.stubEnv('ORCA_LAYOUT_SAVE_DIVERGENCE_LOG', '') + save(session([{ id: 'tab-a' }]), session([{ id: 'tab-c' }])) + expect(warn).not.toHaveBeenCalled() - const read = vi.fn(() => session([{ id: 'tab-a' }])) - expect(() => - writeWindowSaveWatchingLayout( - read, - 'local', - () => { - throw new Error('disk full') - }, - 'session:set' - ) - ).toThrow('disk full') - expect(read).toHaveBeenCalledOnce() + vi.stubEnv('ORCA_LAYOUT_SAVE_DIVERGENCE_LOG', join(logDir!, 'divergence.jsonl')) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a malformed stored copy is the case under test. + expect(() => save(null as never, session([{ id: 'tab-a' }]))).not.toThrow() + expect(warn).toHaveBeenCalledWith( + '[layout-owner] save divergence check failed:', + expect.anything() + ) }) - it('counts panes only rebound to their terminals after the layout changes', () => { + it('leaves out panes only rebound to their terminals', () => { const bound = (ptyId: string): WorkspaceSessionState => { const result = session([{ id: 'tab-a' }, { id: 'tab-b' }]) result.terminalLayoutsByTabId['tab-a'] = { @@ -165,45 +147,30 @@ describe('writeWindowSaveWatchingLayout', () => { expect(warn).toHaveBeenCalledWith( '[layout-owner] window save changed main layout:', 'session:set', - expect.objectContaining({ paths: ['group group-1 changed: tabOrder', '1 pane(s) rebound'] }) + expect.objectContaining({ paths: ['group group-1 changed: tabOrder'] }) ) }) - it('counts a pane’s first terminal binding as a rebind, not a layout change', () => { + // Main writes these itself (spawn, its default-tabs and close commands, D1); a save lagging them asks for nothing. + it('stays quiet for records main keeps through its own writes', () => { + const key = 'repo-1::/Users/dev/notes' const stored = session([{ id: 'tab-a' }]) - const next = structuredClone(stored) - next.terminalLayoutsByTabId['tab-a'] = { - ...next.terminalLayoutsByTabId['tab-a']!, + const bound = structuredClone(stored) + bound.terminalLayoutsByTabId['tab-a'] = { + ...bound.terminalLayoutsByTabId['tab-a']!, ptyIdsByLeafId: { [leaf(1)]: 'pty-1' } } - save(stored, next) - - expect(warn).toHaveBeenCalledWith( - '[layout-owner] window save changed main layout:', - 'session:set', - expect.objectContaining({ paths: ['1 pane(s) rebound'] }) - ) - }) - - it('reports a default-tab record of a workspace with no tabs', () => { - const stored = emptySession() - const next = { + save(stored, bound) + save(emptySession(), { ...emptySession(), - defaultTerminalTabsAppliedByWorktreeId: { 'repo-1::/Users/dev/notes': true as const } - } + defaultTerminalTabsAppliedByWorktreeId: { [key]: true }, + tabsByWorktree: { [key]: [] }, + closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } }, + unifiedTabs: { [key]: [] } + }) - save(stored, next) - - expect(warn).toHaveBeenCalledOnce() - expect(warn).toHaveBeenCalledWith( - '[layout-owner] window save changed main layout:', - 'session:set', - expect.objectContaining({ - workspace: 'repo-1::/Users/dev/notes', - paths: ['defaultTabsAppliedByWorkspace changed'] - }) - ) + expect(warn).not.toHaveBeenCalled() }) it('stays quiet for a save that only names a workspace with nothing in it', () => { @@ -216,61 +183,6 @@ describe('writeWindowSaveWatchingLayout', () => { expect(warn).not.toHaveBeenCalled() }) - it('reports an empty terminal row a save adds or drops: a relaunch reads it there', () => { - const key = 'repo-1::/Users/dev/emptied' - const emptied = { ...emptySession(), tabsByWorktree: { [key]: [] } } - - save(emptySession(), emptied) - save(emptied, emptySession()) - - expect(warn).toHaveBeenCalledTimes(2) - expect(warn).toHaveBeenCalledWith( - '[layout-owner] window save changed main layout:', - 'session:set', - expect.objectContaining({ workspace: key, paths: ['terminal row owner changed'] }) - ) - }) - - it('reports a closed tab record only when the save adds or drops it, not when a key places it', () => { - const key = 'repo-1::/Users/dev/empty' - const closed = { - closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } } - } - const stored = { ...emptySession(), ...closed } - const named = { ...stored, unifiedTabs: { [key]: [] } } - - save(stored, named) - expect(warn).not.toHaveBeenCalled() - - save(named, { ...named, closedTerminalTabTombstonesByTabId: {} }) - save({ ...emptySession(), unifiedTabs: {} }, named) - for (const path of ['closed tab tab-x removed', 'closed tab tab-x added']) { - expect(warn).toHaveBeenCalledWith( - '[layout-owner] window save changed main layout:', - 'session:set', - expect.objectContaining({ workspace: key, paths: [path] }) - ) - } - expect(warn).toHaveBeenCalledTimes(2) - }) - - it('reports a closed tab record the save moves into a workspace only it names', () => { - const from = 'repo-1::/Users/dev/old' - const to = 'repo-1::/Users/dev/new' - const closedIn = (key: string) => ({ - ...emptySession(), - closedTerminalTabTombstonesByTabId: { 'tab-x': { worktreeId: key, closedAt: 1 } } - }) - - save(closedIn(from), { ...closedIn(to), unifiedTabs: { [to]: [] } }) - - expect(warn).toHaveBeenCalledWith( - '[layout-owner] window save changed main layout:', - 'session:set', - expect.objectContaining({ workspace: to, paths: ['closed tab tab-x added'] }) - ) - }) - it('still reports a workspace with tabs that the save adds or drops', () => { save(emptySession(), session([{ id: 'tab-a' }])) save(session([{ id: 'tab-a' }]), emptySession()) @@ -311,11 +223,11 @@ describe('writeWindowSaveWatchingLayout', () => { expect(warn).not.toHaveBeenCalled() }) - it('reports a pane tree main keeps over the save’s, and still writes the save', () => { + it('reports a save whose pane tree differs from main’s', () => { const stored = session([{ id: 'tab-a' }]) - const incoming = structuredClone(stored) - incoming.terminalLayoutsByTabId['tab-a'] = { - ...incoming.terminalLayoutsByTabId['tab-a']!, + const next = structuredClone(stored) + next.terminalLayoutsByTabId['tab-a'] = { + ...next.terminalLayoutsByTabId['tab-a']!, root: { type: 'split', direction: 'vertical', @@ -324,89 +236,19 @@ describe('writeWindowSaveWatchingLayout', () => { second: { type: 'leaf', leafId: leaf(2) } } } - let current = stored - writeWindowSaveWatchingLayout( - () => current, - 'local', - () => { - current = windowSaveForMain(current, incoming, 'local') - }, - 'session:set', - incoming.terminalLayoutsByTabId - ) - expect(current.terminalLayoutsByTabId['tab-a']?.root).toEqual( - stored.terminalLayoutsByTabId['tab-a']?.root - ) - expect(warn).toHaveBeenCalledOnce() - expect(warn).toHaveBeenCalledWith( - '[layout-owner] window save changed main layout:', - 'session:set', - expect.objectContaining({ - workspace: GIT_KEY, - paths: ['pane tree tab-a kept over the save’s'] - }) - ) - }) - - it('reports a pane tree main keeps for a tab no workspace places', () => { - const stored = session([{ id: 'tab-a' }]) - const layout = stored.terminalLayoutsByTabId['tab-a']! - stored.terminalLayoutsByTabId.orphan = { ...layout, root: { type: 'leaf', leafId: leaf(7) } } - const saved = { orphan: { ...layout, root: { type: 'leaf' as const, leafId: leaf(8) } } } - writeWindowSaveWatchingLayout( - () => stored, - 'local', - () => {}, - 'session:set', - saved - ) - - expect(warn).toHaveBeenCalledWith( - '[layout-owner] window save changed main layout:', - 'session:set', - expect.objectContaining({ - workspace: '(no workspace)', - paths: ['pane tree orphan kept over the save’s'] - }) - ) - }) - - it('reports a save that sends no pane tree for a tab main keeps one for', () => { - const stored = session([{ id: 'tab-a' }]) - const saved = { 'tab-a': { ...stored.terminalLayoutsByTabId['tab-a']!, root: null } } - writeWindowSaveWatchingLayout( - () => stored, - 'local', - () => {}, - 'session:set', - saved - ) + save(stored, next) expect(warn).toHaveBeenCalledWith( '[layout-owner] window save changed main layout:', 'session:set', expect.objectContaining({ workspace: GIT_KEY, - paths: ['pane tree tab-a kept over the save’s'] + paths: expect.arrayContaining(['tab tab-a changed: panes']) }) ) }) - it('stays quiet for a save whose pane trees match main’s', () => { - const stored = session([{ id: 'tab-a' }]) - const incoming = structuredClone(stored) - writeWindowSaveWatchingLayout( - () => stored, - 'local', - () => {}, - 'session:set', - incoming.terminalLayoutsByTabId - ) - - expect(warn).not.toHaveBeenCalled() - }) - it('leaves a paired server’s partition alone: its layout is the server’s', () => { const next = session([{ id: 'tab-c' }]) expect(save(session([{ id: 'tab-a' }]), next, 'runtime:env-1')).toBe(next) diff --git a/src/main/persistence/window-save-divergence.ts b/src/main/persistence/window-save-divergence.ts index 02be9977893..a088b595b5d 100644 --- a/src/main/persistence/window-save-divergence.ts +++ b/src/main/persistence/window-save-divergence.ts @@ -1,6 +1,9 @@ -// A layout-owner build's check that the window's session saves no longer carry layout: every tab -// change should reach main as a command, so a save that changes main's layout model is a change -// main learned only from the save. Dark: reports only, never alters or fails the write. +// A layout-owner build's drift check: every tab change reaches main as a command and main keeps +// its own layout over a window save, so a save whose layout differs from main's model shows a +// change main never got as a command. Report-only, and only when its log is set (evidence runs); +// it never alters, fails or delays the write. Records main keeps through its own writes (pane +// bindings, default-tab marks, closed-tab records, terminal row owners) are not compared: a save +// that has not caught up with them asks main for nothing. Deleted with the old path (phase F). import { appendFileSync } from 'node:fs' import { @@ -18,25 +21,16 @@ import type { } from '../../shared/workspace-layout/workspace-layout-model' import type { TabGroupLayoutNode } from '../../shared/tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { - closedTabsOfOneSidedWorkspaces, - droppedTabBarEntries, - overriddenPaneTrees, - terminalTabBarEntries, - type SavedPaneTrees -} from './window-save-raw-session-divergence' +import { droppedTabBarEntries, terminalTabBarEntries } from './window-save-raw-session-divergence' /** Set by evidence runs: each divergence is appended there as one JSON line. */ const LOG_PATH_ENV = 'ORCA_LAYOUT_SAVE_DIVERGENCE_LOG' const MAX_PATHS = 12 -const RECORDS = ['defaultTabsAppliedByWorkspace', 'clientHostedBrowserPagesByWorkspace'] as const -/** A pane's terminal binding: it still reaches main only through saves, on every spawn. */ -const BINDING_FIELDS = new Set(['ptyId', 'incarnationId']) +/** A pane's terminal binding and resume record: main writes them on every spawn and capture. */ +const BINDING_FIELDS = new Set(['ptyId', 'incarnationId', 'sleeping']) export type WindowSaveDivergence = { workspace: string; paths: string[] } -type WorkspaceDiff = { paths: string[]; rebound: number } - /** Main's layout model of one partition, as the session holds it. */ export function layoutModelOf( session: WorkspaceSessionState | null | undefined, @@ -99,14 +93,13 @@ function bindingOnly(leaf: unknown): boolean { ) } -/** Panes whose only change is their terminal binding, first bind and unbind included. */ -function reboundLeaves( +/** Each copy's panes without those whose only change is their binding, first or last included. */ +function unboundLeaves( before: Readonly> | undefined, after: Readonly> | undefined -): { rebound: Set; was: Map; now: Map } { +): { was: Map; now: Map } { const was = recordById(before) const now = recordById(after) - const rebound = new Set() for (const id of new Set([...was.keys(), ...now.keys()])) { const old = was.get(id) const next = now.get(id) @@ -116,12 +109,11 @@ function reboundLeaves( ? changed.length > 0 && changed.every((field) => BINDING_FIELDS.has(field)) : bindingOnly(old ?? next) ) { - rebound.add(id) was.delete(id) now.delete(id) } } - return { rebound, was, now } + return { was, now } } /** @@ -140,14 +132,6 @@ function hasContent(workspace: WorkspaceLayout | undefined): workspace is Worksp ) } -function diffClosedTabs(before: WorkspaceLayout, after: WorkspaceLayout): string[] { - return diffKeyed( - 'closed tab', - recordById(before.closedTerminalTabs), - recordById(after.closedTerminalTabs) - ) -} - /** A lone group with no stored tree is that group's leaf, as main's own save writes it. */ function groupTreeOf(workspace: WorkspaceLayout): TabGroupLayoutNode | null { const [only, ...rest] = workspace.groups @@ -156,19 +140,17 @@ function groupTreeOf(workspace: WorkspaceLayout): TabGroupLayoutNode | null { ) } -/** What a save changed in one workspace of main's model; empty paths and no rebound when nothing. */ -export function diffWorkspaceLayouts( +/** Where one workspace of two copies of main's model differs; empty when nothing. */ +function diffWorkspaceLayouts( before: WorkspaceLayout | undefined, after: WorkspaceLayout | undefined -): WorkspaceDiff { +): string[] { if (!hasContent(before) || !hasContent(after)) { - if (hasContent(before) || hasContent(after)) { - return { paths: [hasContent(after) ? 'workspace added' : 'workspace removed'], rebound: 0 } - } - // Both hold no tab: only a closed-tab record of a workspace both copies name can differ. - return { paths: before && after ? diffClosedTabs(before, after) : [], rebound: 0 } + return hasContent(before) || hasContent(after) + ? [hasContent(after) ? 'workspace added' : 'workspace removed'] + : [] } - const leaves = reboundLeaves(before.leaves, after.leaves) + const leaves = unboundLeaves(before.leaves, after.leaves) const paths = [ ...diffKeyed( 'group', @@ -191,13 +173,12 @@ export function diffWorkspaceLayouts( listById(before.browserTabs, (b) => b.id), listById(after.browserTabs, (b) => b.id) ), - ...diffClosedTabs(before, after), ...diffKeyed('leaf', leaves.was, leaves.now) ] if (stableJson(groupTreeOf(before)) !== stableJson(groupTreeOf(after))) { paths.push('group layout changed') } - return { paths, rebound: leaves.rebound.size } + return paths } /** @@ -209,46 +190,19 @@ export function windowSaveDivergences( now: WorkspaceLayoutModel | null, rawPaths: ReadonlyMap = new Map() ): WindowSaveDivergence[] { - // Records count for a workspace with no tabs too. const workspaces = new Set([ - ...[was, now].flatMap((model) => - model - ? [ - ...Object.keys(model.workspaces), - ...RECORDS.flatMap((record) => Object.keys(model.records[record] ?? {})) - ] - : [] - ), + ...[was, now].flatMap((model) => (model ? Object.keys(model.workspaces) : [])), ...rawPaths.keys() ]) const result: WindowSaveDivergence[] = [] + // Ignored, as main's own writes: pane bindings, resume records, default-tab marks, closed-tab records, row owners. for (const workspace of workspaces) { - const before = was?.workspaces[workspace] - const after = now?.workspaces[workspace] - const { paths: modelPaths, rebound } = diffWorkspaceLayouts(before, after) - const paths = [...modelPaths, ...(rawPaths.get(workspace) ?? [])] - for (const record of RECORDS) { - if ( - stableJson(was?.records[record]?.[workspace] ?? null) !== - stableJson(now?.records[record]?.[workspace] ?? null) - ) { - paths.push(`${record} changed`) - } - } - // Counted for an empty workspace too: a relaunch reads it there to skip the first terminal. - if ( - Boolean(was?.legacy.terminalRowOwners[workspace]) !== - Boolean(now?.legacy.terminalRowOwners[workspace]) - ) { - paths.push('terminal row owner changed') - } - // The binding count goes last and is never cut, so it crowds out no structural change. - const shown = - rebound > 0 - ? [...paths.slice(0, MAX_PATHS - 1), `${rebound} pane(s) rebound`] - : paths.slice(0, MAX_PATHS) - if (shown.length > 0) { - result.push({ workspace, paths: shown }) + const paths = [ + ...diffWorkspaceLayouts(was?.workspaces[workspace], now?.workspaces[workspace]), + ...(rawPaths.get(workspace) ?? []) + ] + if (paths.length > 0) { + result.push({ workspace, paths: paths.slice(0, MAX_PATHS) }) } } return result @@ -271,53 +225,29 @@ function report(source: string, partition: ExecutionHostId, divergence: WindowSa } /** - * Runs the window's save `write` and reports what it changed in main's layout model, and which of - * its `savedPaneTrees` (as the window sent them) main keeps its own tree over. A paired server's - * partition is skipped: its layout is the server's, mirrored by the window. Nothing here can fail - * the save: the write's own errors propagate, the check's are only logged. + * Reports where the window's `save` (a whole session, or a patch laid over `stored`) differs from + * main's layout model of `stored`. A paired server's partition is skipped: its layout is the + * server's, mirrored by the window. Nothing here can fail the save: errors are only logged. */ -export function writeWindowSaveWatchingLayout( - read: () => WorkspaceSessionState | null | undefined, +export function reportWindowSaveDrift( + stored: WorkspaceSessionState, + save: WorkspaceSessionState, hostId: string | null | undefined, - write: () => void, - source: string, - savedPaneTrees?: SavedPaneTrees + source: string ): void { + if (!process.env[LOG_PATH_ENV]) { + return + } const partition = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID if (parseExecutionHostId(partition)?.kind === 'runtime') { - write() - return - } - let was: WorkspaceLayoutModel | null | undefined - let wasEntries: Map> | undefined - let wasClosed: WorkspaceSessionState['closedTerminalTabTombstonesByTabId'] - const rawPaths = new Map() - try { - const stored = read() - was = layoutModelOf(stored, partition) - wasEntries = stored ? terminalTabBarEntries(stored) : new Map() - wasClosed = stored?.closedTerminalTabTombstonesByTabId - if (savedPaneTrees) { - overriddenPaneTrees(stored, was, savedPaneTrees, rawPaths) - } - } catch (error) { - was = undefined - console.warn('[layout-owner] save divergence check failed:', error) - } - write() - if (was === undefined || wasEntries === undefined) { return } try { - const now = read() - const nowModel = layoutModelOf(now, partition) - droppedTabBarEntries(wasEntries, now, rawPaths) - closedTabsOfOneSidedWorkspaces( - { model: was, closed: wasClosed }, - { model: nowModel, closed: now?.closedTerminalTabTombstonesByTabId }, - rawPaths - ) - for (const divergence of windowSaveDivergences(was, nowModel, rawPaths)) { + const was = layoutModelOf(stored, partition) + const now = layoutModelOf(save, partition) + const rawPaths = new Map() + droppedTabBarEntries(terminalTabBarEntries(stored), save, rawPaths) + for (const divergence of windowSaveDivergences(was, now, rawPaths)) { report(source, partition, divergence) } } catch (error) { diff --git a/src/main/persistence/window-save-pane-trees.test.ts b/src/main/persistence/window-save-pane-trees.test.ts deleted file mode 100644 index 22452c8e21c..00000000000 --- a/src/main/persistence/window-save-pane-trees.test.ts +++ /dev/null @@ -1,196 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' -import { normalizeTerminalLayoutPtyOwnership } from '../../shared/terminal-layout-pty-ownership' -import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' -import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load' -import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids' -import { - addWorkspace, - emptySession, - GIT_KEY, - leaf -} from '../../shared/workspace-layout/workspace-layout-session.test-fixture' -import { windowSaveForMain } from './window-save-pane-trees' -import { applyPtyBinding } from './loading-store/pty-binding-session-update' - -vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) - -const A = '11111111-1111-4111-8111-111111111111' -const B = '22222222-2222-4222-8222-222222222222' - -const mains: TerminalLayoutSnapshot = { - root: { - type: 'split', - direction: 'vertical', - ratio: 0.3, - first: { type: 'leaf', leafId: A }, - second: { type: 'leaf', leafId: B } - }, - activeLeafId: A, - expandedLeafId: null -} - -function stored(layouts: Record): WorkspaceSessionState { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: windowSaveForMain reads only terminalLayoutsByTabId. - return { terminalLayoutsByTabId: layouts } as WorkspaceSessionState -} - -describe('windowSaveForMain', () => { - it('keeps main’s pane tree, and the window’s focus, zoom and terminals', () => { - const windows: TerminalLayoutSnapshot = { - root: { type: 'leaf', leafId: A }, - activeLeafId: B, - expandedLeafId: B, - ptyIdsByLeafId: { [A]: 'pty-a' } - } - - const save = windowSaveForMain( - stored({ 'tab-1': mains }), - { terminalLayoutsByTabId: { 'tab-1': windows } }, - 'local' - ) - - expect(save.terminalLayoutsByTabId?.['tab-1']).toEqual({ ...windows, root: mains.root }) - }) - - it('keeps the window’s tree for a tab main holds no tree of', () => { - const windows: TerminalLayoutSnapshot = { - root: { type: 'leaf', leafId: A }, - activeLeafId: A, - expandedLeafId: null - } - const save = { terminalLayoutsByTabId: { 'tab-new': windows } } - - expect(windowSaveForMain(stored({ 'tab-1': mains }), save, 'local')).toBe(save) - }) - - it('passes a save with no pane layouts, or matching trees, through unchanged', () => { - const noLayouts = { activeWorktreeId: 'wt-1' } - expect(windowSaveForMain(stored({ 'tab-1': mains }), noLayouts, 'local')).toBe(noLayouts) - - const same = { terminalLayoutsByTabId: { 'tab-1': { ...mains, activeLeafId: B } } } - expect(windowSaveForMain(stored({ 'tab-1': mains }), same, 'local')).toBe(same) - }) - - it('keeps the window’s trees in a paired server’s partition, where main applies no pane command', () => { - const save = { - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf' as const, leafId: A }, - activeLeafId: A, - expandedLeafId: null - } - } - } - - expect(windowSaveForMain(stored({ 'tab-1': mains }), save, 'runtime:env-1')).toBe(save) - expect(windowSaveForMain(stored({ 'tab-1': mains }), save, 'ssh:target-1')).not.toBe(save) - }) -}) - -describe('pane trees main writes itself', () => { - it('a Codex restart of a rootless pane: main wrote the one-pane tree the window then writes', () => { - const session = { - activeRepoId: null, - activeWorktreeId: 'wt-1', - activeTabId: 'tab-1', - tabsByWorktree: { - 'wt-1': [ - { - id: 'tab-1', - ptyId: 'pty-old', - worktreeId: 'wt-1', - title: 'codex', - customTitle: null, - color: null, - sortOrder: 0, - createdAt: 1 - } - ] - }, - terminalLayoutsByTabId: { - 'tab-1': { - root: null, - activeLeafId: A, - expandedLeafId: null, - ptyIdsByLeafId: { [A]: 'pty-old' } - } - } - } satisfies WorkspaceSessionState - - // The replacement's spawn binds before it replies (codex-detached-pane-restart.ts). - applyPtyBinding( - { - worktreeId: 'wt-1', - tabId: 'tab-1', - leafId: A, - ptyId: 'pty-new', - placement: { kind: 'root' } - }, - session, - 'wt-1', - `tab-1:${A}` - ) - // The window's rebind: singlePaneLayoutSnapshot(A, 'pty-new'). - const windows: TerminalLayoutSnapshot = { - root: { type: 'leaf', leafId: A }, - activeLeafId: A, - expandedLeafId: null, - ptyIdsByLeafId: { [A]: 'pty-new' } - } - - const save = windowSaveForMain( - session, - { terminalLayoutsByTabId: { 'tab-1': windows } }, - 'local' - ) - - expect(session.terminalLayoutsByTabId['tab-1']?.root).toEqual(windows.root) - expect(save.terminalLayoutsByTabId?.['tab-1']).toEqual(windows) - }) -}) - -describe('windowSaveForMain after a duplicate-terminal repair', () => { - it('stores the repaired pane tree, so the next load still draws one pane', () => { - const session = addWorkspace(emptySession(), GIT_KEY, [ - { - id: 'g1', - tabs: [ - { - id: 'tab-a', - leaves: [ - [leaf(1), 'pty-a'], - [leaf(2), 'pty-a'] - ] - } - ] - } - ]) - const paneRoot = (state: WorkspaceSessionState) => { - const workspace = loadWorkspaceLayout(LOCAL_EXECUTION_HOST_ID, state, nameBasedLoadContext()) - .layout.workspaces[GIT_KEY]! - const tab = workspace.tabs.find((candidate) => candidate.id === 'tab-a') - return { - root: tab?.kind === 'terminal' ? tab.panes.root : undefined, - ptyId: workspace.leaves?.[leaf(1)]?.ptyId - } - } - const repaired = { root: { type: 'leaf', leafId: leaf(1) }, ptyId: 'pty-a' } - expect(paneRoot(session)).toEqual(repaired) - - // The window saves its own repair of the same stored layout. - const windowLayout = normalizeTerminalLayoutPtyOwnership( - session.terminalLayoutsByTabId['tab-a']! - ).snapshot - const save = windowSaveForMain( - session, - { terminalLayoutsByTabId: { 'tab-a': windowLayout } }, - 'local' - ) - const next = { ...session, terminalLayoutsByTabId: save.terminalLayoutsByTabId! } - - expect(next.terminalLayoutsByTabId['tab-a']).toEqual(windowLayout) - expect(paneRoot(next)).toEqual(repaired) - }) -}) diff --git a/src/main/persistence/window-save-pane-trees.ts b/src/main/persistence/window-save-pane-trees.ts deleted file mode 100644 index ef0f8dd90d2..00000000000 --- a/src/main/persistence/window-save-pane-trees.ts +++ /dev/null @@ -1,45 +0,0 @@ -// A window's session save in a layout-owner build: every pane change reaches main as a layout -// command, so the save's pane trees (which panes a tab has, their shape and sizes) are dropped for -// tabs main holds, and main keeps its own. The rest of each pane layout stays the window's: which -// pane is focused or zoomed, its terminals and scrollback. A tab main does not hold yet keeps the -// window's tree, as does a paired server's partition: main applies no pane command there. - -import { parseExecutionHostId } from '../../shared/execution-host' -import { normalizeTerminalLayoutPtyOwnership } from '../../shared/terminal-layout-pty-ownership' -import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' -import type { - WorkspaceSessionPatch, - WorkspaceSessionState -} from '../../shared/workspace-session-state-types' - -function withStoredPaneTrees( - stored: Readonly> | undefined, - incoming: Record -): Record { - let result = incoming - for (const [tabId, layout] of Object.entries(incoming)) { - const storedLayout = stored?.[tabId] - // Main's Loader drops a duplicate terminal's pane; storing that tree makes the repair durable. - const root = storedLayout && normalizeTerminalLayoutPtyOwnership(storedLayout).snapshot.root - if (root && layout.root !== root) { - result = result === incoming ? { ...incoming } : result - result[tabId] = { ...layout, root } - } - } - return result -} - -/** The save main applies, in a layout-owner build (the IPC entries call it behind the switch). */ -export function windowSaveForMain( - stored: WorkspaceSessionState, - save: T, - hostId: string | null | undefined -): T { - if (!save.terminalLayoutsByTabId || parseExecutionHostId(hostId)?.kind === 'runtime') { - return save - } - const layouts = withStoredPaneTrees(stored.terminalLayoutsByTabId, save.terminalLayoutsByTabId) - return layouts === save.terminalLayoutsByTabId - ? save - : { ...save, terminalLayoutsByTabId: layouts } -} diff --git a/src/main/persistence/window-save-raw-session-divergence.ts b/src/main/persistence/window-save-raw-session-divergence.ts index 61f3ed08e04..2703212ebab 100644 --- a/src/main/persistence/window-save-raw-session-divergence.ts +++ b/src/main/persistence/window-save-raw-session-divergence.ts @@ -1,18 +1,9 @@ -// What a window's save changes that main's layout model cannot show, read from the stored session -// itself: the Loader rebuilds a dropped terminal tab-bar entry from its row, places closed-tab -// records into whatever workspace a key names, and main keeps its own pane trees over the save's. -// Each finding is added to `paths` under its workspace. +// Where a window's save differs from main in a way main's layout model cannot show, read from the +// sessions themselves: the Loader rebuilds a dropped terminal tab-bar entry from its row. Each +// finding is added to `paths` under its workspace. -import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report' -import type { WorkspaceLayoutModel } from '../../shared/workspace-layout/workspace-layout-model' -import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' -/** The report key of a pane tree whose tab no workspace names. */ -const UNPLACED = '(no workspace)' - -export type SavedPaneTrees = Readonly> - /** The terminal tabs (by entity id) each workspace's stored tab bar names. */ export function terminalTabBarEntries(session: WorkspaceSessionState): Map> { return new Map( @@ -53,58 +44,3 @@ export function droppedTabBarEntries( } } } - -/** - * The save's pane trees that differ from main's: main keeps its own for every tab it has one for - * (windowSaveForMain), so each is reported under the workspace whose tab or row names it. - */ -export function overriddenPaneTrees( - stored: WorkspaceSessionState | null | undefined, - model: WorkspaceLayoutModel | null, - saved: SavedPaneTrees, - paths: Map -): void { - const homes = new Map() - for (const [workspace, rows] of Object.entries(stored?.tabsByWorktree ?? {})) { - rows.forEach((row) => homes.set(row.id, workspace)) - } - for (const [workspace, layout] of Object.entries(model?.workspaces ?? {})) { - layout.tabs.forEach((tab) => homes.set(tab.entityId, workspace)) - } - for (const [tabId, layout] of Object.entries(saved)) { - const mains = stored?.terminalLayoutsByTabId?.[tabId]?.root - if (mains && stableJson(mains) !== stableJson(layout.root ?? null)) { - addPath(paths, homes.get(tabId) ?? UNPLACED, `pane tree ${tabId} kept over the save’s`) - } - } -} - -type PartitionCopy = { - model: WorkspaceLayoutModel | null - closed: WorkspaceSessionState['closedTerminalTabTombstonesByTabId'] -} - -/** - * Closed-tab records of a workspace only one copy has: the model diff cannot tell a record the - * Loader just placed there (already stored, unplaced) from one the save added or dropped. - */ -export function closedTabsOfOneSidedWorkspaces( - was: PartitionCopy, - now: PartitionCopy, - paths: Map -): void { - const oneSided = (from: PartitionCopy, other: PartitionCopy, change: string) => { - for (const [workspace, layout] of Object.entries(from.model?.workspaces ?? {})) { - if (other.model?.workspaces[workspace]) { - continue - } - for (const tabId of Object.keys(layout.closedTerminalTabs ?? {})) { - if (other.closed?.[tabId]?.worktreeId !== layout.worktreeId) { - addPath(paths, workspace, `closed tab ${tabId} ${change}`) - } - } - } - } - oneSided(now, was, 'added') - oneSided(was, now, 'removed') -} diff --git a/src/main/persistence/window-save-view.ts b/src/main/persistence/window-save-view.ts new file mode 100644 index 00000000000..ad766361ec5 --- /dev/null +++ b/src/main/persistence/window-save-view.ts @@ -0,0 +1,140 @@ +// A window's session save in a layout-owner build: every tab and pane change reaches main as a +// layout command, so main keeps its own layout and takes from the save only what the window owns: +// its view (selection, focus, drafts), the facts its views report (titles, scrollback, page +// state) and the fields passed through untouched. A paired server's partition is the server's +// layout, mirrored by the window, and passes through as it is. + +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + parseExecutionHostId, + type ExecutionHostId +} from '../../shared/execution-host' +import type { + WorkspaceSessionPatch, + WorkspaceSessionState +} from '../../shared/workspace-session-state-types' +import { SESSION_FIELD_OWNERS } from '../../shared/workspace-layout/workspace-layout-beside' +import { loadWorkspaceLayoutWithoutReport } from '../../shared/workspace-layout/workspace-layout-load' +import type { LoadedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load-types' +import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids' +import type { WorkspaceLayoutModel } from '../../shared/workspace-layout/workspace-layout-model' +import { patchStoredSession } from '../../shared/workspace-layout/workspace-layout-record-patch' +import { saveWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-save' + +const FIELD_OWNERS: Readonly> = SESSION_FIELD_OWNERS + +/** Fields main writes from its own layout; the Loader's one-time legacy field goes with them. */ +function isLayoutField(field: string): boolean { + const owner = Object.hasOwn(FIELD_OWNERS, field) ? FIELD_OWNERS[field] : undefined + return owner === 'layout' || owner === 'legacy' +} + +/** + * A workspace main holds that the window visited keeps its terminal row, as the window's save + * did: a relaunch then leaves a workspace emptied by a recorded close empty + * (isTerminalWorkspaceEmptiedOnPurpose, admitsInitialSurface). + */ +function withVisitedRowOwners( + layout: WorkspaceLayoutModel, + visited: Readonly> | undefined +): WorkspaceLayoutModel { + const added = Object.keys(visited ?? {}).filter( + (key) => layout.workspaces[key] && !layout.legacy.terminalRowOwners[key] + ) + if (added.length === 0) { + return layout + } + const owners = { ...layout.legacy.terminalRowOwners } + for (const key of added) { + owners[key] = true + } + return { ...layout, legacy: { ...layout.legacy, terminalRowOwners: owners } } +} + +/** Main's layout with the window's view, facts and carried fields, written as a session. */ +function joinWindowView( + main: LoadedWorkspaceLayout, + windows: LoadedWorkspaceLayout +): WorkspaceSessionState { + return saveWorkspaceLayout({ + layout: withVisitedRowOwners(main.layout, windows.desktopView.lastVisitedAtByWorktreeId), + desktopView: windows.desktopView, + facts: windows.facts, + // What names no tab or workspace main holds is main's too. + carried: { + ...windows.carried, + unownedTerminalLayouts: main.carried.unownedTerminalLayouts, + unplacedSleepingRecords: main.carried.unplacedSleepingRecords, + unplacedIncarnations: main.carried.unplacedIncarnations, + unplacedClosedTabs: main.carried.unplacedClosedTabs + } + }) +} + +/** + * The stored partition with the window's view and facts written into main's records. Only records + * the join changes are rewritten, as a layout command writes, so untouched records stay as stored. + */ +function storedWithWindowView( + partition: ExecutionHostId, + stored: WorkspaceSessionState, + window: WorkspaceSessionState +): WorkspaceSessionState { + const context = nameBasedLoadContext() + const main = loadWorkspaceLayoutWithoutReport(partition, stored, context) + const windows = loadWorkspaceLayoutWithoutReport(partition, window, context) + return patchStoredSession(stored, saveWorkspaceLayout(main), joinWindowView(main, windows)) +} + +/** The window's save with each named layout field taken from main's stored partition. */ +function withMainLayoutFields( + save: T, + joined: WorkspaceSessionState, + fields: readonly string[] +): T { + const mains: Readonly> = joined + const result: Record = { ...save } + for (const field of fields) { + result[field] = mains[field] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: every field is the save's own or main's stored session's, the same session type. + return result as T +} + +/** + * A full save main applies in a layout-owner build (`session:set`, the shutdown stage): the + * window's own fields as sent, and main's layout fields with the window's view and facts in them. + */ +export function windowViewSaveForMain( + stored: WorkspaceSessionState, + save: WorkspaceSessionState, + hostId: string | null | undefined +): WorkspaceSessionState { + const partition = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + if (parseExecutionHostId(partition)?.kind === 'runtime') { + return save + } + const joined = storedWithWindowView(partition, stored, save) + const fields = [...new Set([...Object.keys(save), ...Object.keys(joined)])] + return withMainLayoutFields(save, joined, fields.filter(isLayoutField)) +} + +/** + * A `session:patch` in a layout-owner build: its own fields as sent, and each layout field it + * carries comes back as main's with the window's view and facts in it. A patch carrying none + * skips the layout entirely, as today's scalar patch skips terminal normalization. + */ +export function windowViewPatchForMain( + stored: WorkspaceSessionState, + patch: WorkspaceSessionPatch, + hostId: string | null | undefined +): WorkspaceSessionPatch { + const partition = normalizeExecutionHostId(hostId) ?? LOCAL_EXECUTION_HOST_ID + const fields = Object.keys(patch).filter(isLayoutField) + if (parseExecutionHostId(partition)?.kind === 'runtime' || fields.length === 0) { + return patch + } + const joined = storedWithWindowView(partition, stored, { ...stored, ...patch }) + return withMainLayoutFields(patch, joined, fields) +} diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 7e7decb8316..168e23df818 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -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 + /** 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. */ diff --git a/src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts b/src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts index 9bb9137c405..43356c7cd7e 100644 --- a/src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts +++ b/src/renderer/src/app-shell/shutdown-checkpoint-persist.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import type { SleepingAgentSessionRecord } from '../../../shared/agent-session-resume' import { createShutdownCheckpointPersist, type ShutdownCheckpointPersistDeps @@ -21,6 +22,30 @@ function makeDeps( } describe('createShutdownCheckpointPersist', () => { + it('stages the resume records the quit capture changed, for main to record', () => { + const records: SleepingAgentSessionRecord[] = [ + { + paneKey: 'tab-1:leaf-1', + worktreeId: 'wt-1', + agent: 'claude', + providerSession: { key: 'session_id', id: 'session-1' }, + prompt: '', + state: 'working', + capturedAt: 1, + updatedAt: 1 + } + ] + 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({ diff --git a/src/renderer/src/app-shell/shutdown-checkpoint-persist.ts b/src/renderer/src/app-shell/shutdown-checkpoint-persist.ts index b8a1be67eb2..23194e31bc8 100644 --- a/src/renderer/src/app-shell/shutdown-checkpoint-persist.ts +++ b/src/renderer/src/app-shell/shutdown-checkpoint-persist.ts @@ -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 + /** 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 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. diff --git a/src/renderer/src/app-shell/use-app-session-persistence.ts b/src/renderer/src/app-shell/use-app-session-persistence.ts index 5b2235a3944..932083ff52d 100644 --- a/src/renderer/src/app-shell/use-app-session-persistence.ts +++ b/src/renderer/src/app-shell/use-app-session-persistence.ts @@ -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, @@ -33,6 +36,14 @@ import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events' +/** The quit capture, returning the resume records it changed of tabs the window holds. */ +function captureQuitSleepRecordsForMain(): SleepingAgentSessionRecord[] { + const previous = useAppStore.getState().sleepingAgentSessionsByPaneKey + useAppStore.getState().captureAllSleepingAgentSessions('quit') + const state = useAppStore.getState() + return changedSleepingRecords(state, previous, state.sleepingAgentSessionsByPaneKey).recorded +} + // Why: bound the resume-record loss window on a hard kill to ~1 min; capture skips unchanged records so per-tick cost is negligible. const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 @@ -81,8 +92,10 @@ 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'), + // Switch point: the stage writes only the capture's changes into main's records. + captureSleepingAgentSessions: ORCA_LAYOUT_OWNER_IS_MAIN + ? captureQuitSleepRecordsForMain + : () => useAppStore.getState().captureAllSleepingAgentSessions('quit'), // 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. diff --git a/src/renderer/src/runtime/layout-apply-send.test.ts b/src/renderer/src/runtime/layout-apply-send.test.ts index 5291b071009..9a7400a54f0 100644 --- a/src/renderer/src/runtime/layout-apply-send.test.ts +++ b/src/renderer/src/runtime/layout-apply-send.test.ts @@ -2,12 +2,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { LayoutApplyReply } from '../../../shared/workspace-layout/workspace-layout-command-types' const mocks = vi.hoisted(() => { - const sent: { reply: LayoutApplyReply } = { reply: { ok: true, result: {} } } - return { sent } + const sent: { reply: LayoutApplyReply | Error } = { reply: { ok: true, result: {} } } + const owner: { kind: 'resolved' | 'ambiguous' } = { kind: 'resolved' } + return { sent, owner } }) -vi.mock('@/lib/resolve-owner', () => ({ runtimeEnvironmentIdUnlessTwin: () => null })) +vi.mock('@/lib/resolve-owner', () => ({ + runtimeEnvironmentIdUnlessTwin: () => null, + runtimeEnvironmentIdForWorkspaceOwner: () => + mocks.owner.kind === 'resolved' ? { kind: 'resolved', environmentId: null } : mocks.owner +})) vi.mock('@/runtime/runtime-rpc-client', () => ({ - callRuntimeRpc: vi.fn(async () => mocks.sent.reply) + callRuntimeRpc: vi.fn(async () => { + if (mocks.sent.reply instanceof Error) { + throw mocks.sent.reply + } + return mocks.sent.reply + }) })) vi.mock('@/runtime/layout-frame-position', () => ({ afterLayoutFrame: (_seq: number | undefined, run: () => void) => run() @@ -18,7 +28,7 @@ import { sendLayoutApply } from './layout-apply-send' const RENAME = { type: 'renamePane', tabId: 'tab-1', leafId: 'leaf-1', title: 'Pane' } as const -async function send(reply: LayoutApplyReply) { +async function send(reply: LayoutApplyReply | Error) { mocks.sent.reply = reply const handlers = { apply: vi.fn(), asToday: vi.fn(), refused: vi.fn(), unapplied: vi.fn() } // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the owner lookup is mocked and never reads the state. @@ -34,7 +44,35 @@ async function send(reply: LayoutApplyReply) { } describe('sendLayoutApply', () => { - beforeEach(() => vi.spyOn(console, 'warn').mockImplementation(() => {})) + beforeEach(() => { + mocks.owner.kind = 'resolved' + vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + + // A window save no longer carries the change, so doing it here would last only until the next frame. + it.each([ + { reply: { ok: false, code: 'other_partition' } as const, code: 'other_partition' }, + { reply: { ok: false, code: 'host_not_owned' } as const, code: 'host_not_owned' }, + { reply: new Error('disk refused'), code: 'call_failed' } + ])('logs a $code refusal and draws nothing of its own', async ({ reply, code }) => { + const handlers = await send(reply) + + expect(handlers.refused).toHaveBeenCalledWith(code) + expect(handlers.asToday).not.toHaveBeenCalled() + expect(handlers.unapplied).not.toHaveBeenCalled() + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining(`[layout-owner] renamePane refused`), + ...(reply instanceof Error ? ['disk refused'] : [code]) + ) + }) + + it('acts as today on a twin main routed to a paired server', async () => { + mocks.owner.kind = 'ambiguous' + const handlers = await send({ ok: false, code: 'host_not_owned' }) + + expect(handlers.asToday).toHaveBeenCalledOnce() + expect(handlers.refused).not.toHaveBeenCalled() + }) it('refuses a change on a workspace a WSL move has paused, drawing nothing', async () => { const handlers = await send({ ok: false, code: 'wsl_move_in_progress' }) diff --git a/src/renderer/src/runtime/layout-apply-send.ts b/src/renderer/src/runtime/layout-apply-send.ts index b6800e321de..7ece639f7b8 100644 --- a/src/renderer/src/runtime/layout-apply-send.ts +++ b/src/renderer/src/runtime/layout-apply-send.ts @@ -1,5 +1,8 @@ // Store-free so store slices can send; `layout-owner-command` reads the store for other callers. -import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner' +import { + runtimeEnvironmentIdForWorkspaceOwner, + runtimeEnvironmentIdUnlessTwin +} from '@/lib/resolve-owner' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { afterLayoutFrame } from '@/runtime/layout-frame-position' import { holdTabPaneTree } from '@/runtime/layout-owner-pane-intents' @@ -14,7 +17,6 @@ import type { GroupSide, LayoutApplyReply, LayoutCommandResult, - LayoutRefusalCode, NewGroupSplit, TabTitle } from '../../../shared/workspace-layout/workspace-layout-command-types' @@ -131,13 +133,14 @@ export type WindowLayoutCommand = | { type: 'wake'; tabId: string; paneKeys: string[]; recordOnly: true } | { type: 'setDefaultTabsApplied' } -type WindowRefusalCode = LayoutRefusalCode | 'wsl_move_in_progress' +/** `call_failed`: the call threw, and a thrown write is rolled back. */ +type WindowRefusalCode = Extract['code'] | 'call_failed' export type LayoutApplyHandlers = { apply: (result: LayoutCommandResult) => void asToday: () => void refused?: (code: WindowRefusalCode) => void - /** Main's copy lacks the target, or the call threw; `asToday` when absent. A pane split or move + /** Main's copy lacks the target; `asToday` when absent. A pane split or move * passes one: main keeps its pane trees over the window's saves, so that change would not last. * A close keeps `asToday`: a pane main lacks has nothing to keep it. */ unapplied?: () => void @@ -157,10 +160,11 @@ const PANE_COMMANDS: ReadonlySet = new Set([ /** * A layout command in a layout-owner build: main applies it first, then, once the window has the - * frame with main's change, `apply` shows main's result. `asToday` runs instead when main cannot - * act on it: a paired server holds the tab (#18202), a move's workspaces are in two partitions, - * main's copy lacks the workspace, tab, pane or group, or the call threw. `refused` on any other - * refusal. False when a paired server owns the workspace's layout; the caller acts as today. + * frame with main's change, `apply` shows main's result. `asToday` runs instead only when a paired + * server may hold the tab (#18202), or main's copy lacks the workspace, tab, pane or group. Any + * other refusal, and a call that threw, is logged and `refused`: main's frame stays what is drawn, + * since a window save no longer carries the change. False when a paired server owns the + * workspace's layout; the caller acts as today. */ export function sendLayoutApply( state: Parameters[0], @@ -190,17 +194,17 @@ export function sendLayoutApply( (reply) => afterLayoutFrame(reply.frameSeq, () => { try { - handleReply(command, reply, handlers) + handleReply(state, worktreeId, command, reply, handlers) } finally { release() } }), (error: unknown) => { // Why: a thrown write is rolled back. An indeterminate disk write is not, and may leave a - // pane main minted; accepted until main is the only writer. - console.warn(`[layout-owner] ${command.type} failed:`, errorMessage(error)) + // pane main minted, which its frame then draws. + console.warn(`[layout-owner] ${command.type} refused: call_failed`, errorMessage(error)) try { - unapplied(handlers) + handlers.refused?.('call_failed') } finally { release() } @@ -214,16 +218,18 @@ export function sendLayoutApply( } function handleReply( + state: Parameters[0], + worktreeId: string, command: WindowLayoutCommand, reply: LayoutApplyReply, handlers: LayoutApplyHandlers ): void { if (reply.ok) { handlers.apply(reply.result) - } else if (reply.code === 'host_not_owned' || reply.code === 'other_partition') { + } else if (reply.code === 'host_not_owned' && mayBePairedServers(state, worktreeId)) { handlers.asToday() } else if (mainLacksTarget(reply.code)) { - // Why: main's copy lags the window's here (the window still saves its tabs). + // Why: main's copy can lag the window's: a tab the window drew before main's create answered. console.warn(`[layout-owner] ${command.type} found no target:`, reply.code) unapplied(handlers) } else { @@ -232,6 +238,16 @@ function handleReply( } } +/** A twin: main may route it to a paired server, whose layout the window still mirrors. */ +function mayBePairedServers( + state: Parameters[0], + worktreeId: string +): boolean { + return ( + runtimeEnvironmentIdForWorkspaceOwner(state, { workspaceId: worktreeId }).kind !== 'resolved' + ) +} + function unapplied(handlers: LayoutApplyHandlers): void { if (handlers.unapplied) { handlers.unapplied() diff --git a/src/renderer/src/runtime/layout-owner-command.test.ts b/src/renderer/src/runtime/layout-owner-command.test.ts index 02929b32bc8..0d3191eed39 100644 --- a/src/renderer/src/runtime/layout-owner-command.test.ts +++ b/src/renderer/src/runtime/layout-owner-command.test.ts @@ -19,7 +19,11 @@ const mocks = vi.hoisted( vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc })) vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } })) vi.mock('@/lib/resolve-owner', () => ({ - runtimeEnvironmentIdUnlessTwin: () => mocks.ownerEnvironmentId + runtimeEnvironmentIdUnlessTwin: () => mocks.ownerEnvironmentId, + runtimeEnvironmentIdForWorkspaceOwner: () => ({ + kind: 'resolved', + environmentId: mocks.ownerEnvironmentId + }) })) const SPLIT = { @@ -100,8 +104,7 @@ describe('sendLayoutCommandToOwner', () => { [ 'main lacks the workspace', () => mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'workspace_not_found' }) - ], - ['the call fails', () => mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed'))] + ] ])('acts as today when %s', async (_case, arrange) => { arrange() const on = handlers() @@ -112,15 +115,19 @@ describe('sendLayoutCommandToOwner', () => { expect(on.refused).not.toHaveBeenCalled() }) - it.each([ - ['main lacks the pane', { ok: false, code: 'pane_not_found' }], - ['the call fails', new Error('rpc_failed')] - ])('draws nothing for a pane command passing `unapplied` when %s', async (_case, answer) => { - if (answer instanceof Error) { - mocks.callRuntimeRpc.mockRejectedValue(answer) - } else { - mocks.callRuntimeRpc.mockResolvedValue(answer) - } + it('refuses, drawing nothing, when the call fails', async () => { + mocks.callRuntimeRpc.mockRejectedValue(new Error('rpc_failed')) + const on = { ...handlers(), unapplied: vi.fn() } + + sendLayoutCommandToOwner('worktree-1', SPLIT, on) + + await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('call_failed')) + expect(on.asToday).not.toHaveBeenCalled() + expect(on.unapplied).not.toHaveBeenCalled() + }) + + it('draws nothing for a pane command passing `unapplied` when main lacks the pane', async () => { + mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'pane_not_found' }) const on = { ...handlers(), unapplied: vi.fn() } sendLayoutCommandToOwner('worktree-1', SPLIT, on) @@ -188,13 +195,14 @@ describe('sendLayoutCommandToOwner', () => { await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('last_pane')) }) - it('acts as today when main says a paired server holds the tab', async () => { + it('refuses a workspace main routes to no host of its own, drawing nothing', async () => { mocks.callRuntimeRpc.mockResolvedValue({ ok: false, code: 'host_not_owned' }) const on = handlers() expect(sendLayoutCommandToOwner('worktree-1', SPLIT, on)).toBe(true) - await vi.waitFor(() => expect(on.asToday).toHaveBeenCalled()) + await vi.waitFor(() => expect(on.refused).toHaveBeenCalledWith('host_not_owned')) + expect(on.asToday).not.toHaveBeenCalled() expect(on.apply).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/store/slices/tabs/layout-owner-shared-path-gates.test.ts b/src/renderer/src/store/slices/tabs/layout-owner-shared-path-gates.test.ts new file mode 100644 index 00000000000..187a683384e --- /dev/null +++ b/src/renderer/src/store/slices/tabs/layout-owner-shared-path-gates.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTabsSliceMockApi } from '../tabs-slice-test-harness' +import { createTestStore, makeTab } from '../store-test-helpers' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('../../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true })) + +createTabsSliceMockApi() + +const WT = 'repo1::/tmp/feature' + +// Main draws the tab bar from its frames; these shared paths must not write structure of their own. +describe('shared tab paths in a layout-owner build', () => { + let store: ReturnType + + beforeEach(() => { + store = createTestStore() + }) + + it('reconcile counts what would render and leaves the tab bar to main', () => { + const entry = { + id: 'stale-terminal', + entityId: 'stale-terminal', + groupId: 'g-1', + worktreeId: WT, + contentType: 'terminal' as const, + label: 'Terminal 1', + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + store.setState({ + unifiedTabsByWorktree: { [WT]: [entry] }, + groupsByWorktree: { + [WT]: [{ id: 'g-1', worktreeId: WT, activeTabId: entry.id, tabOrder: [entry.id] }] + }, + activeGroupIdByWorktree: { [WT]: 'g-1' }, + tabsByWorktree: { [WT]: [] } + }) + const before = store.getState() + + expect(store.getState().reconcileWorktreeTabModel(WT)).toEqual({ + renderableTabCount: 0, + activeRenderableTabId: null + }) + store.getState().reconcileWorktreeTabModels([WT]) + + expect(store.getState().unifiedTabsByWorktree).toBe(before.unifiedTabsByWorktree) + expect(store.getState().groupsByWorktree).toBe(before.groupsByWorktree) + }) + + it('reconnect advertises restored terminals without rewriting their rows', async () => { + const rows = { [WT]: [makeTab({ id: 'tab-1', worktreeId: WT, ptyId: null })] } + store.setState({ + tabsByWorktree: rows, + terminalLayoutsByTabId: {}, + pendingReconnectWorktreeIds: [WT], + pendingReconnectTabByWorktree: { [WT]: ['tab-1'] }, + pendingReconnectPtyIdByTabId: { 'tab-1': 'pty-1' } + }) + + await store.getState().reconnectPersistedTerminals() + + expect(store.getState().ptyIdsByTabId['tab-1']).toEqual(['pty-1']) + expect(store.getState().tabsByWorktree).toBe(rows) + expect(store.getState().workspaceSessionReady).toBe(true) + }) +}) diff --git a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts index 43955147687..6fb187f53aa 100644 --- a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts @@ -10,6 +10,7 @@ import { buildHydratedTabState } from '../tabs-hydration' import { projectWorktreeTabModelReconciliation } from './tabs-reconciliation' import { createWorktreeTabModelReconciliationBatch } from './tabs-reconciliation-batch' import type { AppState } from '../../types' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch' function replaceWorkspaceRecordKeys( current: Record, @@ -59,6 +60,10 @@ export function createTabsSessionActions( if (worktreeIds.length === 0) { return } + // Switch point: main's frames draw the tab bar, so reconcile writes no structure of its own. + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + return + } const patch = projectWorktreeTabModelReconciliations(get(), worktreeIds) if (Object.keys(patch).length > 0) { set(patch) @@ -67,7 +72,8 @@ export function createTabsSessionActions( reconcileWorktreeTabModel: (worktreeId) => { const reconciliation = projectWorktreeTabModelReconciliation(get(), worktreeId) - if (Object.keys(reconciliation.patch).length > 0) { + // Switch point: the counts only; main's frames draw the tab bar. + if (!ORCA_LAYOUT_OWNER_IS_MAIN && Object.keys(reconciliation.patch).length > 0) { set(reconciliation.patch) } return { diff --git a/src/renderer/src/store/terminals/terminal-sleep-layout-owner.ts b/src/renderer/src/store/terminals/terminal-sleep-layout-owner.ts index a4119022819..0f55d173388 100644 --- a/src/renderer/src/store/terminals/terminal-sleep-layout-owner.ts +++ b/src/renderer/src/store/terminals/terminal-sleep-layout-owner.ts @@ -40,35 +40,49 @@ export function recordPaneSleepThroughLayoutOwner( } } +type HeldRecordsState = Pick + +/** + * 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, + 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>() - 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', diff --git a/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts b/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts index 55dfb303650..a2ec52f584b 100644 --- a/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts +++ b/src/renderer/src/store/terminals/terminal-tab-create-through-layout-owner.ts @@ -86,9 +86,8 @@ export function terminalTabCreateCommand( } /** - * Sends a tab the window just drew to main. Nothing to undo on any answer: main holding the same - * pane already, or a paired server or no partition holding the workspace, leaves the window's own - * session save to carry the tab, as today. + * Sends a tab the window just drew to main. Nothing to undo on any answer: main's frame is what + * stays drawn, since the window's session save no longer carries the tab. A refusal is logged. */ export function recordCreatedTerminalTabInLayoutOwner( state: AppState, @@ -105,13 +104,13 @@ export function recordCreatedTerminalTabInLayoutOwner( } void callRuntimeRpc({ kind: 'local' }, 'layout.apply', { command }).then( (reply) => { - if (!reply.ok && reply.code !== 'host_not_owned') { + if (!reply.ok) { console.warn('[layout-owner] createTerminalTab refused:', reply.code) } }, (error: unknown) => { console.warn( - '[layout-owner] createTerminalTab failed:', + '[layout-owner] createTerminalTab refused: call_failed', error instanceof Error ? error.message : error ) } diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts index 7c4a916ab41..df2c5dc2494 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts @@ -2,6 +2,7 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' import type { TerminalSlice, TerminalStoreGet, TerminalStoreSet } from './terminal-state' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' export function createWorkspaceTerminalReconnectActions( set: TerminalStoreSet, @@ -117,7 +118,10 @@ export function createWorkspaceTerminalReconnectActions( return } set({ - ...(reconnectedTabsByWorktree ? { tabsByWorktree: reconnectedTabsByWorktree } : {}), + // Switch point: main's frames draw the rows, with main's own bindings. + ...(reconnectedTabsByWorktree && !ORCA_LAYOUT_OWNER_IS_MAIN + ? { tabsByWorktree: reconnectedTabsByWorktree } + : {}), ...(reconnectedPtyIdsByTabId ? { ptyIdsByTabId: reconnectedPtyIdsByTabId } : {}), workspaceSessionReady: true, pendingReconnectWorktreeIds: [], diff --git a/tests/e2e/relaunch-first-terminal.spec.ts b/tests/e2e/relaunch-first-terminal.spec.ts new file mode 100644 index 00000000000..bfe56556ede --- /dev/null +++ b/tests/e2e/relaunch-first-terminal.spec.ts @@ -0,0 +1,154 @@ +/** + * Whether a workspace gets its first terminal after a relaunch: one the user emptied by closing + * its terminals stays empty, one holding only an editor tab stays as it is, and one never + * opened gets a terminal when first shown. Main decides this from the terminal rows it keeps, + * so the answer must be the same whether or not the window's saves carry its tabs. + */ + +import path from 'node:path' +import { writeFileSync } from 'node:fs' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { switchToWorktree, waitForSessionReady } from './helpers/store' + +type WorkspaceTabs = { terminals: number; editors: number } + +async function createWorktree(page: Page, name: string): Promise { + return page.evaluate(async (worktreeName) => { + const state = window.__store!.getState() + const repoId = state.allWorktrees()[0]?.repoId + if (!repoId) { + throw new Error('seed repo was not loaded') + } + return (await state.createWorktree(repoId, worktreeName, undefined, 'skip')).worktree.id + }, name) +} + +async function workspaceTabs(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const state = window.__store!.getState() + const entries = state.unifiedTabsByWorktree[id] ?? [] + return { + terminals: (state.tabsByWorktree[id] ?? []).length, + editors: entries.filter((entry) => entry.contentType === 'editor').length + } + }, worktreeId) +} + +async function waitForTabs(page: Page, worktreeId: string, expected: WorkspaceTabs) { + await expect.poll(() => workspaceTabs(page, worktreeId), { timeout: 30_000 }).toEqual(expected) +} + +async function closeTerminals(page: Page, worktreeId: string): Promise { + await page.evaluate((id) => { + const state = window.__store!.getState() + for (const tab of state.tabsByWorktree[id] ?? []) { + state.closeTab(tab.id) + } + }, worktreeId) +} + +async function openEditor( + page: Page, + worktreeId: string, + filePath: string, + readOnly?: true +): Promise { + await page.evaluate( + ({ id, file, readOnly }) => { + window.__store!.getState().openFile({ + filePath: file, + relativePath: file.split('/').pop()!, + worktreeId: id, + language: 'plaintext', + mode: 'edit', + ...(readOnly ? { readOnly } : {}) + }) + }, + { id: worktreeId, file: filePath, readOnly } + ) +} + +async function isReadOnly(page: Page, filePath: string): Promise { + return page.evaluate( + (file) => + window.__store!.getState().openFiles.find((open) => open.filePath === file)?.readOnly ?? null, + filePath + ) +} + +async function worktreePath(page: Page, worktreeId: string): Promise { + return page.evaluate( + (id) => + window + .__store!.getState() + .allWorktrees() + .find((worktree) => worktree.id === id)!.path, + worktreeId + ) +} + +// The editor-only workspace's file also opens read-only, then again for editing: a relaunch +// restores it editable, as the reopen left it. +test('a relaunch seeds only a workspace that was never opened', async ({ + testRepoPath +}, testInfo) => { + const session = createRestartSession(testInfo) + const refusals: string[] = [] + const watch = (page: Page) => + page.on('console', (message) => { + if (/\[layout-owner\].*(refused|failed)/.test(message.text())) { + refusals.push(message.text()) + } + }) + let app: Awaited>['app'] | undefined + try { + const first = await session.launch() + app = first.app + watch(first.page) + await waitForSessionReady(first.page) + await attachRepoAndOpenTerminal(first.page, testRepoPath) + const stamp = Date.now() + const emptied = await createWorktree(first.page, `relaunch-emptied-${stamp}`) + const editorOnly = await createWorktree(first.page, `relaunch-editor-${stamp}`) + const neverOpened = await createWorktree(first.page, `relaunch-never-${stamp}`) + + await switchToWorktree(first.page, emptied) + await waitForTabs(first.page, emptied, { terminals: 1, editors: 0 }) + await closeTerminals(first.page, emptied) + await waitForTabs(first.page, emptied, { terminals: 0, editors: 0 }) + + await switchToWorktree(first.page, editorOnly) + await waitForTabs(first.page, editorOnly, { terminals: 1, editors: 0 }) + const notes = path.join(await worktreePath(first.page, editorOnly), 'relaunch-notes.txt') + writeFileSync(notes, 'notes\n') + await openEditor(first.page, editorOnly, notes, true) + await expect.poll(() => isReadOnly(first.page, notes)).toBe(true) + await openEditor(first.page, editorOnly, notes) + await expect.poll(() => isReadOnly(first.page, notes)).toBe(null) + await closeTerminals(first.page, editorOnly) + await waitForTabs(first.page, editorOnly, { terminals: 0, editors: 1 }) + // Long enough for the window's debounced save and main's commands to land. + await first.page.waitForTimeout(2_000) + await session.close(app) + app = undefined + + const second = await session.launch() + app = second.app + watch(second.page) + await waitForSessionReady(second.page) + for (const worktreeId of [emptied, editorOnly, neverOpened]) { + await switchToWorktree(second.page, worktreeId) + // A first terminal is created shortly after a workspace is shown, or not at all. + await second.page.waitForTimeout(3_000) + } + await waitForTabs(second.page, neverOpened, { terminals: 1, editors: 0 }) + expect(await workspaceTabs(second.page, emptied)).toEqual({ terminals: 0, editors: 0 }) + expect(await workspaceTabs(second.page, editorOnly)).toEqual({ terminals: 0, editors: 1 }) + expect(await isReadOnly(second.page, notes)).toBe(null) + expect(refusals).toEqual([]) + } finally { + await session.dispose(app) + } +}) diff --git a/tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts b/tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts index 98fe4171e8b..d7191bd5c26 100644 --- a/tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts +++ b/tests/e2e/relaunch-sleeping-agents-resume-on-view.spec.ts @@ -127,28 +127,32 @@ async function activateTab(page: Page, tabId: string): Promise { }, tabId) } +/** Moves the tab through the window's own tab-move path, which a layout-owner build sends to main. */ async function moveTabIntoRightGroup( + app: ElectronApplication, page: Page, worktreeId: string, anchorTabId: string, movedTabId: string ): Promise { - await page.evaluate( + const move = await page.evaluate( ({ wtId, anchorTabId, movedTabId }) => { - const store = window.__store!.getState() - const tabs = store.unifiedTabsByWorktree[wtId] ?? [] + const tabs = window.__store!.getState().unifiedTabsByWorktree[wtId] ?? [] const anchor = tabs.find((tab) => tab.entityId === anchorTabId) const moved = tabs.find((tab) => tab.entityId === movedTabId) - if ( - !anchor || - !moved || - !store.dropUnifiedTab(moved.id, { groupId: anchor.groupId, splitDirection: 'right' }) - ) { - throw new Error('Could not move the tab into a side-by-side group') + if (!anchor || !moved) { + throw new Error('Could not find the tabs to place side by side') } + return { tabId: moved.id, targetGroupId: anchor.groupId } }, { wtId: worktreeId, anchorTabId, movedTabId } ) + await app.evaluate( + ({ BrowserWindow }, payload) => { + BrowserWindow.getAllWindows()[0]?.webContents.send('ui:moveSessionTab', payload) + }, + { worktreeId, kind: 'split' as const, splitDirection: 'right' as const, ...move } + ) await expect .poll(() => page.evaluate( @@ -224,7 +228,7 @@ async function relaunchWithSleepingAgents( } } if (options.secondTabInRightGroup) { - await moveTabIntoRightGroup(page, worktreeId, tabIds[0]!, tabIds[1]!) + await moveTabIntoRightGroup(firstApp, page, worktreeId, tabIds[0]!, tabIds[1]!) } await activateTab(page, firstTabId)