fix(team): ensure MCP config cleanup on spawn failures
- 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.
This commit is contained in:
parent
e1f0d61dc5
commit
93f160d731
3 changed files with 490 additions and 1 deletions
|
|
@ -3203,6 +3203,10 @@ export class TeamProvisioningService {
|
||||||
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
const tasksDir = path.join(getTasksBasePath(), request.teamName);
|
||||||
await fs.promises.rm(teamDir, { recursive: true, force: true }).catch(() => {});
|
await fs.promises.rm(teamDir, { recursive: true, force: true }).catch(() => {});
|
||||||
await fs.promises.rm(tasksDir, { recursive: true, force: true }).catch(() => {});
|
await fs.promises.rm(tasksDir, { recursive: true, force: true }).catch(() => {});
|
||||||
|
if (run.mcpConfigPath) {
|
||||||
|
await this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath).catch(() => {});
|
||||||
|
run.mcpConfigPath = null;
|
||||||
|
}
|
||||||
this.runs.delete(runId);
|
this.runs.delete(runId);
|
||||||
this.provisioningRunByTeam.delete(request.teamName);
|
this.provisioningRunByTeam.delete(request.teamName);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
@ -3639,6 +3643,10 @@ export class TeamProvisioningService {
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (run.mcpConfigPath) {
|
||||||
|
await this.mcpConfigBuilder.removeConfigFile(run.mcpConfigPath).catch(() => {});
|
||||||
|
run.mcpConfigPath = null;
|
||||||
|
}
|
||||||
this.runs.delete(runId);
|
this.runs.delete(runId);
|
||||||
this.provisioningRunByTeam.delete(request.teamName);
|
this.provisioningRunByTeam.delete(request.teamName);
|
||||||
await this.restorePrelaunchConfig(request.teamName);
|
await this.restorePrelaunchConfig(request.teamName);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,41 @@
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import Module from 'module';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => ({
|
||||||
|
electronState: {
|
||||||
|
isPackaged: false,
|
||||||
|
version: '9.9.9-test',
|
||||||
|
},
|
||||||
|
execFileMock: vi.fn(
|
||||||
|
(
|
||||||
|
_file: string,
|
||||||
|
_args: readonly string[],
|
||||||
|
_options:
|
||||||
|
| { encoding?: string; timeout?: number }
|
||||||
|
| ((error: Error | null, stdout: string, stderr: string) => void),
|
||||||
|
callback?: (error: Error | null, stdout: string, stderr: string) => void
|
||||||
|
) => {
|
||||||
|
const cb = typeof _options === 'function' ? _options : callback;
|
||||||
|
cb?.(null, '/mock/node', '');
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
let mockHomeDir = '';
|
let mockHomeDir = '';
|
||||||
|
type ModuleLoad = (request: string, parent: NodeModule | undefined, isMain: boolean) => unknown;
|
||||||
|
const moduleInternal = Module as unknown as { _load: ModuleLoad };
|
||||||
|
const originalModuleLoad = moduleInternal._load;
|
||||||
|
|
||||||
|
vi.mock('child_process', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('child_process')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
execFile: hoisted.execFileMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
|
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
|
||||||
|
|
@ -20,15 +52,72 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
const createdPaths: string[] = [];
|
const createdPaths: string[] = [];
|
||||||
const createdDirs: string[] = [];
|
const createdDirs: string[] = [];
|
||||||
let tempAppData: string;
|
let tempAppData: string;
|
||||||
|
let originalResourcesPath: string | undefined;
|
||||||
|
|
||||||
|
function setPackagedMode(isPackaged: boolean, version = '9.9.9-test'): void {
|
||||||
|
hoisted.electronState.isPackaged = isPackaged;
|
||||||
|
hoisted.electronState.version = version;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setResourcesPath(resourcesPath: string | undefined): void {
|
||||||
|
Object.defineProperty(process, 'resourcesPath', {
|
||||||
|
value: resourcesPath,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPackagedServerBundle(baseDir: string, body = '// packaged server'): string {
|
||||||
|
const dir = path.join(baseDir, 'mcp-server');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(dir, 'index.js'), body);
|
||||||
|
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'agent-teams-mcp' }));
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readGeneratedServer(configPath: string): { command?: string; args?: string[] } | undefined {
|
||||||
|
const raw = fs.readFileSync(configPath, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
mcpServers?: Record<string, { command?: string; args?: string[] }>;
|
||||||
|
};
|
||||||
|
return parsed.mcpServers?.['agent-teams'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectNodeEntry(server: { command?: string; args?: string[] } | undefined, entry: string): void {
|
||||||
|
expect(server?.args).toEqual([entry]);
|
||||||
|
expect(server?.command).toMatch(/(^node$|[\\/]node(?:\.exe)?$)/);
|
||||||
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
originalResourcesPath = (process as NodeJS.Process & { resourcesPath?: string }).resourcesPath;
|
||||||
tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-'));
|
tempAppData = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-appdata-'));
|
||||||
createdDirs.push(tempAppData);
|
createdDirs.push(tempAppData);
|
||||||
|
moduleInternal._load = ((request, parent, isMain) => {
|
||||||
|
if (request === 'electron') {
|
||||||
|
return {
|
||||||
|
app: {
|
||||||
|
get isPackaged() {
|
||||||
|
return hoisted.electronState.isPackaged;
|
||||||
|
},
|
||||||
|
getVersion: () => hoisted.electronState.version,
|
||||||
|
getPath: () => '/mock/electron-user-data',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return originalModuleLoad(request, parent, isMain);
|
||||||
|
}) as ModuleLoad;
|
||||||
setAppDataBasePath(tempAppData);
|
setAppDataBasePath(tempAppData);
|
||||||
|
setPackagedMode(false);
|
||||||
|
setResourcesPath(undefined);
|
||||||
|
hoisted.execFileMock.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setAppDataBasePath(null);
|
setAppDataBasePath(null);
|
||||||
|
setPackagedMode(false);
|
||||||
|
setResourcesPath(originalResourcesPath);
|
||||||
|
moduleInternal._load = originalModuleLoad;
|
||||||
|
vi.restoreAllMocks();
|
||||||
for (const filePath of createdPaths.splice(0)) {
|
for (const filePath of createdPaths.splice(0)) {
|
||||||
try {
|
try {
|
||||||
fs.rmSync(filePath, { force: true });
|
fs.rmSync(filePath, { force: true });
|
||||||
|
|
@ -321,4 +410,125 @@ describe('TeamMcpConfigBuilder', () => {
|
||||||
// Should not throw when directory doesn't exist
|
// Should not throw when directory doesn't exist
|
||||||
await builder.gcStaleConfigs();
|
await builder.gcStaleConfigs();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Packaged copy / fallback ──
|
||||||
|
|
||||||
|
it('packaged mode reuses an existing valid stable copy', async () => {
|
||||||
|
setPackagedMode(true, '1.2.3');
|
||||||
|
setResourcesPath(tempAppData);
|
||||||
|
const stableDir = path.join(tempAppData, 'mcp-server', '1.2.3');
|
||||||
|
fs.mkdirSync(stableDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(stableDir, 'index.js'), '// stable copy');
|
||||||
|
fs.writeFileSync(path.join(stableDir, 'package.json'), JSON.stringify({ name: 'stable' }));
|
||||||
|
|
||||||
|
const builder = new TeamMcpConfigBuilder();
|
||||||
|
const configPath = await builder.writeConfigFile();
|
||||||
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
|
expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('packaged mode copies the MCP server from resourcesPath into userData', async () => {
|
||||||
|
setPackagedMode(true, '2.0.0');
|
||||||
|
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||||
|
createdDirs.push(resourcesDir);
|
||||||
|
createPackagedServerBundle(resourcesDir, '// copied server');
|
||||||
|
setResourcesPath(resourcesDir);
|
||||||
|
|
||||||
|
const builder = new TeamMcpConfigBuilder();
|
||||||
|
const configPath = await builder.writeConfigFile();
|
||||||
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
|
const stableDir = path.join(tempAppData, 'mcp-server', '2.0.0');
|
||||||
|
expect(fs.existsSync(path.join(stableDir, 'index.js'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(stableDir, 'package.json'))).toBe(true);
|
||||||
|
expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('packaged mode heals a partial stable copy and rebuilds it from resourcesPath', async () => {
|
||||||
|
setPackagedMode(true, '3.0.0');
|
||||||
|
const stableDir = path.join(tempAppData, 'mcp-server', '3.0.0');
|
||||||
|
fs.mkdirSync(stableDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(stableDir, 'index.js'), '// partial copy only');
|
||||||
|
|
||||||
|
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||||
|
createdDirs.push(resourcesDir);
|
||||||
|
createPackagedServerBundle(resourcesDir, '// healed server');
|
||||||
|
setResourcesPath(resourcesDir);
|
||||||
|
|
||||||
|
const builder = new TeamMcpConfigBuilder();
|
||||||
|
const configPath = await builder.writeConfigFile();
|
||||||
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
|
expect(fs.readFileSync(path.join(stableDir, 'index.js'), 'utf8')).toContain('healed server');
|
||||||
|
expect(fs.existsSync(path.join(stableDir, 'package.json'))).toBe(true);
|
||||||
|
expect(readGeneratedServer(configPath)?.args).toEqual([path.join(stableDir, 'index.js')]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('packaged mode falls back to resourcesPath when stable copy creation fails', async () => {
|
||||||
|
setPackagedMode(true, '4.0.0');
|
||||||
|
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||||
|
createdDirs.push(resourcesDir);
|
||||||
|
createPackagedServerBundle(resourcesDir, '// fallback server');
|
||||||
|
setResourcesPath(resourcesDir);
|
||||||
|
|
||||||
|
vi.spyOn(fs.promises, 'copyFile').mockRejectedValueOnce(new Error('copy failed'));
|
||||||
|
|
||||||
|
const builder = new TeamMcpConfigBuilder();
|
||||||
|
const configPath = await builder.writeConfigFile();
|
||||||
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
|
expectNodeEntry(readGeneratedServer(configPath), path.join(resourcesDir, 'mcp-server', 'index.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('packaged mode uses the winner stable copy when atomic rename loses the race', async () => {
|
||||||
|
setPackagedMode(true, '5.0.0');
|
||||||
|
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||||
|
createdDirs.push(resourcesDir);
|
||||||
|
createPackagedServerBundle(resourcesDir, '// race source');
|
||||||
|
setResourcesPath(resourcesDir);
|
||||||
|
|
||||||
|
const stableDir = path.join(tempAppData, 'mcp-server', '5.0.0');
|
||||||
|
const originalRename = fs.promises.rename.bind(fs.promises);
|
||||||
|
vi.spyOn(fs.promises, 'rename').mockImplementation(async (from, to) => {
|
||||||
|
if (to === stableDir) {
|
||||||
|
fs.mkdirSync(stableDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(stableDir, 'index.js'), '// winner copy');
|
||||||
|
fs.writeFileSync(path.join(stableDir, 'package.json'), JSON.stringify({ name: 'winner' }));
|
||||||
|
const err = new Error('EEXIST') as NodeJS.ErrnoException;
|
||||||
|
err.code = 'EEXIST';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return originalRename(from, to);
|
||||||
|
});
|
||||||
|
|
||||||
|
const builder = new TeamMcpConfigBuilder();
|
||||||
|
const configPath = await builder.writeConfigFile();
|
||||||
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
|
expect(fs.readFileSync(path.join(stableDir, 'index.js'), 'utf8')).toContain('winner copy');
|
||||||
|
expectNodeEntry(readGeneratedServer(configPath), path.join(stableDir, 'index.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('packaged mode falls back to workspace source when resourcesPath bundle is missing', async () => {
|
||||||
|
setPackagedMode(true, '6.0.0');
|
||||||
|
const resourcesDir = fs.mkdtempSync(path.join(os.tmpdir(), 'team-mcp-resources-'));
|
||||||
|
createdDirs.push(resourcesDir);
|
||||||
|
setResourcesPath(resourcesDir);
|
||||||
|
|
||||||
|
const builder = new TeamMcpConfigBuilder();
|
||||||
|
const configPath = await builder.writeConfigFile();
|
||||||
|
createdPaths.push(configPath);
|
||||||
|
|
||||||
|
expect(readGeneratedServer(configPath)).toEqual({
|
||||||
|
command: 'pnpm',
|
||||||
|
args: [
|
||||||
|
'--dir',
|
||||||
|
path.join(process.cwd(), 'mcp-server'),
|
||||||
|
'exec',
|
||||||
|
'tsx',
|
||||||
|
path.join(process.cwd(), 'mcp-server', 'src', 'index.ts'),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,55 @@
|
||||||
import type { ChildProcess } from 'child_process';
|
import type { ChildProcess } from 'child_process';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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', () => ({
|
vi.mock('@main/services/team/ClaudeBinaryResolver', () => ({
|
||||||
ClaudeBinaryResolver: { resolve: vi.fn() },
|
ClaudeBinaryResolver: { resolve: vi.fn() },
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('@main/services/team/TeamTaskReader', () => ({
|
||||||
|
TeamTaskReader: class {
|
||||||
|
async getTasks() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('@main/utils/childProcess', () => ({
|
vi.mock('@main/utils/childProcess', () => ({
|
||||||
spawnCli: vi.fn(),
|
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 { TeamProvisioningService } from '@main/services/team/TeamProvisioningService';
|
||||||
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
import { ClaudeBinaryResolver } from '@main/services/team/ClaudeBinaryResolver';
|
||||||
import { spawnCli } from '@main/utils/childProcess';
|
import { spawnCli } from '@main/utils/childProcess';
|
||||||
|
|
@ -30,9 +69,47 @@ function createFakeChild(exitCode: number): ChildProcess {
|
||||||
return child;
|
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', () => {
|
describe('TeamProvisioningService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
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', () => {
|
describe('warmup', () => {
|
||||||
|
|
@ -53,4 +130,198 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(spawnCli).toHaveBeenCalled();
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue