agent-ecosystem/test/main/services/team/OpenCodeManagedHostProcessCleanup.test.ts
2026-05-16 02:14:34 +03:00

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' });
});
});