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