Skip to content

fix(desktop): refresh renderer stores on workspace change (Closes #404)#541

Closed
advancedresearcharray wants to merge 1 commit into
clawwork-ai:mainfrom
advancedresearcharray:fix/issue-404-workspace-store-refresh
Closed

fix(desktop): refresh renderer stores on workspace change (Closes #404)#541
advancedresearcharray wants to merge 1 commit into
clawwork-ai:mainfrom
advancedresearcharray:fix/issue-404-workspace-store-refresh

Conversation

@advancedresearcharray

Copy link
Copy Markdown
Contributor

Summary

  • Add app-level useWorkspaceRefresh hook subscribed to workspace:changed IPC so store refresh runs regardless of which view is open.
  • Clear all workspace-scoped Zustand stores (tasks, messages, rooms, teams, artifacts, gateway catalogs, dashboard, usage, approvals) and reset session-sync hydration via resetHydration() / syncEpoch.
  • Re-run 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 passed
  • Regression test for store clearing and bootstrap re-init (workspace-refresh.test.ts)
  • Regression tests for resetHydration aborting in-flight sync and allowing re-hydration (session-sync.test.ts)
  • Manual: change workspace to a directory with a different task list; confirm task list and messages update without relaunch

Made with Cursor

)

Signed-off-by: advancedresearcharray <advancedresearcharray@users.noreply.github.com>
Co-authored-by: root <root@array-fleet-ops.array.local>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, 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

  • Workspace Refresh Mechanism: Introduced a new useWorkspaceRefresh hook that listens for workspace:changed IPC events to trigger a full renderer state reset.
  • State Reset and Hydration: Implemented resetHydration in session-sync to clear in-flight operations and added a syncEpoch to prevent stale data writes when switching workspaces.
  • Store Cleanup: Added a comprehensive cleanup utility that resets all workspace-scoped Zustand stores (tasks, messages, rooms, teams, artifacts, etc.) to ensure a clean state after a workspace switch.
Using Gemini Code Assist

The 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 /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

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 .gemini/ folder in the base of the repository. Detailed instructions can be found here.

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

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions

Copy link
Copy Markdown
Contributor

Hi @advancedresearcharray,
Thanks for your pull request!
If the PR is ready, use the /auto-cc command to assign Reviewer to Review.
We will review it shortly.

Details

Instructions for interacting with me using comments are available here.
If you have questions or suggestions related to my behavior, please file an issue against the gh-ci-bot repository.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines 322 to 339
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);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:

  1. syncSessionMessages is called and queues a job in syncChains (e.g., behind an existing promise).
  2. resetHydration() is called (e.g., due to a workspace switch), which increments syncEpoch to 1 and clears syncChains.
  3. The promise chain resolves, and the queued job finally executes syncWithRetry.
  4. syncWithRetry starts and captures const epoch = syncEpoch; which is now 1 (the new epoch).
  5. The job runs with epoch = 1 and syncEpoch = 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);
    }
  }

Comment on lines +17 to +79
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));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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));
}

@advancedresearcharray

Copy link
Copy Markdown
Contributor Author

Superseded by #551 — fleet duplicate gate.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] changeWorkspace does not refresh renderer stores, leaving UI with stale data

1 participant