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)