fix(desktop): refresh renderer stores on workspace change (Closes #404)#541
Conversation
Summary of ChangesHello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! This pull request addresses issue #404 by ensuring that the application state is correctly cleared and re-initialized when a user switches workspaces. By introducing a centralized refresh mechanism and epoch-based synchronization, the changes prevent stale data from persisting across workspace boundaries and ensure that the UI correctly loads data from the newly selected SQLite workspace. Highlights
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here. Footnotes
|
|
Hi @advancedresearcharray, DetailsInstructions for interacting with me using comments are available here. |
There was a problem hiding this comment.
Code Review
This pull request introduces a centralized workspace refresh mechanism that resets workspace-scoped Zustand stores, reinitializes the gateway bootstrap, and increments a synchronization epoch to abort stale, in-flight operations during workspace transitions. The review feedback highlights two potential race conditions: first, queued sync jobs could capture a new epoch after a reset and perform cross-workspace writes, which can be solved by capturing the epoch when the sync is requested; second, concurrent workspace refreshes could interleave, which can be prevented by tracking an active refresh ID.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| async function syncWithRetry(taskId: string, sessionKeyOverride?: string): Promise<void> { | ||
| const epoch = syncEpoch; | ||
| for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) { | ||
| if (syncEpoch !== epoch) return; | ||
| try { | ||
| await doSyncSession(taskId, sessionKeyOverride); | ||
| await doSyncSession(taskId, sessionKeyOverride, epoch); | ||
| return; | ||
| } catch { | ||
| if (attempt < RETRY_DELAYS.length) { | ||
| await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt])); | ||
| if (syncEpoch !== epoch) return; | ||
| } | ||
| } | ||
| } | ||
| console.warn('[sync] syncSessionMessages exhausted retries for task', taskId); | ||
| if (syncEpoch === epoch) { | ||
| console.warn('[sync] syncSessionMessages exhausted retries for task', taskId); | ||
| } | ||
| } |
There was a problem hiding this comment.
Race Condition in Queued Sync Jobs
There is a race condition where a queued sync job can capture the new epoch after a workspace reset, allowing it to run and potentially perform cross-workspace writes.
How it happens:
syncSessionMessagesis called and queues a job insyncChains(e.g., behind an existing promise).resetHydration()is called (e.g., due to a workspace switch), which incrementssyncEpochto1and clearssyncChains.- The promise chain resolves, and the queued job finally executes
syncWithRetry. syncWithRetrystarts and capturesconst epoch = syncEpoch;which is now1(the new epoch).- The job runs with
epoch = 1andsyncEpoch = 1, bypassing the epoch checks and writing stale data to the new workspace.
Solution:
To prevent this, syncSessionMessages should capture the epoch at the moment the sync is requested, and pass it down to syncWithRetry so that any queued job that executes after a reset is correctly aborted.
async function syncWithRetry(taskId: string, sessionKeyOverride?: string, epoch = syncEpoch): Promise<void> {
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
if (syncEpoch !== epoch) return;
try {
await doSyncSession(taskId, sessionKeyOverride, epoch);
return;
} catch {
if (attempt < RETRY_DELAYS.length) {
await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt]));
if (syncEpoch !== epoch) return;
}
}
}
if (syncEpoch === epoch) {
console.warn('[sync] syncSessionMessages exhausted retries for task', taskId);
}
}| export async function refreshRendererAfterWorkspaceChange(): Promise<void> { | ||
| await systemSessionService.end().catch(() => {}); | ||
|
|
||
| useTaskStore.setState({ | ||
| tasks: [], | ||
| activeTaskId: null, | ||
| hydrated: false, | ||
| pendingNewTask: null, | ||
| }); | ||
|
|
||
| useMessageStore.setState({ | ||
| messagesByTask: {}, | ||
| activeTurnBySession: {}, | ||
| processingBySession: new Set(), | ||
| highlightedMessageId: null, | ||
| }); | ||
|
|
||
| useRoomStore.setState({ rooms: {}, subagentKeyMap: {} }); | ||
| useTeamStore.setState({ teams: {}, loading: false, loadedOnce: false }); | ||
|
|
||
| useFileStore.setState({ | ||
| artifacts: [], | ||
| selectedArtifactId: null, | ||
| searchQuery: '', | ||
| searchResults: null, | ||
| isSearching: false, | ||
| typeFilter: 'all', | ||
| }); | ||
|
|
||
| useDashboardStore.getState().clear(); | ||
| useUsageStore.getState().clear(); | ||
| useApprovalStore.getState().clear(); | ||
|
|
||
| useUiStore.setState({ | ||
| gatewayStatusMap: {}, | ||
| gatewayVersionMap: {}, | ||
| gatewayReconnectInfo: {}, | ||
| gatewaysLoaded: false, | ||
| defaultGatewayId: null, | ||
| gatewayInfoMap: {}, | ||
| unreadTaskIds: new Set(), | ||
| modelCatalogByGateway: {}, | ||
| agentCatalogByGateway: {}, | ||
| toolsCatalogByGateway: {}, | ||
| skillsStatusByGateway: {}, | ||
| commandCatalogByGateway: {}, | ||
| }); | ||
|
|
||
| resetHydration(); | ||
|
|
||
| await useSettingsStore | ||
| .getState() | ||
| .refresh() | ||
| .catch((err) => console.error('[workspace-refresh] Failed to refresh settings:', err)); | ||
| await hydrateFromLocal().catch((err) => console.error('[workspace-refresh] Failed to hydrate from local:', err)); | ||
| await reinitializeGatewayBootstrap().catch((err) => | ||
| console.error('[workspace-refresh] Failed to reinitialize gateway bootstrap:', err), | ||
| ); | ||
| await useTeamStore | ||
| .getState() | ||
| .loadTeams() | ||
| .catch((err) => console.error('[workspace-refresh] Failed to load teams:', err)); | ||
| } |
There was a problem hiding this comment.
Prevent Race Conditions from Concurrent Workspace Refreshes
If the user triggers a workspace change multiple times in rapid succession, or if multiple workspace:changed IPC events are received concurrently, the asynchronous steps in refreshRendererAfterWorkspaceChange (such as refresh(), hydrateFromLocal(), reinitializeGatewayBootstrap(), and loadTeams()) can interleave. This can lead to stale data being loaded or gateway bootstrap being initialized multiple times.
Solution:
We can introduce a module-level activeRefreshId counter and check it after each await point to ensure that only the latest workspace refresh is allowed to complete.
let activeRefreshId = 0;
export async function refreshRendererAfterWorkspaceChange(): Promise<void> {
const refreshId = ++activeRefreshId;
await systemSessionService.end().catch(() => {});
if (refreshId !== activeRefreshId) return;
useTaskStore.setState({
tasks: [],
activeTaskId: null,
hydrated: false,
pendingNewTask: null,
});
useMessageStore.setState({
messagesByTask: {},
activeTurnBySession: {},
processingBySession: new Set(),
highlightedMessageId: null,
});
useRoomStore.setState({ rooms: {}, subagentKeyMap: {} });
useTeamStore.setState({ teams: {}, loading: false, loadedOnce: false });
useFileStore.setState({
artifacts: [],
selectedArtifactId: null,
searchQuery: '',
searchResults: null,
isSearching: false,
typeFilter: 'all',
});
useDashboardStore.getState().clear();
useUsageStore.getState().clear();
useApprovalStore.getState().clear();
useUiStore.setState({
gatewayStatusMap: {},
gatewayVersionMap: {},
gatewayReconnectInfo: {},
gatewaysLoaded: false,
defaultGatewayId: null,
gatewayInfoMap: {},
unreadTaskIds: new Set(),
modelCatalogByGateway: {},
agentCatalogByGateway: {},
toolsCatalogByGateway: {},
skillsStatusByGateway: {},
commandCatalogByGateway: {},
});
resetHydration();
await useSettingsStore
.getState()
.refresh()
.catch((err) => console.error('[workspace-refresh] Failed to refresh settings:', err));
if (refreshId !== activeRefreshId) return;
await hydrateFromLocal().catch((err) => console.error('[workspace-refresh] Failed to hydrate from local:', err));
if (refreshId !== activeRefreshId) return;
await reinitializeGatewayBootstrap().catch((err) =>
console.error('[workspace-refresh] Failed to reinitialize gateway bootstrap:', err),
);
if (refreshId !== activeRefreshId) return;
await useTeamStore
.getState()
.loadTeams()
.catch((err) => console.error('[workspace-refresh] Failed to load teams:', err));
}|
Superseded by #551 — fleet duplicate gate. |
Summary
useWorkspaceRefreshhook subscribed toworkspace:changedIPC so store refresh runs regardless of which view is open.resetHydration()/syncEpoch.hydrateFromLocal, gateway bootstrap reinitialization, and team reload so the UI loads data from the new SQLite workspace in place.Closes #404
Test plan
npx vitest run test/workspace-refresh.test.ts test/session-sync.test.ts— 15/15 passedworkspace-refresh.test.ts)resetHydrationaborting in-flight sync and allowing re-hydration (session-sync.test.ts)Made with Cursor