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', () => {
|
||||
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) => {
|
||||
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');
|
||||
let attempts = 0;
|
||||
|
||||
|
|
@ -47,7 +61,7 @@ describe('atomic file writes', () => {
|
|||
});
|
||||
|
||||
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');
|
||||
let attempts = 0;
|
||||
|
||||
|
|
@ -69,7 +83,7 @@ describe('atomic file writes', () => {
|
|||
});
|
||||
|
||||
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');
|
||||
let attempts = 0;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,17 @@ const path = require('path');
|
|||
const { createController } = require('../src/index.js');
|
||||
|
||||
describe('agent-teams-controller API', () => {
|
||||
const tempDirs = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function makeClaudeDir() {
|
||||
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, 'tasks', 'my-team'), { recursive: true });
|
||||
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');
|
||||
|
||||
describe('crossTeam module', () => {
|
||||
const tempDirs = [];
|
||||
|
||||
function makeClaudeDir(teams = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'crossteam-test-'));
|
||||
tempDirs.push(dir);
|
||||
|
||||
for (const [teamName, config] of Object.entries(teams)) {
|
||||
const teamDir = path.join(dir, 'teams', teamName);
|
||||
|
|
@ -28,6 +31,9 @@ describe('crossTeam module', () => {
|
|||
// Reset cascade guard between tests
|
||||
const cascadeGuard = require('../src/internal/cascadeGuard.js');
|
||||
cascadeGuard.reset();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('sendCrossTeamMessage', () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,11 @@ const HTTP_TRANSPORT = 'httpStream';
|
|||
const STDIO_TRANSPORT = 'stdio';
|
||||
const DEFAULT_HTTP_HOST = '127.0.0.1';
|
||||
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 =
|
||||
| {
|
||||
|
|
@ -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({
|
||||
name: 'agent-teams-mcp',
|
||||
version: '1.0.0',
|
||||
...(input.healthIdentity
|
||||
? {
|
||||
health: {
|
||||
enabled: true,
|
||||
path: '/health',
|
||||
status: 200,
|
||||
message: JSON.stringify(input.healthIdentity),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
registerTools(server);
|
||||
|
|
@ -64,6 +91,45 @@ function parsePort(value: string | null | undefined): number {
|
|||
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(
|
||||
argv: string[] = process.argv,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
|
|
@ -92,6 +158,7 @@ export function resolveStartOptions(
|
|||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const server = createServer();
|
||||
void server.start(resolveStartOptions());
|
||||
const startOptions = 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 { resolveStartOptions } from '../src/index';
|
||||
import { buildHttpHealthIdentity, resolveStartOptions } from '../src/index';
|
||||
|
||||
describe('agent-teams MCP start options', () => {
|
||||
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', () => {
|
||||
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) {
|
||||
const tool = tools.get(name);
|
||||
|
|
@ -39,7 +46,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
}
|
||||
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"version": "0.0.44",
|
||||
"sourceRef": "v0.0.44",
|
||||
"version": "0.0.45",
|
||||
"sourceRef": "v0.0.45",
|
||||
"sourceRepository": "777genius/agent_teams_orchestrator",
|
||||
"releaseRepository": "777genius/agent-teams-ai",
|
||||
"releaseTag": "v2.0.0",
|
||||
"releaseTag": "v2.1.0",
|
||||
"assets": {
|
||||
"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",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"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",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"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",
|
||||
"binaryName": "claude-multimodel"
|
||||
},
|
||||
"win32-x64": {
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.44.zip",
|
||||
"file": "agent-teams-runtime-win32-x64-v0.0.45.zip",
|
||||
"archiveKind": "zip",
|
||||
"binaryName": "claude-multimodel.exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ const SNAPSHOT_CACHE_TTL_MS = 5_000;
|
|||
const RATE_LIMITS_CACHE_TTL_MS = 45_000;
|
||||
const LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS = 60_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 {
|
||||
payload: CodexAppServerGetAccountResponse;
|
||||
|
|
@ -487,15 +489,48 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
const now = Date.now();
|
||||
|
||||
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({
|
||||
preferredAuthMode,
|
||||
effectiveAuthMode: null,
|
||||
launchAllowed: false,
|
||||
launchIssueMessage: 'Codex CLI not found. Install Codex to use native account management.',
|
||||
launchIssueMessage: CODEX_CLI_NOT_FOUND_MESSAGE,
|
||||
launchReadinessState: 'runtime_missing',
|
||||
appServerState: 'runtime-missing',
|
||||
appServerStatusMessage:
|
||||
'Codex CLI not found. Install Codex to use native account management.',
|
||||
appServerStatusMessage: CODEX_CLI_NOT_FOUND_MESSAGE,
|
||||
managedAccount: null,
|
||||
apiKey,
|
||||
requiresOpenaiAuth: null,
|
||||
|
|
@ -521,7 +556,15 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
|
|||
let appServerStatusMessage: string | null = null;
|
||||
let accountPayload = this.lastKnownAccount?.payload ?? 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 shouldRequestRateLimits =
|
||||
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> {
|
||||
if (!this.snapshotCache) {
|
||||
return this.refreshSnapshot();
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -900,11 +900,6 @@ const InstalledBanner = ({
|
|||
</div>
|
||||
|
||||
<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 */}
|
||||
{canOpenExtensions && (
|
||||
<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 () => {
|
||||
readAccountMock.mockResolvedValue({
|
||||
account: createAccountResponse(),
|
||||
|
|
|
|||
|
|
@ -6,10 +6,9 @@ import net from 'node:net';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
|
||||
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 fs = require('node:fs');
|
||||
|
|
@ -24,6 +23,23 @@ const host = readArg('--host') || '127.0.0.1';
|
|||
const endpoint = readArg('--endpoint') || '/mcp';
|
||||
const port = Number(readArg('--port'));
|
||||
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() {
|
||||
if (!controlFile) {
|
||||
|
|
@ -58,7 +74,7 @@ const server = http.createServer((request, response) => {
|
|||
return;
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.end('ok');
|
||||
response.end(healthIdentity ? JSON.stringify(healthIdentity) : 'ok');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -255,11 +271,13 @@ async function writeFakeOpenCodeBridgeBinary(tempDir: string): Promise<string> {
|
|||
describePosix('AgentTeamsMcpHttpServer integration', () => {
|
||||
let tempDir: string | null = null;
|
||||
let originalControlFileEnv: string | undefined;
|
||||
let originalMcpHttpPortEnv: string | undefined;
|
||||
const servers: AgentTeamsMcpHttpServer[] = [];
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(path.join(os.tmpdir(), 'agent-teams-mcp-http-integration-'));
|
||||
originalControlFileEnv = process.env.AGENT_TEAMS_MCP_TEST_CONTROL_FILE;
|
||||
originalMcpHttpPortEnv = process.env.CLAUDE_TEAM_OPENCODE_MCP_HTTP_PORT;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
@ -270,6 +288,11 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
|||
} else {
|
||||
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) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
|
|
@ -280,8 +303,11 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
|||
scriptPath: string;
|
||||
controlFile: string;
|
||||
allocatePort?: () => Promise<number>;
|
||||
statePath?: string;
|
||||
}): AgentTeamsMcpHttpServer {
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
statePath: input.statePath ?? path.join(tempDir!, `mcp-http-state-${servers.length}.json`),
|
||||
disableOrphanCleanup: true,
|
||||
resolveLaunchSpec: () =>
|
||||
Promise.resolve({
|
||||
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 () => {
|
||||
const server = new AgentTeamsMcpHttpServer();
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
statePath: path.join(tempDir!, 'actual-mcp-http-state.json'),
|
||||
disableOrphanCleanup: true,
|
||||
});
|
||||
servers.push(server);
|
||||
|
||||
const handle = await server.ensureStarted();
|
||||
|
|
@ -321,6 +350,88 @@ describePosix('AgentTeamsMcpHttpServer integration', () => {
|
|||
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 () => {
|
||||
const scriptPath = await writeFakeMcpHttpServer(tempDir!);
|
||||
const controlFile = path.join(tempDir!, 'health-control.txt');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
import { type ChildProcess } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
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';
|
||||
|
||||
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 {
|
||||
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> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.killProcessTreeMock.mockReset();
|
||||
|
|
@ -52,8 +118,9 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
|
||||
it('starts the MCP server over HTTP with hidden app-owned process env', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const spawnProcess = vi.fn(() => child as any);
|
||||
const spawnProcess = vi.fn(() => child as unknown as ChildProcess);
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
statePath: null,
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
|
|
@ -98,6 +165,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
],
|
||||
expect.objectContaining({
|
||||
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||
AGENT_TEAMS_MCP_HTTP_HOST: '127.0.0.1',
|
||||
AGENT_TEAMS_MCP_HTTP_PORT: '41001',
|
||||
AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp',
|
||||
})
|
||||
|
|
@ -106,8 +174,9 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
|
||||
it('uses a hidden default spawn without holding stdout open', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
hoisted.spawnCliMock.mockReturnValue(child as any);
|
||||
hoisted.spawnCliMock.mockReturnValue(child as unknown as ChildProcess);
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
statePath: null,
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
|
|
@ -135,6 +204,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||
AGENT_TEAMS_MCP_HTTP_HOST: '127.0.0.1',
|
||||
AGENT_TEAMS_MCP_HTTP_PORT: '41005',
|
||||
AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp',
|
||||
}),
|
||||
|
|
@ -146,8 +216,9 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
|
||||
it('coalesces concurrent starts', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const spawnProcess = vi.fn(() => child as any);
|
||||
const spawnProcess = vi.fn(() => child as unknown as ChildProcess);
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
statePath: null,
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
|
|
@ -163,11 +234,206 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
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 () => {
|
||||
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 server = new AgentTeamsMcpHttpServer({
|
||||
statePath: null,
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
|
|
@ -182,7 +448,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
|
||||
expect(second).toBe(first);
|
||||
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(hoisted.killProcessTreeMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -192,8 +458,8 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
const secondChild = new FakeChildProcess(43124);
|
||||
const spawnProcess = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstChild as any)
|
||||
.mockReturnValueOnce(secondChild as any);
|
||||
.mockReturnValueOnce(firstChild as unknown as ChildProcess)
|
||||
.mockReturnValueOnce(secondChild as unknown as ChildProcess);
|
||||
const allocatePort = vi.fn().mockResolvedValueOnce(41007).mockResolvedValueOnce(41008);
|
||||
const waitForPort = vi.fn(async (_host: string, port: number, timeoutMs: number) => {
|
||||
if (port === 41007 && timeoutMs === 3_000) {
|
||||
|
|
@ -201,6 +467,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
}
|
||||
});
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
statePath: null,
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
|
|
@ -208,6 +475,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
allocatePort,
|
||||
spawnProcess,
|
||||
waitForPort,
|
||||
probeHealth: vi.fn(async () => ({ healthy: false, statusCode: null, identity: null })),
|
||||
});
|
||||
|
||||
const first = await server.ensureStarted();
|
||||
|
|
@ -230,9 +498,9 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
expect(spawnProcess).toHaveBeenCalledTimes(2);
|
||||
expect(allocatePort).toHaveBeenCalledTimes(1);
|
||||
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, 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[1]?.join(' ')).toContain(
|
||||
'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 () => {
|
||||
const firstChild = new FakeChildProcess(43123);
|
||||
const secondChild = new FakeChildProcess(43124);
|
||||
const blocker = net.createServer();
|
||||
const blockedPort = await new Promise<number>((resolve, reject) => {
|
||||
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 blockedPort = 41041;
|
||||
const fallbackPort = 41042;
|
||||
const spawnProcess = vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(firstChild as any)
|
||||
.mockReturnValueOnce(secondChild as any);
|
||||
.mockReturnValueOnce(firstChild as unknown as ChildProcess)
|
||||
.mockReturnValueOnce(secondChild as unknown as ChildProcess);
|
||||
const allocatePort = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(blockedPort)
|
||||
|
|
@ -270,6 +527,7 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
}
|
||||
});
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
statePath: null,
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
|
|
@ -277,40 +535,172 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
allocatePort,
|
||||
spawnProcess,
|
||||
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 {
|
||||
const first = await server.ensureStarted();
|
||||
const second = await server.ensureStarted();
|
||||
const handle = await server.ensureStarted();
|
||||
await flushAsyncCleanup();
|
||||
|
||||
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(killProcess).toHaveBeenNthCalledWith(1, 9005);
|
||||
expect(killProcess).toHaveBeenNthCalledWith(2, 9001);
|
||||
expect(handle.diagnostics).toContain(
|
||||
`opencode_app_mcp_legacy_orphan_cleaned:${orphanPort}`
|
||||
);
|
||||
expect(hoisted.killProcessTreeMock).toHaveBeenCalledTimes(1);
|
||||
expect(allocatePort).toHaveBeenCalledTimes(2);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => blocker.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
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 () => {
|
||||
const child = new FakeChildProcess();
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
statePath: null,
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
}),
|
||||
allocatePort: async () => 41003,
|
||||
spawnProcess: vi.fn(() => child as any),
|
||||
spawnProcess: vi.fn(() => child as unknown as ChildProcess),
|
||||
waitForPort: vi.fn(() => {
|
||||
child.emit('exit', 1, null);
|
||||
return new Promise<void>(() => {
|
||||
|
|
@ -332,12 +722,13 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
it('does not return a handle if the child exits during readiness polling', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
statePath: null,
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
}),
|
||||
allocatePort: async () => 41004,
|
||||
spawnProcess: vi.fn(() => child as any),
|
||||
spawnProcess: vi.fn(() => child as unknown as ChildProcess),
|
||||
waitForPort: vi.fn(async () => {
|
||||
await Promise.resolve();
|
||||
child.emit('exit', 0, null);
|
||||
|
|
@ -371,9 +762,10 @@ describe('AgentTeamsMcpHttpServer', () => {
|
|||
const spawnProcess = vi.fn((_command: string, args: string[]) => {
|
||||
expect(args).toContain(String(port));
|
||||
healthServer.listen(port, '127.0.0.1');
|
||||
return child as any;
|
||||
return child as unknown as ChildProcess;
|
||||
});
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
statePath: null,
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
|
|
|
|||
|
|
@ -6,8 +6,40 @@
|
|||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
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
|
||||
// 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:
|
||||
// some services persist state in best-effort background writes after a test has
|
||||
// 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);
|
||||
process.once('exit', () => {
|
||||
fs.rmSync(testHomeDir, { recursive: true, force: true });
|
||||
});
|
||||
let testHomeDirRemoved = false;
|
||||
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 warnSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue