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';
|
||||
|
||||
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')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export function createOpenCodeBridgeClientIdentity(input: {
|
|||
'opencode.handshake',
|
||||
'opencode.commandStatus',
|
||||
'opencode.readiness',
|
||||
'opencode.cleanupHosts',
|
||||
'opencode.launchTeam',
|
||||
'opencode.reconcileTeam',
|
||||
'opencode.stopTeam',
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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', [])
|
||||
|
|
|
|||
Loading…
Reference in a new issue