feat(opencode): add host cleanup lifecycle bridge
This commit is contained in:
parent
7f9afeef14
commit
84412066e8
6 changed files with 152 additions and 7 deletions
|
|
@ -184,6 +184,7 @@ import type { FileChangeEvent } from '@main/types';
|
||||||
import type { TeamChangeEvent } from '@shared/types';
|
import type { TeamChangeEvent } from '@shared/types';
|
||||||
|
|
||||||
const logger = createLogger('App');
|
const logger = createLogger('App');
|
||||||
|
let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null;
|
||||||
if (
|
if (
|
||||||
earlyElectronUserDataMigrationResult.migrated &&
|
earlyElectronUserDataMigrationResult.migrated &&
|
||||||
earlyElectronUserDataMigrationResult.legacyPath &&
|
earlyElectronUserDataMigrationResult.legacyPath &&
|
||||||
|
|
@ -224,6 +225,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
||||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved');
|
logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved');
|
||||||
|
openCodeLifecycleBridge = null;
|
||||||
return new TeamRuntimeAdapterRegistry();
|
return new TeamRuntimeAdapterRegistry();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -272,13 +274,30 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
||||||
teamsBasePath: getTeamsBasePath(),
|
teamsBasePath: getTeamsBasePath(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
return new TeamRuntimeAdapterRegistry([
|
const readinessBridge = new OpenCodeReadinessBridge(bridgeClient, {
|
||||||
new OpenCodeTeamRuntimeAdapter(
|
stateChangingCommands,
|
||||||
new OpenCodeReadinessBridge(bridgeClient, {
|
});
|
||||||
stateChangingCommands,
|
openCodeLifecycleBridge = readinessBridge;
|
||||||
})
|
return new TeamRuntimeAdapterRegistry([new OpenCodeTeamRuntimeAdapter(readinessBridge)]);
|
||||||
),
|
}
|
||||||
]);
|
|
||||||
|
async function cleanupOpenCodeHostsForLifecycle(reason: 'startup' | 'shutdown'): Promise<void> {
|
||||||
|
if (!openCodeLifecycleBridge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await openCodeLifecycleBridge.cleanupOpenCodeHosts({
|
||||||
|
reason,
|
||||||
|
mode: reason === 'shutdown' ? 'force' : 'stale',
|
||||||
|
staleAgeMs: reason === 'startup' ? 5 * 60_000 : null,
|
||||||
|
});
|
||||||
|
if (result.cleaned > 0) {
|
||||||
|
logger.info(
|
||||||
|
`[OpenCode] ${reason} host cleanup removed ${result.cleaned} registry host(s), ${result.remaining} remaining`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const diagnostic of result.diagnostics) {
|
||||||
|
logger.warn(`[OpenCode] ${reason} host cleanup: ${diagnostic}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Team display name cache (avoid listTeams() on every notification) ---
|
// --- Team display name cache (avoid listTeams() on every notification) ---
|
||||||
|
|
@ -1013,6 +1032,9 @@ async function initializeServices(): Promise<void> {
|
||||||
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
|
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
|
||||||
teamProvisioningService = new TeamProvisioningService();
|
teamProvisioningService = new TeamProvisioningService();
|
||||||
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry());
|
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry());
|
||||||
|
await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
|
||||||
|
logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`)
|
||||||
|
);
|
||||||
// Startup GC: remove stale MCP config files from previous sessions (best-effort)
|
// Startup GC: remove stale MCP config files from previous sessions (best-effort)
|
||||||
void new TeamMcpConfigBuilder().gcStaleConfigs();
|
void new TeamMcpConfigBuilder().gcStaleConfigs();
|
||||||
void teamDataService
|
void teamDataService
|
||||||
|
|
@ -1328,6 +1350,11 @@ async function shutdownServices(): Promise<void> {
|
||||||
if (teamProvisioningService) {
|
if (teamProvisioningService) {
|
||||||
await runShutdownStep('stop all teams', () => teamProvisioningService.stopAllTeams(), 10_000);
|
await runShutdownStep('stop all teams', () => teamProvisioningService.stopAllTeams(), 10_000);
|
||||||
}
|
}
|
||||||
|
await runShutdownStep(
|
||||||
|
'OpenCode host registry cleanup',
|
||||||
|
() => cleanupOpenCodeHostsForLifecycle('shutdown'),
|
||||||
|
10_000
|
||||||
|
);
|
||||||
await runShutdownStep('tracked CLI subprocess cleanup', () =>
|
await runShutdownStep('tracked CLI subprocess cleanup', () =>
|
||||||
killTrackedCliProcesses('SIGKILL')
|
killTrackedCliProcesses('SIGKILL')
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ export type OpenCodeBridgeCommandName =
|
||||||
| 'opencode.handshake'
|
| 'opencode.handshake'
|
||||||
| 'opencode.commandStatus'
|
| 'opencode.commandStatus'
|
||||||
| 'opencode.readiness'
|
| 'opencode.readiness'
|
||||||
|
| 'opencode.cleanupHosts'
|
||||||
| 'opencode.launchTeam'
|
| 'opencode.launchTeam'
|
||||||
| 'opencode.reconcileTeam'
|
| 'opencode.reconcileTeam'
|
||||||
| 'opencode.stopTeam'
|
| 'opencode.stopTeam'
|
||||||
|
|
@ -116,6 +117,35 @@ export interface OpenCodeStopTeamCommandData {
|
||||||
runtimeStoreManifestHighWatermark?: number | null;
|
runtimeStoreManifestHighWatermark?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OpenCodeCleanupHostsCommandBody {
|
||||||
|
reason: 'startup' | 'shutdown' | 'manual' | string;
|
||||||
|
mode?: 'stale' | 'force';
|
||||||
|
projectPath?: string;
|
||||||
|
staleAgeMs?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenCodeCleanupHostsCommandData {
|
||||||
|
cleaned: number;
|
||||||
|
remaining: number;
|
||||||
|
hosts: Array<{
|
||||||
|
hostKey: string;
|
||||||
|
projectPath: string;
|
||||||
|
pid: number;
|
||||||
|
port: number;
|
||||||
|
action:
|
||||||
|
| 'disposed'
|
||||||
|
| 'removed_dead'
|
||||||
|
| 'kept_active'
|
||||||
|
| 'kept_leased'
|
||||||
|
| 'kept_recent'
|
||||||
|
| 'kept_filtered'
|
||||||
|
| 'failed';
|
||||||
|
reason: string;
|
||||||
|
leaseCount: number;
|
||||||
|
}>;
|
||||||
|
diagnostics: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface OpenCodeSendMessageCommandBody {
|
export interface OpenCodeSendMessageCommandBody {
|
||||||
runId?: string;
|
runId?: string;
|
||||||
laneId: string;
|
laneId: string;
|
||||||
|
|
@ -274,6 +304,7 @@ const VALID_COMMANDS: ReadonlySet<OpenCodeBridgeCommandName> = new Set([
|
||||||
'opencode.handshake',
|
'opencode.handshake',
|
||||||
'opencode.commandStatus',
|
'opencode.commandStatus',
|
||||||
'opencode.readiness',
|
'opencode.readiness',
|
||||||
|
'opencode.cleanupHosts',
|
||||||
'opencode.launchTeam',
|
'opencode.launchTeam',
|
||||||
'opencode.reconcileTeam',
|
'opencode.reconcileTeam',
|
||||||
'opencode.stopTeam',
|
'opencode.stopTeam',
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ export function createOpenCodeBridgeClientIdentity(input: {
|
||||||
'opencode.handshake',
|
'opencode.handshake',
|
||||||
'opencode.commandStatus',
|
'opencode.commandStatus',
|
||||||
'opencode.readiness',
|
'opencode.readiness',
|
||||||
|
'opencode.cleanupHosts',
|
||||||
'opencode.launchTeam',
|
'opencode.launchTeam',
|
||||||
'opencode.reconcileTeam',
|
'opencode.reconcileTeam',
|
||||||
'opencode.stopTeam',
|
'opencode.stopTeam',
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ import type {
|
||||||
OpenCodeBridgeFailureKind,
|
OpenCodeBridgeFailureKind,
|
||||||
OpenCodeBridgeResult,
|
OpenCodeBridgeResult,
|
||||||
OpenCodeBridgeRuntimeSnapshot,
|
OpenCodeBridgeRuntimeSnapshot,
|
||||||
|
OpenCodeCleanupHostsCommandBody,
|
||||||
|
OpenCodeCleanupHostsCommandData,
|
||||||
OpenCodeLaunchTeamCommandBody,
|
OpenCodeLaunchTeamCommandBody,
|
||||||
OpenCodeLaunchTeamCommandData,
|
OpenCodeLaunchTeamCommandData,
|
||||||
OpenCodeReconcileTeamCommandBody,
|
OpenCodeReconcileTeamCommandBody,
|
||||||
|
|
@ -39,6 +41,7 @@ export interface OpenCodeReadinessBridgeOptions {
|
||||||
reconcileTimeoutMs?: number;
|
reconcileTimeoutMs?: number;
|
||||||
sendTimeoutMs?: number;
|
sendTimeoutMs?: number;
|
||||||
stopTimeoutMs?: number;
|
stopTimeoutMs?: number;
|
||||||
|
cleanupTimeoutMs?: number;
|
||||||
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -53,6 +56,7 @@ const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000;
|
||||||
const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
|
const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
|
||||||
const DEFAULT_SEND_TIMEOUT_MS = 30_000;
|
const DEFAULT_SEND_TIMEOUT_MS = 30_000;
|
||||||
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
|
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
|
||||||
|
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
private readonly lastRuntimeSnapshotsByProjectPath = new Map<
|
private readonly lastRuntimeSnapshotsByProjectPath = new Map<
|
||||||
|
|
@ -168,6 +172,31 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async cleanupOpenCodeHosts(
|
||||||
|
input: OpenCodeCleanupHostsCommandBody
|
||||||
|
): Promise<OpenCodeCleanupHostsCommandData> {
|
||||||
|
const cwd = input.projectPath ?? process.cwd();
|
||||||
|
const result = await this.bridge.execute<
|
||||||
|
OpenCodeCleanupHostsCommandBody,
|
||||||
|
OpenCodeCleanupHostsCommandData
|
||||||
|
>('opencode.cleanupHosts', input, {
|
||||||
|
cwd,
|
||||||
|
timeoutMs: this.options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
if (result.ok) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
cleaned: 0,
|
||||||
|
remaining: 0,
|
||||||
|
hosts: [],
|
||||||
|
diagnostics: [
|
||||||
|
`OpenCode host cleanup bridge failed: ${result.error.kind}: ${result.error.message}`,
|
||||||
|
...result.diagnostics.map(formatDiagnosticEvent),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async sendOpenCodeTeamMessage(
|
async sendOpenCodeTeamMessage(
|
||||||
input: OpenCodeSendMessageCommandBody
|
input: OpenCodeSendMessageCommandBody
|
||||||
): Promise<OpenCodeSendMessageCommandData> {
|
): Promise<OpenCodeSendMessageCommandData> {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
assertBridgeResultCanMutateState,
|
assertBridgeResultCanMutateState,
|
||||||
createOpenCodeBridgeHandshakeIdentityHash,
|
createOpenCodeBridgeHandshakeIdentityHash,
|
||||||
createOpenCodeBridgeIdempotencyKey,
|
createOpenCodeBridgeIdempotencyKey,
|
||||||
|
isOpenCodeBridgeCommandName,
|
||||||
parseSingleBridgeJsonResult,
|
parseSingleBridgeJsonResult,
|
||||||
stableHash,
|
stableHash,
|
||||||
validateBridgeResultEnvelope,
|
validateBridgeResultEnvelope,
|
||||||
|
|
@ -49,6 +50,10 @@ describe('OpenCodeBridgeCommandContract', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('accepts opencode.cleanupHosts as a bridge command', () => {
|
||||||
|
expect(isOpenCodeBridgeCommandName('opencode.cleanupHosts')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('validates result request id and command against the command envelope', () => {
|
it('validates result request id and command against the command envelope', () => {
|
||||||
const envelope: OpenCodeBridgeCommandEnvelope<Record<string, never>> = {
|
const envelope: OpenCodeBridgeCommandEnvelope<Record<string, never>> = {
|
||||||
schemaVersion: 1,
|
schemaVersion: 1,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,58 @@ describe('OpenCodeReadinessBridge', () => {
|
||||||
expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toBeNull();
|
expect(bridge.getLastOpenCodeRuntimeSnapshot('/repo')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('executes host cleanup through the direct bridge command', async () => {
|
||||||
|
const executor = fakeExecutor(
|
||||||
|
bridgeCommandSuccess({
|
||||||
|
command: 'opencode.cleanupHosts',
|
||||||
|
requestId: 'cleanup-req-1',
|
||||||
|
data: {
|
||||||
|
cleaned: 1,
|
||||||
|
remaining: 0,
|
||||||
|
hosts: [
|
||||||
|
{
|
||||||
|
hostKey: 'host-key',
|
||||||
|
projectPath: '/repo',
|
||||||
|
pid: 123,
|
||||||
|
port: 43116,
|
||||||
|
action: 'disposed',
|
||||||
|
reason: 'stale host has no active leases during startup',
|
||||||
|
leaseCount: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const bridge = new OpenCodeReadinessBridge(executor, { cleanupTimeoutMs: 5_000 });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bridge.cleanupOpenCodeHosts({
|
||||||
|
reason: 'startup',
|
||||||
|
mode: 'stale',
|
||||||
|
projectPath: '/repo',
|
||||||
|
staleAgeMs: 1_000,
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
cleaned: 1,
|
||||||
|
remaining: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(executor.execute).toHaveBeenCalledWith(
|
||||||
|
'opencode.cleanupHosts',
|
||||||
|
{
|
||||||
|
reason: 'startup',
|
||||||
|
mode: 'stale',
|
||||||
|
projectPath: '/repo',
|
||||||
|
staleAgeMs: 1_000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
cwd: '/repo',
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('routes state-changing launch commands through the guarded command service when configured', async () => {
|
it('routes state-changing launch commands through the guarded command service when configured', async () => {
|
||||||
const executor = fakeExecutor(
|
const executor = fakeExecutor(
|
||||||
bridgeFailure('internal_error', 'direct bridge must not run', [])
|
bridgeFailure('internal_error', 'direct bridge must not run', [])
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue