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
This commit is contained in:
Neil
2026-10-23 17:25:22 -07:00
committed by GitHub
parent 2351c6de3f
commit cd59239927
13 changed files with 856 additions and 18 deletions
@@ -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) =>
@@ -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, {
@@ -183,11 +183,13 @@ async function sleepWorkspace(
deps: WorkspaceSleepDeps,
target: WorkspaceSleepTarget,
markAsleep: boolean,
batch: { chatsSettled: () => void; batchChatsSettled: Promise<unknown> }
batch: { chatsSettled: () => void; batchChatsSettled: Promise<unknown> },
projectMovePauseId?: string
): Promise<WorkspaceSleepAttempt> {
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
+169
View File
@@ -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<WorkspaceSleepOutcome[]>()
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'])
})
})
+166
View File
@@ -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<WorkspaceSleepOutcome[]>
}
/** 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<WorkspaceSleepOutcome[]> {
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<ProjectMovePauseResponse> => {
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)
}
}
@@ -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<typeof callback>[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)
+6
View File
@@ -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
@@ -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<string, { id: string }[]> = {}
const ptyIdsByTabId: Record<string, string[]> = {}
const worktreesByRepo: Record<string, { id: string; repoId: string; hostId: string }[]> = {}
return {
state: {
activeWorktreeId: null,
setActiveWorktree: vi.fn(),
shutdownWorktreeBrowsers: vi.fn(),
shutdownWorktreeTerminals: vi.fn<AppState['shutdownWorktreeTerminals']>(),
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<void>) => 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<void>()
const saving = Promise.withResolvers<void>()
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<void>()
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'
})
})
})
@@ -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<string>
started: Set<string>
ended: Map<string, Promise<Error | null>>
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<void>
follow: (notice: WorkspaceSleepNotice) => void
} {
const pauses = new Map<string, Pause>()
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)))
)
)
}
}
}
}
@@ -17,7 +17,7 @@ import { toastWorkspaceSleepOutcome } from './sleep-worktree-flow'
type FollowedSleep = {
settle: (stop: HostTerminalStop) => void
stopWatchingReopen: () => void
teardown: Promise<void>
teardown: Promise<Error | null>
/** 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> | 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<HostTerminalStop>((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
}
})
}
@@ -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 }) => {
+38
View File
@@ -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<typeof projectMovePauseResponseSchema>
+4 -1
View File
@@ -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