From dba6a88a28c3a3dfdd47897776f9ba01243089e4 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Sun, 8 Mar 2026 20:54:43 +0100 Subject: [PATCH] fix: resolve workspace teardown race with persistence fence --- v2/src/lib/agent-dispatcher.test.ts | 8 ++++++++ v2/src/lib/agent-dispatcher.ts | 13 ++++++++++++ .../components/Workspace/SettingsTab.svelte | 20 +++++++++---------- v2/src/lib/stores/workspace.svelte.ts | 4 ++++ v2/src/lib/stores/workspace.test.ts | 4 ++++ 5 files changed, 39 insertions(+), 10 deletions(-) diff --git a/v2/src/lib/agent-dispatcher.test.ts b/v2/src/lib/agent-dispatcher.test.ts index afd2833..53840a3 100644 --- a/v2/src/lib/agent-dispatcher.test.ts +++ b/v2/src/lib/agent-dispatcher.test.ts @@ -191,6 +191,7 @@ import { stopAgentDispatcher, isSidecarAlive, setSidecarAlive, + waitForPendingPersistence, } from './agent-dispatcher'; // Stop any previous dispatcher between tests so `unlistenMsg` is null and start works @@ -601,4 +602,11 @@ describe('agent-dispatcher', () => { })); }); }); + + describe('waitForPendingPersistence', () => { + it('resolves immediately when no persistence is in-flight', async () => { + vi.useRealTimers(); + await expect(waitForPendingPersistence()).resolves.toBeUndefined(); + }); + }); }); diff --git a/v2/src/lib/agent-dispatcher.ts b/v2/src/lib/agent-dispatcher.ts index e4e91c8..3861ba7 100644 --- a/v2/src/lib/agent-dispatcher.ts +++ b/v2/src/lib/agent-dispatcher.ts @@ -30,6 +30,9 @@ let unlistenExit: (() => void) | null = null; // Map sessionId -> projectId for persistence routing const sessionProjectMap = new Map(); +// In-flight persistence counter — prevents teardown from racing with async saves +let pendingPersistCount = 0; + export function registerSessionProject(sessionId: string, projectId: string): void { sessionProjectMap.set(sessionId, projectId); } @@ -273,6 +276,13 @@ function spawnSubagentPane(parentSessionId: string, tc: ToolCallContent): void { } } +/** Wait until all in-flight persistence operations complete */ +export async function waitForPendingPersistence(): Promise { + while (pendingPersistCount > 0) { + await new Promise(r => setTimeout(r, 10)); + } +} + /** Persist session state + messages to SQLite for the project that owns this session */ async function persistSessionForProject(sessionId: string): Promise { const projectId = sessionProjectMap.get(sessionId); @@ -281,6 +291,7 @@ async function persistSessionForProject(sessionId: string): Promise { const session = getAgentSession(sessionId); if (!session) return; + pendingPersistCount++; try { // Save agent state await saveProjectAgentState({ @@ -313,6 +324,8 @@ async function persistSessionForProject(sessionId: string): Promise { } } catch (e) { console.warn('Failed to persist agent session:', e); + } finally { + pendingPersistCount--; } } diff --git a/v2/src/lib/components/Workspace/SettingsTab.svelte b/v2/src/lib/components/Workspace/SettingsTab.svelte index c27a0fb..2ebe569 100644 --- a/v2/src/lib/components/Workspace/SettingsTab.svelte +++ b/v2/src/lib/components/Workspace/SettingsTab.svelte @@ -444,7 +444,7 @@
{#each groups as group}
- {group.projects.length} projects @@ -764,14 +764,14 @@ display: inline-block; width: 14px; height: 14px; - border-radius: 3px; + border-radius: 0.1875rem; border: 1px solid; flex-shrink: 0; } .theme-colors { display: flex; - gap: 3px; + gap: 0.1875rem; flex-shrink: 0; } @@ -786,13 +786,13 @@ .size-control { display: flex; align-items: center; - gap: 2px; + gap: 0.125rem; flex-shrink: 0; } .size-btn { - width: 28px; - height: 28px; + width: 1.75rem; + height: 1.75rem; display: flex; align-items: center; justify-content: center; @@ -814,7 +814,7 @@ } .size-input { - width: 40px; + width: 2.5rem; padding: 0.25rem 0.125rem; background: var(--ctp-surface0); border: 1px solid var(--ctp-surface1); @@ -833,7 +833,7 @@ .size-unit { font-size: 0.7rem; color: var(--ctp-overlay0); - margin-right: 2px; + margin-right: 0.125rem; } /* Groups */ @@ -959,8 +959,8 @@ .toggle-thumb { position: absolute; - top: 2px; - left: 2px; + top: 0.125rem; + left: 0.125rem; width: 0.875rem; height: 0.875rem; background: var(--ctp-text); diff --git a/v2/src/lib/stores/workspace.svelte.ts b/v2/src/lib/stores/workspace.svelte.ts index ea2d5b0..d14dbe4 100644 --- a/v2/src/lib/stores/workspace.svelte.ts +++ b/v2/src/lib/stores/workspace.svelte.ts @@ -1,6 +1,7 @@ import { loadGroups, saveGroups, getCliGroup } from '../adapters/groups-bridge'; import type { GroupsFile, GroupConfig, ProjectConfig } from '../types/groups'; import { clearAllAgentSessions } from '../stores/agents.svelte'; +import { waitForPendingPersistence } from '../agent-dispatcher'; export type WorkspaceTab = 'sessions' | 'docs' | 'context' | 'settings'; @@ -69,6 +70,9 @@ export function setActiveProject(projectId: string | null): void { export async function switchGroup(groupId: string): Promise { if (groupId === activeGroupId) return; + // Wait for any in-flight persistence before clearing state + await waitForPendingPersistence(); + // Teardown: clear terminal tabs and agent sessions for the old group projectTerminals = {}; clearAllAgentSessions(); diff --git a/v2/src/lib/stores/workspace.test.ts b/v2/src/lib/stores/workspace.test.ts index 22b9111..6d15862 100644 --- a/v2/src/lib/stores/workspace.test.ts +++ b/v2/src/lib/stores/workspace.test.ts @@ -30,6 +30,10 @@ vi.mock('../stores/agents.svelte', () => ({ clearAllAgentSessions: vi.fn(), })); +vi.mock('../agent-dispatcher', () => ({ + waitForPendingPersistence: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../adapters/groups-bridge', () => ({ loadGroups: vi.fn().mockImplementation(() => Promise.resolve(mockGroupsData())), saveGroups: vi.fn().mockResolvedValue(undefined),