fix(runtime): keep opencode liveness in sync
This commit is contained in:
parent
8d2e7808d0
commit
d25c65381f
11 changed files with 440 additions and 54 deletions
|
|
@ -57,6 +57,7 @@ import {
|
||||||
type RuntimeProviderManagementFeatureFacade,
|
type RuntimeProviderManagementFeatureFacade,
|
||||||
} from '@features/runtime-provider-management/main';
|
} from '@features/runtime-provider-management/main';
|
||||||
import { createWorkspaceTrustCoordinator } from '@features/workspace-trust/main';
|
import { createWorkspaceTrustCoordinator } from '@features/workspace-trust/main';
|
||||||
|
import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '@main/services/runtime/openCodeBridgeRuntimeEnv';
|
||||||
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
|
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
|
||||||
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
|
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
|
||||||
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
|
||||||
|
|
@ -411,18 +412,15 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv);
|
copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
try {
|
const ensureOpenCodeRuntimeBinaryEnv = async (targetEnv: NodeJS.ProcessEnv): Promise<void> => {
|
||||||
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
if (appManagedOpenCodeBinary && !bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH) {
|
targetEnv,
|
||||||
bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary;
|
bridgeEnv,
|
||||||
}
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
|
||||||
} catch (error) {
|
onWarning: (message) => logger.warn(message),
|
||||||
logger.warn(
|
});
|
||||||
`[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${
|
};
|
||||||
error instanceof Error ? error.message : String(error)
|
await ensureOpenCodeRuntimeBinaryEnv(bridgeEnv);
|
||||||
}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
|
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
|
||||||
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||||
|
|
@ -465,6 +463,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
|
||||||
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
||||||
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
|
||||||
const nextEnv = { ...bridgeEnv };
|
const nextEnv = { ...bridgeEnv };
|
||||||
|
await ensureOpenCodeRuntimeBinaryEnv(nextEnv);
|
||||||
if (!useHttpMcpBridge) {
|
if (!useHttpMcpBridge) {
|
||||||
return nextEnv;
|
return nextEnv;
|
||||||
}
|
}
|
||||||
|
|
@ -897,6 +896,23 @@ function isShutdownStarted(): boolean {
|
||||||
return shutdownComplete || shutdownPromise !== null;
|
return shutdownComplete || shutdownPromise !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasActiveTeamRuntimesForWindowClose(): boolean {
|
||||||
|
if (!servicesReady || !teamProvisioningService) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return teamProvisioningService.hasActiveTeamRuntimes();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`Failed to check active team runtimes before closing last window: ${
|
||||||
|
error instanceof Error ? error.message : String(error)
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function scheduleStartupTask(action: () => void, delayMs: number): void {
|
function scheduleStartupTask(action: () => void, delayMs: number): void {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
startupTimers.delete(timer);
|
startupTimers.delete(timer);
|
||||||
|
|
@ -2748,10 +2764,16 @@ void app.whenReady().then(async () => {
|
||||||
* All windows closed handler.
|
* All windows closed handler.
|
||||||
*/
|
*/
|
||||||
app.on('window-all-closed', () => {
|
app.on('window-all-closed', () => {
|
||||||
|
const hasActiveTeamRuntimes = hasActiveTeamRuntimesForWindowClose();
|
||||||
const shouldQuitWhenAllWindowsClosed =
|
const shouldQuitWhenAllWindowsClosed =
|
||||||
process.platform !== 'darwin' || !configManager.getConfig().general.showDockIcon;
|
hasActiveTeamRuntimes ||
|
||||||
|
process.platform !== 'darwin' ||
|
||||||
|
!configManager.getConfig().general.showDockIcon;
|
||||||
|
|
||||||
if (shouldQuitWhenAllWindowsClosed) {
|
if (shouldQuitWhenAllWindowsClosed) {
|
||||||
|
if (hasActiveTeamRuntimes) {
|
||||||
|
logger.info('Quitting after last window closed because active team runtimes are running');
|
||||||
|
}
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
38
src/main/services/runtime/openCodeBridgeRuntimeEnv.ts
Normal file
38
src/main/services/runtime/openCodeBridgeRuntimeEnv.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { getErrorMessage } from '@shared/utils/errorHandling';
|
||||||
|
|
||||||
|
import { applyOpenCodeRuntimeBinaryEnv } from './openCodeRuntimeBinaryEnv';
|
||||||
|
|
||||||
|
export interface EnsureOpenCodeBridgeRuntimeBinaryEnvOptions {
|
||||||
|
targetEnv: NodeJS.ProcessEnv;
|
||||||
|
bridgeEnv?: NodeJS.ProcessEnv;
|
||||||
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise<string | null>;
|
||||||
|
onWarning?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
|
targetEnv,
|
||||||
|
bridgeEnv = targetEnv,
|
||||||
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
|
||||||
|
onWarning,
|
||||||
|
}: EnsureOpenCodeBridgeRuntimeBinaryEnvOptions): Promise<void> {
|
||||||
|
if (targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH?.trim()) {
|
||||||
|
applyOpenCodeRuntimeBinaryEnv(targetEnv, null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
|
||||||
|
applyOpenCodeRuntimeBinaryEnv(targetEnv, appManagedOpenCodeBinary);
|
||||||
|
if (
|
||||||
|
targetEnv !== bridgeEnv &&
|
||||||
|
targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH &&
|
||||||
|
!bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH
|
||||||
|
) {
|
||||||
|
applyOpenCodeRuntimeBinaryEnv(bridgeEnv, targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
onWarning?.(
|
||||||
|
`[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${getErrorMessage(error)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/main/services/runtime/openCodeRuntimeBinaryEnv.ts
Normal file
54
src/main/services/runtime/openCodeRuntimeBinaryEnv.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const OPENCODE_RUNTIME_BINARY_PATH_ENV = 'CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH';
|
||||||
|
|
||||||
|
function normalizePathEntryForCompare(value: string): string {
|
||||||
|
const normalized = path.resolve(value.trim());
|
||||||
|
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prependPathEntry(env: NodeJS.ProcessEnv, directory: string): void {
|
||||||
|
const trimmedDirectory = directory.trim();
|
||||||
|
if (!trimmedDirectory) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPath = env.PATH ?? '';
|
||||||
|
const currentEntries = currentPath.split(path.delimiter).filter(Boolean);
|
||||||
|
const normalizedDirectory = normalizePathEntryForCompare(trimmedDirectory);
|
||||||
|
const alreadyPresent = currentEntries.some(
|
||||||
|
(entry) => normalizePathEntryForCompare(entry) === normalizedDirectory
|
||||||
|
);
|
||||||
|
|
||||||
|
if (alreadyPresent) {
|
||||||
|
env.PATH = currentEntries.join(path.delimiter);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
env.PATH = [trimmedDirectory, ...currentEntries].join(path.delimiter);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyOpenCodeRuntimeBinaryEnv(
|
||||||
|
env: NodeJS.ProcessEnv,
|
||||||
|
discoveredBinaryPath: string | null | undefined
|
||||||
|
): void {
|
||||||
|
const existingBinaryPath = env[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim();
|
||||||
|
const nextBinaryPath = existingBinaryPath || discoveredBinaryPath?.trim() || '';
|
||||||
|
if (!nextBinaryPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existingBinaryPath) {
|
||||||
|
env[OPENCODE_RUNTIME_BINARY_PATH_ENV] = nextBinaryPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path.isAbsolute(nextBinaryPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facts:
|
||||||
|
// - The app-managed OpenCode status is resolved from the app runtime manifest.
|
||||||
|
// - Older claude-multimodel readiness inventory still resolves "opencode" through PATH.
|
||||||
|
// - Exposing the selected binary directory keeps both checks on the same runtime.
|
||||||
|
prependPathEntry(env, path.dirname(nextBinaryPath));
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath } from '../infrastru
|
||||||
|
|
||||||
import { ensureAgentTeamsMcpLocalLaunchEnv } from './agentTeamsMcpLaunchEnv';
|
import { ensureAgentTeamsMcpLocalLaunchEnv } from './agentTeamsMcpLaunchEnv';
|
||||||
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
|
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
|
||||||
|
import { applyOpenCodeRuntimeBinaryEnv } from './openCodeRuntimeBinaryEnv';
|
||||||
import { providerConnectionService } from './ProviderConnectionService';
|
import { providerConnectionService } from './ProviderConnectionService';
|
||||||
|
|
||||||
import type { CliProviderId, TeamProviderId } from '@shared/types';
|
import type { CliProviderId, TeamProviderId } from '@shared/types';
|
||||||
|
|
@ -43,13 +44,9 @@ export async function buildProviderAwareCliEnv(
|
||||||
shellEnv,
|
shellEnv,
|
||||||
env: options.env,
|
env: options.env,
|
||||||
});
|
});
|
||||||
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
|
if (!resolvedProviderId || resolvedProviderId === 'opencode') {
|
||||||
if (
|
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
|
||||||
appManagedOpenCodeBinary &&
|
applyOpenCodeRuntimeBinaryEnv(env, appManagedOpenCodeBinary);
|
||||||
!env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH &&
|
|
||||||
(!resolvedProviderId || resolvedProviderId === 'opencode')
|
|
||||||
) {
|
|
||||||
env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary;
|
|
||||||
}
|
}
|
||||||
const appManagedCodexBinary = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
const appManagedCodexBinary = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -24376,6 +24376,15 @@ export class TeamProvisioningService {
|
||||||
return Array.from(this.aliveRunByTeam.keys()).filter((name) => this.isTeamAlive(name));
|
return Array.from(this.aliveRunByTeam.keys()).filter((name) => this.isTeamAlive(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when shutdown has team runtime state that must not be left headless.
|
||||||
|
* Includes active leads, provisioning runs, runtime-adapter runs, secondary lanes,
|
||||||
|
* and in-flight team operations that may expose a runtime shortly.
|
||||||
|
*/
|
||||||
|
hasActiveTeamRuntimes(): boolean {
|
||||||
|
return this.getShutdownTrackedTeamNames().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
async getRuntimeState(teamName: string): Promise<TeamRuntimeState> {
|
async getRuntimeState(teamName: string): Promise<TeamRuntimeState> {
|
||||||
const runId = this.getTrackedRunId(teamName);
|
const runId = this.getTrackedRunId(teamName);
|
||||||
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
||||||
|
|
|
||||||
|
|
@ -780,7 +780,10 @@ export const MemberList = memo(function MemberList({
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (spawnEntry?.runtimeAlive === false && spawnEntry.status !== 'online') {
|
if (spawnEntry?.runtimeAlive === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (runtimeEntry?.alive === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
|
|
|
||||||
|
|
@ -797,7 +797,7 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
|
||||||
case 'starting_stale':
|
case 'starting_stale':
|
||||||
return 'bg-amber-400';
|
return 'bg-amber-400';
|
||||||
case 'registered_only':
|
case 'registered_only':
|
||||||
return SPAWN_DOT_COLORS.waiting;
|
return STATUS_DOT_COLORS.terminated;
|
||||||
case 'shell_only':
|
case 'shell_only':
|
||||||
return 'bg-amber-400';
|
return 'bg-amber-400';
|
||||||
case 'stale_runtime':
|
case 'stale_runtime':
|
||||||
|
|
@ -807,6 +807,38 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurrentRuntimeOfflineVisualState(
|
||||||
|
runtimeEntry: TeamAgentRuntimeEntry | undefined,
|
||||||
|
spawnStatus: MemberSpawnStatus | undefined,
|
||||||
|
spawnLaunchState: MemberLaunchState | undefined,
|
||||||
|
spawnRuntimeAlive: boolean | undefined
|
||||||
|
): MemberLaunchVisualState {
|
||||||
|
if (runtimeEntry?.livenessKind === 'registered_only') {
|
||||||
|
return 'registered_only';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
runtimeEntry?.livenessKind === 'stale_metadata' ||
|
||||||
|
runtimeEntry?.livenessKind === 'not_found'
|
||||||
|
) {
|
||||||
|
return 'stale_runtime';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
runtimeEntry?.alive === false &&
|
||||||
|
(runtimeEntry.livenessKind == null ||
|
||||||
|
runtimeEntry.livenessKind === 'runtime_process' ||
|
||||||
|
runtimeEntry.livenessKind === 'confirmed_bootstrap')
|
||||||
|
) {
|
||||||
|
return 'stale_runtime';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
spawnRuntimeAlive === false &&
|
||||||
|
(spawnStatus === 'online' || spawnLaunchState === 'confirmed_alive')
|
||||||
|
) {
|
||||||
|
return 'stale_runtime';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function shouldDisplayMemberCurrentTask({
|
export function shouldDisplayMemberCurrentTask({
|
||||||
member,
|
member,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
|
|
@ -846,10 +878,10 @@ export function shouldDisplayMemberCurrentTask({
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (runtimeEntry?.alive === false && spawnStatus !== 'online') {
|
if (runtimeEntry?.alive === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (spawnRuntimeAlive === false && spawnStatus !== 'online') {
|
if (spawnRuntimeAlive === false) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -1039,13 +1071,26 @@ export function buildMemberLaunchPresentation({
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
nowMs?: number;
|
nowMs?: number;
|
||||||
}): MemberLaunchPresentation {
|
}): MemberLaunchPresentation {
|
||||||
|
const currentRuntimeOfflineVisualState = getCurrentRuntimeOfflineVisualState(
|
||||||
|
runtimeEntry,
|
||||||
|
spawnStatus,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnRuntimeAlive
|
||||||
|
);
|
||||||
const hasConfirmedSpawnLaunch =
|
const hasConfirmedSpawnLaunch =
|
||||||
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
|
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
|
||||||
const effectiveSpawnStatus =
|
const effectiveSpawnStatus =
|
||||||
hasConfirmedSpawnLaunch && (spawnStatus === 'waiting' || spawnStatus === 'spawning')
|
hasConfirmedSpawnLaunch &&
|
||||||
|
currentRuntimeOfflineVisualState == null &&
|
||||||
|
(spawnStatus === 'waiting' || spawnStatus === 'spawning')
|
||||||
? 'online'
|
? 'online'
|
||||||
: spawnStatus;
|
: spawnStatus;
|
||||||
const effectiveSpawnRuntimeAlive = hasConfirmedSpawnLaunch ? true : spawnRuntimeAlive;
|
const effectiveSpawnRuntimeAlive =
|
||||||
|
currentRuntimeOfflineVisualState != null
|
||||||
|
? false
|
||||||
|
: hasConfirmedSpawnLaunch
|
||||||
|
? true
|
||||||
|
: spawnRuntimeAlive;
|
||||||
const presenceLabel = getLaunchAwarePresenceLabel(
|
const presenceLabel = getLaunchAwarePresenceLabel(
|
||||||
member,
|
member,
|
||||||
effectiveSpawnStatus,
|
effectiveSpawnStatus,
|
||||||
|
|
@ -1100,21 +1145,12 @@ export function buildMemberLaunchPresentation({
|
||||||
launchVisualState = 'permission_pending';
|
launchVisualState = 'permission_pending';
|
||||||
} else if (spawnBootstrapStalled === true) {
|
} else if (spawnBootstrapStalled === true) {
|
||||||
launchVisualState = 'bootstrap_stalled';
|
launchVisualState = 'bootstrap_stalled';
|
||||||
} else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'shell_only') {
|
} else if (currentRuntimeOfflineVisualState != null) {
|
||||||
|
launchVisualState = currentRuntimeOfflineVisualState;
|
||||||
|
} else if (runtimeEntry?.livenessKind === 'shell_only') {
|
||||||
launchVisualState = 'shell_only';
|
launchVisualState = 'shell_only';
|
||||||
} else if (
|
} else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') {
|
||||||
!hasConfirmedSpawnLaunch &&
|
|
||||||
runtimeEntry?.livenessKind === 'runtime_process_candidate'
|
|
||||||
) {
|
|
||||||
launchVisualState = 'runtime_candidate';
|
launchVisualState = 'runtime_candidate';
|
||||||
} else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'registered_only') {
|
|
||||||
launchVisualState = 'registered_only';
|
|
||||||
} else if (
|
|
||||||
!hasConfirmedSpawnLaunch &&
|
|
||||||
(runtimeEntry?.livenessKind === 'stale_metadata' ||
|
|
||||||
runtimeEntry?.livenessKind === 'not_found')
|
|
||||||
) {
|
|
||||||
launchVisualState = 'stale_runtime';
|
|
||||||
} else if (!hasConfirmedSpawnLaunch && startingIsStale) {
|
} else if (!hasConfirmedSpawnLaunch && startingIsStale) {
|
||||||
launchVisualState = 'starting_stale';
|
launchVisualState = 'starting_stale';
|
||||||
} else if (
|
} else if (
|
||||||
|
|
|
||||||
77
test/main/services/runtime/openCodeBridgeRuntimeEnv.test.ts
Normal file
77
test/main/services/runtime/openCodeBridgeRuntimeEnv.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeBridgeRuntimeEnv';
|
||||||
|
|
||||||
|
describe('ensureOpenCodeBridgeRuntimeBinaryEnv', () => {
|
||||||
|
it('makes an app-managed OpenCode binary visible to PATH-based bridge inventory', async () => {
|
||||||
|
const binaryPath = path.join(process.cwd(), 'managed opencode', 'bin', 'opencode');
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
PATH: ['/usr/bin', '/bin'].join(path.delimiter),
|
||||||
|
};
|
||||||
|
|
||||||
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
|
targetEnv: env,
|
||||||
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise.resolve(binaryPath),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
|
||||||
|
expect(env.PATH?.split(path.delimiter)).toEqual([
|
||||||
|
path.dirname(binaryPath),
|
||||||
|
'/usr/bin',
|
||||||
|
'/bin',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers when managed OpenCode is installed after the bridge base env was created', async () => {
|
||||||
|
const binaryPath = path.join(process.cwd(), 'late managed opencode', 'opencode');
|
||||||
|
const bridgeEnv: NodeJS.ProcessEnv = {
|
||||||
|
PATH: ['/usr/bin', '/bin'].join(path.delimiter),
|
||||||
|
};
|
||||||
|
const resolver = vi.fn<() => Promise<string | null>>().mockResolvedValueOnce(null);
|
||||||
|
|
||||||
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
|
targetEnv: bridgeEnv,
|
||||||
|
bridgeEnv,
|
||||||
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
|
||||||
|
|
||||||
|
resolver.mockResolvedValueOnce(binaryPath);
|
||||||
|
const commandEnv = { ...bridgeEnv };
|
||||||
|
await ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
|
targetEnv: commandEnv,
|
||||||
|
bridgeEnv,
|
||||||
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolver,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(commandEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
|
||||||
|
expect(commandEnv.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath));
|
||||||
|
expect(bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
|
||||||
|
expect(bridgeEnv.PATH?.split(path.delimiter)[0]).toBe(path.dirname(binaryPath));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps bridge startup non-fatal when the managed resolver fails', async () => {
|
||||||
|
const onWarning = vi.fn();
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
ensureOpenCodeBridgeRuntimeBinaryEnv({
|
||||||
|
targetEnv: env,
|
||||||
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () =>
|
||||||
|
Promise.reject(new Error('manifest unreadable')),
|
||||||
|
onWarning,
|
||||||
|
})
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
expect(onWarning).toHaveBeenCalledWith(
|
||||||
|
'[OpenCode] Runtime adapter bundled OpenCode binary unresolved: manifest unreadable'
|
||||||
|
);
|
||||||
|
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBeUndefined();
|
||||||
|
expect(env.PATH).toBe('/usr/bin');
|
||||||
|
});
|
||||||
|
});
|
||||||
49
test/main/services/runtime/openCodeRuntimeBinaryEnv.test.ts
Normal file
49
test/main/services/runtime/openCodeRuntimeBinaryEnv.test.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { applyOpenCodeRuntimeBinaryEnv } from '../../../../src/main/services/runtime/openCodeRuntimeBinaryEnv';
|
||||||
|
|
||||||
|
describe('applyOpenCodeRuntimeBinaryEnv', () => {
|
||||||
|
it('sets the OpenCode binary env var and prepends its directory to PATH', () => {
|
||||||
|
const binaryPath = path.join(process.cwd(), 'mock app data', 'opencode', 'opencode');
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
PATH: ['/usr/bin', '/bin'].join(path.delimiter),
|
||||||
|
};
|
||||||
|
|
||||||
|
applyOpenCodeRuntimeBinaryEnv(env, binaryPath);
|
||||||
|
|
||||||
|
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(binaryPath);
|
||||||
|
expect(env.PATH?.split(path.delimiter)).toEqual([
|
||||||
|
path.dirname(binaryPath),
|
||||||
|
'/usr/bin',
|
||||||
|
'/bin',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an explicit OpenCode binary override but still exposes it on PATH', () => {
|
||||||
|
const explicitBinaryPath = path.join(process.cwd(), 'custom opencode', 'opencode');
|
||||||
|
const discoveredBinaryPath = path.join(process.cwd(), 'managed opencode', 'opencode');
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: explicitBinaryPath,
|
||||||
|
PATH: '/usr/bin',
|
||||||
|
};
|
||||||
|
|
||||||
|
applyOpenCodeRuntimeBinaryEnv(env, discoveredBinaryPath);
|
||||||
|
|
||||||
|
expect(env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(explicitBinaryPath);
|
||||||
|
expect(env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(explicitBinaryPath));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not duplicate the binary directory in PATH on repeated application', () => {
|
||||||
|
const binaryPath = path.join(process.cwd(), 'mock app data', 'opencode', 'opencode');
|
||||||
|
const env: NodeJS.ProcessEnv = {
|
||||||
|
PATH: [path.dirname(binaryPath), '/usr/bin'].join(path.delimiter),
|
||||||
|
};
|
||||||
|
|
||||||
|
applyOpenCodeRuntimeBinaryEnv(env, binaryPath);
|
||||||
|
applyOpenCodeRuntimeBinaryEnv(env, binaryPath);
|
||||||
|
|
||||||
|
expect(env.PATH?.split(path.delimiter)).toEqual([path.dirname(binaryPath), '/usr/bin']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const buildEnrichedEnvMock = vi.fn();
|
const buildEnrichedEnvMock = vi.fn();
|
||||||
|
|
@ -350,9 +352,15 @@ describe('buildProviderAwareCliEnv', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('injects the verified app-managed OpenCode binary for OpenCode launches', async () => {
|
it('injects the verified app-managed OpenCode binary for OpenCode launches', async () => {
|
||||||
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(
|
const appManagedBinaryPath = path.join(
|
||||||
'/Users/tester/App Support/runtimes/opencode/current/opencode'
|
process.cwd(),
|
||||||
|
'App Support',
|
||||||
|
'runtimes',
|
||||||
|
'opencode',
|
||||||
|
'current',
|
||||||
|
'opencode'
|
||||||
);
|
);
|
||||||
|
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPathMock.mockResolvedValue(appManagedBinaryPath);
|
||||||
|
|
||||||
const { buildProviderAwareCliEnv } =
|
const { buildProviderAwareCliEnv } =
|
||||||
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||||
|
|
@ -362,15 +370,29 @@ describe('buildProviderAwareCliEnv', () => {
|
||||||
|
|
||||||
expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith(
|
expect(applyConfiguredConnectionEnvMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH:
|
CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: appManagedBinaryPath,
|
||||||
'/Users/tester/App Support/runtimes/opencode/current/opencode',
|
|
||||||
}),
|
}),
|
||||||
'opencode',
|
'opencode',
|
||||||
undefined
|
undefined
|
||||||
);
|
);
|
||||||
expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(
|
expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(appManagedBinaryPath);
|
||||||
'/Users/tester/App Support/runtimes/opencode/current/opencode'
|
expect(result.env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(appManagedBinaryPath));
|
||||||
);
|
});
|
||||||
|
|
||||||
|
it('exposes an explicit OpenCode binary override on PATH when the app-managed resolver is cold', async () => {
|
||||||
|
const explicitBinaryPath = path.join(process.cwd(), 'custom opencode', 'opencode');
|
||||||
|
|
||||||
|
const { buildProviderAwareCliEnv } =
|
||||||
|
await import('../../../../src/main/services/runtime/providerAwareCliEnv');
|
||||||
|
const result = await buildProviderAwareCliEnv({
|
||||||
|
providerId: 'opencode',
|
||||||
|
env: {
|
||||||
|
CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH: explicitBinaryPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH).toBe(explicitBinaryPath);
|
||||||
|
expect(result.env.PATH?.split(path.delimiter)[0]).toBe(path.dirname(explicitBinaryPath));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not inject the app-managed OpenCode binary into non-OpenCode provider launches', async () => {
|
it('does not inject the app-managed OpenCode binary into non-OpenCode provider launches', async () => {
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,16 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
})
|
})
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldDisplayMemberCurrentTask({
|
||||||
|
member: { ...member, currentTaskId: 'task-1' },
|
||||||
|
isTeamAlive: true,
|
||||||
|
spawnStatus: 'online',
|
||||||
|
spawnLaunchState: 'confirmed_alive',
|
||||||
|
spawnRuntimeAlive: false,
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
shouldDisplayMemberCurrentTask({
|
shouldDisplayMemberCurrentTask({
|
||||||
member: { ...member, currentTaskId: 'task-1' },
|
member: { ...member, currentTaskId: 'task-1' },
|
||||||
|
|
@ -73,6 +83,22 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
shouldDisplayMemberCurrentTask({
|
||||||
|
member: { ...member, currentTaskId: 'task-1' },
|
||||||
|
isTeamAlive: true,
|
||||||
|
spawnStatus: 'online',
|
||||||
|
spawnLaunchState: 'confirmed_alive',
|
||||||
|
runtimeEntry: {
|
||||||
|
memberName: 'alice',
|
||||||
|
alive: false,
|
||||||
|
restartable: true,
|
||||||
|
providerId: 'opencode',
|
||||||
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps current task labels for confirmed online members', () => {
|
it('keeps current task labels for confirmed online members', () => {
|
||||||
|
|
@ -493,10 +519,10 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
isTeamProvisioning: false,
|
isTeamProvisioning: false,
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
presenceLabel: 'online',
|
presenceLabel: 'registered',
|
||||||
launchVisualState: null,
|
launchVisualState: 'registered_only',
|
||||||
launchStatusLabel: null,
|
launchStatusLabel: 'registered',
|
||||||
dotClass: expect.stringContaining('bg-emerald-400'),
|
dotClass: expect.stringContaining('bg-red-400'),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -521,13 +547,66 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
isTeamProvisioning: false,
|
isTeamProvisioning: false,
|
||||||
})
|
})
|
||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
presenceLabel: 'online',
|
presenceLabel: 'registered',
|
||||||
launchVisualState: null,
|
launchVisualState: 'registered_only',
|
||||||
launchStatusLabel: null,
|
launchStatusLabel: 'registered',
|
||||||
dotClass: expect.stringContaining('bg-emerald-400'),
|
dotClass: expect.stringContaining('bg-red-400'),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('marks confirmed members offline when spawn runtime liveness is false', () => {
|
||||||
|
expect(
|
||||||
|
buildMemberLaunchPresentation({
|
||||||
|
member,
|
||||||
|
spawnStatus: 'online',
|
||||||
|
spawnLaunchState: 'confirmed_alive',
|
||||||
|
spawnLivenessSource: 'process',
|
||||||
|
spawnRuntimeAlive: false,
|
||||||
|
spawnBootstrapConfirmed: true,
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isLaunchSettling: false,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
presenceLabel: 'stale runtime',
|
||||||
|
launchVisualState: 'stale_runtime',
|
||||||
|
launchStatusLabel: 'stale runtime',
|
||||||
|
dotClass: expect.stringContaining('bg-red-400'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks dead confirmed runtime entries as stale runtime', () => {
|
||||||
|
for (const livenessKind of ['runtime_process', 'confirmed_bootstrap'] as const) {
|
||||||
|
expect(
|
||||||
|
buildMemberLaunchPresentation({
|
||||||
|
member,
|
||||||
|
spawnStatus: 'online',
|
||||||
|
spawnLaunchState: 'confirmed_alive',
|
||||||
|
spawnLivenessSource: 'process',
|
||||||
|
spawnRuntimeAlive: true,
|
||||||
|
spawnBootstrapConfirmed: true,
|
||||||
|
runtimeEntry: {
|
||||||
|
memberName: 'alice',
|
||||||
|
alive: false,
|
||||||
|
restartable: true,
|
||||||
|
livenessKind,
|
||||||
|
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||||
|
},
|
||||||
|
runtimeAdvisory: undefined,
|
||||||
|
isLaunchSettling: false,
|
||||||
|
isTeamAlive: true,
|
||||||
|
isTeamProvisioning: false,
|
||||||
|
})
|
||||||
|
).toMatchObject({
|
||||||
|
presenceLabel: 'stale runtime',
|
||||||
|
launchVisualState: 'stale_runtime',
|
||||||
|
launchStatusLabel: 'stale runtime',
|
||||||
|
dotClass: expect.stringContaining('bg-red-400'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('marks stuck OpenCode launch states as manually relaunchable', () => {
|
it('marks stuck OpenCode launch states as manually relaunchable', () => {
|
||||||
const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' };
|
const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' };
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue