fix: resolve workspace teardown race with persistence fence

This commit is contained in:
Hibryda 2026-03-08 20:54:43 +01:00
parent a69022756a
commit dba6a88a28
5 changed files with 39 additions and 10 deletions

View file

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

View file

@ -30,6 +30,9 @@ let unlistenExit: (() => void) | null = null;
// Map sessionId -> projectId for persistence routing
const sessionProjectMap = new Map<string, string>();
// 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<void> {
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<void> {
const projectId = sessionProjectMap.get(sessionId);
@ -281,6 +291,7 @@ async function persistSessionForProject(sessionId: string): Promise<void> {
const session = getAgentSession(sessionId);
if (!session) return;
pendingPersistCount++;
try {
// Save agent state
await saveProjectAgentState({
@ -313,6 +324,8 @@ async function persistSessionForProject(sessionId: string): Promise<void> {
}
} catch (e) {
console.warn('Failed to persist agent session:', e);
} finally {
pendingPersistCount--;
}
}

View file

@ -444,7 +444,7 @@
<div class="group-list">
{#each groups as group}
<div class="group-row" class:active={group.id === activeGroupId}>
<button class="group-name" onclick={() => switchGroup(group.id)}>
<button class="group-name" onclick={async () => await switchGroup(group.id)}>
{group.name}
</button>
<span class="group-count">{group.projects.length} projects</span>
@ -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);

View file

@ -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<void> {
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();

View file

@ -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),