feat(opencode): add host cleanup lifecycle bridge

This commit is contained in:
777genius 2026-04-24 22:57:58 +03:00
parent 7f9afeef14
commit 84412066e8
6 changed files with 152 additions and 7 deletions

View file

@ -184,6 +184,7 @@ import type { FileChangeEvent } from '@main/types';
import type { TeamChangeEvent } from '@shared/types';
const logger = createLogger('App');
let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null;
if (
earlyElectronUserDataMigrationResult.migrated &&
earlyElectronUserDataMigrationResult.legacyPath &&
@ -224,6 +225,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
const binaryPath = await ClaudeBinaryResolver.resolve();
if (!binaryPath) {
logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved');
openCodeLifecycleBridge = null;
return new TeamRuntimeAdapterRegistry();
}
@ -272,13 +274,30 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
teamsBasePath: getTeamsBasePath(),
}),
});
return new TeamRuntimeAdapterRegistry([
new OpenCodeTeamRuntimeAdapter(
new OpenCodeReadinessBridge(bridgeClient, {
stateChangingCommands,
})
),
]);
const readinessBridge = 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) ---
@ -1013,6 +1032,9 @@ async function initializeServices(): Promise<void> {
teamDataService.setMemberRuntimeAdvisoryService(teamMemberRuntimeAdvisoryService);
teamProvisioningService = new TeamProvisioningService();
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)
void new TeamMcpConfigBuilder().gcStaleConfigs();
void teamDataService
@ -1328,6 +1350,11 @@ async function shutdownServices(): Promise<void> {
if (teamProvisioningService) {
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', () =>
killTrackedCliProcesses('SIGKILL')
);

View file

@ -6,6 +6,7 @@ export type OpenCodeBridgeCommandName =
| 'opencode.handshake'
| 'opencode.commandStatus'
| 'opencode.readiness'
| 'opencode.cleanupHosts'
| 'opencode.launchTeam'
| 'opencode.reconcileTeam'
| 'opencode.stopTeam'
@ -116,6 +117,35 @@ export interface OpenCodeStopTeamCommandData {
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 {
runId?: string;
laneId: string;
@ -274,6 +304,7 @@ const VALID_COMMANDS: ReadonlySet<OpenCodeBridgeCommandName> = new Set([
'opencode.handshake',
'opencode.commandStatus',
'opencode.readiness',
'opencode.cleanupHosts',
'opencode.launchTeam',
'opencode.reconcileTeam',
'opencode.stopTeam',

View file

@ -86,6 +86,7 @@ export function createOpenCodeBridgeClientIdentity(input: {
'opencode.handshake',
'opencode.commandStatus',
'opencode.readiness',
'opencode.cleanupHosts',
'opencode.launchTeam',
'opencode.reconcileTeam',
'opencode.stopTeam',

View file

@ -9,6 +9,8 @@ import type {
OpenCodeBridgeFailureKind,
OpenCodeBridgeResult,
OpenCodeBridgeRuntimeSnapshot,
OpenCodeCleanupHostsCommandBody,
OpenCodeCleanupHostsCommandData,
OpenCodeLaunchTeamCommandBody,
OpenCodeLaunchTeamCommandData,
OpenCodeReconcileTeamCommandBody,
@ -39,6 +41,7 @@ export interface OpenCodeReadinessBridgeOptions {
reconcileTimeoutMs?: number;
sendTimeoutMs?: number;
stopTimeoutMs?: number;
cleanupTimeoutMs?: number;
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
}
@ -53,6 +56,7 @@ const DEFAULT_LAUNCH_TIMEOUT_MS = 120_000;
const DEFAULT_RECONCILE_TIMEOUT_MS = 30_000;
const DEFAULT_SEND_TIMEOUT_MS = 30_000;
const DEFAULT_STOP_TIMEOUT_MS = 30_000;
const DEFAULT_CLEANUP_TIMEOUT_MS = 10_000;
export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
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(
input: OpenCodeSendMessageCommandBody
): Promise<OpenCodeSendMessageCommandData> {

View file

@ -5,6 +5,7 @@ import {
assertBridgeResultCanMutateState,
createOpenCodeBridgeHandshakeIdentityHash,
createOpenCodeBridgeIdempotencyKey,
isOpenCodeBridgeCommandName,
parseSingleBridgeJsonResult,
stableHash,
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', () => {
const envelope: OpenCodeBridgeCommandEnvelope<Record<string, never>> = {
schemaVersion: 1,

View file

@ -85,6 +85,58 @@ describe('OpenCodeReadinessBridge', () => {
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 () => {
const executor = fakeExecutor(
bridgeFailure('internal_error', 'direct bridge must not run', [])