feat(runtime): harden MCP launch orchestration
This commit is contained in:
parent
9b2a53863d
commit
9ad4269ebc
15 changed files with 2029 additions and 120 deletions
|
|
@ -19,9 +19,23 @@ function withMockedRenameSync(mockRenameSync, callback) {
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('atomic file writes', () => {
|
describe('atomic file writes', () => {
|
||||||
|
const tempDirs = [];
|
||||||
|
|
||||||
|
function makeTempDir() {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
['EPERM', 'EACCES', 'EBUSY'].forEach((code) => {
|
['EPERM', 'EACCES', 'EBUSY'].forEach((code) => {
|
||||||
it(`retries transient ${code} rename failures before publishing JSON`, () => {
|
it(`retries transient ${code} rename failures before publishing JSON`, () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
|
const dir = makeTempDir();
|
||||||
const filePath = path.join(dir, 'state.json');
|
const filePath = path.join(dir, 'state.json');
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
|
|
||||||
|
|
@ -47,7 +61,7 @@ describe('atomic file writes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not retry ENOENT rename failures and removes the temp file', () => {
|
it('does not retry ENOENT rename failures and removes the temp file', () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
|
const dir = makeTempDir();
|
||||||
const filePath = path.join(dir, 'state.json');
|
const filePath = path.join(dir, 'state.json');
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
|
|
||||||
|
|
@ -69,7 +83,7 @@ describe('atomic file writes', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('removes the temp file after retryable rename failures are exhausted', () => {
|
it('removes the temp file after retryable rename failures are exhausted', () => {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-atomic-'));
|
const dir = makeTempDir();
|
||||||
const filePath = path.join(dir, 'state.json');
|
const filePath = path.join(dir, 'state.json');
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,17 @@ const path = require('path');
|
||||||
const { createController } = require('../src/index.js');
|
const { createController } = require('../src/index.js');
|
||||||
|
|
||||||
describe('agent-teams-controller API', () => {
|
describe('agent-teams-controller API', () => {
|
||||||
|
const tempDirs = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function makeClaudeDir() {
|
function makeClaudeDir() {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-controller-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-controller-'));
|
||||||
|
tempDirs.push(dir);
|
||||||
fs.mkdirSync(path.join(dir, 'teams', 'my-team'), { recursive: true });
|
fs.mkdirSync(path.join(dir, 'teams', 'my-team'), { recursive: true });
|
||||||
fs.mkdirSync(path.join(dir, 'tasks', 'my-team'), { recursive: true });
|
fs.mkdirSync(path.join(dir, 'tasks', 'my-team'), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,11 @@ const { createController } = require('../src/index.js');
|
||||||
const { CROSS_TEAM_SOURCE, CROSS_TEAM_TAG_NAME } = require('../src/internal/crossTeamProtocol.js');
|
const { CROSS_TEAM_SOURCE, CROSS_TEAM_TAG_NAME } = require('../src/internal/crossTeamProtocol.js');
|
||||||
|
|
||||||
describe('crossTeam module', () => {
|
describe('crossTeam module', () => {
|
||||||
|
const tempDirs = [];
|
||||||
|
|
||||||
function makeClaudeDir(teams = {}) {
|
function makeClaudeDir(teams = {}) {
|
||||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossteam-test-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossteam-test-'));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
|
||||||
for (const [teamName, config] of Object.entries(teams)) {
|
for (const [teamName, config] of Object.entries(teams)) {
|
||||||
const teamDir = path.join(dir, 'teams', teamName);
|
const teamDir = path.join(dir, 'teams', teamName);
|
||||||
|
|
@ -28,6 +31,9 @@ describe('crossTeam module', () => {
|
||||||
// Reset cascade guard between tests
|
// Reset cascade guard between tests
|
||||||
const cascadeGuard = require('../src/internal/cascadeGuard.js');
|
const cascadeGuard = require('../src/internal/cascadeGuard.js');
|
||||||
cascadeGuard.reset();
|
cascadeGuard.reset();
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sendCrossTeamMessage', () => {
|
describe('sendCrossTeamMessage', () => {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,11 @@ const HTTP_TRANSPORT = 'httpStream';
|
||||||
const STDIO_TRANSPORT = 'stdio';
|
const STDIO_TRANSPORT = 'stdio';
|
||||||
const DEFAULT_HTTP_HOST = '127.0.0.1';
|
const DEFAULT_HTTP_HOST = '127.0.0.1';
|
||||||
const DEFAULT_HTTP_ENDPOINT = '/mcp';
|
const DEFAULT_HTTP_ENDPOINT = '/mcp';
|
||||||
|
const MCP_HTTP_IDENTITY_SERVICE = 'agent-teams-mcp-http';
|
||||||
|
const MCP_HTTP_IDENTITY_SERVICE_ENV = 'AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE';
|
||||||
|
const MCP_HTTP_CLAUDE_DIR_HASH_ENV = 'AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH';
|
||||||
|
const MCP_HTTP_LAUNCH_SPEC_HASH_ENV = 'AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH';
|
||||||
|
const MCP_HTTP_OWNER_INSTANCE_ID_ENV = 'AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID';
|
||||||
|
|
||||||
export type AgentTeamsMcpStartOptions =
|
export type AgentTeamsMcpStartOptions =
|
||||||
| {
|
| {
|
||||||
|
|
@ -23,10 +28,32 @@ export type AgentTeamsMcpStartOptions =
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createServer() {
|
export interface AgentTeamsMcpHttpHealthIdentity {
|
||||||
|
schemaVersion: 1;
|
||||||
|
service: typeof MCP_HTTP_IDENTITY_SERVICE;
|
||||||
|
transport: typeof HTTP_TRANSPORT;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
endpoint: `/${string}`;
|
||||||
|
claudeDirHash: string;
|
||||||
|
launchSpecHash: string;
|
||||||
|
ownerInstanceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createServer(input: { healthIdentity?: AgentTeamsMcpHttpHealthIdentity | null } = {}) {
|
||||||
const server = new FastMCP({
|
const server = new FastMCP({
|
||||||
name: 'agent-teams-mcp',
|
name: 'agent-teams-mcp',
|
||||||
version: '1.0.0',
|
version: '1.0.0',
|
||||||
|
...(input.healthIdentity
|
||||||
|
? {
|
||||||
|
health: {
|
||||||
|
enabled: true,
|
||||||
|
path: '/health',
|
||||||
|
status: 200,
|
||||||
|
message: JSON.stringify(input.healthIdentity),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
registerTools(server);
|
registerTools(server);
|
||||||
|
|
@ -64,6 +91,45 @@ function parsePort(value: string | null | undefined): number {
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readIdentityValue(env: NodeJS.ProcessEnv, name: string): string | null {
|
||||||
|
const value = env[name]?.trim();
|
||||||
|
return value && value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHttpHealthIdentity(
|
||||||
|
options: AgentTeamsMcpStartOptions,
|
||||||
|
env: NodeJS.ProcessEnv = process.env
|
||||||
|
): AgentTeamsMcpHttpHealthIdentity | null {
|
||||||
|
if (options.transportType !== HTTP_TRANSPORT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = readIdentityValue(env, MCP_HTTP_IDENTITY_SERVICE_ENV);
|
||||||
|
const claudeDirHash = readIdentityValue(env, MCP_HTTP_CLAUDE_DIR_HASH_ENV);
|
||||||
|
const launchSpecHash = readIdentityValue(env, MCP_HTTP_LAUNCH_SPEC_HASH_ENV);
|
||||||
|
const ownerInstanceId = readIdentityValue(env, MCP_HTTP_OWNER_INSTANCE_ID_ENV);
|
||||||
|
if (
|
||||||
|
service !== MCP_HTTP_IDENTITY_SERVICE ||
|
||||||
|
!claudeDirHash ||
|
||||||
|
!launchSpecHash ||
|
||||||
|
!ownerInstanceId
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
service: MCP_HTTP_IDENTITY_SERVICE,
|
||||||
|
transport: HTTP_TRANSPORT,
|
||||||
|
host: options.httpStream.host,
|
||||||
|
port: options.httpStream.port,
|
||||||
|
endpoint: options.httpStream.endpoint,
|
||||||
|
claudeDirHash,
|
||||||
|
launchSpecHash,
|
||||||
|
ownerInstanceId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveStartOptions(
|
export function resolveStartOptions(
|
||||||
argv: string[] = process.argv,
|
argv: string[] = process.argv,
|
||||||
env: NodeJS.ProcessEnv = process.env
|
env: NodeJS.ProcessEnv = process.env
|
||||||
|
|
@ -92,6 +158,7 @@ export function resolveStartOptions(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
const server = createServer();
|
const startOptions = resolveStartOptions();
|
||||||
void server.start(resolveStartOptions());
|
const server = createServer({ healthIdentity: buildHttpHealthIdentity(startOptions) });
|
||||||
|
void server.start(startOptions);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
133
mcp-server/test/http.e2e.test.ts
Normal file
133
mcp-server/test/http.e2e.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { spawn, type ChildProcess } from 'node:child_process';
|
||||||
|
import http from 'node:http';
|
||||||
|
import net from 'node:net';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const serverEntry = path.join(repoRoot, 'dist', 'index.js');
|
||||||
|
|
||||||
|
const children: ChildProcess[] = [];
|
||||||
|
|
||||||
|
async function allocateLoopbackPort(): Promise<number> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === 'string') {
|
||||||
|
server.close(() => reject(new Error('Failed to allocate HTTP e2e port')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
server.close(() => resolve(address.port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readHealthBody(port: number): Promise<{ statusCode: number | null; body: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let body = '';
|
||||||
|
const request = http.get(
|
||||||
|
{
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
path: '/health',
|
||||||
|
timeout: 1_000,
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
response.setEncoding('utf8');
|
||||||
|
response.on('data', (chunk: string) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
response.on('end', () => resolve({ statusCode: response.statusCode ?? null, body }));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
request.once('timeout', () => {
|
||||||
|
request.destroy();
|
||||||
|
resolve({ statusCode: null, body: '' });
|
||||||
|
});
|
||||||
|
request.once('error', () => resolve({ statusCode: null, body: '' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealthBody(port: number): Promise<{ statusCode: number | null; body: string }> {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < 10_000) {
|
||||||
|
const result = await readHealthBody(port);
|
||||||
|
if (result.statusCode === 200) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error(`HTTP MCP server did not become healthy on port ${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
children.splice(0).map(
|
||||||
|
(child) =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
child.once('exit', () => resolve());
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null) {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
}, 500).unref();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agent-teams-mcp HTTP e2e', () => {
|
||||||
|
it('returns app-managed JSON identity from /health when identity env is present', async () => {
|
||||||
|
const port = await allocateLoopbackPort();
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
serverEntry,
|
||||||
|
'--transport',
|
||||||
|
'httpStream',
|
||||||
|
'--host',
|
||||||
|
'127.0.0.1',
|
||||||
|
'--port',
|
||||||
|
String(port),
|
||||||
|
'--endpoint',
|
||||||
|
'mcp',
|
||||||
|
],
|
||||||
|
{
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE: 'agent-teams-mcp-http',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH: 'claude-dir-hash-e2e',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH: 'launch-spec-hash-e2e',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID: 'owner-e2e',
|
||||||
|
},
|
||||||
|
stdio: ['ignore', 'ignore', 'pipe'],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
children.push(child);
|
||||||
|
|
||||||
|
const health = await waitForHealthBody(port);
|
||||||
|
const parsed = JSON.parse(health.body) as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(health.statusCode).toBe(200);
|
||||||
|
expect(parsed).toEqual({
|
||||||
|
schemaVersion: 1,
|
||||||
|
service: 'agent-teams-mcp-http',
|
||||||
|
transport: 'httpStream',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port,
|
||||||
|
endpoint: '/mcp',
|
||||||
|
claudeDirHash: 'claude-dir-hash-e2e',
|
||||||
|
launchSpecHash: 'launch-spec-hash-e2e',
|
||||||
|
ownerInstanceId: 'owner-e2e',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { resolveStartOptions } from '../src/index';
|
import { buildHttpHealthIdentity, resolveStartOptions } from '../src/index';
|
||||||
|
|
||||||
describe('agent-teams MCP start options', () => {
|
describe('agent-teams MCP start options', () => {
|
||||||
it('defaults to stdio transport', () => {
|
it('defaults to stdio transport', () => {
|
||||||
|
|
@ -51,4 +51,42 @@ describe('agent-teams MCP start options', () => {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds HTTP health identity only when app identity env is present', () => {
|
||||||
|
const options = resolveStartOptions(
|
||||||
|
['node', 'index.js', '--transport', 'httpStream', '--port', '43125'],
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(buildHttpHealthIdentity(options, {})).toBeNull();
|
||||||
|
expect(
|
||||||
|
buildHttpHealthIdentity(options, {
|
||||||
|
AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE: 'agent-teams-mcp-http',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH: 'claude-hash',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH: 'launch-hash',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID: 'owner-id',
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
schemaVersion: 1,
|
||||||
|
service: 'agent-teams-mcp-http',
|
||||||
|
transport: 'httpStream',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: 43125,
|
||||||
|
endpoint: '/mcp',
|
||||||
|
claudeDirHash: 'claude-hash',
|
||||||
|
launchSpecHash: 'launch-hash',
|
||||||
|
ownerInstanceId: 'owner-id',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not build HTTP health identity for stdio transport', () => {
|
||||||
|
const options = resolveStartOptions(['node', 'index.js'], {
|
||||||
|
AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE: 'agent-teams-mcp-http',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH: 'claude-hash',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH: 'launch-hash',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID: 'owner-id',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buildHttpHealthIdentity(options)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,13 @@ function parseJsonToolResult(result: unknown) {
|
||||||
|
|
||||||
describe('agent-teams-mcp tools', () => {
|
describe('agent-teams-mcp tools', () => {
|
||||||
const tools = collectTools();
|
const tools = collectTools();
|
||||||
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of tempDirs.splice(0)) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function getTool(name: string) {
|
function getTool(name: string) {
|
||||||
const tool = tools.get(name);
|
const tool = tools.get(name);
|
||||||
|
|
@ -39,7 +46,9 @@ describe('agent-teams-mcp tools', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeClaudeDir() {
|
function makeClaudeDir() {
|
||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-mcp-'));
|
||||||
|
tempDirs.push(dir);
|
||||||
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeTeamConfig(
|
function writeTeamConfig(
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,27 @@
|
||||||
{
|
{
|
||||||
"version": "0.0.44",
|
"version": "0.0.45",
|
||||||
"sourceRef": "v0.0.44",
|
"sourceRef": "v0.0.45",
|
||||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||||
"releaseRepository": "777genius/agent-teams-ai",
|
"releaseRepository": "777genius/agent-teams-ai",
|
||||||
"releaseTag": "v2.0.0",
|
"releaseTag": "v2.1.0",
|
||||||
"assets": {
|
"assets": {
|
||||||
"darwin-arm64": {
|
"darwin-arm64": {
|
||||||
"file": "agent-teams-runtime-darwin-arm64-v0.0.44.tar.gz",
|
"file": "agent-teams-runtime-darwin-arm64-v0.0.45.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"darwin-x64": {
|
"darwin-x64": {
|
||||||
"file": "agent-teams-runtime-darwin-x64-v0.0.44.tar.gz",
|
"file": "agent-teams-runtime-darwin-x64-v0.0.45.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"linux-x64": {
|
"linux-x64": {
|
||||||
"file": "agent-teams-runtime-linux-x64-v0.0.44.tar.gz",
|
"file": "agent-teams-runtime-linux-x64-v0.0.45.tar.gz",
|
||||||
"archiveKind": "tar.gz",
|
"archiveKind": "tar.gz",
|
||||||
"binaryName": "claude-multimodel"
|
"binaryName": "claude-multimodel"
|
||||||
},
|
},
|
||||||
"win32-x64": {
|
"win32-x64": {
|
||||||
"file": "agent-teams-runtime-win32-x64-v0.0.44.zip",
|
"file": "agent-teams-runtime-win32-x64-v0.0.45.zip",
|
||||||
"archiveKind": "zip",
|
"archiveKind": "zip",
|
||||||
"binaryName": "claude-multimodel.exe"
|
"binaryName": "claude-multimodel.exe"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,8 @@ const SNAPSHOT_CACHE_TTL_MS = 5_000;
|
||||||
const RATE_LIMITS_CACHE_TTL_MS = 45_000;
|
const RATE_LIMITS_CACHE_TTL_MS = 45_000;
|
||||||
const LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS = 60_000;
|
const LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS = 60_000;
|
||||||
const CODEX_BINARY_COLD_RETRY_TIMEOUT_MS = 12_000;
|
const CODEX_BINARY_COLD_RETRY_TIMEOUT_MS = 12_000;
|
||||||
|
const CODEX_CLI_NOT_FOUND_MESSAGE =
|
||||||
|
'Codex CLI not found. Install Codex to use native account management.';
|
||||||
|
|
||||||
interface CodexLastKnownAccount {
|
interface CodexLastKnownAccount {
|
||||||
payload: CodexAppServerGetAccountResponse;
|
payload: CodexAppServerGetAccountResponse;
|
||||||
|
|
@ -487,15 +489,48 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
|
const freshRuntimeContext = this.getFreshLastKnownRuntimeContext(now);
|
||||||
|
if (freshRuntimeContext) {
|
||||||
|
const freshAccountPayload = this.getFreshLastKnownAccount(now);
|
||||||
|
const accountPayload = freshAccountPayload ?? null;
|
||||||
|
const managedAccount = asCodexManagedAccount(accountPayload?.account ?? null);
|
||||||
|
const readiness = evaluateCodexLaunchReadiness({
|
||||||
|
preferredAuthMode,
|
||||||
|
managedAccount,
|
||||||
|
apiKey,
|
||||||
|
appServerState: 'healthy',
|
||||||
|
appServerStatusMessage: null,
|
||||||
|
localActiveChatgptAccountPresent,
|
||||||
|
});
|
||||||
|
const snapshot = this.setSnapshot({
|
||||||
|
preferredAuthMode,
|
||||||
|
effectiveAuthMode: readiness.effectiveAuthMode,
|
||||||
|
launchAllowed: readiness.launchAllowed,
|
||||||
|
launchIssueMessage: readiness.issueMessage,
|
||||||
|
launchReadinessState: readiness.state,
|
||||||
|
appServerState: 'healthy',
|
||||||
|
appServerStatusMessage: null,
|
||||||
|
managedAccount,
|
||||||
|
apiKey,
|
||||||
|
requiresOpenaiAuth: accountPayload?.requiresOpenaiAuth ?? null,
|
||||||
|
localAccountArtifactsPresent,
|
||||||
|
localActiveChatgptAccountPresent,
|
||||||
|
runtimeContext: freshRuntimeContext,
|
||||||
|
login,
|
||||||
|
rateLimits: this.snapshotCache?.rateLimits ?? null,
|
||||||
|
updatedAt: new Date(now).toISOString(),
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
const snapshot = this.setSnapshot({
|
const snapshot = this.setSnapshot({
|
||||||
preferredAuthMode,
|
preferredAuthMode,
|
||||||
effectiveAuthMode: null,
|
effectiveAuthMode: null,
|
||||||
launchAllowed: false,
|
launchAllowed: false,
|
||||||
launchIssueMessage: 'Codex CLI not found. Install Codex to use native account management.',
|
launchIssueMessage: CODEX_CLI_NOT_FOUND_MESSAGE,
|
||||||
launchReadinessState: 'runtime_missing',
|
launchReadinessState: 'runtime_missing',
|
||||||
appServerState: 'runtime-missing',
|
appServerState: 'runtime-missing',
|
||||||
appServerStatusMessage:
|
appServerStatusMessage: CODEX_CLI_NOT_FOUND_MESSAGE,
|
||||||
'Codex CLI not found. Install Codex to use native account management.',
|
|
||||||
managedAccount: null,
|
managedAccount: null,
|
||||||
apiKey,
|
apiKey,
|
||||||
requiresOpenaiAuth: null,
|
requiresOpenaiAuth: null,
|
||||||
|
|
@ -521,7 +556,15 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
let appServerStatusMessage: string | null = null;
|
let appServerStatusMessage: string | null = null;
|
||||||
let accountPayload = this.lastKnownAccount?.payload ?? null;
|
let accountPayload = this.lastKnownAccount?.payload ?? null;
|
||||||
let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null;
|
let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null;
|
||||||
let runtimeContext = createRuntimeContext(binaryPath, null);
|
const previousRuntimeContext = this.getFreshLastKnownRuntimeContext(now);
|
||||||
|
let runtimeContext = createRuntimeContext(
|
||||||
|
binaryPath,
|
||||||
|
previousRuntimeContext?.binaryPath === binaryPath ? previousRuntimeContext.codexHome : null
|
||||||
|
);
|
||||||
|
this.lastKnownRuntimeContext = {
|
||||||
|
payload: runtimeContext,
|
||||||
|
observedAt: now,
|
||||||
|
};
|
||||||
const cachedRateLimitsAreFresh = this.hasFreshRateLimits(now);
|
const cachedRateLimitsAreFresh = this.hasFreshRateLimits(now);
|
||||||
const shouldRequestRateLimits =
|
const shouldRequestRateLimits =
|
||||||
options?.includeRateLimits === true && !cachedRateLimitsAreFresh;
|
options?.includeRateLimits === true && !cachedRateLimitsAreFresh;
|
||||||
|
|
@ -706,6 +749,29 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getFreshLastKnownRuntimeContext(now: number): CodexRuntimeContext | null {
|
||||||
|
if (
|
||||||
|
!this.lastKnownRuntimeContext ||
|
||||||
|
now - this.lastKnownRuntimeContext.observedAt > LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS ||
|
||||||
|
!this.lastKnownRuntimeContext.payload.binaryPath
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.lastKnownRuntimeContext.payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFreshLastKnownAccount(now: number): CodexAppServerGetAccountResponse | null {
|
||||||
|
if (
|
||||||
|
!this.lastKnownAccount ||
|
||||||
|
now - this.lastKnownAccount.observedAt > LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.lastKnownAccount.payload;
|
||||||
|
}
|
||||||
|
|
||||||
private async emitCurrentSnapshot(): Promise<CodexAccountSnapshotDto> {
|
private async emitCurrentSnapshot(): Promise<CodexAccountSnapshotDto> {
|
||||||
if (!this.snapshotCache) {
|
if (!this.snapshotCache) {
|
||||||
return this.refreshSnapshot();
|
return this.refreshSnapshot();
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -900,11 +900,6 @@ const InstalledBanner = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-8">
|
<div className="flex shrink-0 items-center gap-8">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
|
||||||
Multimodel
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* Extensions button — available whenever the runtime is installed */}
|
{/* Extensions button — available whenever the runtime is installed */}
|
||||||
{canOpenExtensions && (
|
{canOpenExtensions && (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -432,6 +432,93 @@ describe('createCodexAccountFeature', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps the last known Codex account when binary discovery transiently misses after a healthy snapshot', async () => {
|
||||||
|
readAccountMock.mockResolvedValue({
|
||||||
|
account: createAccountResponse(),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
|
||||||
|
PATH: '/usr/bin:/bin',
|
||||||
|
});
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger: createLoggerPort(),
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
const dateNowSpy = vi.spyOn(Date, 'now');
|
||||||
|
|
||||||
|
try {
|
||||||
|
dateNowSpy.mockReturnValue(1_776_000_000_000);
|
||||||
|
const firstSnapshot = await feature.refreshSnapshot();
|
||||||
|
|
||||||
|
binaryResolveMock.mockResolvedValue(null);
|
||||||
|
dateNowSpy.mockReturnValue(1_776_000_020_000);
|
||||||
|
const secondSnapshot = await feature.refreshSnapshot({ forceRefreshToken: true });
|
||||||
|
|
||||||
|
expect(firstSnapshot.managedAccount?.email).toBe('user@example.com');
|
||||||
|
expect(secondSnapshot.appServerState).toBe('healthy');
|
||||||
|
expect(secondSnapshot.launchReadinessState).toBe('ready_chatgpt');
|
||||||
|
expect(secondSnapshot.launchAllowed).toBe(true);
|
||||||
|
expect(secondSnapshot.launchIssueMessage).toBeNull();
|
||||||
|
expect(secondSnapshot.managedAccount).toMatchObject({
|
||||||
|
type: 'chatgpt',
|
||||||
|
email: 'user@example.com',
|
||||||
|
});
|
||||||
|
expect(secondSnapshot.runtimeContext).toEqual({
|
||||||
|
binaryPath: '/usr/local/bin/codex',
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
});
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(binaryClearCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
dateNowSpy.mockRestore();
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports runtime-missing once the last known Codex runtime is too old to trust', async () => {
|
||||||
|
readAccountMock.mockResolvedValue({
|
||||||
|
account: createAccountResponse(),
|
||||||
|
initialize: {
|
||||||
|
codexHome: '/Users/test/.codex',
|
||||||
|
platformFamily: 'unix',
|
||||||
|
platformOs: 'macos',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
|
||||||
|
PATH: '/usr/bin:/bin',
|
||||||
|
});
|
||||||
|
const feature = createCodexAccountFeature({
|
||||||
|
logger: createLoggerPort(),
|
||||||
|
configManager: createConfigManager('chatgpt'),
|
||||||
|
});
|
||||||
|
const dateNowSpy = vi.spyOn(Date, 'now');
|
||||||
|
|
||||||
|
try {
|
||||||
|
dateNowSpy.mockReturnValue(1_776_000_000_000);
|
||||||
|
await feature.refreshSnapshot();
|
||||||
|
|
||||||
|
binaryResolveMock.mockResolvedValue(null);
|
||||||
|
dateNowSpy.mockReturnValue(1_776_000_060_001);
|
||||||
|
const snapshot = await feature.refreshSnapshot({ forceRefreshToken: true });
|
||||||
|
|
||||||
|
expect(snapshot.appServerState).toBe('runtime-missing');
|
||||||
|
expect(snapshot.launchReadinessState).toBe('runtime_missing');
|
||||||
|
expect(snapshot.launchIssueMessage).toContain('Codex CLI not found');
|
||||||
|
expect(snapshot.managedAccount).toBeNull();
|
||||||
|
expect(readAccountMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(binaryClearCacheMock).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
dateNowSpy.mockRestore();
|
||||||
|
await feature.dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('reuses a fresh refresh snapshot when the request does not need stronger data', async () => {
|
it('reuses a fresh refresh snapshot when the request does not need stronger data', async () => {
|
||||||
readAccountMock.mockResolvedValue({
|
readAccountMock.mockResolvedValue({
|
||||||
account: createAccountResponse(),
|
account: createAccountResponse(),
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,9 @@ import net from 'node:net';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
|
import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
|
||||||
import { OpenCodeBridgeCommandClient } from '@main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
import { OpenCodeBridgeCommandClient } from '@main/services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const FAKE_MCP_HTTP_SERVER_SOURCE = String.raw`
|
const FAKE_MCP_HTTP_SERVER_SOURCE = String.raw`
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
|
@ -24,6 +23,23 @@ const host = readArg('--host') || '127.0.0.1';
|
||||||
const endpoint = readArg('--endpoint') || '/mcp';
|
const endpoint = readArg('--endpoint') || '/mcp';
|
||||||
const port = Number(readArg('--port'));
|
const port = Number(readArg('--port'));
|
||||||
const controlFile = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
|
const controlFile = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
|
||||||
|
const healthIdentity =
|
||||||
|
process.env.AGENT_TEAMS_MCP_HTTP_IDENTITY_SERVICE === 'agent-teams-mcp-http' &&
|
||||||
|
process.env.AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH &&
|
||||||
|
process.env.AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH &&
|
||||||
|
process.env.AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID
|
||||||
|
? {
|
||||||
|
schemaVersion: 1,
|
||||||
|
service: 'agent-teams-mcp-http',
|
||||||
|
transport: 'httpStream',
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
endpoint,
|
||||||
|
claudeDirHash: process.env.AGENT_TEAMS_MCP_HTTP_CLAUDE_DIR_HASH,
|
||||||
|
launchSpecHash: process.env.AGENT_TEAMS_MCP_HTTP_LAUNCH_SPEC_HASH,
|
||||||
|
ownerInstanceId: process.env.AGENT_TEAMS_MCP_HTTP_OWNER_INSTANCE_ID,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
function readControl() {
|
function readControl() {
|
||||||
if (!controlFile) {
|
if (!controlFile) {
|
||||||
|
|
@ -58,7 +74,7 @@ const server = http.createServer((request, response) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||||
response.end('ok');
|
response.end(healthIdentity ? JSON.stringify(healthIdentity) : 'ok');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,11 +271,13 @@ async function writeFakeOpenCodeBridgeBinary(tempDir: string): Promise<string> {
|
||||||
describePosix('AgentTeamsMcpHttpServer integration', () => {
|
describePosix('AgentTeamsMcpHttpServer integration', () => {
|
||||||
let tempDir: string | null = null;
|
let tempDir: string | null = null;
|
||||||
let originalControlFileEnv: string | undefined;
|
let originalControlFileEnv: string | undefined;
|
||||||
|
let originalMcpHttpPortEnv: string | undefined;
|
||||||
const servers: AgentTeamsMcpHttpServer[] = [];
|
const servers: AgentTeamsMcpHttpServer[] = [];
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-http-integration-'));
|
tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-http-integration-'));
|
||||||
originalControlFileEnv = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
|
originalControlFileEnv = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
|
||||||
|
originalMcpHttpPortEnv = process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|
@ -270,6 +288,11 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
||||||
} else {
|
} else {
|
||||||
process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE = originalControlFileEnv;
|
process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE = originalControlFileEnv;
|
||||||
}
|
}
|
||||||
|
if (originalMcpHttpPortEnv === undefined) {
|
||||||
|
delete process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT;
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT = originalMcpHttpPortEnv;
|
||||||
|
}
|
||||||
if (tempDir) {
|
if (tempDir) {
|
||||||
await rm(tempDir, { recursive: true, force: true });
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
tempDir = null;
|
tempDir = null;
|
||||||
|
|
@ -280,8 +303,11 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
||||||
scriptPath: string;
|
scriptPath: string;
|
||||||
controlFile: string;
|
controlFile: string;
|
||||||
allocatePort?: () => Promise<number>;
|
allocatePort?: () => Promise<number>;
|
||||||
|
statePath?: string;
|
||||||
}): AgentTeamsMcpHttpServer {
|
}): AgentTeamsMcpHttpServer {
|
||||||
const server = new AgentTeamsMcpHttpServer({
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath: input.statePath ?? path.join(tempDir!, `mcp-http-state-${servers.length}.json`),
|
||||||
|
disableOrphanCleanup: true,
|
||||||
resolveLaunchSpec: () =>
|
resolveLaunchSpec: () =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
command: process.execPath,
|
command: process.execPath,
|
||||||
|
|
@ -296,7 +322,10 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
it('starts the actual Agent Teams MCP HTTP server and proves its health endpoint', async () => {
|
it('starts the actual Agent Teams MCP HTTP server and proves its health endpoint', async () => {
|
||||||
const server = new AgentTeamsMcpHttpServer();
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath: path.join(tempDir!, 'actual-mcp-http-state.json'),
|
||||||
|
disableOrphanCleanup: true,
|
||||||
|
});
|
||||||
servers.push(server);
|
servers.push(server);
|
||||||
|
|
||||||
const handle = await server.ensureStarted();
|
const handle = await server.ensureStarted();
|
||||||
|
|
@ -321,6 +350,88 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
||||||
expect(vi.mocked(console.warn).mock.calls.slice(warnCountAfterFirstStart)).toEqual([]);
|
expect(vi.mocked(console.warn).mock.calls.slice(warnCountAfterFirstStart)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('adopts a healthy MCP HTTP child from persistent state across server managers', async () => {
|
||||||
|
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
|
||||||
|
const controlFile = path.join(tempDir!, 'health-control.txt');
|
||||||
|
const statePath = path.join(tempDir!, 'shared-mcp-http-state.json');
|
||||||
|
await writeFile(controlFile, 'healthy', 'utf8');
|
||||||
|
const firstServer = createControlledServer({ scriptPath, controlFile, statePath });
|
||||||
|
|
||||||
|
const first = await firstServer.ensureStarted();
|
||||||
|
const secondServer = createControlledServer({ scriptPath, controlFile, statePath });
|
||||||
|
const second = await secondServer.ensureStarted();
|
||||||
|
|
||||||
|
expect(second.port).toBe(first.port);
|
||||||
|
expect(second.pid).toBe(first.pid);
|
||||||
|
expect(second.diagnostics).toContain(`opencode_app_mcp_adopted_state_server:${first.port}`);
|
||||||
|
expect(await readHealthStatus(second.url)).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back from an unknown healthy stable-port server and leaves it alive', async () => {
|
||||||
|
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
|
||||||
|
const controlFile = path.join(tempDir!, 'health-control.txt');
|
||||||
|
await writeFile(controlFile, 'healthy', 'utf8');
|
||||||
|
|
||||||
|
let unknownServer: http.Server | null = null;
|
||||||
|
let stablePort = 0;
|
||||||
|
let fallbackPort = 0;
|
||||||
|
for (let candidate = 43_100; candidate < 65_534; candidate += 1) {
|
||||||
|
const stableCandidate = http.createServer((request, response) => {
|
||||||
|
if (request.url === '/health') {
|
||||||
|
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||||
|
response.end('unknown-ok');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(404);
|
||||||
|
response.end('not found');
|
||||||
|
});
|
||||||
|
const fallbackProbe = net.createServer();
|
||||||
|
try {
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
stableCandidate.once('error', reject);
|
||||||
|
stableCandidate.listen(candidate, '127.0.0.1', () => resolve());
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
fallbackProbe.once('error', reject);
|
||||||
|
fallbackProbe.listen(candidate + 1, '127.0.0.1', () => resolve());
|
||||||
|
});
|
||||||
|
await new Promise<void>((resolve) => fallbackProbe.close(() => resolve()));
|
||||||
|
unknownServer = stableCandidate;
|
||||||
|
stablePort = candidate;
|
||||||
|
fallbackPort = candidate + 1;
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
await new Promise<void>((resolve) => fallbackProbe.close(() => resolve())).catch(
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
await new Promise<void>((resolve) => stableCandidate.close(() => resolve())).catch(
|
||||||
|
() => undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!unknownServer) {
|
||||||
|
throw new Error('Failed to reserve contiguous ports for unknown stable-port test');
|
||||||
|
}
|
||||||
|
|
||||||
|
process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT = String(stablePort);
|
||||||
|
const server = createControlledServer({ scriptPath, controlFile });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await server.ensureStarted();
|
||||||
|
|
||||||
|
expect(handle.port).toBe(fallbackPort);
|
||||||
|
expect(handle.diagnostics).toContain(`opencode_app_mcp_port_occupied_unknown:${stablePort}`);
|
||||||
|
expect(handle.diagnostics).toContain(
|
||||||
|
`opencode_app_mcp_preferred_port_unavailable:${stablePort}`
|
||||||
|
);
|
||||||
|
expect(await readHealthStatus(`http://127.0.0.1:${stablePort}/mcp`)).toBe(200);
|
||||||
|
expect(await readHealthStatus(handle.url)).toBe(200);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => unknownServer?.close(() => resolve()));
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('restarts a stale but still-running MCP HTTP child when cached URL health turns unhealthy', async () => {
|
it('restarts a stale but still-running MCP HTTP child when cached URL health turns unhealthy', async () => {
|
||||||
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
|
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
|
||||||
const controlFile = path.join(tempDir!, 'health-control.txt');
|
const controlFile = path.join(tempDir!, 'health-control.txt');
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import { EventEmitter } from 'events';
|
import { type ChildProcess } from 'node:child_process';
|
||||||
import http from 'http';
|
import { createHash } from 'node:crypto';
|
||||||
import net from 'net';
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import http from 'node:http';
|
||||||
|
import net from 'node:net';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => ({
|
const hoisted = vi.hoisted(() => ({
|
||||||
|
|
@ -17,7 +24,10 @@ vi.mock('@main/utils/childProcess', async (importOriginal) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
|
import {
|
||||||
|
AgentTeamsMcpHttpServer,
|
||||||
|
type AgentTeamsMcpHttpServerDeps,
|
||||||
|
} from '@main/services/team/AgentTeamsMcpHttpServer';
|
||||||
|
|
||||||
class FakeChildProcess extends EventEmitter {
|
class FakeChildProcess extends EventEmitter {
|
||||||
pid: number;
|
pid: number;
|
||||||
|
|
@ -29,6 +39,57 @@ class FakeChildProcess extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sha256Hex(value: string): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLaunchSpecHash(launchSpec: { command: string; args: string[] }): string {
|
||||||
|
return sha256Hex(JSON.stringify({ command: launchSpec.command, args: launchSpec.args }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTempStatePath(): Promise<{ root: string; statePath: string }> {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-http-state-test-'));
|
||||||
|
const statePath = path.join(root, 'mcp-http-server', 'state.json');
|
||||||
|
await mkdir(path.dirname(statePath), { recursive: true });
|
||||||
|
return { root, statePath };
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIdentity(input: {
|
||||||
|
port: number;
|
||||||
|
launchSpec: { command: string; args: string[] };
|
||||||
|
ownerInstanceId?: string;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
service: 'agent-teams-mcp-http' as const,
|
||||||
|
transport: 'httpStream' as const,
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: input.port,
|
||||||
|
endpoint: '/mcp',
|
||||||
|
claudeDirHash: sha256Hex(getClaudeBasePath()),
|
||||||
|
launchSpecHash: buildLaunchSpecHash(input.launchSpec),
|
||||||
|
ownerInstanceId: input.ownerInstanceId ?? 'previous-owner',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildState(input: {
|
||||||
|
port: number;
|
||||||
|
pid?: number | null;
|
||||||
|
launchSpec: { command: string; args: string[] };
|
||||||
|
ownerInstanceId?: string;
|
||||||
|
}) {
|
||||||
|
const identity = buildIdentity(input);
|
||||||
|
const url = `http://127.0.0.1:${input.port}/mcp`;
|
||||||
|
return {
|
||||||
|
...identity,
|
||||||
|
url,
|
||||||
|
urlHash: sha256Hex(url),
|
||||||
|
pid: input.pid ?? null,
|
||||||
|
startedAt: '2026-05-21T00:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-21T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function allocateLoopbackPort(): Promise<number> {
|
async function allocateLoopbackPort(): Promise<number> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const server = net.createServer();
|
const server = net.createServer();
|
||||||
|
|
@ -44,6 +105,11 @@ async function allocateLoopbackPort(): Promise<number> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flushAsyncCleanup(): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
await Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
describe('AgentTeamsMcpHttpServer', () => {
|
describe('AgentTeamsMcpHttpServer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
hoisted.killProcessTreeMock.mockReset();
|
hoisted.killProcessTreeMock.mockReset();
|
||||||
|
|
@ -52,8 +118,9 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
|
|
||||||
it('starts the MCP server over HTTP with hidden app-owned process env', async () => {
|
it('starts the MCP server over HTTP with hidden app-owned process env', async () => {
|
||||||
const child = new FakeChildProcess();
|
const child = new FakeChildProcess();
|
||||||
const spawnProcess = vi.fn(() => child as any);
|
const spawnProcess = vi.fn(() => child as unknown as ChildProcess);
|
||||||
const server = new AgentTeamsMcpHttpServer({
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath: null,
|
||||||
resolveLaunchSpec: async () => ({
|
resolveLaunchSpec: async () => ({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['mcp-server/dist/index.js'],
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
|
@ -98,6 +165,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
],
|
],
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_HOST: '127.0.0.1',
|
||||||
AGENT_TEAMS_MCP_HTTP_PORT: '41001',
|
AGENT_TEAMS_MCP_HTTP_PORT: '41001',
|
||||||
AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp',
|
AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp',
|
||||||
})
|
})
|
||||||
|
|
@ -106,8 +174,9 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
|
|
||||||
it('uses a hidden default spawn without holding stdout open', async () => {
|
it('uses a hidden default spawn without holding stdout open', async () => {
|
||||||
const child = new FakeChildProcess();
|
const child = new FakeChildProcess();
|
||||||
hoisted.spawnCliMock.mockReturnValue(child as any);
|
hoisted.spawnCliMock.mockReturnValue(child as unknown as ChildProcess);
|
||||||
const server = new AgentTeamsMcpHttpServer({
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath: null,
|
||||||
resolveLaunchSpec: async () => ({
|
resolveLaunchSpec: async () => ({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['mcp-server/dist/index.js'],
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
|
@ -135,6 +204,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
env: expect.objectContaining({
|
env: expect.objectContaining({
|
||||||
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||||
|
AGENT_TEAMS_MCP_HTTP_HOST: '127.0.0.1',
|
||||||
AGENT_TEAMS_MCP_HTTP_PORT: '41005',
|
AGENT_TEAMS_MCP_HTTP_PORT: '41005',
|
||||||
AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp',
|
AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp',
|
||||||
}),
|
}),
|
||||||
|
|
@ -146,8 +216,9 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
|
|
||||||
it('coalesces concurrent starts', async () => {
|
it('coalesces concurrent starts', async () => {
|
||||||
const child = new FakeChildProcess();
|
const child = new FakeChildProcess();
|
||||||
const spawnProcess = vi.fn(() => child as any);
|
const spawnProcess = vi.fn(() => child as unknown as ChildProcess);
|
||||||
const server = new AgentTeamsMcpHttpServer({
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath: null,
|
||||||
resolveLaunchSpec: async () => ({
|
resolveLaunchSpec: async () => ({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['mcp-server/dist/index.js'],
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
|
@ -163,11 +234,206 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
expect(spawnProcess).toHaveBeenCalledTimes(1);
|
expect(spawnProcess).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the persistent state lock so a concurrent second instance adopts the first', async () => {
|
||||||
|
const { root, statePath } = await createTempStatePath();
|
||||||
|
const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] };
|
||||||
|
const child = new FakeChildProcess(43131);
|
||||||
|
const spawnProcess = vi.fn(() => child as unknown as ChildProcess);
|
||||||
|
const probeHealth = vi.fn(async (_host: string, port: number) => ({
|
||||||
|
healthy: true,
|
||||||
|
statusCode: 200,
|
||||||
|
identity: buildIdentity({ port, launchSpec, ownerInstanceId: 'first-instance' }),
|
||||||
|
}));
|
||||||
|
let lockTail = Promise.resolve();
|
||||||
|
let activeLocks = 0;
|
||||||
|
let maxActiveLocks = 0;
|
||||||
|
const withStateLock: NonNullable<AgentTeamsMcpHttpServerDeps['withStateLock']> = async (
|
||||||
|
_filePath,
|
||||||
|
fn
|
||||||
|
) => {
|
||||||
|
const previous = lockTail;
|
||||||
|
let release!: () => void;
|
||||||
|
lockTail = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
await previous;
|
||||||
|
activeLocks += 1;
|
||||||
|
maxActiveLocks = Math.max(maxActiveLocks, activeLocks);
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
activeLocks -= 1;
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let releaseFirstReady!: () => void;
|
||||||
|
const firstReady = new Promise<void>((resolve) => {
|
||||||
|
releaseFirstReady = resolve;
|
||||||
|
});
|
||||||
|
const waitForPort = vi.fn(async () => {
|
||||||
|
await firstReady;
|
||||||
|
});
|
||||||
|
const firstServer = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath,
|
||||||
|
disableOrphanCleanup: true,
|
||||||
|
resolveLaunchSpec: async () => launchSpec,
|
||||||
|
allocatePort: async () => 41024,
|
||||||
|
spawnProcess,
|
||||||
|
waitForPort,
|
||||||
|
probeHealth,
|
||||||
|
withStateLock,
|
||||||
|
});
|
||||||
|
const secondServer = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath,
|
||||||
|
disableOrphanCleanup: true,
|
||||||
|
resolveLaunchSpec: async () => launchSpec,
|
||||||
|
allocatePort: async () => 41025,
|
||||||
|
spawnProcess,
|
||||||
|
waitForPort,
|
||||||
|
probeHealth,
|
||||||
|
withStateLock,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const firstStart = firstServer.ensureStarted();
|
||||||
|
await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledTimes(1));
|
||||||
|
const secondStart = secondServer.ensureStarted();
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
|
||||||
|
expect(spawnProcess).toHaveBeenCalledTimes(1);
|
||||||
|
releaseFirstReady();
|
||||||
|
const [first, second] = await Promise.all([firstStart, secondStart]);
|
||||||
|
|
||||||
|
expect(first.port).toBe(41024);
|
||||||
|
expect(second.port).toBe(41024);
|
||||||
|
expect(second.diagnostics).toContain('opencode_app_mcp_adopted_state_server:41024');
|
||||||
|
expect(spawnProcess).toHaveBeenCalledTimes(1);
|
||||||
|
expect(waitForPort).toHaveBeenCalledTimes(1);
|
||||||
|
expect(maxActiveLocks).toBe(1);
|
||||||
|
} finally {
|
||||||
|
releaseFirstReady();
|
||||||
|
await lockTail;
|
||||||
|
await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 });
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts a healthy MCP HTTP server from persistent state without spawning', async () => {
|
||||||
|
const { root, statePath } = await createTempStatePath();
|
||||||
|
const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] };
|
||||||
|
const port = 41021;
|
||||||
|
const identity = buildIdentity({ port, launchSpec });
|
||||||
|
await writeFile(
|
||||||
|
statePath,
|
||||||
|
`${JSON.stringify(buildState({ port, pid: 51234, launchSpec }), null, 2)}\n`
|
||||||
|
);
|
||||||
|
const spawnProcess = vi.fn();
|
||||||
|
const probeHealth = vi.fn(async () => ({
|
||||||
|
healthy: true,
|
||||||
|
statusCode: 200,
|
||||||
|
identity,
|
||||||
|
}));
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath,
|
||||||
|
disableOrphanCleanup: true,
|
||||||
|
resolveLaunchSpec: async () => launchSpec,
|
||||||
|
spawnProcess: spawnProcess as AgentTeamsMcpHttpServerDeps['spawnProcess'],
|
||||||
|
probeHealth,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await server.ensureStarted();
|
||||||
|
|
||||||
|
expect(handle).toMatchObject({
|
||||||
|
url: `http://127.0.0.1:${port}/mcp`,
|
||||||
|
port,
|
||||||
|
pid: 51234,
|
||||||
|
diagnostics: [`opencode_app_mcp_adopted_state_server:${port}`],
|
||||||
|
});
|
||||||
|
expect(spawnProcess).not.toHaveBeenCalled();
|
||||||
|
expect(probeHealth).toHaveBeenCalledWith('127.0.0.1', port);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores corrupt persistent state and starts a fresh server', async () => {
|
||||||
|
const { root, statePath } = await createTempStatePath();
|
||||||
|
const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] };
|
||||||
|
await writeFile(statePath, '{not-json', 'utf8');
|
||||||
|
const child = new FakeChildProcess();
|
||||||
|
const spawnProcess = vi.fn(() => child as unknown as ChildProcess);
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath,
|
||||||
|
disableOrphanCleanup: true,
|
||||||
|
resolveLaunchSpec: async () => launchSpec,
|
||||||
|
allocatePort: async () => 41022,
|
||||||
|
spawnProcess,
|
||||||
|
waitForPort: vi.fn(async () => undefined),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await server.ensureStarted();
|
||||||
|
|
||||||
|
expect(handle.port).toBe(41022);
|
||||||
|
expect(handle.diagnostics).toContain('opencode_app_mcp_state_ignored:parse_failed');
|
||||||
|
expect(spawnProcess).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('adopts a healthy matching MCP HTTP server on the configured stable port', async () => {
|
||||||
|
const { root, statePath } = await createTempStatePath();
|
||||||
|
const launchSpec = { command: 'node', args: ['mcp-server/dist/index.js'] };
|
||||||
|
const port = 41023;
|
||||||
|
const identity = buildIdentity({ port, launchSpec });
|
||||||
|
const previousPortEnv = process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT;
|
||||||
|
process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT = String(port);
|
||||||
|
const spawnProcess = vi.fn();
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath,
|
||||||
|
disableOrphanCleanup: true,
|
||||||
|
resolveLaunchSpec: async () => launchSpec,
|
||||||
|
spawnProcess: spawnProcess as AgentTeamsMcpHttpServerDeps['spawnProcess'],
|
||||||
|
canListenOnPort: async () => false,
|
||||||
|
probeHealth: vi.fn(async () => ({
|
||||||
|
healthy: true,
|
||||||
|
statusCode: 200,
|
||||||
|
identity,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await server.ensureStarted();
|
||||||
|
await server.stop();
|
||||||
|
|
||||||
|
expect(handle).toMatchObject({
|
||||||
|
port,
|
||||||
|
pid: null,
|
||||||
|
diagnostics: [`opencode_app_mcp_adopted_port_server:${port}`],
|
||||||
|
});
|
||||||
|
expect(spawnProcess).not.toHaveBeenCalled();
|
||||||
|
expect(hoisted.killProcessTreeMock).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
if (previousPortEnv === undefined) {
|
||||||
|
delete process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT;
|
||||||
|
} else {
|
||||||
|
process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT = previousPortEnv;
|
||||||
|
}
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('reuses an existing handle only after its health check still passes', async () => {
|
it('reuses an existing handle only after its health check still passes', async () => {
|
||||||
const child = new FakeChildProcess();
|
const child = new FakeChildProcess();
|
||||||
const spawnProcess = vi.fn(() => child as any);
|
const spawnProcess = vi.fn(() => child as unknown as ChildProcess);
|
||||||
const waitForPort = vi.fn(async () => undefined);
|
const waitForPort = vi.fn(async () => undefined);
|
||||||
const server = new AgentTeamsMcpHttpServer({
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath: null,
|
||||||
resolveLaunchSpec: async () => ({
|
resolveLaunchSpec: async () => ({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['mcp-server/dist/index.js'],
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
|
@ -182,7 +448,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
|
|
||||||
expect(second).toBe(first);
|
expect(second).toBe(first);
|
||||||
expect(spawnProcess).toHaveBeenCalledTimes(1);
|
expect(spawnProcess).toHaveBeenCalledTimes(1);
|
||||||
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41006, 5_000);
|
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41006, 10_000);
|
||||||
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41006, 3_000);
|
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41006, 3_000);
|
||||||
expect(hoisted.killProcessTreeMock).not.toHaveBeenCalled();
|
expect(hoisted.killProcessTreeMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
@ -192,8 +458,8 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
const secondChild = new FakeChildProcess(43124);
|
const secondChild = new FakeChildProcess(43124);
|
||||||
const spawnProcess = vi
|
const spawnProcess = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockReturnValueOnce(firstChild as any)
|
.mockReturnValueOnce(firstChild as unknown as ChildProcess)
|
||||||
.mockReturnValueOnce(secondChild as any);
|
.mockReturnValueOnce(secondChild as unknown as ChildProcess);
|
||||||
const allocatePort = vi.fn().mockResolvedValueOnce(41007).mockResolvedValueOnce(41008);
|
const allocatePort = vi.fn().mockResolvedValueOnce(41007).mockResolvedValueOnce(41008);
|
||||||
const waitForPort = vi.fn(async (_host: string, port: number, timeoutMs: number) => {
|
const waitForPort = vi.fn(async (_host: string, port: number, timeoutMs: number) => {
|
||||||
if (port === 41007 && timeoutMs === 3_000) {
|
if (port === 41007 && timeoutMs === 3_000) {
|
||||||
|
|
@ -201,6 +467,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const server = new AgentTeamsMcpHttpServer({
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath: null,
|
||||||
resolveLaunchSpec: async () => ({
|
resolveLaunchSpec: async () => ({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['mcp-server/dist/index.js'],
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
|
@ -208,6 +475,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
allocatePort,
|
allocatePort,
|
||||||
spawnProcess,
|
spawnProcess,
|
||||||
waitForPort,
|
waitForPort,
|
||||||
|
probeHealth: vi.fn(async () => ({ healthy: false, statusCode: null, identity: null })),
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = await server.ensureStarted();
|
const first = await server.ensureStarted();
|
||||||
|
|
@ -230,9 +498,9 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
expect(spawnProcess).toHaveBeenCalledTimes(2);
|
expect(spawnProcess).toHaveBeenCalledTimes(2);
|
||||||
expect(allocatePort).toHaveBeenCalledTimes(1);
|
expect(allocatePort).toHaveBeenCalledTimes(1);
|
||||||
expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(firstChild, 'SIGKILL');
|
expect(hoisted.killProcessTreeMock).toHaveBeenCalledWith(firstChild, 'SIGKILL');
|
||||||
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 5_000);
|
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 10_000);
|
||||||
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 3_000);
|
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 3_000);
|
||||||
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 5_000);
|
expect(waitForPort).toHaveBeenCalledWith('127.0.0.1', 41007, 10_000);
|
||||||
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check');
|
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain('failed health reuse check');
|
||||||
expect(vi.mocked(console.warn).mock.calls[1]?.join(' ')).toContain(
|
expect(vi.mocked(console.warn).mock.calls[1]?.join(' ')).toContain(
|
||||||
'opencode_app_mcp_restart_reason:health_reuse_failed'
|
'opencode_app_mcp_restart_reason:health_reuse_failed'
|
||||||
|
|
@ -243,23 +511,12 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
it('falls back without killing unknown processes when the preferred restart port stays occupied', async () => {
|
it('falls back without killing unknown processes when the preferred restart port stays occupied', async () => {
|
||||||
const firstChild = new FakeChildProcess(43123);
|
const firstChild = new FakeChildProcess(43123);
|
||||||
const secondChild = new FakeChildProcess(43124);
|
const secondChild = new FakeChildProcess(43124);
|
||||||
const blocker = net.createServer();
|
const blockedPort = 41041;
|
||||||
const blockedPort = await new Promise<number>((resolve, reject) => {
|
const fallbackPort = 41042;
|
||||||
blocker.once('error', reject);
|
|
||||||
blocker.listen(0, '127.0.0.1', () => {
|
|
||||||
const address = blocker.address();
|
|
||||||
if (!address || typeof address === 'string') {
|
|
||||||
reject(new Error('Failed to allocate blocked test port'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(address.port);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const fallbackPort = blockedPort === 65_535 ? blockedPort - 1 : blockedPort + 1;
|
|
||||||
const spawnProcess = vi
|
const spawnProcess = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockReturnValueOnce(firstChild as any)
|
.mockReturnValueOnce(firstChild as unknown as ChildProcess)
|
||||||
.mockReturnValueOnce(secondChild as any);
|
.mockReturnValueOnce(secondChild as unknown as ChildProcess);
|
||||||
const allocatePort = vi
|
const allocatePort = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(blockedPort)
|
.mockResolvedValueOnce(blockedPort)
|
||||||
|
|
@ -270,6 +527,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const server = new AgentTeamsMcpHttpServer({
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath: null,
|
||||||
resolveLaunchSpec: async () => ({
|
resolveLaunchSpec: async () => ({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['mcp-server/dist/index.js'],
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
|
@ -277,40 +535,172 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
allocatePort,
|
allocatePort,
|
||||||
spawnProcess,
|
spawnProcess,
|
||||||
waitForPort,
|
waitForPort,
|
||||||
|
canListenOnPort: async (_host, port) => port !== blockedPort,
|
||||||
|
probeHealth: vi.fn(async () => ({ healthy: false, statusCode: null, identity: null })),
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await server.ensureStarted();
|
||||||
|
const second = await server.ensureStarted();
|
||||||
|
|
||||||
|
expect(first.url).toBe(`http://127.0.0.1:${blockedPort}/mcp`);
|
||||||
|
expect(second).toMatchObject({
|
||||||
|
url: `http://127.0.0.1:${fallbackPort}/mcp`,
|
||||||
|
port: fallbackPort,
|
||||||
|
pid: 43124,
|
||||||
|
generation: 2,
|
||||||
|
});
|
||||||
|
expect(second.diagnostics).toContain('opencode_app_mcp_public_url_changed');
|
||||||
|
expect(second.diagnostics).toContain(
|
||||||
|
`opencode_app_mcp_preferred_port_unavailable:${blockedPort}`
|
||||||
|
);
|
||||||
|
expect(hoisted.killProcessTreeMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(allocatePort).toHaveBeenCalledTimes(2);
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cleans up a proven legacy orphan MCP HTTP process without live consumers', async () => {
|
||||||
|
const { root, statePath } = await createTempStatePath();
|
||||||
|
const child = new FakeChildProcess(43123);
|
||||||
|
const orphanPort = 41031;
|
||||||
|
const alivePids = new Set([9001, 9005]);
|
||||||
|
const killProcess = vi.fn((pid: number) => {
|
||||||
|
alivePids.delete(pid);
|
||||||
|
});
|
||||||
|
const command = `node /repo/mcp-server/src/index.ts --transport httpStream --host 127.0.0.1 --port ${orphanPort} --endpoint /mcp`;
|
||||||
|
const rows = [
|
||||||
|
{ pid: 9001, ppid: 1, command },
|
||||||
|
{ pid: 9005, ppid: 9001, command },
|
||||||
|
{ pid: 43123, ppid: process.pid, command: 'current child' },
|
||||||
|
];
|
||||||
|
const details = `${command} AGENT_TEAMS_MCP_CLAUDE_DIR=${getClaudeBasePath()} AGENT_TEAMS_MCP_TRANSPORT=httpStream AGENT_TEAMS_MCP_HTTP_HOST=127.0.0.1 AGENT_TEAMS_MCP_HTTP_PORT=${orphanPort} AGENT_TEAMS_MCP_HTTP_ENDPOINT=/mcp`;
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath,
|
||||||
|
resolveLaunchSpec: async () => ({
|
||||||
|
command: 'node',
|
||||||
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
}),
|
||||||
|
allocatePort: async () => 41030,
|
||||||
|
spawnProcess: vi.fn(() => child as unknown as ChildProcess),
|
||||||
|
waitForPort: vi.fn(async () => undefined),
|
||||||
|
listProcessRows: async () => rows,
|
||||||
|
readProcessDetails: async (pid) => (pid === 9001 || pid === 9005 ? details : null),
|
||||||
|
readProcessStartTimeMs: async () => 0,
|
||||||
|
killProcess,
|
||||||
|
forceKillProcess: vi.fn(),
|
||||||
|
isProcessAlive: (pid) => alivePids.has(pid),
|
||||||
|
sleepMs: async () => undefined,
|
||||||
|
probeHealth: vi.fn(async () => ({ healthy: true, statusCode: 200, identity: null })),
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const first = await server.ensureStarted();
|
const handle = await server.ensureStarted();
|
||||||
const second = await server.ensureStarted();
|
await flushAsyncCleanup();
|
||||||
|
|
||||||
expect(first.url).toBe(`http://127.0.0.1:${blockedPort}/mcp`);
|
expect(killProcess).toHaveBeenNthCalledWith(1, 9005);
|
||||||
expect(second).toMatchObject({
|
expect(killProcess).toHaveBeenNthCalledWith(2, 9001);
|
||||||
url: `http://127.0.0.1:${fallbackPort}/mcp`,
|
expect(handle.diagnostics).toContain(
|
||||||
port: fallbackPort,
|
`opencode_app_mcp_legacy_orphan_cleaned:${orphanPort}`
|
||||||
pid: 43124,
|
|
||||||
generation: 2,
|
|
||||||
});
|
|
||||||
expect(second.diagnostics).toContain('opencode_app_mcp_public_url_changed');
|
|
||||||
expect(second.diagnostics).toContain(
|
|
||||||
`opencode_app_mcp_preferred_port_unavailable:${blockedPort}`
|
|
||||||
);
|
);
|
||||||
expect(hoisted.killProcessTreeMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(allocatePort).toHaveBeenCalledTimes(2);
|
|
||||||
} finally {
|
} finally {
|
||||||
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
await rm(root, { recursive: true, force: true });
|
||||||
vi.mocked(console.warn).mockClear();
|
vi.mocked(console.warn).mockClear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps a proven legacy orphan MCP HTTP process when live consumers still reference it', async () => {
|
||||||
|
const { root, statePath } = await createTempStatePath();
|
||||||
|
const child = new FakeChildProcess(43123);
|
||||||
|
const orphanPort = 41033;
|
||||||
|
const url = `http://127.0.0.1:${orphanPort}/mcp`;
|
||||||
|
const command = `node /repo/mcp-server/src/index.ts --transport httpStream --host 127.0.0.1 --port ${orphanPort} --endpoint /mcp`;
|
||||||
|
const rows = [
|
||||||
|
{ pid: 9002, ppid: 1, command },
|
||||||
|
{ pid: 9003, ppid: 1, command: 'consumer process' },
|
||||||
|
{ pid: 43123, ppid: process.pid, command: 'current child' },
|
||||||
|
];
|
||||||
|
const orphanDetails = `${command} AGENT_TEAMS_MCP_CLAUDE_DIR=${getClaudeBasePath()} AGENT_TEAMS_MCP_TRANSPORT=httpStream AGENT_TEAMS_MCP_HTTP_HOST=127.0.0.1 AGENT_TEAMS_MCP_HTTP_PORT=${orphanPort} AGENT_TEAMS_MCP_HTTP_ENDPOINT=/mcp`;
|
||||||
|
const killProcess = vi.fn();
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath,
|
||||||
|
resolveLaunchSpec: async () => ({
|
||||||
|
command: 'node',
|
||||||
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
}),
|
||||||
|
allocatePort: async () => 41032,
|
||||||
|
spawnProcess: vi.fn(() => child as unknown as ChildProcess),
|
||||||
|
waitForPort: vi.fn(async () => undefined),
|
||||||
|
listProcessRows: async () => rows,
|
||||||
|
readProcessDetails: async (pid) =>
|
||||||
|
pid === 9002
|
||||||
|
? orphanDetails
|
||||||
|
: `CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=${url}`,
|
||||||
|
readProcessStartTimeMs: async () => 0,
|
||||||
|
killProcess,
|
||||||
|
isProcessAlive: () => false,
|
||||||
|
sleepMs: async () => undefined,
|
||||||
|
probeHealth: vi.fn(async () => ({ healthy: true, statusCode: 200, identity: null })),
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await server.ensureStarted();
|
||||||
|
await flushAsyncCleanup();
|
||||||
|
|
||||||
|
expect(killProcess).not.toHaveBeenCalled();
|
||||||
|
expect(handle.diagnostics).toContain(
|
||||||
|
`opencode_app_mcp_legacy_orphan_kept_live_consumers:${orphanPort}`
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not clean up MCP-like processes that still have a live parent', async () => {
|
||||||
|
const { root, statePath } = await createTempStatePath();
|
||||||
|
const child = new FakeChildProcess(43123);
|
||||||
|
const orphanPort = 41035;
|
||||||
|
const command = `node /repo/mcp-server/src/index.ts --transport httpStream --host 127.0.0.1 --port ${orphanPort} --endpoint /mcp`;
|
||||||
|
const killProcess = vi.fn();
|
||||||
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath,
|
||||||
|
resolveLaunchSpec: async () => ({
|
||||||
|
command: 'node',
|
||||||
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
}),
|
||||||
|
allocatePort: async () => 41034,
|
||||||
|
spawnProcess: vi.fn(() => child as unknown as ChildProcess),
|
||||||
|
waitForPort: vi.fn(async () => undefined),
|
||||||
|
listProcessRows: async () => [
|
||||||
|
{ pid: 9004, ppid: 1234, command },
|
||||||
|
{ pid: 43123, ppid: process.pid, command: 'current child' },
|
||||||
|
],
|
||||||
|
readProcessDetails: vi.fn(),
|
||||||
|
readProcessStartTimeMs: vi.fn(),
|
||||||
|
killProcess,
|
||||||
|
isProcessAlive: (pid) => pid === 1234,
|
||||||
|
sleepMs: async () => undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.ensureStarted();
|
||||||
|
await flushAsyncCleanup();
|
||||||
|
|
||||||
|
expect(killProcess).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('fails startup promptly when the child exits before readiness', async () => {
|
it('fails startup promptly when the child exits before readiness', async () => {
|
||||||
const child = new FakeChildProcess();
|
const child = new FakeChildProcess();
|
||||||
const server = new AgentTeamsMcpHttpServer({
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath: null,
|
||||||
resolveLaunchSpec: async () => ({
|
resolveLaunchSpec: async () => ({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['mcp-server/dist/index.js'],
|
args: ['mcp-server/dist/index.js'],
|
||||||
}),
|
}),
|
||||||
allocatePort: async () => 41003,
|
allocatePort: async () => 41003,
|
||||||
spawnProcess: vi.fn(() => child as any),
|
spawnProcess: vi.fn(() => child as unknown as ChildProcess),
|
||||||
waitForPort: vi.fn(() => {
|
waitForPort: vi.fn(() => {
|
||||||
child.emit('exit', 1, null);
|
child.emit('exit', 1, null);
|
||||||
return new Promise<void>(() => {
|
return new Promise<void>(() => {
|
||||||
|
|
@ -332,12 +722,13 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
it('does not return a handle if the child exits during readiness polling', async () => {
|
it('does not return a handle if the child exits during readiness polling', async () => {
|
||||||
const child = new FakeChildProcess();
|
const child = new FakeChildProcess();
|
||||||
const server = new AgentTeamsMcpHttpServer({
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath: null,
|
||||||
resolveLaunchSpec: async () => ({
|
resolveLaunchSpec: async () => ({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['mcp-server/dist/index.js'],
|
args: ['mcp-server/dist/index.js'],
|
||||||
}),
|
}),
|
||||||
allocatePort: async () => 41004,
|
allocatePort: async () => 41004,
|
||||||
spawnProcess: vi.fn(() => child as any),
|
spawnProcess: vi.fn(() => child as unknown as ChildProcess),
|
||||||
waitForPort: vi.fn(async () => {
|
waitForPort: vi.fn(async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
child.emit('exit', 0, null);
|
child.emit('exit', 0, null);
|
||||||
|
|
@ -371,9 +762,10 @@ describe('AgentTeamsMcpHttpServer', () => {
|
||||||
const spawnProcess = vi.fn((_command: string, args: string[]) => {
|
const spawnProcess = vi.fn((_command: string, args: string[]) => {
|
||||||
expect(args).toContain(String(port));
|
expect(args).toContain(String(port));
|
||||||
healthServer.listen(port, '127.0.0.1');
|
healthServer.listen(port, '127.0.0.1');
|
||||||
return child as any;
|
return child as unknown as ChildProcess;
|
||||||
});
|
});
|
||||||
const server = new AgentTeamsMcpHttpServer({
|
const server = new AgentTeamsMcpHttpServer({
|
||||||
|
statePath: null,
|
||||||
resolveLaunchSpec: async () => ({
|
resolveLaunchSpec: async () => ({
|
||||||
command: 'node',
|
command: 'node',
|
||||||
args: ['mcp-server/dist/index.js'],
|
args: ['mcp-server/dist/index.js'],
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,40 @@
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { afterAll, afterEach, beforeEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
import { afterEach, beforeEach, expect, vi } from 'vitest';
|
const TEST_HOME_PREFIX = 'agent-teams-vitest-home-';
|
||||||
|
const DEFAULT_STALE_TEST_HOME_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function getStaleTestHomeMaxAgeMs(): number {
|
||||||
|
const value = Number(process.env.AGENT_TEAMS_VITEST_STALE_HOME_MAX_AGE_MS);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : DEFAULT_STALE_TEST_HOME_MAX_AGE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupStaleTestHomeDirs(): void {
|
||||||
|
const cutoff = Date.now() - getStaleTestHomeMaxAgeMs();
|
||||||
|
|
||||||
|
for (const entry of fs.readdirSync(os.tmpdir(), { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory() || !entry.name.startsWith(TEST_HOME_PREFIX)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dir = path.join(os.tmpdir(), entry.name);
|
||||||
|
try {
|
||||||
|
const stat = fs.statSync(dir);
|
||||||
|
if (stat.mtimeMs < cutoff) {
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Best effort cleanup only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.AGENT_TEAMS_VITEST_TEMP_CLEANUP_DONE !== '1') {
|
||||||
|
process.env.AGENT_TEAMS_VITEST_TEMP_CLEANUP_DONE = '1';
|
||||||
|
cleanupStaleTestHomeDirs();
|
||||||
|
}
|
||||||
|
|
||||||
// Mock Sentry Electron SDK - it requires the real `electron` package at import
|
// Mock Sentry Electron SDK - it requires the real `electron` package at import
|
||||||
// time which is unavailable in the vitest/happy-dom environment.
|
// time which is unavailable in the vitest/happy-dom environment.
|
||||||
|
|
@ -33,11 +65,22 @@ vi.mock('@sentry/react', () => sentryNoOp);
|
||||||
// Mock HOME for tests that need a predictable home path. It must be writable:
|
// Mock HOME for tests that need a predictable home path. It must be writable:
|
||||||
// some services persist state in best-effort background writes after a test has
|
// some services persist state in best-effort background writes after a test has
|
||||||
// already reset path overrides.
|
// already reset path overrides.
|
||||||
const testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-teams-vitest-home-'));
|
const testHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), TEST_HOME_PREFIX));
|
||||||
vi.stubEnv('HOME', testHomeDir);
|
vi.stubEnv('HOME', testHomeDir);
|
||||||
process.once('exit', () => {
|
let testHomeDirRemoved = false;
|
||||||
fs.rmSync(testHomeDir, { recursive: true, force: true });
|
function removeTestHomeDir(): void {
|
||||||
});
|
if (testHomeDirRemoved) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
testHomeDirRemoved = true;
|
||||||
|
try {
|
||||||
|
fs.rmSync(testHomeDir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Best effort cleanup only.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
afterAll(removeTestHomeDir);
|
||||||
|
process.once('exit', removeTestHomeDir);
|
||||||
|
|
||||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||||
let warnSpy: ReturnType<typeof vi.spyOn>;
|
let warnSpy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue