- Added logic to remove generated MCP config files when team creation or launch fails due to spawn errors. - Updated tests to verify that MCP config files are correctly removed in failure scenarios, ensuring no leftover configurations persist. - Enhanced error handling in the TeamProvisioningService to maintain clean state during provisioning operations.
327 lines
10 KiB
TypeScript
327 lines
10 KiB
TypeScript
import type { ChildProcess } from 'child_process';
|
|
import { EventEmitter } from 'events';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const hoisted = vi.hoisted(() => ({
|
|
paths: {
|
|
claudeRoot: '',
|
|
teamsBase: '',
|
|
tasksBase: '',
|
|
projectsBase: '',
|
|
},
|
|
}));
|
|
|
|
let tempClaudeRoot = '';
|
|
let tempTeamsBase = '';
|
|
let tempTasksBase = '';
|
|
let tempProjectsBase = '';
|
|
|
|
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
|
ClaudeBinaryResolver: { resolve: vi.fn() },
|
|
}));
|
|
|
|
vi.mock('@main/services/team/TeamTaskReader', () => ({
|
|
TeamTaskReader: class {
|
|
async getTasks() {
|
|
return [];
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock('@main/utils/childProcess', () => ({
|
|
spawnCli: vi.fn(),
|
|
killProcessTree: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
|
|
return {
|
|
...actual,
|
|
getAutoDetectedClaudeBasePath: () => hoisted.paths.claudeRoot,
|
|
getClaudeBasePath: () => hoisted.paths.claudeRoot,
|
|
getHomeDir: () => hoisted.paths.claudeRoot,
|
|
getProjectsBasePath: () => hoisted.paths.projectsBase,
|
|
getTasksBasePath: () => hoisted.paths.tasksBase,
|
|
getTeamsBasePath: () => hoisted.paths.teamsBase,
|
|
};
|
|
});
|
|
|
|
import { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
|
import { spawnCli } from '@main/utils/childProcess';
|
|
|
|
function allowConsoleLogs() {
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
}
|
|
|
|
function createFakeChild(exitCode: number): ChildProcess {
|
|
const child = Object.assign(new EventEmitter(), {
|
|
stdout: null,
|
|
stderr: null,
|
|
stdin: null,
|
|
}) as unknown as ChildProcess;
|
|
setImmediate(() => child.emit('close', exitCode));
|
|
return child;
|
|
}
|
|
|
|
function createRunningChild() {
|
|
return Object.assign(new EventEmitter(), {
|
|
pid: 12345,
|
|
stdin: {
|
|
writable: true,
|
|
write: vi.fn(() => true),
|
|
end: vi.fn(),
|
|
},
|
|
stdout: new EventEmitter(),
|
|
stderr: new EventEmitter(),
|
|
kill: vi.fn(),
|
|
});
|
|
}
|
|
|
|
describe('TeamProvisioningService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-provisioning-'));
|
|
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
|
|
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
|
|
tempProjectsBase = path.join(tempClaudeRoot, 'projects');
|
|
hoisted.paths.claudeRoot = tempClaudeRoot;
|
|
hoisted.paths.teamsBase = tempTeamsBase;
|
|
hoisted.paths.tasksBase = tempTasksBase;
|
|
hoisted.paths.projectsBase = tempProjectsBase;
|
|
fs.mkdirSync(tempTeamsBase, { recursive: true });
|
|
fs.mkdirSync(tempTasksBase, { recursive: true });
|
|
fs.mkdirSync(tempProjectsBase, { recursive: true });
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
try {
|
|
fs.rmSync(tempClaudeRoot, { recursive: true, force: true });
|
|
} catch {
|
|
// ignore temp cleanup failures
|
|
}
|
|
hoisted.paths.claudeRoot = '';
|
|
hoisted.paths.teamsBase = '';
|
|
hoisted.paths.tasksBase = '';
|
|
hoisted.paths.projectsBase = '';
|
|
});
|
|
|
|
describe('warmup', () => {
|
|
it('does not throw when spawnCli rejects', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('C:\\path\\claude');
|
|
let callCount = 0;
|
|
vi.mocked(spawnCli).mockImplementation(() => {
|
|
callCount++;
|
|
if (callCount === 1) {
|
|
throw new Error('spawn EINVAL');
|
|
}
|
|
return createFakeChild(0);
|
|
});
|
|
|
|
const svc = new TeamProvisioningService();
|
|
await expect(svc.warmup()).resolves.not.toThrow();
|
|
expect(spawnCli).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('removes generated MCP config when createTeam spawn fails synchronously', async () => {
|
|
allowConsoleLogs();
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
|
vi.mocked(spawnCli).mockImplementation(() => {
|
|
throw new Error('spawn EINVAL');
|
|
});
|
|
|
|
const mcpConfigBuilder = {
|
|
writeConfigFile: vi.fn(async () => '/mock/mcp-config-create.json'),
|
|
removeConfigFile: vi.fn(async () => {}),
|
|
};
|
|
const membersMetaStore = {
|
|
writeMembers: vi.fn(async () => {}),
|
|
};
|
|
const teamMetaStore = {
|
|
writeMeta: vi.fn(async () => {}),
|
|
deleteMeta: vi.fn(async () => {}),
|
|
};
|
|
|
|
const svc = new TeamProvisioningService(
|
|
undefined,
|
|
undefined,
|
|
membersMetaStore as any,
|
|
undefined,
|
|
mcpConfigBuilder as any,
|
|
teamMetaStore as any
|
|
);
|
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
|
env: { ANTHROPIC_API_KEY: 'test' },
|
|
authSource: 'anthropic_api_key',
|
|
}));
|
|
(svc as any).pathExists = vi.fn(async () => false);
|
|
|
|
await expect(
|
|
svc.createTeam(
|
|
{
|
|
teamName: 'cleanup-team',
|
|
cwd: tempClaudeRoot,
|
|
members: [{ name: 'alice' }],
|
|
},
|
|
() => {}
|
|
)
|
|
).rejects.toThrow('spawn EINVAL');
|
|
|
|
expect(mcpConfigBuilder.writeConfigFile).toHaveBeenCalledWith(tempClaudeRoot);
|
|
expect(mcpConfigBuilder.removeConfigFile).toHaveBeenCalledWith('/mock/mcp-config-create.json');
|
|
expect(teamMetaStore.deleteMeta).toHaveBeenCalledWith('cleanup-team');
|
|
});
|
|
|
|
it('removes generated MCP config when launchTeam spawn fails synchronously', async () => {
|
|
allowConsoleLogs();
|
|
const teamName = 'launch-cleanup-team';
|
|
const teamDir = path.join(tempTeamsBase, teamName);
|
|
fs.mkdirSync(teamDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(teamDir, 'config.json'),
|
|
JSON.stringify({
|
|
name: teamName,
|
|
projectPath: tempClaudeRoot,
|
|
members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'alice' }],
|
|
}),
|
|
'utf8'
|
|
);
|
|
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
|
vi.mocked(spawnCli).mockImplementation(() => {
|
|
throw new Error('launch spawn EINVAL');
|
|
});
|
|
|
|
const mcpConfigBuilder = {
|
|
writeConfigFile: vi.fn(async () => '/mock/mcp-config-launch.json'),
|
|
removeConfigFile: vi.fn(async () => {}),
|
|
};
|
|
const restorePrelaunchConfig = vi.fn(async () => {});
|
|
|
|
const svc = new TeamProvisioningService(
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
mcpConfigBuilder as any
|
|
);
|
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
|
env: { ANTHROPIC_API_KEY: 'test' },
|
|
authSource: 'anthropic_api_key',
|
|
}));
|
|
(svc as any).resolveLaunchExpectedMembers = vi.fn(async () => ({
|
|
members: [{ name: 'alice' }],
|
|
source: 'members-meta',
|
|
warning: undefined,
|
|
}));
|
|
(svc as any).normalizeTeamConfigForLaunch = vi.fn(async () => {});
|
|
(svc as any).assertConfigLeadOnlyForLaunch = vi.fn(async () => {});
|
|
(svc as any).updateConfigProjectPath = vi.fn(async () => {});
|
|
(svc as any).restorePrelaunchConfig = restorePrelaunchConfig;
|
|
(svc as any).pathExists = vi.fn(async () => false);
|
|
|
|
await expect(svc.launchTeam({ teamName, cwd: tempClaudeRoot }, () => {})).rejects.toThrow(
|
|
'launch spawn EINVAL'
|
|
);
|
|
|
|
expect(mcpConfigBuilder.writeConfigFile).toHaveBeenCalledWith(tempClaudeRoot);
|
|
expect(mcpConfigBuilder.removeConfigFile).toHaveBeenCalledWith('/mock/mcp-config-launch.json');
|
|
expect(restorePrelaunchConfig).toHaveBeenCalledWith(teamName);
|
|
});
|
|
|
|
it('regenerates a missing --mcp-config before auth-failure respawn', async () => {
|
|
vi.useFakeTimers();
|
|
allowConsoleLogs();
|
|
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude');
|
|
|
|
const firstChild = createRunningChild();
|
|
const secondChild = createRunningChild();
|
|
vi.mocked(spawnCli)
|
|
.mockImplementationOnce(() => firstChild as any)
|
|
.mockImplementationOnce(() => secondChild as any);
|
|
|
|
const mcpConfigBuilder = {
|
|
writeConfigFile: vi
|
|
.fn()
|
|
.mockResolvedValueOnce('/missing/original-mcp-config.json')
|
|
.mockResolvedValueOnce('/regenerated/mcp-config.json'),
|
|
removeConfigFile: vi.fn(async () => {}),
|
|
};
|
|
const membersMetaStore = {
|
|
writeMembers: vi.fn(async () => {}),
|
|
};
|
|
const teamMetaStore = {
|
|
writeMeta: vi.fn(async () => {}),
|
|
deleteMeta: vi.fn(async () => {}),
|
|
};
|
|
|
|
const svc = new TeamProvisioningService(
|
|
undefined,
|
|
undefined,
|
|
membersMetaStore as any,
|
|
undefined,
|
|
mcpConfigBuilder as any,
|
|
teamMetaStore as any
|
|
);
|
|
(svc as any).buildProvisioningEnv = vi.fn(async () => ({
|
|
env: { ANTHROPIC_API_KEY: 'test' },
|
|
authSource: 'anthropic_api_key',
|
|
}));
|
|
(svc as any).pathExists = vi.fn(async () => false);
|
|
(svc as any).startFilesystemMonitor = vi.fn();
|
|
(svc as any).stopFilesystemMonitor = vi.fn();
|
|
(svc as any).startStallWatchdog = vi.fn();
|
|
(svc as any).stopStallWatchdog = vi.fn();
|
|
(svc as any).attachStdoutHandler = vi.fn();
|
|
(svc as any).attachStderrHandler = vi.fn();
|
|
|
|
const { runId } = await svc.createTeam(
|
|
{
|
|
teamName: 'retry-team',
|
|
cwd: tempClaudeRoot,
|
|
members: [{ name: 'alice' }],
|
|
},
|
|
() => {}
|
|
);
|
|
|
|
const run = (svc as any).runs.get(runId);
|
|
expect(run).toBeTruthy();
|
|
|
|
const mcpFlagIdx = run.spawnContext.args.indexOf('--mcp-config');
|
|
expect(mcpFlagIdx).toBeGreaterThanOrEqual(0);
|
|
run.spawnContext.args[mcpFlagIdx + 1] = path.join(tempClaudeRoot, 'deleted-mcp-config.json');
|
|
run.mcpConfigPath = run.spawnContext.args[mcpFlagIdx + 1];
|
|
run.authRetryInProgress = true;
|
|
|
|
const respawnPromise = (svc as any).respawnAfterAuthFailure(run);
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
await respawnPromise;
|
|
|
|
expect(mcpConfigBuilder.writeConfigFile).toHaveBeenNthCalledWith(2, tempClaudeRoot);
|
|
expect(run.spawnContext.args[mcpFlagIdx + 1]).toBe('/regenerated/mcp-config.json');
|
|
expect(run.mcpConfigPath).toBe('/regenerated/mcp-config.json');
|
|
expect(vi.mocked(spawnCli)).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/mock/claude',
|
|
run.spawnContext.args,
|
|
expect.objectContaining({
|
|
cwd: tempClaudeRoot,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
})
|
|
);
|
|
expect(run.child).toBe(secondChild);
|
|
|
|
if (run.timeoutHandle) {
|
|
clearTimeout(run.timeoutHandle);
|
|
run.timeoutHandle = null;
|
|
}
|
|
});
|
|
});
|