diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index deafc1b8ac..d0cc0c3ea0 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -1365,6 +1365,9 @@ function ArchiveRemoteRuntimeTaskProbe() { > archive remote task + ) } @@ -2514,6 +2517,71 @@ describe('WorkbenchProvider runtime tasks', () => { ) }) + test('does not leave cloud work stuck syncing when a sync is superseded', async () => { + const runtimeWork = deferred() + const services = createWorkbenchServices({ + cloudBackgroundApi: { + listTeams: vi.fn().mockResolvedValue([]), + listDevices: vi.fn().mockResolvedValue([]), + listRuntimeWork: vi.fn(() => runtimeWork.promise), + }, + }) + + renderWorkbench( + <> + + + , + services + ) + + await waitFor(() => + expect(screen.getByTestId('cloud-work-availability')).toHaveTextContent('syncing') + ) + + await userEvent.click(screen.getByRole('button', { name: 'Refresh devices' })) + await act(async () => { + runtimeWork.resolve({ projects: [], chats: [], totalTasks: 0 }) + await runtimeWork.promise + }) + + await waitFor(() => + expect(screen.getByTestId('cloud-work-availability')).not.toHaveTextContent('syncing') + ) + }) + + test('does not leave cloud work stuck syncing when a task is archived mid-sync', async () => { + const runtimeWork = deferred() + const runtimeWorkApi = createRuntimeWorkApiMock() + const services = createWorkbenchServices({ + runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], + cloudBackgroundApi: { + listTeams: vi.fn().mockResolvedValue([]), + listDevices: vi.fn().mockResolvedValue([]), + listRuntimeWork: vi.fn(() => runtimeWork.promise), + }, + }) + + renderWorkbench( + <> + + + , + services + ) + + await waitFor(() => + expect(screen.getByTestId('cloud-work-availability')).toHaveTextContent('syncing') + ) + + await userEvent.click(screen.getByText('archive remote task')) + await waitFor(() => expect(runtimeWorkApi.archiveConversation).toHaveBeenCalledTimes(1)) + + await waitFor(() => + expect(screen.getByTestId('cloud-work-availability')).not.toHaveTextContent('syncing') + ) + }) + test('restores cached remote task summaries when the device is offline at startup', async () => { writeCachedRemoteRuntimeWork(1, { projects: [ @@ -6476,7 +6544,7 @@ describe('WorkbenchProvider runtime tasks', () => { await waitFor(() => expect(screen.getByTestId('archive-result')).toHaveTextContent('archived')) }) - test('does not restore an archived remote task from the previous cloud snapshot', async () => { + test('archives a remote task locally without triggering a cloud sync', async () => { const remoteRuntimeWork: RuntimeWorkListResponse = { projects: [ { @@ -6540,15 +6608,83 @@ describe('WorkbenchProvider runtime tasks', () => { await userEvent.click(screen.getByText('archive remote task')) await waitFor(() => expect(runtimeWorkApi.archiveConversation).toHaveBeenCalledTimes(1)) - await waitFor(() => expect(cloudListRuntimeWork).toHaveBeenCalledTimes(2)) expect(screen.getByTestId('archive-remote-task-titles')).toHaveTextContent('') + expect(cloudListRuntimeWork).toHaveBeenCalledTimes(1) + await userEvent.click(screen.getByText('refresh work lists')) + await waitFor(() => expect(cloudListRuntimeWork).toHaveBeenCalledTimes(2)) postArchiveCloudWork.resolve({ projects: [], chats: [], totalTasks: 0 }) await waitFor(() => expect(screen.getByTestId('archive-remote-task-titles')).toHaveTextContent('') ) }) + test('keeps an archived remote task hidden when the local list refresh fails', async () => { + const remoteRuntimeWork: RuntimeWorkListResponse = { + projects: [ + { + project: { key: 'remote-project', name: 'Remote Wegent' }, + deviceWorkspaces: [ + { + deviceId: 'remote-device', + deviceName: '10.201.3.200', + deviceStatus: 'online', + available: true, + workspacePath: '/srv/Wegent', + workspaceSource: 'remote', + remoteHostId: 'remote-device', + tasks: [ + { + taskId: 'remote-task', + workspacePath: '/srv/Wegent', + title: 'Remote task', + runtime: 'codex', + }, + ], + }, + ], + totalTasks: 1, + }, + ], + chats: [], + totalTasks: 1, + } + const runtimeWorkApi = createRuntimeWorkApiMock({ + listRuntimeWork: vi.fn().mockRejectedValue(new Error('local list unavailable')), + }) + const services = createWorkbenchServices({ + runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], + cloudBackgroundApi: { + listTeams: vi.fn().mockResolvedValue([]), + listDevices: vi.fn().mockResolvedValue([ + createDevice({ + id: 2, + device_id: 'remote-device', + name: '10.201.3.200', + status: 'online', + is_default: false, + device_type: 'remote', + }), + ]), + listRuntimeWork: vi.fn().mockResolvedValue(remoteRuntimeWork), + }, + }) + + renderWorkbench(, services) + + await waitFor(() => + expect(screen.getByTestId('archive-remote-task-titles')).toHaveTextContent('Remote task') + ) + runtimeWorkApi.listRuntimeWork.mockClear() + await userEvent.click(screen.getByText('archive remote task')) + + await waitFor(() => expect(runtimeWorkApi.archiveConversation).toHaveBeenCalledTimes(1)) + expect(runtimeWorkApi.listRuntimeWork).toHaveBeenCalledTimes(1) + await waitFor(() => + expect(screen.getByTestId('archive-remote-task-titles')).toHaveTextContent('') + ) + }) + test('renders streaming runtime task chunks when the socket connects after chat start', async () => { let streamHandlers: ChatStreamHandlers = {} const subscribe = vi.fn((handlers: ChatStreamHandlers) => { diff --git a/wework/src/features/workbench/WorkbenchProvider.tsx b/wework/src/features/workbench/WorkbenchProvider.tsx index 69852f8b44..5ccc74d5ca 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -532,7 +532,7 @@ export function WorkbenchProvider({ remoteProjectSyncSignatureRef.current = signature void executorClient.runtime .syncRuntimeRemoteProjects({ deviceId: localRuntimeStateDeviceId, projects }) - .then(refreshWorkLists) + .then(() => refreshWorkLists()) .catch(error => { remoteProjectSyncSignatureRef.current = '' console.warn('[Wework] Failed to sync remote projects into Codex global state', error) diff --git a/wework/src/features/workbench/useWorkbenchDataRefresh.ts b/wework/src/features/workbench/useWorkbenchDataRefresh.ts index 5367d164af..4cc317b09f 100644 --- a/wework/src/features/workbench/useWorkbenchDataRefresh.ts +++ b/wework/src/features/workbench/useWorkbenchDataRefresh.ts @@ -14,6 +14,8 @@ import type { CloudRuntimeState, CloudWorkCheckKey, WorkbenchState } from '@/typ import { EMPTY_CLOUD_RUNTIME_STATE, EMPTY_RUNTIME_WORK, + abandonCloudRuntimeSync, + clearCloudRuntimeSync, filterDisconnectedRemoteRuntimeWork, finishCloudRuntimeSync, mergeDeviceLists, @@ -36,6 +38,7 @@ import { runtimeWorkContainsTask, } from './workbenchRuntimeHelpers' import type { WorkbenchServices } from './workbenchServices' +import type { RefreshWorkLists } from './workbenchContextTypes' import { readCachedRemoteRuntimeWork, reconcileCachedRemoteRuntimeWork, @@ -92,14 +95,11 @@ function removeRuntimeTasksFromCloudState( runtimeWork: removeRuntimeTasks(snapshot.runtimeWork, addresses), } : null - return { + return clearCloudRuntimeSync({ ...state, - availability: - state.inFlightRevision == null ? state.availability : state.lastGood ? 'stale' : 'idle', current: removeFromSnapshot(state.current), lastGood: removeFromSnapshot(state.lastGood), - inFlightRevision: null, - } + }) } function removeRuntimeProjectFromCloudState( @@ -335,7 +335,6 @@ export function useWorkbenchDataRefresh({ try { if (cloudRuntimeStateRef.current.inFlightRevision != null) { if (options?.trigger !== 'manual-refresh' || !backgroundApi?.listDevices) return - const inFlightRevision = cloudRuntimeStateRef.current.inFlightRevision const inFlightBackgroundApi = backgroundApi const devicesResult = await timedWorkbenchBootstrapRequest( 'cloudDevices', @@ -347,7 +346,6 @@ export function useWorkbenchDataRefresh({ !isCurrentRefresh() || options?.isCancelled?.() || devicesResult.status !== 'fulfilled' || - cloudRuntimeStateRef.current.inFlightRevision !== inFlightRevision || cloudBackgroundApiRef.current !== inFlightBackgroundApi ) { return @@ -436,6 +434,9 @@ export function useWorkbenchDataRefresh({ cloudRuntimeStateRef.current.inFlightRevision !== revision || cloudBackgroundApiRef.current !== backgroundApi ) { + if (revision != null) { + updateCloudRuntimeState(abandonCloudRuntimeSync(cloudRuntimeStateRef.current, revision)) + } return } @@ -623,64 +624,75 @@ export function useWorkbenchDataRefresh({ user, ]) - const refreshWorkLists = useCallback(async () => { - const [devicesResult, runtimeWorkResult] = await Promise.all([ - executorClient.commands.listDevices().catch(error => { - const cachedDevices = readCachedDeviceList() - if (cachedDevices.length === 0) throw error - return cachedDevices - }), - executorClient.runtime.listRuntimeWork().catch(() => undefined), - ]) - const devices = resolveDeviceListWithCache(devicesResult) - const visibleDevices = resolveDeviceListWithCache( - selectVisibleDevices(devices, cloudRuntimeStateRef.current) - ) - const filteredRuntimeWorkResult = runtimeWorkResult - ? filterRemovedRuntimeProjects(runtimeWorkResult) - : undefined - if (filteredRuntimeWorkResult) { - localRuntimeWorkRef.current = filteredRuntimeWorkResult - } - const localRuntimeWork = filteredRuntimeWorkResult ?? state.runtimeWork ?? EMPTY_RUNTIME_WORK - if (filteredRuntimeWorkResult && !services.cloudBackgroundApi?.listRuntimeWork) { - releaseConfirmedArchivedRuntimeTasks(filteredRuntimeWorkResult) - } - const runtimeWork = filteredRuntimeWorkResult - ? selectVisibleRuntimeWork(localRuntimeWork, cloudRuntimeStateRef.current, visibleDevices) - : hasCloudBackgroundApi - ? localRuntimeWork - : filterDisconnectedRemoteRuntimeWork(localRuntimeWork) - debugRuntimeSidebarState('refresh-resolved', { - source: filteredRuntimeWorkResult ? 'executor' : 'current-state', - executorTaskIds: summarizeRuntimeWorkTaskIds(filteredRuntimeWorkResult ?? null), - visibleTaskIds: summarizeRuntimeWorkTaskIds(runtimeWork), - }) - dispatch({ - type: 'lists_refreshed', - projects: state.projects, - devices: visibleDevices, - runtimeWork, - standaloneDeviceId: getPreferredStandaloneDeviceId(visibleDevices, state.standaloneDeviceId), - }) - void refreshCloudBackgroundData(devices, localRuntimeWork, { - projects: state.projects, - standaloneDeviceId: state.standaloneDeviceId, - trigger: 'manual-refresh', - }).catch(() => undefined) - }, [ - dispatch, - executorClient, - filterRemovedRuntimeProjects, - refreshCloudBackgroundData, - hasCloudBackgroundApi, - releaseConfirmedArchivedRuntimeTasks, - selectVisibleRuntimeWork, - services.cloudBackgroundApi, - state.projects, - state.runtimeWork, - state.standaloneDeviceId, - ]) + const refreshWorkLists: RefreshWorkLists = useCallback( + async options => { + const [devicesResult, runtimeWorkResult] = await Promise.all([ + executorClient.commands.listDevices().catch(error => { + const cachedDevices = readCachedDeviceList() + if (cachedDevices.length === 0) throw error + return cachedDevices + }), + executorClient.runtime.listRuntimeWork().catch(() => undefined), + ]) + const devices = resolveDeviceListWithCache(devicesResult) + const visibleDevices = resolveDeviceListWithCache( + selectVisibleDevices(devices, cloudRuntimeStateRef.current) + ) + const filteredRuntimeWorkResult = runtimeWorkResult + ? filterRemovedRuntimeProjects(runtimeWorkResult) + : undefined + if (filteredRuntimeWorkResult) { + localRuntimeWorkRef.current = filteredRuntimeWorkResult + } + const localRuntimeWork = filteredRuntimeWorkResult ?? state.runtimeWork ?? EMPTY_RUNTIME_WORK + if (filteredRuntimeWorkResult && !services.cloudBackgroundApi?.listRuntimeWork) { + releaseConfirmedArchivedRuntimeTasks(filteredRuntimeWorkResult) + } + const runtimeWork = filteredRuntimeWorkResult + ? selectVisibleRuntimeWork(localRuntimeWork, cloudRuntimeStateRef.current, visibleDevices) + : removeRuntimeTasks( + hasCloudBackgroundApi + ? localRuntimeWork + : filterDisconnectedRemoteRuntimeWork(localRuntimeWork), + archivedRuntimeTaskAddressesRef.current + ) + debugRuntimeSidebarState('refresh-resolved', { + source: filteredRuntimeWorkResult ? 'executor' : 'current-state', + executorTaskIds: summarizeRuntimeWorkTaskIds(filteredRuntimeWorkResult ?? null), + visibleTaskIds: summarizeRuntimeWorkTaskIds(runtimeWork), + }) + dispatch({ + type: 'lists_refreshed', + projects: state.projects, + devices: visibleDevices, + runtimeWork, + standaloneDeviceId: getPreferredStandaloneDeviceId( + visibleDevices, + state.standaloneDeviceId + ), + }) + if (options?.syncCloud !== false) { + void refreshCloudBackgroundData(devices, localRuntimeWork, { + projects: state.projects, + standaloneDeviceId: state.standaloneDeviceId, + trigger: 'manual-refresh', + }).catch(() => undefined) + } + }, + [ + dispatch, + executorClient, + filterRemovedRuntimeProjects, + refreshCloudBackgroundData, + hasCloudBackgroundApi, + releaseConfirmedArchivedRuntimeTasks, + selectVisibleRuntimeWork, + services.cloudBackgroundApi, + state.projects, + state.runtimeWork, + state.standaloneDeviceId, + ] + ) const loadDevicesForRefresh = useCallback( async (options?: { useCacheFallback?: boolean }): Promise => { diff --git a/wework/src/features/workbench/useWorkbenchRuntimeTasks.ts b/wework/src/features/workbench/useWorkbenchRuntimeTasks.ts index dcfd41fd4a..c207169134 100644 --- a/wework/src/features/workbench/useWorkbenchRuntimeTasks.ts +++ b/wework/src/features/workbench/useWorkbenchRuntimeTasks.ts @@ -41,6 +41,7 @@ import type { ArchiveRuntimeTaskOptions, ArchiveRuntimeTaskResult, ArchiveRuntimeConversationsResult, + RefreshWorkLists, } from './workbenchContextTypes' import { evictRuntimeConversation } from './runtimeConversationCache' import type { RuntimeTaskLifecycleStore } from './runtimeTaskLifecycle' @@ -53,7 +54,7 @@ interface UseWorkbenchRuntimeTasksOptions { services: WorkbenchServices lifecycleStore: RuntimeTaskLifecycleStore markRuntimeTasksArchived: (addresses: RuntimeTaskAddress[]) => void - refreshWorkLists: () => Promise + refreshWorkLists: RefreshWorkLists } const runtimeTranscriptRequests = new Map>() @@ -265,7 +266,7 @@ export function useWorkbenchRuntimeTasks({ findRuntimeTaskWorktrees(state.runtimeWork, archivedAddresses) ) clearCurrentRuntimeTaskIfArchived(archivedAddresses) - await refreshWorkLists() + await refreshWorkLists({ syncCloud: false }) } const failedResult = results.find(result => !result.response?.accepted) if (!failedResult) return { status: 'archived' } diff --git a/wework/src/features/workbench/workbenchCloudStatus.ts b/wework/src/features/workbench/workbenchCloudStatus.ts index a61df8e7da..5223dcde62 100644 --- a/wework/src/features/workbench/workbenchCloudStatus.ts +++ b/wework/src/features/workbench/workbenchCloudStatus.ts @@ -103,6 +103,46 @@ function cloneChecks( } } +function resetSyncingChecks( + checks: Record, + previous: CloudRuntimeSnapshot | null +): Record { + const next = cloneChecks(checks) + ;(Object.keys(next) as CloudWorkCheckKey[]).forEach(key => { + if (next[key].status === 'syncing') { + next[key] = previous?.checks[key] ?? EMPTY_SYNC_CHECKS[key] + } + }) + return next +} + +export function abandonCloudRuntimeSync( + state: CloudRuntimeState, + revision: number +): CloudRuntimeState { + if (state.inFlightRevision !== revision) return state + return { + ...state, + availability: state.lastGood ? 'stale' : 'idle', + inFlightRevision: null, + current: state.current + ? { ...state.current, checks: resetSyncingChecks(state.current.checks, state.lastGood) } + : state.current, + } +} + +export function clearCloudRuntimeSync(state: CloudRuntimeState): CloudRuntimeState { + if (state.inFlightRevision == null) return state + return { + ...state, + availability: state.lastGood ? 'stale' : 'idle', + inFlightRevision: null, + current: state.current + ? { ...state.current, checks: resetSyncingChecks(state.current.checks, state.lastGood) } + : state.current, + } +} + function syncCheckResult( result: PromiseSettledResult | undefined, previous: SyncCheckState, diff --git a/wework/src/features/workbench/workbenchContextTypes.ts b/wework/src/features/workbench/workbenchContextTypes.ts index 09ffea27c8..61bff45182 100644 --- a/wework/src/features/workbench/workbenchContextTypes.ts +++ b/wework/src/features/workbench/workbenchContextTypes.ts @@ -73,6 +73,8 @@ export type ArchiveRuntimeTaskResult = { status: 'archived' | 'dirty_worktree' | 'failed' } +export type RefreshWorkLists = (options?: { syncCloud?: boolean }) => Promise + export type ArchiveRuntimeConversationsResult = ArchiveRuntimeTaskResult export interface SendCurrentInputOptions { @@ -229,7 +231,7 @@ export interface WorkbenchContextValue { address: RuntimeTaskAddress ) => Promise rememberExecutionDevice: (deviceId: string) => void - refreshWorkLists: () => Promise + refreshWorkLists: RefreshWorkLists refreshDevices: () => Promise getRemoteDeviceStartupCommand: () => Promise upgradeDevice: (deviceId: string) => Promise