From 84412066e82a236c55bddacdb09b595941f9b7aa Mon Sep 17 00:00:00 2001 From: 777genius Date: Fri, 24 Apr 2026 22:57:58 +0300 Subject: [PATCH] feat(opencode): add host cleanup lifecycle bridge --- src/main/index.ts | 41 ++++++++++++--- .../bridge/OpenCodeBridgeCommandContract.ts | 31 +++++++++++ .../bridge/OpenCodeBridgeHandshakeClient.ts | 1 + .../bridge/OpenCodeReadinessBridge.ts | 29 +++++++++++ .../OpenCodeBridgeCommandContract.test.ts | 5 ++ .../team/OpenCodeReadinessBridge.test.ts | 52 +++++++++++++++++++ 6 files changed, 152 insertions(+), 7 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index d7ef63f1..18bbfd1c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 { + 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 { 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 { 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') ); diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index 1ff3c554..2b76b01b 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -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 = new Set([ 'opencode.handshake', 'opencode.commandStatus', 'opencode.readiness', + 'opencode.cleanupHosts', 'opencode.launchTeam', 'opencode.reconcileTeam', 'opencode.stopTeam', diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts index e5f7e48a..cb81d9fa 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeHandshakeClient.ts @@ -86,6 +86,7 @@ export function createOpenCodeBridgeClientIdentity(input: { 'opencode.handshake', 'opencode.commandStatus', 'opencode.readiness', + 'opencode.cleanupHosts', 'opencode.launchTeam', 'opencode.reconcileTeam', 'opencode.stopTeam', diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index 7405f082..da16bcb1 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -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; } @@ -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 { + 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 { diff --git a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts index 884cd948..432e791b 100644 --- a/test/main/services/team/OpenCodeBridgeCommandContract.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandContract.test.ts @@ -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> = { schemaVersion: 1, diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index 8a170f89..216f056c 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -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', [])