From cd59239927b1acecf667e4cd920fba060220d377 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:25:22 -0700 Subject: [PATCH] fix(workspaces): add main-owned project move pause barrier (#32940) * fix(workspaces): add main-owned project move pause barrier * test(workspaces): type project move pause reply fixtures --- .../sleep/register-worktree-sleep-handlers.ts | 5 +- .../workspace-sleep-execution.test.ts | 36 ++- .../workspaces/workspace-sleep-execution.ts | 22 +- src/main/window/project-move-pause.test.ts | 169 +++++++++++++ src/main/window/project-move-pause.ts | 166 +++++++++++++ ...ui-bridge-clipboard-and-window-controls.ts | 9 + src/preload/api/ui-command-event-api.ts | 6 + .../project-move-pause-follower.test.ts | 234 ++++++++++++++++++ .../sidebar/project-move-pause-follower.ts | 143 +++++++++++ .../sidebar/workspace-sleep-follower.ts | 26 +- .../mobile-terminal-close-ipc-bridge.ts | 15 +- src/shared/project-move-pause.ts | 38 +++ src/shared/workspace-sleep.ts | 5 +- 13 files changed, 856 insertions(+), 18 deletions(-) create mode 100644 src/main/window/project-move-pause.test.ts create mode 100644 src/main/window/project-move-pause.ts create mode 100644 src/renderer/src/components/sidebar/project-move-pause-follower.test.ts create mode 100644 src/renderer/src/components/sidebar/project-move-pause-follower.ts create mode 100644 src/shared/project-move-pause.ts diff --git a/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts b/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts index c46fd506e88..74e38456f5b 100644 --- a/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts +++ b/src/main/ipc/worktrees/sleep/register-worktree-sleep-handlers.ts @@ -19,7 +19,10 @@ function isRestoredPane(value: unknown): value is RestoredPane { /** The window's Sleep: main runs it on each workspace's hosts and the window follows its notices. */ export function registerWorktreeSleepHandlers(context: WorktreeIpcContext): void { ipcMain.handle('worktrees:sleep', (_event, request: WorkspaceSleepRequest) => - context.runtime.sleepWorkspaces(request) + context.runtime.sleepWorkspaces({ + targets: request.targets, + markChatsAsleep: request.markChatsAsleep + }) ) // The user opened a workspace whose sleep is still running: its VM keeps running. ipcMain.handle('worktrees:sleepReopened', (_event, worktreeId: string) => diff --git a/src/main/runtime/workspaces/workspace-sleep-execution.test.ts b/src/main/runtime/workspaces/workspace-sleep-execution.test.ts index 1163d184985..a9befbae273 100644 --- a/src/main/runtime/workspaces/workspace-sleep-execution.test.ts +++ b/src/main/runtime/workspaces/workspace-sleep-execution.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { RuntimeWorktreeTerminalSleepResult } from '../../../shared/runtime-types' -import type { WorkspaceSleepTarget } from '../../../shared/workspace-sleep' +import type { WorkspaceSleepNotice, WorkspaceSleepTarget } from '../../../shared/workspace-sleep' import { toRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' import { WorkspaceLifecycleQueue } from './workspace-lifecycle-queue' import { @@ -58,6 +58,40 @@ function queued(queue = new WorkspaceLifecycleQueue()): WorkspaceSleepQueue { const enqueue = queued() describe('sleepWorkspaces', () => { + it('correlates a project move with renderer notices and retains chat stop receipts without marking chats asleep', async () => { + const notices: WorkspaceSleepNotice[] = [] + const { deps: d } = deps({ + notify: (notice) => notices.push(notice), + stopOwnChats: async (_id, markAsleep) => { + expect(markAsleep).toBe(false) + return { stoppedSessionIds: ['chat'] } + } + }) + const outcomes = await sleepWorkspaces(d, queued(), { + targets: [local('wt')], + markChatsAsleep: false, + projectMovePauseId: 'move-1' + }) + expect(notices).toEqual([ + { worktreeId: 'wt', phase: 'started', projectMovePauseId: 'move-1' }, + { + worktreeId: 'wt', + phase: 'ended', + projectMovePauseId: 'move-1', + slept: true, + stoppedPtyIds: ['pty-wt'], + records: [] + } + ]) + expect(outcomes).toEqual([ + { + worktreeId: 'wt', + chatFailed: false, + receipts: [{ executionHostId: 'local', stoppedSessionIds: ['chat'] }] + } + ]) + }) + it('runs a sleep in order: the window follows, terminals stop, chats stop, then the VM', async () => { const { log, deps: d } = deps() const outcomes = await sleepWorkspaces(d, enqueue, { diff --git a/src/main/runtime/workspaces/workspace-sleep-execution.ts b/src/main/runtime/workspaces/workspace-sleep-execution.ts index b49a53aa20a..466ef724926 100644 --- a/src/main/runtime/workspaces/workspace-sleep-execution.ts +++ b/src/main/runtime/workspaces/workspace-sleep-execution.ts @@ -183,11 +183,13 @@ async function sleepWorkspace( deps: WorkspaceSleepDeps, target: WorkspaceSleepTarget, markAsleep: boolean, - batch: { chatsSettled: () => void; batchChatsSettled: Promise } + batch: { chatsSettled: () => void; batchChatsSettled: Promise }, + projectMovePauseId?: string ): Promise { const { worktreeId } = target const reopened = deps.watchReopen(worktreeId) - deps.notify({ worktreeId, phase: 'started' }) + const move = projectMovePauseId ? { projectMovePauseId } : {} + deps.notify({ worktreeId, phase: 'started', ...move }) let failure: string | undefined let vmSuspendFailure: string | undefined let unverifiable = false @@ -224,7 +226,7 @@ async function sleepWorkspace( } } const records = target.terminalHost.kind === 'self' ? deps.ownRecords(worktreeId) : null - deps.notify({ worktreeId, phase: 'ended', slept: !failure, stoppedPtyIds, records }) + deps.notify({ worktreeId, phase: 'ended', slept: !failure, stoppedPtyIds, records, ...move }) return { worktreeId, ...(failure ? { failure } : {}), @@ -261,10 +263,16 @@ export async function sleepWorkspaces( const { unverifiable: _unverifiable, ...outcome } = await queue.submit(target, { kind: 'sleep', run: () => - sleepWorkspace(deps, target, request.markChatsAsleep === true, { - chatsSettled, - batchChatsSettled - }), + sleepWorkspace( + deps, + target, + request.markChatsAsleep === true, + { + chatsSettled, + batchChatsSettled + }, + request.projectMovePauseId + ), outcome: (attempt) => (attempt.unverifiable ? 'unverifiable' : 'settled') }) return outcome diff --git a/src/main/window/project-move-pause.test.ts b/src/main/window/project-move-pause.test.ts new file mode 100644 index 00000000000..4fb9600e369 --- /dev/null +++ b/src/main/window/project-move-pause.test.ts @@ -0,0 +1,169 @@ +import { EventEmitter } from 'node:events' +import { ipcMain as ipc, type BrowserWindow } from 'electron' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProjectMovePauseRequest } from '../../shared/project-move-pause' +import type { WorkspaceSleepOutcome, WorkspaceSleepTarget } from '../../shared/workspace-sleep' + +type PauseResponseFixture = { requestId?: string; protocol?: number } & ( + | { phase: 'prepare'; targets: WorkspaceSleepTarget[] } + | { phase: 'persist' } + | { phase: 'error'; error: string } +) + +vi.mock('electron', async () => { + const { EventEmitter } = await import('node:events') + return { ipcMain: new EventEmitter() } +}) +import { pauseWorkspacesForProjectMove } from './project-move-pause' + +function fixture() { + const windowEvents = new EventEmitter() + const webContents = Object.assign(new EventEmitter(), { + isDestroyed: () => false, + send: vi.fn<(channel: string, request: ProjectMovePauseRequest) => void>() + }) + const window = Object.assign(windowEvents, { isDestroyed: () => false, webContents }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the broker uses only the lifecycle and webContents members supplied here. + const mainWindow = window as unknown as BrowserWindow + const outcomes: WorkspaceSleepOutcome[] = [ + { + worktreeId: 'wt', + chatFailed: false, + receipts: [{ executionHostId: 'local', stoppedSessionIds: ['chat'] }] + } + ] + const sleepWorkspaces = vi.fn(async () => outcomes) + const request = (): ProjectMovePauseRequest => { + const value = webContents.send.mock.calls.at(-1)?.[1] + if (!value) { + throw new Error('no request') + } + return value + } + const respond = (value: PauseResponseFixture, sender: unknown = webContents): void => { + ipc.emit( + 'ui:projectMovePauseResponse', + { sender }, + { requestId: request().requestId, protocol: 1, ...value } + ) + } + const prepare = (): void => + respond({ + phase: 'prepare', + targets: [{ worktreeId: 'wt', terminalHost: { kind: 'self' }, chatHosts: [{ kind: 'self' }] }] + }) + return { window, mainWindow, webContents, sleepWorkspaces, outcomes, request, respond, prepare } +} + +describe('main-owned project move pause', () => { + beforeEach(() => { + vi.useFakeTimers() + ipc.removeAllListeners() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('waits for an explicit renderer persistence receipt and preserves all stop receipts', async () => { + const f = fixture() + const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt', 'wt']) + f.prepare() + await vi.advanceTimersByTimeAsync(0) + expect(f.sleepWorkspaces).toHaveBeenCalledWith({ + targets: [ + { worktreeId: 'wt', terminalHost: { kind: 'self' }, chatHosts: [{ kind: 'self' }] } + ], + markChatsAsleep: false, + projectMovePauseId: expect.any(String) + }) + let returned = false + void paused.then(() => { + returned = true + }) + expect(f.request().phase).toBe('persist') + expect(returned).toBe(false) + f.respond({ phase: 'persist' }) + await expect(paused).resolves.toEqual(f.outcomes) + expect(f.request().phase).toBe('release') + expect(ipc.listenerCount('ui:projectMovePauseResponse')).toBe(0) + expect(f.webContents.eventNames()).toEqual([]) + }) + + it('does not stop hosts when an old preload or follower never answers the handshake', async () => { + const f = fixture() + const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt']) + const rejected = expect(paused).rejects.toThrow('renderer_timeout') + await vi.advanceTimersByTimeAsync(20_000) + await rejected + expect(f.sleepWorkspaces).not.toHaveBeenCalled() + expect(ipc.listenerCount('ui:projectMovePauseResponse')).toBe(0) + }) + + it('ignores another window, stale request and unsupported protocol', async () => { + const f = fixture() + const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt']) + f.respond({ phase: 'prepare', targets: [] }, {}) + f.respond({ phase: 'prepare', targets: [], requestId: 'stale' }) + f.respond({ phase: 'prepare', targets: [], protocol: 0 }) + await vi.advanceTimersByTimeAsync(0) + expect(f.sleepWorkspaces).not.toHaveBeenCalled() + f.prepare() + await vi.advanceTimersByTimeAsync(0) + f.respond({ phase: 'persist' }) + await paused + }) + + it('refuses a renderer that omits or substitutes a workspace before stopping hosts', async () => { + const f = fixture() + const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt']) + f.respond({ phase: 'prepare', targets: [] }) + await expect(paused).rejects.toThrow('invalid_scope') + expect(f.sleepWorkspaces).not.toHaveBeenCalled() + }) + + it('rejects reload during the host stop instead of accepting a new renderer persistence reply', async () => { + const f = fixture() + const stop = Promise.withResolvers() + f.sleepWorkspaces.mockReturnValue(stop.promise) + const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt']) + f.prepare() + await vi.advanceTimersByTimeAsync(0) + f.webContents.emit('did-start-navigation', {}, 'about:blank', false, true) + stop.resolve(f.outcomes) + await expect(paused).rejects.toThrow('renderer_unavailable') + expect(f.webContents.send.mock.calls.map(([, value]) => value.phase)).toEqual(['prepare']) + }) + + it('propagates failed durability without returning success to the exporter', async () => { + const f = fixture() + const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt']) + f.prepare() + await vi.advanceTimersByTimeAsync(0) + f.respond({ phase: 'error', error: 'disk full' }) + await expect(paused).rejects.toMatchObject({ message: 'disk full', outcomes: f.outcomes }) + expect(f.request().phase).toBe('release') + }) + + it('retains partial stop failures and successful sibling receipts when renderer rollback fails', async () => { + const f = fixture() + f.outcomes[0].failure = 'terminal_liveness_unavailable' + f.outcomes.push({ + worktreeId: 'sibling', + chatFailed: false, + receipts: [{ executionHostId: 'runtime:env', stoppedSessionIds: ['remote-chat'] }] + }) + const paused = pauseWorkspacesForProjectMove(f.mainWindow, f, ['wt', 'sibling']) + f.respond({ + phase: 'prepare', + targets: ['wt', 'sibling'].map((worktreeId) => ({ + worktreeId, + terminalHost: { kind: 'self' }, + chatHosts: [{ kind: 'self' }] + })) + }) + await vi.advanceTimersByTimeAsync(0) + f.respond({ phase: 'error', error: 'host_terminal_stop_failed' }) + await expect(paused).rejects.toMatchObject({ outcomes: f.outcomes }) + expect(f.outcomes[1]?.receipts[0]?.stoppedSessionIds).toEqual(['remote-chat']) + }) +}) diff --git a/src/main/window/project-move-pause.ts b/src/main/window/project-move-pause.ts new file mode 100644 index 00000000000..b475f895e78 --- /dev/null +++ b/src/main/window/project-move-pause.ts @@ -0,0 +1,166 @@ +import { randomUUID } from 'node:crypto' +import { ipcMain, type BrowserWindow, type IpcMainEvent } from 'electron' +import { + PROJECT_MOVE_PAUSE_PROTOCOL, + projectMovePauseResponseSchema, + type ProjectMovePauseRequest, + type ProjectMovePauseResponse +} from '../../shared/project-move-pause' +import type { WorkspaceSleepOutcome, WorkspaceSleepRequest } from '../../shared/workspace-sleep' + +type SleepRuntime = { + sleepWorkspaces: (request: WorkspaceSleepRequest) => Promise +} + +/** Known stop receipts survive a renderer failure so the mover can journal partial progress. */ +export class ProjectMovePauseError extends Error { + constructor( + cause: unknown, + readonly outcomes: readonly WorkspaceSleepOutcome[] + ) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + this.name = 'ProjectMovePauseError' + } +} + +/** + * Caller holds its move gate/fence. Check every returned outcome before export; on rejection, + * journal ProjectMovePauseError.outcomes too. Main owns stops; the window confirms persistence. + */ +export async function pauseWorkspacesForProjectMove( + mainWindow: BrowserWindow, + runtime: SleepRuntime, + worktreeIds: readonly string[] +): Promise { + const ids = [...new Set(worktreeIds)] + if (ids.length === 0) { + return [] + } + const webContents = mainWindow.webContents + if (mainWindow.isDestroyed() || webContents.isDestroyed()) { + throw new Error('project_move_pause_renderer_unavailable') + } + const requestId = randomUUID() + let unavailable = false + let waiting: + | { + finish: (response?: ProjectMovePauseResponse, error?: Error) => void + } + | undefined + const onUnavailable = (): void => { + unavailable = true + waiting?.finish(undefined, new Error('project_move_pause_renderer_unavailable')) + } + const onNavigation = ( + _event: Electron.Event, + _url: string, + inPlace: boolean, + mainFrame: boolean + ): void => { + if (mainFrame && !inPlace) { + onUnavailable() + } + } + const onResponse = (event: IpcMainEvent, value: unknown): void => { + if (event.sender !== webContents) { + return + } + const response = projectMovePauseResponseSchema.safeParse(value) + if (!response.success || response.data.requestId !== requestId) { + return + } + waiting?.finish(response.data) + } + const ask = ( + request: ProjectMovePauseRequest, + timeoutMs: number + ): Promise => { + if (unavailable) { + return Promise.reject(new Error('project_move_pause_renderer_unavailable')) + } + return new Promise((resolve, reject) => { + const finish = (response?: ProjectMovePauseResponse, error?: Error): void => { + clearTimeout(timer) + waiting = undefined + if (error) { + reject(error) + } else if (response?.phase === 'error') { + reject(new Error(response.error)) + } else if (response?.phase !== request.phase) { + reject(new Error('project_move_pause_invalid_phase')) + } else { + resolve(response) + } + } + const timer = setTimeout( + () => finish(undefined, new Error('project_move_pause_renderer_timeout')), + timeoutMs + ) + waiting = { finish } + try { + webContents.send('ui:projectMovePause', request) + } catch { + onUnavailable() + } + }) + } + ipcMain.on('ui:projectMovePauseResponse', onResponse) + mainWindow.once('closed', onUnavailable) + webContents.once('destroyed', onUnavailable) + webContents.once('render-process-gone', onUnavailable) + webContents.on('did-start-navigation', onNavigation) + const envelope = { requestId, protocol: PROJECT_MOVE_PAUSE_PROTOCOL } as const + let outcomes: WorkspaceSleepOutcome[] = [] + try { + const prepared = await ask({ ...envelope, phase: 'prepare', worktreeIds: ids }, 20_000) + if ( + prepared.phase !== 'prepare' || + prepared.targets.length !== ids.length || + new Set(prepared.targets.map((target) => target.worktreeId)).size !== ids.length || + prepared.targets.some((target) => !ids.includes(target.worktreeId)) + ) { + throw new Error('project_move_pause_invalid_scope') + } + if (unavailable) { + throw new Error('project_move_pause_renderer_unavailable') + } + outcomes = await runtime.sleepWorkspaces({ + targets: prepared.targets, + markChatsAsleep: false, + projectMovePauseId: requestId + }) + if ( + outcomes.length !== ids.length || + new Set(outcomes.map((outcome) => outcome.worktreeId)).size !== ids.length || + outcomes.some((outcome) => !ids.includes(outcome.worktreeId)) + ) { + throw new Error('project_move_pause_invalid_outcomes') + } + await ask( + { + ...envelope, + phase: 'persist', + successfulWorktreeIds: outcomes + .filter((outcome) => !outcome.failure) + .map((outcome) => outcome.worktreeId) + }, + 120_000 + ) + return outcomes + } catch (error) { + throw new ProjectMovePauseError(error, outcomes) + } finally { + if (!unavailable) { + try { + webContents.send('ui:projectMovePause', { ...envelope, phase: 'release' }) + } catch { + /* The renderer no longer holds a live reservation. */ + } + } + ipcMain.removeListener('ui:projectMovePauseResponse', onResponse) + mainWindow.removeListener('closed', onUnavailable) + webContents.removeListener('destroyed', onUnavailable) + webContents.removeListener('render-process-gone', onUnavailable) + webContents.removeListener('did-start-navigation', onNavigation) + } +} diff --git a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts index fc2c91cbedd..101e1b9699d 100644 --- a/src/preload/api/ui-bridge-clipboard-and-window-controls.ts +++ b/src/preload/api/ui-bridge-clipboard-and-window-controls.ts @@ -59,6 +59,15 @@ export const uiClipboardAndWindowControlsApi = { ipcRenderer.on('ui:workspaceSleep', listener) return () => ipcRenderer.removeListener('ui:workspaceSleep', listener) }, + onProjectMovePause: (callback) => { + const listener = (_event: Electron.IpcRendererEvent, request: Parameters[0]) => + callback(request) + ipcRenderer.on('ui:projectMovePause', listener) + return () => ipcRenderer.removeListener('ui:projectMovePause', listener) + }, + respondProjectMovePause: (response) => { + ipcRenderer.send('ui:projectMovePauseResponse', response) + }, onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { worktreeId: string }) => callback(data) diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index 2ebe9072fba..94059083a37 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -21,6 +21,10 @@ import type { TerminalPaneRevealRequest } from '../../shared/terminal-pane-reveal' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' +import type { + ProjectMovePauseRequest, + ProjectMovePauseResponse +} from '../../shared/project-move-pause' import type { TerminalSurfaceCloseTarget } from '../../shared/terminal-surface-close-target' import type { WorktreeDefaultTabsLaunch, @@ -283,6 +287,8 @@ export type UiCommandEventApi = { respondTerminalTabClose: (response: TerminalTabCloseResponse) => void /** Follow main's workspace sleep even when another client requested it. */ onWorkspaceSleep: (callback: (notice: WorkspaceSleepNotice) => void) => () => void + onProjectMovePause?: (callback: (request: ProjectMovePauseRequest) => void) => () => void + respondProjectMovePause?: (response: ProjectMovePauseResponse) => void onResumeSleepingAgents: (callback: (data: { worktreeId: string }) => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void onSystemResumed: (callback: () => void) => () => void diff --git a/src/renderer/src/components/sidebar/project-move-pause-follower.test.ts b/src/renderer/src/components/sidebar/project-move-pause-follower.test.ts new file mode 100644 index 00000000000..21bb2d2b766 --- /dev/null +++ b/src/renderer/src/components/sidebar/project-move-pause-follower.test.ts @@ -0,0 +1,234 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store/types' +import type { ProjectMovePauseResponse } from '../../../../shared/project-move-pause' +import { toRemoteRuntimePtyId } from '../../../../shared/remote-runtime-pty-id' + +const mocks = vi.hoisted(() => { + const tabsByWorktree: Record = {} + const ptyIdsByTabId: Record = {} + const worktreesByRepo: Record = {} + return { + state: { + activeWorktreeId: null, + setActiveWorktree: vi.fn(), + shutdownWorktreeBrowsers: vi.fn(), + shutdownWorktreeTerminals: vi.fn(), + tabsByWorktree, + ptyIdsByTabId, + worktreesByRepo, + detectedWorktreesByRepo: {} + }, + persist: vi.fn(async () => {}), + payload: vi.fn(() => ({})) + } +}) +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mocks.state, subscribe: () => () => {} } +})) +vi.mock('@/lib/worktree-sleep-intent', () => ({ + clearWorktreeSleepIntent: vi.fn(), + markWorktreeSleepIntent: vi.fn(), + withWorktreeSleepTeardown: (_id: string, action: () => Promise) => action() +})) +vi.mock('@/lib/workspace-session', () => ({ buildWorkspaceSessionPayload: mocks.payload })) +vi.mock('@/lib/workspace-session-host-persistence', () => ({ + persistWorkspaceSessionByHost: mocks.persist +})) +vi.mock('sonner', () => ({ toast: { error: vi.fn(), warning: vi.fn() } })) +import { createProjectMovePauseFollower } from './project-move-pause-follower' + +function fixture() { + const responses: ProjectMovePauseResponse[] = [] + const follower = createProjectMovePauseFollower((response) => responses.push(response)) + const requestId = 'move-1' + const prepare = (ids = ['wt']) => + follower.request({ requestId, protocol: 1, phase: 'prepare', worktreeIds: ids }) + const persist = (ids = ['wt']) => + follower.request({ requestId, protocol: 1, phase: 'persist', successfulWorktreeIds: ids }) + const notice = (phase: 'started' | 'ended', worktreeId = 'wt'): void => { + follower.follow( + phase === 'started' + ? { phase, worktreeId, projectMovePauseId: requestId } + : { + phase, + worktreeId, + projectMovePauseId: requestId, + slept: true, + stoppedPtyIds: ['pty'], + records: [] + } + ) + } + return { follower, responses, prepare, persist, notice } +} + +describe('project move renderer completion', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.persist.mockResolvedValue(undefined) + mocks.state.shutdownWorktreeTerminals.mockImplementation(async (_id, options) => { + await options?.hostStop + }) + mocks.state.tabsByWorktree = {} + mocks.state.ptyIdsByTabId = {} + mocks.state.worktreesByRepo = {} + vi.stubGlobal('window', { api: { session: {} } }) + }) + + it('awaits renderer teardown and durable session save while retaining browsers', async () => { + const f = fixture() + const teardown = Promise.withResolvers() + const saving = Promise.withResolvers() + mocks.state.shutdownWorktreeTerminals.mockImplementation(async (_id, options) => { + await options?.hostStop + await teardown.promise + }) + mocks.persist.mockReturnValue(saving.promise) + await f.prepare() + const prepared = f.responses[0] + if (prepared?.phase !== 'prepare') { + throw new Error('prepare failed') + } + f.notice('started') + f.notice('ended') + const completion = f.persist() + expect(mocks.state.shutdownWorktreeBrowsers).not.toHaveBeenCalled() + expect(mocks.persist).not.toHaveBeenCalled() + teardown.resolve() + await vi.waitFor(() => expect(mocks.persist).toHaveBeenCalledOnce()) + expect(f.responses.map((response) => response.phase)).toEqual(['prepare']) + saving.resolve() + await completion + expect(f.responses.map((response) => response.phase)).toEqual(['prepare', 'persist']) + }) + + it('resolves every twin chat host and the terminal owner instead of assuming local', async () => { + const f = fixture() + const id = 'repo::/wt' + mocks.state.worktreesByRepo = { + repo: [ + { id, repoId: 'repo', hostId: 'local' }, + { id, repoId: 'repo', hostId: 'runtime:env' } + ] + } + mocks.state.tabsByWorktree = { [id]: [{ id: 'tab' }] } + mocks.state.ptyIdsByTabId = { tab: [toRemoteRuntimePtyId('pty', 'env')] } + await f.prepare([id]) + expect(f.responses[0]).toEqual({ + requestId: 'move-1', + protocol: 1, + phase: 'prepare', + targets: [ + { + worktreeId: id, + terminalHost: { kind: 'environment', environmentId: 'env' }, + chatHosts: [{ kind: 'self' }, { kind: 'environment', environmentId: 'env' }] + } + ] + }) + }) + + it('refuses ambiguous twins before starting a sleep', async () => { + const f = fixture() + const id = 'repo::/wt' + mocks.state.worktreesByRepo = { + repo: [ + { id, repoId: 'repo', hostId: 'local' }, + { id, repoId: 'repo', hostId: 'runtime:env' } + ] + } + await f.prepare([id]) + expect(f.responses[0]?.phase).toBe('error') + expect(mocks.state.shutdownWorktreeTerminals).not.toHaveBeenCalled() + }) + + it('refuses completion when the follower did not see a supported sleep', async () => { + const f = fixture() + await f.prepare() + await f.persist() + expect(f.responses.at(-1)).toMatchObject({ + phase: 'error', + error: 'project_move_pause_missing_completion' + }) + expect(mocks.persist).not.toHaveBeenCalled() + }) + + it('reports renderer teardown failures instead of persisting and acknowledging success', async () => { + const f = fixture() + mocks.state.shutdownWorktreeTerminals.mockRejectedValue(new Error('capture failed')) + await f.prepare() + f.notice('started') + f.notice('ended') + await f.persist() + expect(f.responses.at(-1)).toMatchObject({ phase: 'error', error: 'capture failed' }) + expect(mocks.persist).not.toHaveBeenCalled() + }) + + it('reports durable write failure after teardown', async () => { + const f = fixture() + mocks.persist.mockRejectedValue(new Error('disk full')) + await f.prepare() + f.notice('started') + f.notice('ended') + await f.persist() + expect(f.responses.at(-1)).toMatchObject({ phase: 'error', error: 'disk full' }) + }) + + it('keeps ordinary user sleep behavior and refuses a superseded move pause', async () => { + const f = fixture() + await f.prepare() + f.notice('started') + f.notice('ended') + f.follower.follow({ worktreeId: 'wt', phase: 'started' }) + f.follower.follow({ + worktreeId: 'wt', + phase: 'ended', + slept: true, + stoppedPtyIds: [], + records: [] + }) + await f.persist() + expect(mocks.state.shutdownWorktreeBrowsers).toHaveBeenCalledWith('wt') + expect(f.responses.at(-1)).toMatchObject({ + phase: 'error', + error: 'project_move_pause_sleep_superseded' + }) + }) + + it('reserves overlapping scopes until main releases them', async () => { + const f = fixture() + await f.prepare() + const next = { + requestId: 'move-2', + protocol: 1, + phase: 'prepare', + worktreeIds: ['wt'] + } as const + await f.follower.request({ ...next, worktreeIds: [...next.worktreeIds] }) + expect(f.responses.at(-1)).toMatchObject({ + phase: 'error', + error: 'project_move_pause_scope_busy' + }) + await f.follower.request({ requestId: 'move-1', protocol: 1, phase: 'release' }) + await f.follower.request({ ...next, worktreeIds: [...next.worktreeIds] }) + expect(f.responses.at(-1)).toMatchObject({ requestId: 'move-2', phase: 'prepare' }) + }) + + it('does not acknowledge a write that main released while it was in flight', async () => { + const f = fixture() + const saving = Promise.withResolvers() + mocks.persist.mockReturnValue(saving.promise) + await f.prepare() + f.notice('started') + f.notice('ended') + const completion = f.persist() + await vi.waitFor(() => expect(mocks.persist).toHaveBeenCalledOnce()) + await f.follower.request({ requestId: 'move-1', protocol: 1, phase: 'release' }) + saving.resolve() + await completion + expect(f.responses.at(-1)).toMatchObject({ + phase: 'error', + error: 'project_move_pause_released' + }) + }) +}) diff --git a/src/renderer/src/components/sidebar/project-move-pause-follower.ts b/src/renderer/src/components/sidebar/project-move-pause-follower.ts new file mode 100644 index 00000000000..f91c47a8a0f --- /dev/null +++ b/src/renderer/src/components/sidebar/project-move-pause-follower.ts @@ -0,0 +1,143 @@ +import { + PROJECT_MOVE_PAUSE_PROTOCOL, + type ProjectMovePauseRequest, + type ProjectMovePauseResponse +} from '../../../../shared/project-move-pause' +import type { WorkspaceSleepNotice } from '../../../../shared/workspace-sleep' +import { useAppStore } from '@/store' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' +import { workspaceSleepTarget } from './workspace-sleep-target' +import { followWorkspaceSleep } from './workspace-sleep-follower' + +type Pause = { + worktreeIds: Set + started: Set + ended: Map> + error?: Error +} + +/** The handshake and sleep follower share one lifetime, so an old follower cannot claim support. */ +export function createProjectMovePauseFollower( + respond: (response: ProjectMovePauseResponse) => void +): { + request: (request: ProjectMovePauseRequest) => Promise + follow: (notice: WorkspaceSleepNotice) => void +} { + const pauses = new Map() + return { + request: async (request) => { + const envelope = { + requestId: request.requestId, + protocol: PROJECT_MOVE_PAUSE_PROTOCOL + } as const + try { + if (request.protocol !== PROJECT_MOVE_PAUSE_PROTOCOL) { + throw new Error('project_move_pause_unsupported_protocol') + } + if (request.phase === 'release') { + pauses.delete(request.requestId) + return + } + if (request.phase === 'prepare') { + const ids = new Set(request.worktreeIds) + if ( + ids.size === 0 || + ids.size !== request.worktreeIds.length || + pauses.has(request.requestId) + ) { + throw new Error('project_move_pause_invalid_scope') + } + if ( + [...pauses.values()].some((pause) => [...ids].some((id) => pause.worktreeIds.has(id))) + ) { + throw new Error('project_move_pause_scope_busy') + } + const state = useAppStore.getState() + const targets = [...ids].map((id) => { + const target = workspaceSleepTarget(state, id) + if ('refused' in target) { + throw new Error(target.refused) + } + return target + }) + pauses.set(request.requestId, { worktreeIds: ids, started: new Set(), ended: new Map() }) + respond({ ...envelope, phase: 'prepare', targets }) + return + } + const pause = pauses.get(request.requestId) + if (!pause) { + throw new Error('project_move_pause_not_prepared') + } + if (pause.error) { + throw pause.error + } + if (request.successfulWorktreeIds.some((id) => !pause.ended.has(id))) { + throw new Error('project_move_pause_missing_completion') + } + if ([...pause.started].some((id) => !pause.ended.has(id))) { + throw new Error('project_move_pause_incomplete_sleep') + } + const errors = await Promise.all(pause.ended.values()) + const error = errors.find((failure) => failure !== null) + if (error) { + throw error + } + if (pause.error || pauses.get(request.requestId) !== pause) { + throw pause.error ?? new Error('project_move_pause_released') + } + const state = useAppStore.getState() + await persistWorkspaceSessionByHost( + window.api.session, + buildWorkspaceSessionPayload(state), + state + ) + if (pause.error || pauses.get(request.requestId) !== pause) { + throw pause.error ?? new Error('project_move_pause_released') + } + respond({ ...envelope, phase: 'persist' }) + } catch (error) { + respond({ + ...envelope, + phase: 'error', + error: error instanceof Error ? error.message : String(error) + }) + } + }, + follow: (notice) => { + if (notice.phase === 'failed') { + followWorkspaceSleep(notice) + return + } + const pause = notice.projectMovePauseId ? pauses.get(notice.projectMovePauseId) : undefined + const prepared = pause?.worktreeIds.has(notice.worktreeId) === true + for (const other of pauses.values()) { + if (other.worktreeIds.has(notice.worktreeId) && (other !== pause || !prepared)) { + other.error = new Error('project_move_pause_sleep_superseded') + } + } + if (pause && prepared && notice.phase === 'started') { + if (pause.started.has(notice.worktreeId)) { + pause.error = new Error('project_move_pause_duplicate_sleep') + } + pause.started.add(notice.worktreeId) + } + const completion = followWorkspaceSleep(notice, { + keepBrowserTabs: prepared, + reportTeardownFailure: prepared + }) + if (pause && prepared && notice.phase === 'ended') { + if (!pause.started.has(notice.worktreeId) || pause.ended.has(notice.worktreeId)) { + pause.error = new Error('project_move_pause_unexpected_completion') + } + pause.ended.set( + notice.worktreeId, + Promise.resolve(completion).then( + () => null, + (error: unknown) => (error instanceof Error ? error : new Error(String(error))) + ) + ) + } + } + } +} diff --git a/src/renderer/src/components/sidebar/workspace-sleep-follower.ts b/src/renderer/src/components/sidebar/workspace-sleep-follower.ts index 02a11e99fe6..6cbe3cb1286 100644 --- a/src/renderer/src/components/sidebar/workspace-sleep-follower.ts +++ b/src/renderer/src/components/sidebar/workspace-sleep-follower.ts @@ -17,7 +17,7 @@ import { toastWorkspaceSleepOutcome } from './sleep-worktree-flow' type FollowedSleep = { settle: (stop: HostTerminalStop) => void stopWatchingReopen: () => void - teardown: Promise + teardown: Promise /** The selection to put back if the sleep fails and the user picked nothing meanwhile. */ selectionBeforeSleep: WorkspaceSelection | null } @@ -43,7 +43,10 @@ function watchReopenDuringSleep(worktreeId: string): () => void { * guards its panes before any terminal exits (the notice arrives ahead of every exit), and on * `ended` it commits or rolls back those panes and wakes the workspace if the sleep failed. */ -export function followWorkspaceSleep(notice: WorkspaceSleepNotice): void { +export function followWorkspaceSleep( + notice: WorkspaceSleepNotice, + options: { keepBrowserTabs?: boolean; reportTeardownFailure?: boolean } = {} +): Promise | void { const { worktreeId } = notice if (notice.phase === 'failed') { toastWorkspaceSleepOutcome(notice.outcome) @@ -64,7 +67,9 @@ export function followWorkspaceSleep(notice: WorkspaceSleepNotice): void { state.setActiveWorktree(null) restoreSidebarPosition() } - state.shutdownWorktreeBrowsers(worktreeId) + if (!options.keepBrowserTabs) { + state.shutdownWorktreeBrowsers(worktreeId) + } let settle!: (stop: HostTerminalStop) => void const hostStop = new Promise((resolve) => (settle = resolve)) const teardown = withWorktreeSleepTeardown(worktreeId, () => @@ -72,9 +77,13 @@ export function followWorkspaceSleep(notice: WorkspaceSleepNotice): void { keepIdentifiers: true, hostStop }) - ).catch((error: unknown) => { - console.warn('[sleep-worktree] panes kept awake', { worktreeId, error }) - }) + ).then( + () => null, + (error: unknown) => { + console.warn('[sleep-worktree] panes kept awake', { worktreeId, error }) + return error instanceof Error ? error : new Error(String(error)) + } + ) followed.set(worktreeId, { settle, teardown, @@ -91,7 +100,7 @@ export function followWorkspaceSleep(notice: WorkspaceSleepNotice): void { ? { failed: false, records: notice.records } : { failed: true, stoppedPtyIds: notice.stoppedPtyIds ?? [] } ) - void (entry?.teardown ?? Promise.resolve()).then(() => { + return (entry?.teardown ?? Promise.resolve(null)).then((error) => { const state = useAppStore.getState() // A failed sleep leaves the workspace awake and retryable; one the user reopened is theirs. if (!notice.slept || state.activeWorktreeId === worktreeId) { @@ -100,5 +109,8 @@ export function followWorkspaceSleep(notice: WorkspaceSleepNotice): void { if (!notice.slept && entry?.selectionBeforeSleep && state.activeWorktreeId === null) { restoreWorkspaceSelection(state, entry.selectionBeforeSleep) } + if (error && options.reportTeardownFailure) { + throw error + } }) } diff --git a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts index 605af746dc1..4192770b503 100644 --- a/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/mobile-terminal-close-ipc-bridge.ts @@ -3,6 +3,7 @@ import { applyClosedTerminalLeafNotice } from '@/components/terminal-pane/closed import { closeTerminalTab } from '@/components/terminal/terminal-tab-actions' import { detectLanguage } from '@/lib/language-detect' import { followWorkspaceSleep } from '@/components/sidebar/workspace-sleep-follower' +import { createProjectMovePauseFollower } from '@/components/sidebar/project-move-pause-follower' import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' import { persistWorkspaceSessionByHost } from '@/lib/workspace-session-host-persistence' import { revealWorkspaceSurface } from '@/lib/workspace-surface-reveal' @@ -198,7 +199,19 @@ export function registerMobileAndTerminalCloseIpcBridge( } // A sleep main runs, from this window or a phone: the window hides the workspace and its panes. - unsubs.push(window.api.ui.onWorkspaceSleep(followWorkspaceSleep)) + if (window.api.ui.onProjectMovePause && window.api.ui.respondProjectMovePause) { + const movePause = createProjectMovePauseFollower(window.api.ui.respondProjectMovePause) + unsubs.push( + window.api.ui.onProjectMovePause((request) => { + void movePause.request(request).catch((error: unknown) => { + console.warn('[project-move-pause] renderer reply failed', error) + }) + }) + ) + unsubs.push(window.api.ui.onWorkspaceSleep(movePause.follow)) + } else { + unsubs.push(window.api.ui.onWorkspaceSleep(followWorkspaceSleep)) + } unsubs.push( window.api.ui.onResumeSleepingAgents(({ worktreeId }) => { diff --git a/src/shared/project-move-pause.ts b/src/shared/project-move-pause.ts new file mode 100644 index 00000000000..7a823a4d3b5 --- /dev/null +++ b/src/shared/project-move-pause.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' + +export const PROJECT_MOVE_PAUSE_PROTOCOL = 1 + +export type ProjectMovePauseRequest = { requestId: string; protocol: 1 } & ( + | { phase: 'prepare'; worktreeIds: string[] } + | { phase: 'persist'; successfulWorktreeIds: string[] } + | { phase: 'release' } +) + +const host = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('self') }), + z.object({ kind: z.literal('environment'), environmentId: z.string().min(1) }) +]) + +export const projectMovePauseResponseSchema = z.discriminatedUnion('phase', [ + z.object({ + requestId: z.string(), + protocol: z.literal(PROJECT_MOVE_PAUSE_PROTOCOL), + phase: z.literal('prepare'), + targets: z.array( + z.object({ worktreeId: z.string(), terminalHost: host, chatHosts: z.array(host) }) + ) + }), + z.object({ + requestId: z.string(), + protocol: z.literal(PROJECT_MOVE_PAUSE_PROTOCOL), + phase: z.literal('persist') + }), + z.object({ + requestId: z.string(), + protocol: z.literal(PROJECT_MOVE_PAUSE_PROTOCOL), + phase: z.literal('error'), + error: z.string() + }) +]) + +export type ProjectMovePauseResponse = z.infer diff --git a/src/shared/workspace-sleep.ts b/src/shared/workspace-sleep.ts index fabf0339f14..9a582119d98 100644 --- a/src/shared/workspace-sleep.ts +++ b/src/shared/workspace-sleep.ts @@ -14,6 +14,8 @@ export type WorkspaceSleepTarget = { export type WorkspaceSleepRequest = { targets: WorkspaceSleepTarget[] + /** A main-owned project move with a negotiated renderer persistence barrier. */ + projectMovePauseId?: string /** Only the user's Sleep hides its chats; a project move pauses through the same sleep. */ markChatsAsleep?: boolean } @@ -36,10 +38,11 @@ export type WorkspaceSleepOutcome = { /** What the window follows of a sleep main runs, whoever asked for it. */ export type WorkspaceSleepNotice = - | { worktreeId: string; phase: 'started' } + | { worktreeId: string; phase: 'started'; projectMovePauseId?: string } | { worktreeId: string phase: 'ended' + projectMovePauseId?: string /** The terminals main proved stopped, as the window's PTY ids; null when the stop failed. */ stoppedPtyIds: string[] | null slept: boolean