480 lines
17 KiB
TypeScript
480 lines
17 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
|
|
import {
|
|
cleanupManagedOpenCodeServeProcesses,
|
|
getOpenCodeServeLoopbackBaseUrl,
|
|
isAppManagedWindowsOpenCodeServeCommand,
|
|
isManagedOpenCodeServeProcessDetails,
|
|
isOpenCodeServeCommand,
|
|
} from '@main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup';
|
|
|
|
const MANAGED_DETAILS = [
|
|
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
|
|
'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime',
|
|
'OPENCODE_CONFIG_CONTENT={}',
|
|
'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',
|
|
'OPENCODE_CONFIG_CONTENT={}',
|
|
'AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude',
|
|
].join(' ');
|
|
|
|
function resolved<T>(value: T): Promise<T> {
|
|
return Promise.resolve(value);
|
|
}
|
|
|
|
describe('OpenCodeManagedHostProcessCleanup', () => {
|
|
it('identifies OpenCode serve commands without matching other OpenCode commands', () => {
|
|
expect(isOpenCodeServeCommand('/opt/homebrew/bin/opencode serve --hostname 127.0.0.1')).toBe(
|
|
true
|
|
);
|
|
expect(isOpenCodeServeCommand('opencode runtime opencode-command --json')).toBe(false);
|
|
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(
|
|
'opencode serve CLAUDE_MULTIMODEL_DATA_HOME=/tmp OPENCODE_CONFIG_CONTENT={}'
|
|
)
|
|
).toBe(false);
|
|
expect(
|
|
isManagedOpenCodeServeProcessDetails(
|
|
'opencode serve OPENCODE_CONFIG_CONTENT={} AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude'
|
|
)
|
|
).toBe(false);
|
|
expect(
|
|
isManagedOpenCodeServeProcessDetails(
|
|
'opencode serve NOT_CLAUDE_MULTIMODEL_DATA_HOME=/tmp OPENCODE_CONFIG_CONTENT={} AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude'
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it('extracts only loopback OpenCode serve base URLs for disposal', () => {
|
|
expect(
|
|
getOpenCodeServeLoopbackBaseUrl(
|
|
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171'
|
|
)
|
|
).toBe('http://127.0.0.1:54171');
|
|
expect(getOpenCodeServeLoopbackBaseUrl('opencode serve --hostname=localhost --port=3000')).toBe(
|
|
'http://localhost:3000'
|
|
);
|
|
expect(getOpenCodeServeLoopbackBaseUrl('opencode serve --hostname ::1 --port 3001')).toBe(
|
|
['http:', '//[::1]:3001'].join('')
|
|
);
|
|
expect(getOpenCodeServeLoopbackBaseUrl('opencode serve --hostname 0.0.0.0 --port 3000')).toBe(
|
|
null
|
|
);
|
|
expect(
|
|
getOpenCodeServeLoopbackBaseUrl('opencode serve --hostname 127.0.0.1 --port 70000')
|
|
).toBe(null);
|
|
});
|
|
|
|
it('kills old orphaned managed OpenCode serve processes that are missing from registry cleanup', async () => {
|
|
const killProcess = vi.fn();
|
|
const disposeServeHost = vi.fn(() => resolved(undefined));
|
|
|
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
|
mode: 'orphaned',
|
|
platform: 'darwin',
|
|
startedBeforeMs: Date.parse('2026-05-13T17:00:00.000Z'),
|
|
listProcessRows: () =>
|
|
resolved([
|
|
{
|
|
pid: 51569,
|
|
ppid: 1,
|
|
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
|
|
},
|
|
{
|
|
pid: 51570,
|
|
ppid: 1,
|
|
command: '/opt/homebrew/bin/opencode runtime opencode-command --json',
|
|
},
|
|
]),
|
|
readProcessDetails: () => resolved(MANAGED_DETAILS),
|
|
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-13T16:27:14.000Z')),
|
|
disposeServeHost,
|
|
killProcess,
|
|
});
|
|
|
|
expect(disposeServeHost).toHaveBeenCalledWith('http://127.0.0.1:54171');
|
|
expect(killProcess).toHaveBeenCalledWith(51569);
|
|
expect(result.killed).toBe(1);
|
|
expect(result.scanned).toBe(1);
|
|
expect(result.candidates[0]).toMatchObject({ pid: 51569, action: 'killed' });
|
|
});
|
|
|
|
it('keeps registry-known pids during startup fallback cleanup', async () => {
|
|
const killProcess = vi.fn();
|
|
const readProcessDetails = vi.fn(() => resolved(MANAGED_DETAILS));
|
|
|
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
|
mode: 'orphaned',
|
|
platform: 'darwin',
|
|
excludePids: new Set([99469]),
|
|
startedBeforeMs: Date.parse('2026-05-13T17:00:00.000Z'),
|
|
listProcessRows: () =>
|
|
resolved([
|
|
{
|
|
pid: 99469,
|
|
ppid: 1,
|
|
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 60130',
|
|
},
|
|
]),
|
|
readProcessDetails,
|
|
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-13T16:27:14.000Z')),
|
|
killProcess,
|
|
});
|
|
|
|
expect(killProcess).not.toHaveBeenCalled();
|
|
expect(readProcessDetails).not.toHaveBeenCalled();
|
|
expect(result.candidates[0]).toMatchObject({ pid: 99469, action: 'kept_excluded' });
|
|
});
|
|
|
|
it('does not kill unmanaged OpenCode serve processes', async () => {
|
|
const killProcess = vi.fn();
|
|
|
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
|
mode: 'orphaned',
|
|
platform: 'darwin',
|
|
startedBeforeMs: Date.parse('2026-05-13T17:00:00.000Z'),
|
|
listProcessRows: () =>
|
|
resolved([
|
|
{
|
|
pid: 200,
|
|
ppid: 1,
|
|
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
|
|
},
|
|
]),
|
|
readProcessDetails: () => resolved('opencode serve HOME=/Users/belief'),
|
|
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-13T16:27:14.000Z')),
|
|
killProcess,
|
|
});
|
|
|
|
expect(killProcess).not.toHaveBeenCalled();
|
|
expect(result.candidates[0]).toMatchObject({ pid: 200, action: 'kept_unmanaged' });
|
|
});
|
|
|
|
it('continues killing a managed orphan when loopback dispose fails', async () => {
|
|
const killProcess = vi.fn();
|
|
const disposeServeHost = vi.fn(() => Promise.reject(new Error('dispose failed')));
|
|
|
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
|
mode: 'orphaned',
|
|
platform: 'darwin',
|
|
startedBeforeMs: Date.parse('2026-05-13T17:00:00.000Z'),
|
|
listProcessRows: () =>
|
|
resolved([
|
|
{
|
|
pid: 210,
|
|
ppid: 1,
|
|
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
|
|
},
|
|
]),
|
|
readProcessDetails: () => resolved(MANAGED_DETAILS),
|
|
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-13T16:27:14.000Z')),
|
|
disposeServeHost,
|
|
killProcess,
|
|
});
|
|
|
|
expect(disposeServeHost).toHaveBeenCalledWith('http://127.0.0.1:3000');
|
|
expect(killProcess).toHaveBeenCalledWith(210);
|
|
expect(result.diagnostics).toEqual([]);
|
|
});
|
|
|
|
it('keeps orphaned managed processes that started after this app instance began', async () => {
|
|
const killProcess = vi.fn();
|
|
|
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
|
mode: 'orphaned',
|
|
platform: 'darwin',
|
|
startedBeforeMs: Date.parse('2026-05-13T17:00:00.000Z'),
|
|
listProcessRows: () =>
|
|
resolved([
|
|
{
|
|
pid: 300,
|
|
ppid: 1,
|
|
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
|
|
},
|
|
]),
|
|
readProcessDetails: () => resolved(MANAGED_DETAILS),
|
|
readProcessStartTimeMs: () => resolved(Date.parse('2026-05-13T17:00:01.000Z')),
|
|
killProcess,
|
|
});
|
|
|
|
expect(killProcess).not.toHaveBeenCalled();
|
|
expect(result.candidates[0]).toMatchObject({ pid: 300, action: 'kept_recent' });
|
|
});
|
|
|
|
it('force-cleans managed OpenCode serve processes regardless of parent pid', async () => {
|
|
const killProcess = vi.fn();
|
|
|
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
|
mode: 'force',
|
|
platform: 'darwin',
|
|
listProcessRows: () =>
|
|
resolved([
|
|
{
|
|
pid: 400,
|
|
ppid: 123,
|
|
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
|
|
},
|
|
]),
|
|
readProcessDetails: () => resolved(MANAGED_DETAILS),
|
|
disposeServeHost: () => resolved(undefined),
|
|
isProcessAlive: () => false,
|
|
killProcess,
|
|
});
|
|
|
|
expect(killProcess).toHaveBeenCalledWith(400);
|
|
expect(result.candidates[0]).toMatchObject({ pid: 400, action: 'killed' });
|
|
});
|
|
|
|
it('escalates force cleanup when a managed OpenCode serve process survives SIGTERM', async () => {
|
|
const killProcess = vi.fn();
|
|
const forceKillProcess = vi.fn();
|
|
const isProcessAlive = vi.fn(() => true);
|
|
const sleepMs = vi.fn(() => resolved(undefined));
|
|
|
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
|
mode: 'force',
|
|
platform: 'darwin',
|
|
listProcessRows: () =>
|
|
resolved([
|
|
{
|
|
pid: 401,
|
|
ppid: 123,
|
|
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
|
|
},
|
|
]),
|
|
readProcessDetails: () => resolved(MANAGED_DETAILS),
|
|
disposeServeHost: () => resolved(undefined),
|
|
killProcess,
|
|
forceKillProcess,
|
|
isProcessAlive,
|
|
sleepMs,
|
|
});
|
|
|
|
expect(killProcess).toHaveBeenCalledWith(401);
|
|
expect(sleepMs).toHaveBeenCalledWith(250);
|
|
expect(forceKillProcess).toHaveBeenCalledWith(401);
|
|
expect(result.killed).toBe(1);
|
|
});
|
|
|
|
it('treats a raced force-kill ESRCH as success when the process is already gone', async () => {
|
|
const killProcess = vi.fn();
|
|
const forceKillProcess = vi.fn(() => {
|
|
throw new Error('ESRCH');
|
|
});
|
|
const isProcessAlive = vi
|
|
.fn()
|
|
.mockReturnValueOnce(true)
|
|
.mockReturnValueOnce(true)
|
|
.mockReturnValue(false);
|
|
|
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
|
mode: 'force',
|
|
platform: 'darwin',
|
|
listProcessRows: () =>
|
|
resolved([
|
|
{
|
|
pid: 402,
|
|
ppid: 123,
|
|
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
|
|
},
|
|
]),
|
|
readProcessDetails: () => resolved(MANAGED_DETAILS),
|
|
disposeServeHost: () => resolved(undefined),
|
|
killProcess,
|
|
forceKillProcess,
|
|
isProcessAlive,
|
|
sleepMs: () => resolved(undefined),
|
|
});
|
|
|
|
expect(result.killed).toBe(1);
|
|
expect(result.diagnostics).toEqual([]);
|
|
});
|
|
|
|
it('requires additional process detail markers when provided', async () => {
|
|
const killProcess = vi.fn();
|
|
|
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
|
mode: 'force',
|
|
platform: 'darwin',
|
|
requiredDetailsMarkers: ['CLAUDE_TEAM_APP_INSTANCE_ID=app-1'],
|
|
listProcessRows: () =>
|
|
resolved([
|
|
{
|
|
pid: 410,
|
|
ppid: 123,
|
|
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3000',
|
|
},
|
|
{
|
|
pid: 411,
|
|
ppid: 123,
|
|
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3001',
|
|
},
|
|
{
|
|
pid: 412,
|
|
ppid: 123,
|
|
command: '/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 3002',
|
|
},
|
|
]),
|
|
readProcessDetails: (pid) => {
|
|
if (pid === 410) {
|
|
return resolved(`${MANAGED_DETAILS} CLAUDE_TEAM_APP_INSTANCE_ID=app-1`);
|
|
}
|
|
if (pid === 412) {
|
|
return resolved(`${MANAGED_DETAILS} CLAUDE_TEAM_APP_INSTANCE_ID=app-10`);
|
|
}
|
|
return resolved(MANAGED_DETAILS);
|
|
},
|
|
disposeServeHost: () => resolved(undefined),
|
|
isProcessAlive: () => false,
|
|
killProcess,
|
|
});
|
|
|
|
expect(killProcess).toHaveBeenCalledTimes(1);
|
|
expect(killProcess).toHaveBeenCalledWith(410);
|
|
expect(result.candidates.map((candidate) => [candidate.pid, candidate.action])).toEqual([
|
|
[410, 'killed'],
|
|
[411, 'kept_unmanaged'],
|
|
[412, 'kept_unmanaged'],
|
|
]);
|
|
});
|
|
|
|
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('does not require unreadable Windows details for app-managed command fallback cleanup', async () => {
|
|
const killProcess = vi.fn();
|
|
|
|
const result = await cleanupManagedOpenCodeServeProcesses({
|
|
mode: 'force',
|
|
platform: 'win32',
|
|
requiredDetailsMarkers: ['CLAUDE_TEAM_APP_INSTANCE_ID=app-1'],
|
|
listProcessRows: () =>
|
|
resolved([
|
|
{
|
|
pid: 71629,
|
|
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 49914',
|
|
},
|
|
]),
|
|
readProcessDetails: () => resolved(null),
|
|
disposeServeHost: () => resolved(undefined),
|
|
isProcessAlive: () => false,
|
|
killProcess,
|
|
});
|
|
|
|
expect(killProcess).toHaveBeenCalledWith(71629);
|
|
expect(result.candidates[0]).toMatchObject({ pid: 71629, action: 'killed' });
|
|
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({
|
|
mode: 'force',
|
|
platform: 'win32',
|
|
listProcessRows: () =>
|
|
resolved([
|
|
{
|
|
pid: 500,
|
|
ppid: 1,
|
|
command: 'C:\\tools\\opencode.exe serve --hostname 127.0.0.1',
|
|
},
|
|
]),
|
|
killProcess,
|
|
});
|
|
|
|
expect(killProcess).not.toHaveBeenCalled();
|
|
expect(result.scanned).toBe(1);
|
|
expect(result.diagnostics).toEqual([]);
|
|
expect(result.candidates[0]).toMatchObject({ pid: 500, action: 'kept_unmanaged' });
|
|
});
|
|
});
|