fix(opencode): hide app mcp child processes on windows
This commit is contained in:
parent
58ff926f4b
commit
3ceef1fb82
9 changed files with 854 additions and 37 deletions
|
|
@ -5,6 +5,24 @@ import { FastMCP } from 'fastmcp';
|
|||
|
||||
import { registerTools } from './tools';
|
||||
|
||||
const HTTP_TRANSPORT = 'httpStream';
|
||||
const STDIO_TRANSPORT = 'stdio';
|
||||
const DEFAULT_HTTP_HOST = '127.0.0.1';
|
||||
const DEFAULT_HTTP_ENDPOINT = '/mcp';
|
||||
|
||||
export type AgentTeamsMcpStartOptions =
|
||||
| {
|
||||
transportType: typeof STDIO_TRANSPORT;
|
||||
}
|
||||
| {
|
||||
transportType: typeof HTTP_TRANSPORT;
|
||||
httpStream: {
|
||||
host: string;
|
||||
port: number;
|
||||
endpoint: `/${string}`;
|
||||
};
|
||||
};
|
||||
|
||||
export function createServer() {
|
||||
const server = new FastMCP({
|
||||
name: 'agent-teams-mcp',
|
||||
|
|
@ -16,9 +34,64 @@ export function createServer() {
|
|||
return server;
|
||||
}
|
||||
|
||||
function getArgValue(argv: string[], name: string): string | null {
|
||||
const directPrefix = `${name}=`;
|
||||
for (let index = 2; index < argv.length; index += 1) {
|
||||
const value = argv[index];
|
||||
if (value === name) {
|
||||
return argv[index + 1] ?? null;
|
||||
}
|
||||
if (value.startsWith(directPrefix)) {
|
||||
return value.slice(directPrefix.length);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeEndpoint(value: string | null | undefined): `/${string}` {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_HTTP_ENDPOINT;
|
||||
}
|
||||
return (trimmed.startsWith('/') ? trimmed : `/${trimmed}`) as `/${string}`;
|
||||
}
|
||||
|
||||
function parsePort(value: string | null | undefined): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
|
||||
throw new Error(`Invalid agent-teams MCP HTTP port: ${value ?? '<empty>'}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function resolveStartOptions(
|
||||
argv: string[] = process.argv,
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): AgentTeamsMcpStartOptions {
|
||||
const transport =
|
||||
getArgValue(argv, '--transport') ??
|
||||
getArgValue(argv, '--transportType') ??
|
||||
env.AGENT_TEAMS_MCP_TRANSPORT ??
|
||||
STDIO_TRANSPORT;
|
||||
|
||||
if (transport !== HTTP_TRANSPORT) {
|
||||
return { transportType: STDIO_TRANSPORT };
|
||||
}
|
||||
|
||||
return {
|
||||
transportType: HTTP_TRANSPORT,
|
||||
httpStream: {
|
||||
host:
|
||||
getArgValue(argv, '--host')?.trim() ||
|
||||
env.AGENT_TEAMS_MCP_HTTP_HOST?.trim() ||
|
||||
DEFAULT_HTTP_HOST,
|
||||
port: parsePort(getArgValue(argv, '--port') ?? env.AGENT_TEAMS_MCP_HTTP_PORT),
|
||||
endpoint: normalizeEndpoint(getArgValue(argv, '--endpoint') ?? env.AGENT_TEAMS_MCP_HTTP_ENDPOINT),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const server = createServer();
|
||||
void server.start({
|
||||
transportType: 'stdio',
|
||||
});
|
||||
void server.start(resolveStartOptions());
|
||||
}
|
||||
|
|
|
|||
54
mcp-server/test/startOptions.test.ts
Normal file
54
mcp-server/test/startOptions.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveStartOptions } from '../src/index';
|
||||
|
||||
describe('agent-teams MCP start options', () => {
|
||||
it('defaults to stdio transport', () => {
|
||||
expect(resolveStartOptions(['node', 'index.js'], {})).toEqual({
|
||||
transportType: 'stdio',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves HTTP stream transport from CLI args', () => {
|
||||
expect(
|
||||
resolveStartOptions(
|
||||
[
|
||||
'node',
|
||||
'index.js',
|
||||
'--transport',
|
||||
'httpStream',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'43123',
|
||||
'--endpoint',
|
||||
'mcp',
|
||||
],
|
||||
{}
|
||||
)
|
||||
).toEqual({
|
||||
transportType: 'httpStream',
|
||||
httpStream: {
|
||||
host: '127.0.0.1',
|
||||
port: 43123,
|
||||
endpoint: '/mcp',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves HTTP stream transport from environment', () => {
|
||||
expect(
|
||||
resolveStartOptions(['node', 'index.js'], {
|
||||
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||
AGENT_TEAMS_MCP_HTTP_PORT: '43124',
|
||||
})
|
||||
).toEqual({
|
||||
transportType: 'httpStream',
|
||||
httpStream: {
|
||||
host: '127.0.0.1',
|
||||
port: 43124,
|
||||
endpoint: '/mcp',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -133,6 +133,7 @@ import {
|
|||
import { startEventLoopLagMonitor } from './services/infrastructure/EventLoopLagMonitor';
|
||||
import { HttpServer } from './services/infrastructure/HttpServer';
|
||||
import { clearAutoResumeService } from './services/team/AutoResumeService';
|
||||
import { agentTeamsMcpHttpServer } from './services/team/AgentTeamsMcpHttpServer';
|
||||
import { LaunchIoGovernor } from './services/team/LaunchIoGovernor';
|
||||
import { OpenCodeBridgeCommandClient } from './services/team/opencode/bridge/OpenCodeBridgeCommandClient';
|
||||
import {
|
||||
|
|
@ -381,23 +382,37 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
|||
);
|
||||
}
|
||||
try {
|
||||
reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...');
|
||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({
|
||||
onProgress: ({ phase, message }) => reportProgress(`mcp-${phase}`, message),
|
||||
});
|
||||
const mcpEntry = mcpLaunchSpec.args[0];
|
||||
if (mcpEntry) {
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry;
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args);
|
||||
}
|
||||
reportProgress('runtime-mcp-http', 'Starting Agent Teams MCP server...');
|
||||
const mcpHttpServer = await agentTeamsMcpHttpServer.ensureStarted();
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL = mcpHttpServer.url;
|
||||
reportProgress('runtime-mcp-http-ready', 'Agent Teams MCP server is ready...');
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${
|
||||
`[OpenCode] Runtime adapter bridge MCP HTTP server unavailable: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
if (!bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL) {
|
||||
try {
|
||||
reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...');
|
||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({
|
||||
onProgress: ({ phase, message }) => reportProgress(`mcp-${phase}`, message),
|
||||
});
|
||||
const mcpEntry = mcpLaunchSpec.args[0];
|
||||
if (mcpEntry) {
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY = mcpEntry;
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ARGS_JSON = JSON.stringify(mcpLaunchSpec.args);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[OpenCode] Runtime adapter bridge MCP entrypoint unresolved: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
||||
const bridgeClient = new OpenCodeBridgeCommandClient({
|
||||
|
|
@ -2081,6 +2096,9 @@ async function shutdownServices(): Promise<void> {
|
|||
() => cleanupOpenCodeHostsForLifecycle('shutdown'),
|
||||
10_000
|
||||
);
|
||||
await runShutdownStep('Agent Teams MCP HTTP server cleanup', () =>
|
||||
agentTeamsMcpHttpServer.stop()
|
||||
);
|
||||
await runShutdownStep('tracked CLI subprocess cleanup', () =>
|
||||
killTrackedCliProcesses('SIGKILL')
|
||||
);
|
||||
|
|
|
|||
212
src/main/services/team/AgentTeamsMcpHttpServer.ts
Normal file
212
src/main/services/team/AgentTeamsMcpHttpServer.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { spawnCli, killProcessTree } from '@main/utils/childProcess';
|
||||
import { getClaudeBasePath } from '@main/utils/pathDecoder';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { type ChildProcess } from 'child_process';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
|
||||
import { type McpLaunchSpec, resolveAgentTeamsMcpLaunchSpec } from './TeamMcpConfigBuilder';
|
||||
|
||||
const logger = createLogger('Service:AgentTeamsMcpHttpServer');
|
||||
const MCP_HTTP_HOST = '127.0.0.1';
|
||||
const MCP_HTTP_ENDPOINT = '/mcp';
|
||||
const MCP_HTTP_READY_TIMEOUT_MS = 5_000;
|
||||
const MCP_HTTP_READY_POLL_MS = 100;
|
||||
|
||||
export interface AgentTeamsMcpHttpServerHandle {
|
||||
url: string;
|
||||
port: number;
|
||||
pid: number | null;
|
||||
}
|
||||
|
||||
export interface AgentTeamsMcpHttpServerDeps {
|
||||
resolveLaunchSpec?: () => Promise<McpLaunchSpec>;
|
||||
allocatePort?: () => Promise<number>;
|
||||
spawnProcess?: (command: string, args: string[], env: NodeJS.ProcessEnv) => ChildProcess;
|
||||
waitForPort?: (host: string, port: number, timeoutMs: number) => Promise<void>;
|
||||
}
|
||||
|
||||
async function allocateLoopbackPort(): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.once('error', reject);
|
||||
server.listen(0, MCP_HTTP_HOST, () => {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to allocate Agent Teams MCP HTTP port')));
|
||||
return;
|
||||
}
|
||||
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(address.port);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function isHealthReady(host: string, port: number): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const request = http.get(
|
||||
{
|
||||
host,
|
||||
port,
|
||||
path: '/health',
|
||||
timeout: MCP_HTTP_READY_POLL_MS,
|
||||
},
|
||||
(response) => {
|
||||
response.resume();
|
||||
resolve((response.statusCode ?? 500) >= 200 && (response.statusCode ?? 500) < 300);
|
||||
}
|
||||
);
|
||||
request.once('timeout', () => {
|
||||
request.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
request.once('error', () => {
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForLoopbackPort(host: string, port: number, timeoutMs: number): Promise<void> {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
if (await isHealthReady(host, port)) {
|
||||
return;
|
||||
}
|
||||
await sleep(MCP_HTTP_READY_POLL_MS);
|
||||
}
|
||||
throw new Error(
|
||||
`Agent Teams MCP HTTP server did not become healthy at ${host}:${port} in ${timeoutMs}ms`
|
||||
);
|
||||
}
|
||||
|
||||
function defaultSpawnProcess(
|
||||
command: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv
|
||||
): ChildProcess {
|
||||
return spawnCli(command, args, {
|
||||
env,
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
}
|
||||
|
||||
function buildHttpServerArgs(launchSpec: McpLaunchSpec, port: number): string[] {
|
||||
return [
|
||||
...launchSpec.args,
|
||||
'--transport',
|
||||
'httpStream',
|
||||
'--host',
|
||||
MCP_HTTP_HOST,
|
||||
'--port',
|
||||
String(port),
|
||||
'--endpoint',
|
||||
MCP_HTTP_ENDPOINT,
|
||||
];
|
||||
}
|
||||
|
||||
export class AgentTeamsMcpHttpServer {
|
||||
private startPromise: Promise<AgentTeamsMcpHttpServerHandle> | null = null;
|
||||
private child: ChildProcess | null = null;
|
||||
private handle: AgentTeamsMcpHttpServerHandle | null = null;
|
||||
|
||||
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
|
||||
|
||||
async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> {
|
||||
if (this.handle) {
|
||||
return this.handle;
|
||||
}
|
||||
if (this.startPromise) {
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
this.startPromise = this.startOnce().finally(() => {
|
||||
this.startPromise = null;
|
||||
});
|
||||
return this.startPromise;
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
const child = this.child;
|
||||
this.child = null;
|
||||
this.handle = null;
|
||||
if (child) {
|
||||
killProcessTree(child, 'SIGKILL');
|
||||
}
|
||||
}
|
||||
|
||||
private async startOnce(): Promise<AgentTeamsMcpHttpServerHandle> {
|
||||
const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec;
|
||||
const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort;
|
||||
const spawnProcess = this.deps.spawnProcess ?? defaultSpawnProcess;
|
||||
const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort;
|
||||
const launchSpec = await resolveLaunchSpec();
|
||||
const port = await allocatePort();
|
||||
const args = buildHttpServerArgs(launchSpec, port);
|
||||
const child = spawnProcess(launchSpec.command, args, {
|
||||
...process.env,
|
||||
AGENT_TEAMS_MCP_CLAUDE_DIR: getClaudeBasePath(),
|
||||
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||
AGENT_TEAMS_MCP_HTTP_HOST: MCP_HTTP_HOST,
|
||||
AGENT_TEAMS_MCP_HTTP_PORT: String(port),
|
||||
AGENT_TEAMS_MCP_HTTP_ENDPOINT: MCP_HTTP_ENDPOINT,
|
||||
});
|
||||
|
||||
const clearIfCurrent = (): void => {
|
||||
if (this.child === child) {
|
||||
this.child = null;
|
||||
this.handle = null;
|
||||
}
|
||||
};
|
||||
child.once('exit', (code, signal) => {
|
||||
clearIfCurrent();
|
||||
logger.warn(
|
||||
`Agent Teams MCP HTTP server exited${typeof code === 'number' ? ` with code ${code}` : ''}${
|
||||
signal ? ` (${signal})` : ''
|
||||
}`
|
||||
);
|
||||
});
|
||||
child.once('error', (error) => {
|
||||
clearIfCurrent();
|
||||
logger.warn(
|
||||
`Agent Teams MCP HTTP server process error: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
});
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
const text = chunk.toString('utf8').trim();
|
||||
if (text) {
|
||||
logger.debug(`Agent Teams MCP HTTP stderr: ${text.slice(0, 1000)}`);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await waitForPort(MCP_HTTP_HOST, port, MCP_HTTP_READY_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
killProcessTree(child, 'SIGKILL');
|
||||
throw error;
|
||||
}
|
||||
|
||||
this.child = child;
|
||||
this.handle = {
|
||||
url: `http://${MCP_HTTP_HOST}:${port}${MCP_HTTP_ENDPOINT}`,
|
||||
port,
|
||||
pid: child.pid ?? null,
|
||||
};
|
||||
logger.info(`Agent Teams MCP HTTP server running at ${this.handle.url}`);
|
||||
return this.handle;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentTeamsMcpHttpServer = new AgentTeamsMcpHttpServer();
|
||||
|
|
@ -5996,6 +5996,10 @@ export class TeamProvisioningService {
|
|||
private toolApprovalSettingsByTeam = new Map<string, ToolApprovalSettings>();
|
||||
private pendingTimeouts = new Map<string, NodeJS.Timeout>();
|
||||
private inFlightResponses = new Set<string>();
|
||||
private readonly prepareForProvisioningInFlight = new Map<
|
||||
string,
|
||||
Promise<TeamProvisioningPrepareResult>
|
||||
>();
|
||||
private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null;
|
||||
private controlApiBaseUrlResolver: (() => Promise<string | null>) | null = null;
|
||||
private workspaceTrustCoordinator: WorkspaceTrustCoordinator | null = null;
|
||||
|
|
@ -18064,6 +18068,74 @@ export class TeamProvisioningService {
|
|||
limitContext?: boolean;
|
||||
modelVerificationMode?: TeamProvisioningModelVerificationMode;
|
||||
}
|
||||
): Promise<TeamProvisioningPrepareResult> {
|
||||
const inFlightKey = this.createPrepareForProvisioningInFlightKey(cwd, opts);
|
||||
const inFlight = this.prepareForProvisioningInFlight.get(inFlightKey);
|
||||
if (inFlight) {
|
||||
return this.clonePrepareForProvisioningResult(await inFlight);
|
||||
}
|
||||
|
||||
const request = this.prepareForProvisioningOnce(cwd, opts).finally(() => {
|
||||
if (this.prepareForProvisioningInFlight.get(inFlightKey) === request) {
|
||||
this.prepareForProvisioningInFlight.delete(inFlightKey);
|
||||
}
|
||||
});
|
||||
this.prepareForProvisioningInFlight.set(inFlightKey, request);
|
||||
return this.clonePrepareForProvisioningResult(await request);
|
||||
}
|
||||
|
||||
private createPrepareForProvisioningInFlightKey(
|
||||
cwd?: string,
|
||||
opts?: {
|
||||
forceFresh?: boolean;
|
||||
providerId?: TeamProviderId;
|
||||
providerIds?: TeamProviderId[];
|
||||
modelIds?: string[];
|
||||
limitContext?: boolean;
|
||||
modelVerificationMode?: TeamProvisioningModelVerificationMode;
|
||||
}
|
||||
): string {
|
||||
const providerIds = Array.from(
|
||||
new Set(
|
||||
[opts?.providerId, ...(opts?.providerIds ?? [])]
|
||||
.map((providerId) => resolveTeamProviderId(providerId))
|
||||
.filter((providerId): providerId is TeamProviderId => Boolean(providerId))
|
||||
)
|
||||
);
|
||||
const modelIds = Array.from(
|
||||
new Set((opts?.modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean))
|
||||
);
|
||||
return JSON.stringify({
|
||||
cwd: cwd?.trim() || process.cwd(),
|
||||
forceFresh: opts?.forceFresh === true,
|
||||
providerIds,
|
||||
modelIds,
|
||||
limitContext: opts?.limitContext === true,
|
||||
modelVerificationMode: opts?.modelVerificationMode ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
private clonePrepareForProvisioningResult(
|
||||
result: TeamProvisioningPrepareResult
|
||||
): TeamProvisioningPrepareResult {
|
||||
return {
|
||||
...result,
|
||||
details: result.details ? [...result.details] : undefined,
|
||||
warnings: result.warnings ? [...result.warnings] : undefined,
|
||||
issues: result.issues?.map((issue) => ({ ...issue })),
|
||||
};
|
||||
}
|
||||
|
||||
private async prepareForProvisioningOnce(
|
||||
cwd?: string,
|
||||
opts?: {
|
||||
forceFresh?: boolean;
|
||||
providerId?: TeamProviderId;
|
||||
providerIds?: TeamProviderId[];
|
||||
modelIds?: string[];
|
||||
limitContext?: boolean;
|
||||
modelVerificationMode?: TeamProvisioningModelVerificationMode;
|
||||
}
|
||||
): Promise<TeamProvisioningPrepareResult> {
|
||||
const targetCwdForValidation = cwd?.trim() || process.cwd();
|
||||
await this.validatePrepareCwd(targetCwdForValidation);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
type RuntimeProcessTableRow,
|
||||
} from '@features/tmux-installer/main';
|
||||
import { killProcessByPid } from '@main/utils/processKill';
|
||||
import { listWindowsProcessTable } from '@main/utils/windowsProcessTable';
|
||||
import { execFile, type ExecFileException } from 'child_process';
|
||||
|
||||
export type OpenCodeManagedHostCleanupMode = 'orphaned' | 'force';
|
||||
|
|
@ -37,11 +38,15 @@ export interface OpenCodeManagedHostProcessCleanupOptions {
|
|||
sleepMs?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
const OPENCODE_SERVE_COMMAND_RE = /(^|[/\\\s])opencode(?:\.exe)?(?=\s|$).*?(?:^|\s)serve(?=\s|$)/i;
|
||||
const OPENCODE_SERVE_COMMAND_RE =
|
||||
/(^|[/\\\s"])opencode(?:\.exe)?(?:"?)(?=\s|$).*?(?:^|\s)serve(?=\s|$)/i;
|
||||
const WINDOWS_APP_MANAGED_OPENCODE_SERVE_RE =
|
||||
/[\\/]runtimes[\\/]opencode[\\/]versions[\\/][^"'\s]+[\\/]opencode-windows-[^"'\s]+[\\/]opencode\.exe(?:"|\s|$)/i;
|
||||
const MANAGED_ENV_MARKERS = ['CLAUDE_MULTIMODEL_DATA_HOME=', 'OPENCODE_CONFIG_CONTENT='] as const;
|
||||
const MANAGED_ENV_IDENTITY_MARKERS = [
|
||||
'AGENT_TEAMS_MCP_CLAUDE_DIR=',
|
||||
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=',
|
||||
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=',
|
||||
] as const;
|
||||
|
||||
export async function cleanupManagedOpenCodeServeProcesses(
|
||||
|
|
@ -55,18 +60,18 @@ export async function cleanupManagedOpenCodeServeProcesses(
|
|||
diagnostics: [],
|
||||
};
|
||||
|
||||
if (platform === 'win32') {
|
||||
result.diagnostics.push(
|
||||
'Managed OpenCode serve process fallback cleanup is skipped on Windows.'
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
const rows = await (options.listProcessRows ?? listRuntimeProcessesForCurrentTmuxPlatform)();
|
||||
const rows = await (
|
||||
options.listProcessRows ??
|
||||
(platform === 'win32' ? listWindowsProcessTable : listRuntimeProcessesForCurrentTmuxPlatform)
|
||||
)();
|
||||
const excludePids = options.excludePids ?? new Set<number>();
|
||||
const requiredDetailsMarkers = options.requiredDetailsMarkers ?? [];
|
||||
const readDetails = options.readProcessDetails ?? readNativeProcessCommandWithEnv;
|
||||
const readStartTimeMs = options.readProcessStartTimeMs ?? readNativeProcessStartTimeMs;
|
||||
const readDetails =
|
||||
options.readProcessDetails ??
|
||||
(platform === 'win32' ? async () => null : readNativeProcessCommandWithEnv);
|
||||
const readStartTimeMs =
|
||||
options.readProcessStartTimeMs ??
|
||||
(platform === 'win32' ? readWindowsProcessStartTimeMs : readNativeProcessStartTimeMs);
|
||||
const disposeServeHost = options.disposeServeHost ?? disposeOpenCodeServeHost;
|
||||
const killProcess = options.killProcess ?? killProcessByPid;
|
||||
const forceKillProcess =
|
||||
|
|
@ -91,16 +96,23 @@ export async function cleanupManagedOpenCodeServeProcesses(
|
|||
}
|
||||
|
||||
const details = await readDetails(row.pid);
|
||||
if (
|
||||
!details ||
|
||||
!isManagedOpenCodeServeProcessDetails(details) ||
|
||||
!processDetailsIncludeMarkers(details, requiredDetailsMarkers)
|
||||
) {
|
||||
const isManaged =
|
||||
platform === 'win32'
|
||||
? isAppManagedWindowsOpenCodeServeCommand(row.command) ||
|
||||
Boolean(details && isManagedOpenCodeServeProcessDetails(details))
|
||||
: Boolean(details && isManagedOpenCodeServeProcessDetails(details));
|
||||
const hasRequiredDetailsMarkers =
|
||||
requiredDetailsMarkers.length === 0 ||
|
||||
Boolean(details && processDetailsIncludeMarkers(details, requiredDetailsMarkers));
|
||||
if (!isManaged || !hasRequiredDetailsMarkers) {
|
||||
result.candidates.push({
|
||||
pid: row.pid,
|
||||
ppid: row.ppid,
|
||||
action: 'kept_unmanaged',
|
||||
reason: 'process does not carry Agent Teams managed OpenCode environment markers',
|
||||
reason:
|
||||
platform === 'win32'
|
||||
? 'process is not an app-managed Windows OpenCode serve command'
|
||||
: 'process does not carry Agent Teams managed OpenCode environment markers',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
|
@ -122,7 +134,9 @@ export async function cleanupManagedOpenCodeServeProcesses(
|
|||
});
|
||||
continue;
|
||||
}
|
||||
if (row.ppid !== 1) {
|
||||
const parentMayStillOwnProcess =
|
||||
platform === 'win32' ? row.ppid > 0 && isProcessAlive(row.ppid) : row.ppid !== 1;
|
||||
if (parentMayStillOwnProcess) {
|
||||
result.candidates.push({
|
||||
pid: row.pid,
|
||||
ppid: row.ppid,
|
||||
|
|
@ -177,6 +191,14 @@ export function isOpenCodeServeCommand(command: string): boolean {
|
|||
return OPENCODE_SERVE_COMMAND_RE.test(command.trim());
|
||||
}
|
||||
|
||||
export function isAppManagedWindowsOpenCodeServeCommand(command: string): boolean {
|
||||
const normalizedCommand = command.trim().replace(/\//g, '\\');
|
||||
return (
|
||||
isOpenCodeServeCommand(normalizedCommand) &&
|
||||
WINDOWS_APP_MANAGED_OPENCODE_SERVE_RE.test(normalizedCommand)
|
||||
);
|
||||
}
|
||||
|
||||
export function isManagedOpenCodeServeProcessDetails(details: string): boolean {
|
||||
return (
|
||||
processDetailsIncludeMarkers(details, MANAGED_ENV_MARKERS) &&
|
||||
|
|
@ -251,6 +273,30 @@ async function readNativeProcessStartTimeMs(pid: number): Promise<number | null>
|
|||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
async function readWindowsProcessStartTimeMs(pid: number): Promise<number | null> {
|
||||
const normalizedPid = Math.trunc(pid);
|
||||
if (!Number.isFinite(normalizedPid) || normalizedPid <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const script = [
|
||||
'$ErrorActionPreference = "Stop"',
|
||||
`$process = Get-Process -Id ${normalizedPid} -ErrorAction Stop`,
|
||||
'$process.StartTime.ToUniversalTime().ToString("o")',
|
||||
].join('; ');
|
||||
const output = await execFileText(
|
||||
'powershell.exe',
|
||||
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
||||
2_000,
|
||||
64 * 1024
|
||||
);
|
||||
if (!output) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Date.parse(output.trim());
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function isNativeProcessAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
|
|
|
|||
185
test/main/services/team/AgentTeamsMcpHttpServer.test.ts
Normal file
185
test/main/services/team/AgentTeamsMcpHttpServer.test.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import { EventEmitter } from 'events';
|
||||
import http from 'http';
|
||||
import net from 'net';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
killProcessTreeMock: vi.fn(),
|
||||
spawnCliMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@main/utils/childProcess', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@main/utils/childProcess')>();
|
||||
return {
|
||||
...actual,
|
||||
killProcessTree: (...args: unknown[]) => hoisted.killProcessTreeMock(...args),
|
||||
spawnCli: (...args: unknown[]) => hoisted.spawnCliMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
import { AgentTeamsMcpHttpServer } from '@main/services/team/AgentTeamsMcpHttpServer';
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
pid = 43123;
|
||||
stderr = new EventEmitter();
|
||||
}
|
||||
|
||||
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 port')));
|
||||
return;
|
||||
}
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe('AgentTeamsMcpHttpServer', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.killProcessTreeMock.mockReset();
|
||||
hoisted.spawnCliMock.mockReset();
|
||||
});
|
||||
|
||||
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 server = new AgentTeamsMcpHttpServer({
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
}),
|
||||
allocatePort: async () => 41001,
|
||||
spawnProcess,
|
||||
waitForPort: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const handle = await server.ensureStarted();
|
||||
|
||||
expect(handle).toEqual({
|
||||
url: 'http://127.0.0.1:41001/mcp',
|
||||
port: 41001,
|
||||
pid: 43123,
|
||||
});
|
||||
expect(spawnProcess).toHaveBeenCalledWith(
|
||||
'node',
|
||||
[
|
||||
'mcp-server/dist/index.js',
|
||||
'--transport',
|
||||
'httpStream',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'41001',
|
||||
'--endpoint',
|
||||
'/mcp',
|
||||
],
|
||||
expect.objectContaining({
|
||||
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||
AGENT_TEAMS_MCP_HTTP_PORT: '41001',
|
||||
AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('uses a hidden default spawn without holding stdout open', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
hoisted.spawnCliMock.mockReturnValue(child as any);
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
}),
|
||||
allocatePort: async () => 41005,
|
||||
waitForPort: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const handle = await server.ensureStarted();
|
||||
|
||||
expect(handle.pid).toBe(43123);
|
||||
expect(hoisted.spawnCliMock).toHaveBeenCalledWith(
|
||||
'node',
|
||||
[
|
||||
'mcp-server/dist/index.js',
|
||||
'--transport',
|
||||
'httpStream',
|
||||
'--host',
|
||||
'127.0.0.1',
|
||||
'--port',
|
||||
'41005',
|
||||
'--endpoint',
|
||||
'/mcp',
|
||||
],
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
AGENT_TEAMS_MCP_TRANSPORT: 'httpStream',
|
||||
AGENT_TEAMS_MCP_HTTP_PORT: '41005',
|
||||
AGENT_TEAMS_MCP_HTTP_ENDPOINT: '/mcp',
|
||||
}),
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
windowsHide: true,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('coalesces concurrent starts', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const spawnProcess = vi.fn(() => child as any);
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
}),
|
||||
allocatePort: async () => 41002,
|
||||
spawnProcess,
|
||||
waitForPort: async () => undefined,
|
||||
});
|
||||
|
||||
const [first, second] = await Promise.all([server.ensureStarted(), server.ensureStarted()]);
|
||||
|
||||
expect(first).toBe(second);
|
||||
expect(spawnProcess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('waits for the HTTP health endpoint before marking the server ready', async () => {
|
||||
const child = new FakeChildProcess();
|
||||
const port = await allocateLoopbackPort();
|
||||
let healthRequests = 0;
|
||||
const healthServer = http.createServer((request, response) => {
|
||||
if (request.url === '/health') {
|
||||
healthRequests += 1;
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.end('ok');
|
||||
return;
|
||||
}
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
});
|
||||
const spawnProcess = vi.fn((_command: string, args: string[]) => {
|
||||
expect(args).toContain(String(port));
|
||||
healthServer.listen(port, '127.0.0.1');
|
||||
return child as any;
|
||||
});
|
||||
const server = new AgentTeamsMcpHttpServer({
|
||||
resolveLaunchSpec: async () => ({
|
||||
command: 'node',
|
||||
args: ['mcp-server/dist/index.js'],
|
||||
}),
|
||||
allocatePort: async () => port,
|
||||
spawnProcess,
|
||||
});
|
||||
|
||||
try {
|
||||
const handle = await server.ensureStarted();
|
||||
|
||||
expect(handle.url).toBe(`http://127.0.0.1:${port}/mcp`);
|
||||
expect(healthRequests).toBeGreaterThan(0);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => healthServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||
import {
|
||||
cleanupManagedOpenCodeServeProcesses,
|
||||
getOpenCodeServeLoopbackBaseUrl,
|
||||
isAppManagedWindowsOpenCodeServeCommand,
|
||||
isManagedOpenCodeServeProcessDetails,
|
||||
isOpenCodeServeCommand,
|
||||
} from '@main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup';
|
||||
|
|
@ -14,6 +15,12 @@ const MANAGED_DETAILS = [
|
|||
'AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude',
|
||||
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=/tmp/mcp-entry.js',
|
||||
].join(' ');
|
||||
const MANAGED_DETAILS_WITH_REMOTE_MCP = [
|
||||
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
|
||||
'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime',
|
||||
'OPENCODE_CONFIG_CONTENT={}',
|
||||
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=http://127.0.0.1:58461/mcp',
|
||||
].join(' ');
|
||||
const MANAGED_DETAILS_WITH_WORKSPACE_MCP = [
|
||||
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
|
||||
'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime',
|
||||
|
|
@ -34,8 +41,27 @@ describe('OpenCodeManagedHostProcessCleanup', () => {
|
|||
expect(isOpenCodeServeCommand('node mcp-server/src/index.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('identifies app-managed Windows OpenCode serve commands', () => {
|
||||
expect(
|
||||
isAppManagedWindowsOpenCodeServeCommand(
|
||||
'"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913'
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAppManagedWindowsOpenCodeServeCommand(
|
||||
'C:\\tools\\opencode.exe serve --hostname 127.0.0.1 --port 49913'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAppManagedWindowsOpenCodeServeCommand(
|
||||
'C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe auth login'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('requires Agent Teams managed environment markers', () => {
|
||||
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS)).toBe(true);
|
||||
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_REMOTE_MCP)).toBe(true);
|
||||
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_WORKSPACE_MCP)).toBe(true);
|
||||
expect(
|
||||
isManagedOpenCodeServeProcessDetails(
|
||||
|
|
@ -347,7 +373,62 @@ describe('OpenCodeManagedHostProcessCleanup', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('skips fallback cleanup on Windows because environment markers are unavailable', async () => {
|
||||
it('kills old orphaned app-managed Windows OpenCode serve processes', async () => {
|
||||
const killProcess = vi.fn();
|
||||
const disposeServeHost = vi.fn(() => resolved(undefined));
|
||||
|
||||
const result = await cleanupManagedOpenCodeServeProcesses({
|
||||
mode: 'orphaned',
|
||||
platform: 'win32',
|
||||
startedBeforeMs: Date.parse('2026-05-16T00:47:55.000Z'),
|
||||
listProcessRows: () =>
|
||||
resolved([
|
||||
{
|
||||
pid: 71628,
|
||||
ppid: 86256,
|
||||
command:
|
||||
'"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913',
|
||||
},
|
||||
]),
|
||||
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-16T00:35:31.000Z')),
|
||||
disposeServeHost,
|
||||
isProcessAlive: () => false,
|
||||
killProcess,
|
||||
});
|
||||
|
||||
expect(disposeServeHost).toHaveBeenCalledWith('http://127.0.0.1:49913');
|
||||
expect(killProcess).toHaveBeenCalledWith(71628);
|
||||
expect(result.killed).toBe(1);
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps app-managed Windows OpenCode serve processes while their parent is still alive', async () => {
|
||||
const killProcess = vi.fn();
|
||||
|
||||
const result = await cleanupManagedOpenCodeServeProcesses({
|
||||
mode: 'orphaned',
|
||||
platform: 'win32',
|
||||
startedBeforeMs: Date.parse('2026-05-16T00:47:55.000Z'),
|
||||
listProcessRows: () =>
|
||||
resolved([
|
||||
{
|
||||
pid: 71628,
|
||||
ppid: 86256,
|
||||
command:
|
||||
'"C:\\Users\\User\\AppData\\Roaming\\claude-agent-teams-ui\\data\\runtimes\\opencode\\versions\\1.14.48\\opencode-windows-x64\\opencode.exe" serve --hostname 127.0.0.1 --port 49913',
|
||||
},
|
||||
]),
|
||||
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-16T00:35:31.000Z')),
|
||||
isProcessAlive: (pid) => pid === 86256,
|
||||
killProcess,
|
||||
});
|
||||
|
||||
expect(killProcess).not.toHaveBeenCalled();
|
||||
expect(result.candidates[0]).toMatchObject({ pid: 71628, action: 'kept_recent' });
|
||||
});
|
||||
|
||||
it('does not kill unmanaged Windows OpenCode serve commands', async () => {
|
||||
const killProcess = vi.fn();
|
||||
|
||||
const result = await cleanupManagedOpenCodeServeProcesses({
|
||||
|
|
@ -358,15 +439,15 @@ describe('OpenCodeManagedHostProcessCleanup', () => {
|
|||
{
|
||||
pid: 500,
|
||||
ppid: 1,
|
||||
command: 'opencode.exe serve --hostname 127.0.0.1',
|
||||
command: 'C:\\tools\\opencode.exe serve --hostname 127.0.0.1',
|
||||
},
|
||||
]),
|
||||
readProcessDetails: () => resolved(MANAGED_DETAILS),
|
||||
killProcess,
|
||||
});
|
||||
|
||||
expect(killProcess).not.toHaveBeenCalled();
|
||||
expect(result.scanned).toBe(0);
|
||||
expect(result.diagnostics[0]).toContain('skipped on Windows');
|
||||
expect(result.scanned).toBe(1);
|
||||
expect(result.diagnostics).toEqual([]);
|
||||
expect(result.candidates[0]).toMatchObject({ pid: 500, action: 'kept_unmanaged' });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -661,6 +661,82 @@ describe('TeamProvisioningService prepare/auth behavior', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('coalesces duplicate OpenCode compatibility preflight requests while prepare is in flight', async () => {
|
||||
const prepareGate: { release?: () => void } = {};
|
||||
const prepare = vi.fn(
|
||||
async () =>
|
||||
new Promise<{
|
||||
ok: true;
|
||||
providerId: 'opencode';
|
||||
modelId: null;
|
||||
diagnostics: string[];
|
||||
warnings: string[];
|
||||
}>((resolve) => {
|
||||
prepareGate.release = () =>
|
||||
resolve({
|
||||
ok: true,
|
||||
providerId: 'opencode',
|
||||
modelId: null,
|
||||
diagnostics: [],
|
||||
warnings: [],
|
||||
});
|
||||
})
|
||||
);
|
||||
const registry = new TeamRuntimeAdapterRegistry([
|
||||
{
|
||||
providerId: 'opencode',
|
||||
prepare,
|
||||
getLastOpenCodeTeamLaunchReadiness: vi.fn(() => ({
|
||||
state: 'ready',
|
||||
launchAllowed: true,
|
||||
modelId: 'opencode/big-pickle',
|
||||
availableModels: ['opencode/big-pickle'],
|
||||
opencodeVersion: '1.0.0',
|
||||
installMethod: 'unknown',
|
||||
binaryPath: 'opencode',
|
||||
hostHealthy: true,
|
||||
appMcpConnected: true,
|
||||
requiredToolsPresent: true,
|
||||
permissionBridgeReady: true,
|
||||
issues: [],
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
})),
|
||||
launch: vi.fn(),
|
||||
reconcile: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
} as any,
|
||||
]);
|
||||
const svc = new TeamProvisioningService();
|
||||
svc.setRuntimeAdapterRegistry(registry);
|
||||
const opts = {
|
||||
providerId: 'opencode' as const,
|
||||
forceFresh: true,
|
||||
modelIds: ['opencode/big-pickle'],
|
||||
modelVerificationMode: 'compatibility' as const,
|
||||
};
|
||||
|
||||
const first = svc.prepareForProvisioning(tempRoot, opts);
|
||||
const second = svc.prepareForProvisioning(tempRoot, opts);
|
||||
|
||||
for (let attempt = 0; attempt < 20 && prepare.mock.calls.length === 0; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
expect(prepareGate.release).toBeTypeOf('function');
|
||||
prepareGate.release?.();
|
||||
|
||||
const [firstResult, secondResult] = await Promise.all([first, second]);
|
||||
|
||||
expect(prepare).toHaveBeenCalledTimes(1);
|
||||
expect(firstResult).not.toBe(secondResult);
|
||||
expect(firstResult.ready).toBe(true);
|
||||
expect(secondResult.ready).toBe(true);
|
||||
expect(firstResult.details).toContain(
|
||||
'Selected model opencode/big-pickle is compatible. Deep verification pending.'
|
||||
);
|
||||
});
|
||||
|
||||
it('checks every selected OpenCode model instead of only the first one', async () => {
|
||||
const prepare = vi.fn(async (input: { model?: string }) => {
|
||||
if (input.model === 'opencode/nemotron-3-super-free') {
|
||||
|
|
|
|||
Loading…
Reference in a new issue