From c23a80dbebfc58aeb080c5be02384dbc9a61e98f Mon Sep 17 00:00:00 2001 From: axb Date: Tue, 4 Aug 2026 23:28:24 +0800 Subject: [PATCH 1/7] fix(wework): archive tasks without triggering a cloud sync --- .../workbench/WorkbenchProvider.test.tsx | 9 +- .../features/workbench/WorkbenchProvider.tsx | 2 +- .../workbench/useWorkbenchDataRefresh.ts | 124 ++++++++++-------- .../workbench/useWorkbenchRuntimeTasks.ts | 5 +- .../workbench/workbenchContextTypes.ts | 4 +- 5 files changed, 80 insertions(+), 64 deletions(-) diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index deafc1b8ac..87b5d6517e 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 + ) } @@ -6476,7 +6479,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,9 +6543,11 @@ 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('') 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..b4a9635a02 100644 --- a/wework/src/features/workbench/useWorkbenchDataRefresh.ts +++ b/wework/src/features/workbench/useWorkbenchDataRefresh.ts @@ -623,64 +623,72 @@ 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 = useCallback( + async (options?: { syncCloud?: boolean }) => { + 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 + ), + }) + 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/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 From fa11411d3573b5fdfe2fddff6d7dcc099ceb0105 Mon Sep 17 00:00:00 2001 From: axb Date: Wed, 5 Aug 2026 00:11:42 +0800 Subject: [PATCH 2/7] fix(wework): keep archived tasks hidden when local list refresh fails --- .../workbench/WorkbenchProvider.test.tsx | 64 +++++++++++++++++++ .../workbench/useWorkbenchDataRefresh.ts | 14 ++-- 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index 87b5d6517e..7e0e2fdbd8 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -6554,6 +6554,70 @@ describe('WorkbenchProvider runtime tasks', () => { ) }) + 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') + ) + await userEvent.click(screen.getByText('archive remote task')) + + await waitFor(() => expect(runtimeWorkApi.archiveConversation).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/useWorkbenchDataRefresh.ts b/wework/src/features/workbench/useWorkbenchDataRefresh.ts index b4a9635a02..dc5ed6d7a0 100644 --- a/wework/src/features/workbench/useWorkbenchDataRefresh.ts +++ b/wework/src/features/workbench/useWorkbenchDataRefresh.ts @@ -36,6 +36,7 @@ import { runtimeWorkContainsTask, } from './workbenchRuntimeHelpers' import type { WorkbenchServices } from './workbenchServices' +import type { RefreshWorkLists } from './workbenchContextTypes' import { readCachedRemoteRuntimeWork, reconcileCachedRemoteRuntimeWork, @@ -623,8 +624,8 @@ export function useWorkbenchDataRefresh({ user, ]) - const refreshWorkLists = useCallback( - async (options?: { syncCloud?: boolean }) => { + const refreshWorkLists: RefreshWorkLists = useCallback( + async options => { const [devicesResult, runtimeWorkResult] = await Promise.all([ executorClient.commands.listDevices().catch(error => { const cachedDevices = readCachedDeviceList() @@ -649,9 +650,12 @@ export function useWorkbenchDataRefresh({ } const runtimeWork = filteredRuntimeWorkResult ? selectVisibleRuntimeWork(localRuntimeWork, cloudRuntimeStateRef.current, visibleDevices) - : hasCloudBackgroundApi - ? localRuntimeWork - : filterDisconnectedRemoteRuntimeWork(localRuntimeWork) + : removeRuntimeTasks( + hasCloudBackgroundApi + ? localRuntimeWork + : filterDisconnectedRemoteRuntimeWork(localRuntimeWork), + archivedRuntimeTaskAddressesRef.current + ) debugRuntimeSidebarState('refresh-resolved', { source: filteredRuntimeWorkResult ? 'executor' : 'current-state', executorTaskIds: summarizeRuntimeWorkTaskIds(filteredRuntimeWorkResult ?? null), From 3f59a3aaff5be196ce77dd0189704a29bb5c7708 Mon Sep 17 00:00:00 2001 From: axb Date: Wed, 5 Aug 2026 11:44:47 +0800 Subject: [PATCH 3/7] fix(wework): keep runtime stream events from triggering cloud syncs --- .../workbench/WorkbenchProvider.test.tsx | 83 +++++++++++++++++++ .../features/workbench/WorkbenchProvider.tsx | 2 +- 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index 7e0e2fdbd8..d3d7e48b52 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -6618,6 +6618,89 @@ describe('WorkbenchProvider runtime tasks', () => { ) }) + test('does not trigger a cloud sync for stream events on an archived task', 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, + } + let streamHandlers: ChatStreamHandlers = {} + const subscribe = vi.fn((handlers: ChatStreamHandlers) => { + if (hasRuntimeStreamHandler(handlers)) streamHandlers = handlers + return vi.fn() + }) + const cloudListRuntimeWork = vi.fn().mockResolvedValue(remoteRuntimeWork) + const runtimeWorkApi = createRuntimeWorkApiMock({ + listRuntimeWork: vi.fn().mockResolvedValue({ projects: [], chats: [], totalTasks: 0 }), + }) + const services = createWorkbenchServices({ + runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], + chatStream: { + subscribe, + } as unknown as WorkbenchServices['chatStream'], + 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: cloudListRuntimeWork, + }, + }) + + renderWorkbench(, services) + + await waitFor(() => + expect(screen.getByTestId('archive-remote-task-titles')).toHaveTextContent('Remote task') + ) + await userEvent.click(screen.getByText('archive remote task')) + await waitFor(() => expect(runtimeWorkApi.archiveConversation).toHaveBeenCalledTimes(1)) + await waitFor(() => + expect(screen.getByTestId('archive-remote-task-titles')).toHaveTextContent('') + ) + expect(cloudListRuntimeWork).toHaveBeenCalledTimes(1) + + await act(async () => { + streamHandlers.onRuntimeGoalCleared?.({ + deviceId: 'remote-device', + taskId: 'remote-task', + }) + }) + + expect(cloudListRuntimeWork).toHaveBeenCalledTimes(1) + 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 5ccc74d5ca..1efbfc5525 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -1215,7 +1215,7 @@ export function WorkbenchProvider({ ) const stableRefreshWorkLists = useStableEvent(refreshWorkLists) const refreshRuntimeWorkLists = useStableEvent((address: RuntimeTaskAddress) => { - void stableRefreshWorkLists().catch(error => { + void stableRefreshWorkLists({ syncCloud: false }).catch(error => { console.warn('[Wework] Runtime work list refresh failed', { deviceId: address.deviceId, taskId: address.taskId, From f09038da1fb5089d2babeeecfc2abfee76d11c5f Mon Sep 17 00:00:00 2001 From: axb Date: Wed, 5 Aug 2026 12:04:30 +0800 Subject: [PATCH 4/7] test(wework): harden archive cloud-sync regression coverage --- wework/src/features/workbench/WorkbenchProvider.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index d3d7e48b52..2a97d23a63 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -6610,9 +6610,11 @@ describe('WorkbenchProvider runtime tasks', () => { 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('') ) @@ -6690,8 +6692,9 @@ describe('WorkbenchProvider runtime tasks', () => { ) expect(cloudListRuntimeWork).toHaveBeenCalledTimes(1) + expect(typeof streamHandlers.onRuntimeGoalCleared).toBe('function') await act(async () => { - streamHandlers.onRuntimeGoalCleared?.({ + streamHandlers.onRuntimeGoalCleared!({ deviceId: 'remote-device', taskId: 'remote-task', }) From e6d560102367087ea64c25f6b218f1dba7a084ac Mon Sep 17 00:00:00 2001 From: axb Date: Wed, 5 Aug 2026 15:22:41 +0800 Subject: [PATCH 5/7] fix(wework): keep socket device events from triggering cloud syncs --- .../workbench/WorkbenchProvider.test.tsx | 33 +++++++++++++++++++ .../workbench/useWorkbenchDataRefresh.ts | 14 ++++---- .../workbench/useWorkbenchDeviceUpgrades.ts | 4 +-- .../workbench/workbenchContextTypes.ts | 7 +++- 4 files changed, 49 insertions(+), 9 deletions(-) diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index 2a97d23a63..699774e626 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -2807,6 +2807,39 @@ describe('WorkbenchProvider runtime tasks', () => { expect(screen.getByTestId('device-status')).toHaveTextContent('online') }) + test('does not trigger a cloud sync for socket device events', async () => { + let streamHandlers: ChatStreamHandlers = {} + const subscribe = vi.fn((handlers: ChatStreamHandlers) => { + streamHandlers = handlers + return vi.fn() + }) + const cloudListRuntimeWork = vi.fn().mockResolvedValue({ + projects: [], + chats: [], + totalTasks: 0, + }) + const services = createWorkbenchServices({ + chatStream: { + subscribe, + } as unknown as WorkbenchServices['chatStream'], + cloudBackgroundApi: { + listTeams: vi.fn().mockResolvedValue([]), + listDevices: vi.fn().mockResolvedValue([createDevice()]), + listRuntimeWork: cloudListRuntimeWork, + }, + }) + + renderWorkbench(, services) + + await waitFor(() => expect(cloudListRuntimeWork).toHaveBeenCalledTimes(1)) + + await act(async () => { + streamHandlers.onDeviceSlotUpdate?.({ device_id: 'device-1' }) + }) + + expect(cloudListRuntimeWork).toHaveBeenCalledTimes(1) + }) + test('keeps the last confirmed online state when an offline event refresh fails', async () => { let streamHandlers: ChatStreamHandlers = {} const subscribe = vi.fn((handlers: ChatStreamHandlers) => { diff --git a/wework/src/features/workbench/useWorkbenchDataRefresh.ts b/wework/src/features/workbench/useWorkbenchDataRefresh.ts index dc5ed6d7a0..cdc6369ed2 100644 --- a/wework/src/features/workbench/useWorkbenchDataRefresh.ts +++ b/wework/src/features/workbench/useWorkbenchDataRefresh.ts @@ -766,18 +766,20 @@ export function useWorkbenchDataRefresh({ ) const refreshDevices = useCallback( - async (options?: { useCacheFallback?: boolean }) => { + async (options?: { useCacheFallback?: boolean; syncCloud?: boolean }) => { const devices = await loadDevicesForRefresh(options) dispatch({ type: 'devices_refreshed', devices, standaloneDeviceId: getPreferredStandaloneDeviceId(devices, state.standaloneDeviceId), }) - void refreshCloudBackgroundData(devices, state.runtimeWork ?? EMPTY_RUNTIME_WORK, { - projects: state.projects, - standaloneDeviceId: state.standaloneDeviceId, - trigger: 'manual-refresh', - }).catch(() => undefined) + if (options?.syncCloud !== false) { + void refreshCloudBackgroundData(devices, state.runtimeWork ?? EMPTY_RUNTIME_WORK, { + projects: state.projects, + standaloneDeviceId: state.standaloneDeviceId, + trigger: 'manual-refresh', + }).catch(() => undefined) + } }, [ dispatch, diff --git a/wework/src/features/workbench/useWorkbenchDeviceUpgrades.ts b/wework/src/features/workbench/useWorkbenchDeviceUpgrades.ts index b17c1848d3..d8e9cacabd 100644 --- a/wework/src/features/workbench/useWorkbenchDeviceUpgrades.ts +++ b/wework/src/features/workbench/useWorkbenchDeviceUpgrades.ts @@ -24,7 +24,7 @@ interface UseWorkbenchDeviceUpgradesOptions { dispatch: Dispatch executorClient: ExecutorClient services: WorkbenchServices - refreshDevices: (options?: { useCacheFallback?: boolean }) => Promise + refreshDevices: (options?: { useCacheFallback?: boolean; syncCloud?: boolean }) => Promise } export function useWorkbenchDeviceUpgrades({ @@ -118,7 +118,7 @@ export function useWorkbenchDeviceUpgrades({ useEffect(() => { const refreshDevicesAfterEvent = () => { - void refreshDevices({ useCacheFallback: false }).catch(() => undefined) + void refreshDevices({ useCacheFallback: false, syncCloud: false }).catch(() => undefined) } const handleDeviceOnline = (payload: unknown) => { const deviceId = getDeviceEventId(payload) diff --git a/wework/src/features/workbench/workbenchContextTypes.ts b/wework/src/features/workbench/workbenchContextTypes.ts index 61bff45182..f752d11d6a 100644 --- a/wework/src/features/workbench/workbenchContextTypes.ts +++ b/wework/src/features/workbench/workbenchContextTypes.ts @@ -75,6 +75,11 @@ export type ArchiveRuntimeTaskResult = { export type RefreshWorkLists = (options?: { syncCloud?: boolean }) => Promise +export type RefreshDevices = (options?: { + useCacheFallback?: boolean + syncCloud?: boolean +}) => Promise + export type ArchiveRuntimeConversationsResult = ArchiveRuntimeTaskResult export interface SendCurrentInputOptions { @@ -232,7 +237,7 @@ export interface WorkbenchContextValue { ) => Promise rememberExecutionDevice: (deviceId: string) => void refreshWorkLists: RefreshWorkLists - refreshDevices: () => Promise + refreshDevices: RefreshDevices getRemoteDeviceStartupCommand: () => Promise upgradeDevice: (deviceId: string) => Promise createProject: ( From 500347e689cc72b3107293d088e843cca5fcf992 Mon Sep 17 00:00:00 2001 From: axb Date: Wed, 5 Aug 2026 15:39:01 +0800 Subject: [PATCH 6/7] fix(wework): clear stuck cloud sync state when interrupted --- .../workbench/WorkbenchProvider.test.tsx | 65 +++++++++++++++++++ .../workbench/useWorkbenchDataRefresh.ts | 14 ++-- .../workbench/workbenchCloudStatus.ts | 40 ++++++++++++ 3 files changed, 112 insertions(+), 7 deletions(-) diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index 699774e626..2be3ed7434 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -2517,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: [ diff --git a/wework/src/features/workbench/useWorkbenchDataRefresh.ts b/wework/src/features/workbench/useWorkbenchDataRefresh.ts index cdc6369ed2..d0aa7ef201 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, @@ -93,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( @@ -336,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', @@ -348,7 +346,6 @@ export function useWorkbenchDataRefresh({ !isCurrentRefresh() || options?.isCancelled?.() || devicesResult.status !== 'fulfilled' || - cloudRuntimeStateRef.current.inFlightRevision !== inFlightRevision || cloudBackgroundApiRef.current !== inFlightBackgroundApi ) { return @@ -437,6 +434,9 @@ export function useWorkbenchDataRefresh({ cloudRuntimeStateRef.current.inFlightRevision !== revision || cloudBackgroundApiRef.current !== backgroundApi ) { + if (revision != null) { + updateCloudRuntimeState(abandonCloudRuntimeSync(cloudRuntimeStateRef.current, revision)) + } return } 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, From 8063c234c3175e7f5259d775b2a33619354bb77d Mon Sep 17 00:00:00 2001 From: axb Date: Wed, 5 Aug 2026 15:47:52 +0800 Subject: [PATCH 7/7] refactor(wework): drop unrelated cloud sync scope from archive fix --- .../workbench/WorkbenchProvider.test.tsx | 117 ------------------ .../features/workbench/WorkbenchProvider.tsx | 2 +- .../workbench/useWorkbenchDataRefresh.ts | 14 +-- .../workbench/useWorkbenchDeviceUpgrades.ts | 4 +- .../workbench/workbenchContextTypes.ts | 7 +- 5 files changed, 10 insertions(+), 134 deletions(-) diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index 2be3ed7434..d0cc0c3ea0 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -2872,39 +2872,6 @@ describe('WorkbenchProvider runtime tasks', () => { expect(screen.getByTestId('device-status')).toHaveTextContent('online') }) - test('does not trigger a cloud sync for socket device events', async () => { - let streamHandlers: ChatStreamHandlers = {} - const subscribe = vi.fn((handlers: ChatStreamHandlers) => { - streamHandlers = handlers - return vi.fn() - }) - const cloudListRuntimeWork = vi.fn().mockResolvedValue({ - projects: [], - chats: [], - totalTasks: 0, - }) - const services = createWorkbenchServices({ - chatStream: { - subscribe, - } as unknown as WorkbenchServices['chatStream'], - cloudBackgroundApi: { - listTeams: vi.fn().mockResolvedValue([]), - listDevices: vi.fn().mockResolvedValue([createDevice()]), - listRuntimeWork: cloudListRuntimeWork, - }, - }) - - renderWorkbench(, services) - - await waitFor(() => expect(cloudListRuntimeWork).toHaveBeenCalledTimes(1)) - - await act(async () => { - streamHandlers.onDeviceSlotUpdate?.({ device_id: 'device-1' }) - }) - - expect(cloudListRuntimeWork).toHaveBeenCalledTimes(1) - }) - test('keeps the last confirmed online state when an offline event refresh fails', async () => { let streamHandlers: ChatStreamHandlers = {} const subscribe = vi.fn((handlers: ChatStreamHandlers) => { @@ -6718,90 +6685,6 @@ describe('WorkbenchProvider runtime tasks', () => { ) }) - test('does not trigger a cloud sync for stream events on an archived task', 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, - } - let streamHandlers: ChatStreamHandlers = {} - const subscribe = vi.fn((handlers: ChatStreamHandlers) => { - if (hasRuntimeStreamHandler(handlers)) streamHandlers = handlers - return vi.fn() - }) - const cloudListRuntimeWork = vi.fn().mockResolvedValue(remoteRuntimeWork) - const runtimeWorkApi = createRuntimeWorkApiMock({ - listRuntimeWork: vi.fn().mockResolvedValue({ projects: [], chats: [], totalTasks: 0 }), - }) - const services = createWorkbenchServices({ - runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], - chatStream: { - subscribe, - } as unknown as WorkbenchServices['chatStream'], - 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: cloudListRuntimeWork, - }, - }) - - renderWorkbench(, services) - - await waitFor(() => - expect(screen.getByTestId('archive-remote-task-titles')).toHaveTextContent('Remote task') - ) - await userEvent.click(screen.getByText('archive remote task')) - await waitFor(() => expect(runtimeWorkApi.archiveConversation).toHaveBeenCalledTimes(1)) - await waitFor(() => - expect(screen.getByTestId('archive-remote-task-titles')).toHaveTextContent('') - ) - expect(cloudListRuntimeWork).toHaveBeenCalledTimes(1) - - expect(typeof streamHandlers.onRuntimeGoalCleared).toBe('function') - await act(async () => { - streamHandlers.onRuntimeGoalCleared!({ - deviceId: 'remote-device', - taskId: 'remote-task', - }) - }) - - expect(cloudListRuntimeWork).toHaveBeenCalledTimes(1) - 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 1efbfc5525..5ccc74d5ca 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -1215,7 +1215,7 @@ export function WorkbenchProvider({ ) const stableRefreshWorkLists = useStableEvent(refreshWorkLists) const refreshRuntimeWorkLists = useStableEvent((address: RuntimeTaskAddress) => { - void stableRefreshWorkLists({ syncCloud: false }).catch(error => { + void stableRefreshWorkLists().catch(error => { console.warn('[Wework] Runtime work list refresh failed', { deviceId: address.deviceId, taskId: address.taskId, diff --git a/wework/src/features/workbench/useWorkbenchDataRefresh.ts b/wework/src/features/workbench/useWorkbenchDataRefresh.ts index d0aa7ef201..4cc317b09f 100644 --- a/wework/src/features/workbench/useWorkbenchDataRefresh.ts +++ b/wework/src/features/workbench/useWorkbenchDataRefresh.ts @@ -766,20 +766,18 @@ export function useWorkbenchDataRefresh({ ) const refreshDevices = useCallback( - async (options?: { useCacheFallback?: boolean; syncCloud?: boolean }) => { + async (options?: { useCacheFallback?: boolean }) => { const devices = await loadDevicesForRefresh(options) dispatch({ type: 'devices_refreshed', devices, standaloneDeviceId: getPreferredStandaloneDeviceId(devices, state.standaloneDeviceId), }) - if (options?.syncCloud !== false) { - void refreshCloudBackgroundData(devices, state.runtimeWork ?? EMPTY_RUNTIME_WORK, { - projects: state.projects, - standaloneDeviceId: state.standaloneDeviceId, - trigger: 'manual-refresh', - }).catch(() => undefined) - } + void refreshCloudBackgroundData(devices, state.runtimeWork ?? EMPTY_RUNTIME_WORK, { + projects: state.projects, + standaloneDeviceId: state.standaloneDeviceId, + trigger: 'manual-refresh', + }).catch(() => undefined) }, [ dispatch, diff --git a/wework/src/features/workbench/useWorkbenchDeviceUpgrades.ts b/wework/src/features/workbench/useWorkbenchDeviceUpgrades.ts index d8e9cacabd..b17c1848d3 100644 --- a/wework/src/features/workbench/useWorkbenchDeviceUpgrades.ts +++ b/wework/src/features/workbench/useWorkbenchDeviceUpgrades.ts @@ -24,7 +24,7 @@ interface UseWorkbenchDeviceUpgradesOptions { dispatch: Dispatch executorClient: ExecutorClient services: WorkbenchServices - refreshDevices: (options?: { useCacheFallback?: boolean; syncCloud?: boolean }) => Promise + refreshDevices: (options?: { useCacheFallback?: boolean }) => Promise } export function useWorkbenchDeviceUpgrades({ @@ -118,7 +118,7 @@ export function useWorkbenchDeviceUpgrades({ useEffect(() => { const refreshDevicesAfterEvent = () => { - void refreshDevices({ useCacheFallback: false, syncCloud: false }).catch(() => undefined) + void refreshDevices({ useCacheFallback: false }).catch(() => undefined) } const handleDeviceOnline = (payload: unknown) => { const deviceId = getDeviceEventId(payload) diff --git a/wework/src/features/workbench/workbenchContextTypes.ts b/wework/src/features/workbench/workbenchContextTypes.ts index f752d11d6a..61bff45182 100644 --- a/wework/src/features/workbench/workbenchContextTypes.ts +++ b/wework/src/features/workbench/workbenchContextTypes.ts @@ -75,11 +75,6 @@ export type ArchiveRuntimeTaskResult = { export type RefreshWorkLists = (options?: { syncCloud?: boolean }) => Promise -export type RefreshDevices = (options?: { - useCacheFallback?: boolean - syncCloud?: boolean -}) => Promise - export type ArchiveRuntimeConversationsResult = ArchiveRuntimeTaskResult export interface SendCurrentInputOptions { @@ -237,7 +232,7 @@ export interface WorkbenchContextValue { ) => Promise rememberExecutionDevice: (deviceId: string) => void refreshWorkLists: RefreshWorkLists - refreshDevices: RefreshDevices + refreshDevices: () => Promise getRemoteDeviceStartupCommand: () => Promise upgradeDevice: (deviceId: string) => Promise createProject: (