diff --git a/src/main/services/team/TeamLaunchStateStore.ts b/src/main/services/team/TeamLaunchStateStore.ts index bb67cd8b..dac0afff 100644 --- a/src/main/services/team/TeamLaunchStateStore.ts +++ b/src/main/services/team/TeamLaunchStateStore.ts @@ -24,13 +24,24 @@ export function getTeamLaunchSummaryPath(teamName: string): string { return path.join(getTeamsBasePath(), teamName, TEAM_LAUNCH_SUMMARY_FILE); } -async function isMissingTeamDirectoryWriteRace(teamName: string, error: unknown): Promise { +async function isMissingTeamDirectoryWriteRace( + targetPath: string, + error: unknown +): Promise { const code = (error as NodeJS.ErrnoException).code; if (code !== 'ENOENT' && code !== 'EINVAL') { return false; } + const targetDir = path.dirname(targetPath); + const errorPaths = [ + (error as NodeJS.ErrnoException).path, + (error as NodeJS.ErrnoException & { dest?: string }).dest, + ].filter((value): value is string => typeof value === 'string' && value.length > 0); + if (code === 'ENOENT' && errorPaths.some((errorPath) => path.dirname(errorPath) === targetDir)) { + return true; + } try { - await fs.promises.access(path.dirname(getTeamLaunchStatePath(teamName))); + await fs.promises.access(targetDir); return false; } catch { return true; @@ -53,17 +64,16 @@ export class TeamLaunchStateStore { } async write(teamName: string, snapshot: PersistedTeamLaunchSnapshot): Promise { + const launchStatePath = getTeamLaunchStatePath(teamName); + const launchSummaryPath = getTeamLaunchSummaryPath(teamName); try { + await atomicWriteAsync(launchStatePath, `${JSON.stringify(snapshot, null, 2)}\n`); await atomicWriteAsync( - getTeamLaunchStatePath(teamName), - `${JSON.stringify(snapshot, null, 2)}\n` - ); - await atomicWriteAsync( - getTeamLaunchSummaryPath(teamName), + launchSummaryPath, `${JSON.stringify(createPersistedLaunchSummaryProjection(snapshot), null, 2)}\n` ); } catch (error) { - if (await isMissingTeamDirectoryWriteRace(teamName, error)) { + if (await isMissingTeamDirectoryWriteRace(launchStatePath, error)) { return; } logger.warn( diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index 144c00c4..6798b9d5 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -11962,8 +11962,8 @@ describe('Team agent launch matrix safe e2e', () => { svc.stopTeam(teamName); await waitForCondition(() => adapter.stopInputs.length === 2); - expect(staleKillCount).toBe(0); - expect(currentKillCount).toBe(1); + expectDirectChildKillCount(staleKillCount, 0); + expectDirectChildKillCount(currentKillCount, 1); expect(staleRun.cancelRequested).toBe(false); expect(currentRun.cancelRequested).toBe(true); expect(adapter.stopInputs.map((input) => input.laneId).sort()).toEqual([ @@ -12019,8 +12019,8 @@ describe('Team agent launch matrix safe e2e', () => { await svc.cancelProvisioning(staleRun.runId); - expect(staleKillCount).toBe(1); - expect(currentKillCount).toBe(0); + expectDirectChildKillCount(staleKillCount, 1); + expectDirectChildKillCount(currentKillCount, 0); expect(staleRun.cancelRequested).toBe(true); expect(currentRun.cancelRequested).toBe(false); expect(adapter.stopInputs).toEqual([]); @@ -12085,8 +12085,8 @@ describe('Team agent launch matrix safe e2e', () => { svc.stopTeam(teamName); - expect(staleKillCount).toBe(0); - expect(currentKillCount).toBe(1); + expectDirectChildKillCount(staleKillCount, 0); + expectDirectChildKillCount(currentKillCount, 1); expect(staleRun.cancelRequested).toBe(false); expect(currentRun.cancelRequested).toBe(true); expect(await svc.getRuntimeState(teamName)).toMatchObject({ @@ -12120,8 +12120,8 @@ describe('Team agent launch matrix safe e2e', () => { await svc.cancelProvisioning(staleRun.runId); - expect(staleKillCount).toBe(1); - expect(currentKillCount).toBe(0); + expectDirectChildKillCount(staleKillCount, 1); + expectDirectChildKillCount(currentKillCount, 0); expect(staleRun.cancelRequested).toBe(true); expect(currentRun.cancelRequested).toBe(false); expect(svc.isTeamAlive(teamName)).toBe(true); @@ -12157,8 +12157,8 @@ describe('Team agent launch matrix safe e2e', () => { await svc.cancelProvisioning(currentRun.runId); - expect(staleKillCount).toBe(0); - expect(currentKillCount).toBe(1); + expectDirectChildKillCount(staleKillCount, 0); + expectDirectChildKillCount(currentKillCount, 1); expect(staleRun.cancelRequested).toBe(false); expect(currentRun.cancelRequested).toBe(true); expect(svc.isTeamAlive(teamName)).toBe(false); @@ -16465,6 +16465,11 @@ function trackLiveRun(svc: TeamProvisioningService, run: any): void { (svc as any).aliveRunByTeam.set(run.teamName, run.runId); } +function expectDirectChildKillCount(actual: number, expected: number): void { + // Windows uses taskkill.exe for process-tree termination, so fake child.kill is not called. + expect(actual).toBe(process.platform === 'win32' ? 0 : expected); +} + function injectStaleTerminalProvisioningRun( svc: TeamProvisioningService, teamName: string, diff --git a/test/main/utils/electronUserDataMigration.test.ts b/test/main/utils/electronUserDataMigration.test.ts index d7e4cb84..2b0fe945 100644 --- a/test/main/utils/electronUserDataMigration.test.ts +++ b/test/main/utils/electronUserDataMigration.test.ts @@ -69,13 +69,14 @@ describe('electron userData migration', () => { } it('derives legacy candidates beside the current Electron userData directory', () => { - expect( - getLegacyElectronUserDataCandidates('/Users/me/Library/Application Support/Agent Teams UI') - ).toEqual([ - '/Users/me/Library/Application Support/Claude Agent Teams UI', - '/Users/me/Library/Application Support/claude-agent-teams-ui', - '/Users/me/Library/Application Support/claude-devtools', - '/Users/me/Library/Application Support/claude-code-context', + const currentPath = path.join('/Users/me/Library/Application Support', 'Agent Teams UI'); + const parentPath = path.dirname(currentPath); + + expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([ + path.join(parentPath, 'Claude Agent Teams UI'), + path.join(parentPath, 'claude-agent-teams-ui'), + path.join(parentPath, 'claude-devtools'), + path.join(parentPath, 'claude-code-context'), ]); });