fix(server): reconcile stale workspaces by pruning missing directories and fully-archived agents

Workspaces whose directories were deleted (e.g. cleaned-up worktrees) and
workspaces where every agent has been archived now get auto-archived during
reconciliation, cascading to project archival when no active siblings remain.

Extracts detectStaleWorkspaces() as a pure function in workspace-registry-model.ts
with standalone unit tests.
This commit is contained in:
Mohamed Boudra
2026-03-21 13:04:57 +07:00
parent a042fbbe43
commit 2003f308f3
4 changed files with 413 additions and 37 deletions
@@ -68,7 +68,7 @@ function createSessionForWorkspaceTests(): Session {
error: vi.fn(),
}
return new Session({
const session = new Session({
clientId: 'test-client',
onMessage: vi.fn(),
logger: logger as any,
@@ -108,7 +108,8 @@ function createSessionForWorkspaceTests(): Session {
stt: null,
tts: null,
terminalManager: null,
})
}) as any
return session
}
describe('workspace aggregation', () => {
@@ -528,11 +529,14 @@ describe('workspace aggregation', () => {
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>()
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>()
const mainWorkspaceId = '/tmp/inkwell'
const worktreeWorkspaceId = '/tmp/inkwell/.paseo/worktrees/feature-a'
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), 'session-workspace-reconcile-')))
const mainWorkspaceId = path.join(tempDir, 'inkwell')
const worktreeWorkspaceId = path.join(mainWorkspaceId, '.paseo', 'worktrees', 'feature-a')
const localProjectId = mainWorkspaceId
const remoteProjectId = 'remote:github.com/zimakki/inkwell'
execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`)
projects.set(
localProjectId,
createPersistedProjectRecord({
@@ -595,25 +599,29 @@ describe('workspace aggregation', () => {
},
})
await session.handleMessage({
type: 'open_project_request',
cwd: worktreeWorkspaceId,
requestId: 'req-open-worktree',
})
try {
await session.handleMessage({
type: 'open_project_request',
cwd: worktreeWorkspaceId,
requestId: 'req-open-worktree',
})
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(remoteProjectId)
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId)
expect(projects.get(localProjectId)?.archivedAt).toBeTruthy()
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(remoteProjectId)
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(remoteProjectId)
expect(projects.get(localProjectId)?.archivedAt).toBeTruthy()
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
expect(workspaceUpdates).toHaveLength(2)
expect(workspaceUpdates.map((message) => message.payload.workspace.id).sort()).toEqual([
mainWorkspaceId,
worktreeWorkspaceId,
])
expect(
workspaceUpdates.every((message) => message.payload.workspace.projectId === remoteProjectId)
).toBe(true)
const workspaceUpdates = emitted.filter((message) => message.type === 'workspace_update') as any[]
expect(workspaceUpdates).toHaveLength(2)
expect(workspaceUpdates.map((message) => message.payload.workspace.id).sort()).toEqual([
mainWorkspaceId,
worktreeWorkspaceId,
])
expect(
workspaceUpdates.every((message) => message.payload.workspace.projectId === remoteProjectId)
).toBe(true)
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
test('fetch_workspaces_request reconciles remote URL changes for existing workspaces', async () => {
@@ -621,11 +629,14 @@ describe('workspace aggregation', () => {
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>()
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>()
const mainWorkspaceId = '/tmp/inkwell'
const worktreeWorkspaceId = '/tmp/inkwell/.paseo/worktrees/feature-a'
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), 'session-workspace-fetch-')))
const mainWorkspaceId = path.join(tempDir, 'inkwell')
const worktreeWorkspaceId = path.join(mainWorkspaceId, '.paseo', 'worktrees', 'feature-a')
const oldProjectId = 'remote:github.com/old-owner/inkwell'
const newProjectId = 'remote:github.com/new-owner/inkwell'
execSync(`mkdir -p ${JSON.stringify(worktreeWorkspaceId)}`)
projects.set(
oldProjectId,
createPersistedProjectRecord({
@@ -687,14 +698,18 @@ describe('workspace aggregation', () => {
},
})
const result = await session.listFetchWorkspacesEntries({
type: 'fetch_workspaces_request',
requestId: 'req-fetch-reconcile',
})
try {
const result = await session.listFetchWorkspacesEntries({
type: 'fetch_workspaces_request',
requestId: 'req-fetch-reconcile',
})
expect(result.entries.map((entry: any) => entry.projectId)).toEqual([newProjectId, newProjectId])
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(newProjectId)
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(newProjectId)
expect(projects.get(oldProjectId)?.archivedAt).toBeTruthy()
expect(result.entries.map((entry: any) => entry.projectId)).toEqual([newProjectId, newProjectId])
expect(workspaces.get(mainWorkspaceId)?.projectId).toBe(newProjectId)
expect(workspaces.get(worktreeWorkspaceId)?.projectId).toBe(newProjectId)
expect(projects.get(oldProjectId)?.archivedAt).toBeTruthy()
} finally {
rmSync(tempDir, { recursive: true, force: true })
}
})
})