Skip to content

Commit d25c653

Browse files
committed
fix(runtime): keep opencode liveness in sync
1 parent 8d2e780 commit d25c653

11 files changed

Lines changed: 440 additions & 54 deletions

File tree

src/main/index.ts

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
type RuntimeProviderManagementFeatureFacade,
5858
} from '@features/runtime-provider-management/main';
5959
import { createWorkspaceTrustCoordinator } from '@features/workspace-trust/main';
60+
import { ensureOpenCodeBridgeRuntimeBinaryEnv } from '@main/services/runtime/openCodeBridgeRuntimeEnv';
6061
import { ClaudeMultimodelBridgeService } from '@main/services/runtime/ClaudeMultimodelBridgeService';
6162
import { applyOpenCodeAutoUpdatePolicy } from '@main/services/runtime/openCodeAutoUpdatePolicy';
6263
import { providerConnectionService } from '@main/services/runtime/ProviderConnectionService';
@@ -411,18 +412,15 @@ async function createOpenCodeRuntimeAdapterRegistry(
411412
copyOpenCodeLocalMcpLaunchEnv(targetEnv, bridgeEnv);
412413
}
413414
};
414-
try {
415-
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
416-
if (appManagedOpenCodeBinary && !bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH) {
417-
bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary;
418-
}
419-
} catch (error) {
420-
logger.warn(
421-
`[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${
422-
error instanceof Error ? error.message : String(error)
423-
}`
424-
);
425-
}
415+
const ensureOpenCodeRuntimeBinaryEnv = async (targetEnv: NodeJS.ProcessEnv): Promise<void> => {
416+
await ensureOpenCodeBridgeRuntimeBinaryEnv({
417+
targetEnv,
418+
bridgeEnv,
419+
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
420+
onWarning: (message) => logger.warn(message),
421+
});
422+
};
423+
await ensureOpenCodeRuntimeBinaryEnv(bridgeEnv);
426424
try {
427425
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
428426
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
@@ -465,6 +463,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
465463
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
466464
const resolveBridgeCommandEnv = async (): Promise<NodeJS.ProcessEnv> => {
467465
const nextEnv = { ...bridgeEnv };
466+
await ensureOpenCodeRuntimeBinaryEnv(nextEnv);
468467
if (!useHttpMcpBridge) {
469468
return nextEnv;
470469
}
@@ -897,6 +896,23 @@ function isShutdownStarted(): boolean {
897896
return shutdownComplete || shutdownPromise !== null;
898897
}
899898

899+
function hasActiveTeamRuntimesForWindowClose(): boolean {
900+
if (!servicesReady || !teamProvisioningService) {
901+
return false;
902+
}
903+
904+
try {
905+
return teamProvisioningService.hasActiveTeamRuntimes();
906+
} catch (error) {
907+
logger.warn(
908+
`Failed to check active team runtimes before closing last window: ${
909+
error instanceof Error ? error.message : String(error)
910+
}`
911+
);
912+
return false;
913+
}
914+
}
915+
900916
function scheduleStartupTask(action: () => void, delayMs: number): void {
901917
const timer = setTimeout(() => {
902918
startupTimers.delete(timer);
@@ -2748,10 +2764,16 @@ void app.whenReady().then(async () => {
27482764
* All windows closed handler.
27492765
*/
27502766
app.on('window-all-closed', () => {
2767+
const hasActiveTeamRuntimes = hasActiveTeamRuntimesForWindowClose();
27512768
const shouldQuitWhenAllWindowsClosed =
2752-
process.platform !== 'darwin' || !configManager.getConfig().general.showDockIcon;
2769+
hasActiveTeamRuntimes ||
2770+
process.platform !== 'darwin' ||
2771+
!configManager.getConfig().general.showDockIcon;
27532772

27542773
if (shouldQuitWhenAllWindowsClosed) {
2774+
if (hasActiveTeamRuntimes) {
2775+
logger.info('Quitting after last window closed because active team runtimes are running');
2776+
}
27552777
app.quit();
27562778
}
27572779
});
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { getErrorMessage } from '@shared/utils/errorHandling';
2+
3+
import { applyOpenCodeRuntimeBinaryEnv } from './openCodeRuntimeBinaryEnv';
4+
5+
export interface EnsureOpenCodeBridgeRuntimeBinaryEnvOptions {
6+
targetEnv: NodeJS.ProcessEnv;
7+
bridgeEnv?: NodeJS.ProcessEnv;
8+
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise<string | null>;
9+
onWarning?: (message: string) => void;
10+
}
11+
12+
export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
13+
targetEnv,
14+
bridgeEnv = targetEnv,
15+
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
16+
onWarning,
17+
}: EnsureOpenCodeBridgeRuntimeBinaryEnvOptions): Promise<void> {
18+
if (targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH?.trim()) {
19+
applyOpenCodeRuntimeBinaryEnv(targetEnv, null);
20+
return;
21+
}
22+
23+
try {
24+
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
25+
applyOpenCodeRuntimeBinaryEnv(targetEnv, appManagedOpenCodeBinary);
26+
if (
27+
targetEnv !== bridgeEnv &&
28+
targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH &&
29+
!bridgeEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH
30+
) {
31+
applyOpenCodeRuntimeBinaryEnv(bridgeEnv, targetEnv.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH);
32+
}
33+
} catch (error) {
34+
onWarning?.(
35+
`[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${getErrorMessage(error)}`
36+
);
37+
}
38+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import path from 'node:path';
2+
3+
export const OPENCODE_RUNTIME_BINARY_PATH_ENV = 'CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH';
4+
5+
function normalizePathEntryForCompare(value: string): string {
6+
const normalized = path.resolve(value.trim());
7+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
8+
}
9+
10+
function prependPathEntry(env: NodeJS.ProcessEnv, directory: string): void {
11+
const trimmedDirectory = directory.trim();
12+
if (!trimmedDirectory) {
13+
return;
14+
}
15+
16+
const currentPath = env.PATH ?? '';
17+
const currentEntries = currentPath.split(path.delimiter).filter(Boolean);
18+
const normalizedDirectory = normalizePathEntryForCompare(trimmedDirectory);
19+
const alreadyPresent = currentEntries.some(
20+
(entry) => normalizePathEntryForCompare(entry) === normalizedDirectory
21+
);
22+
23+
if (alreadyPresent) {
24+
env.PATH = currentEntries.join(path.delimiter);
25+
return;
26+
}
27+
28+
env.PATH = [trimmedDirectory, ...currentEntries].join(path.delimiter);
29+
}
30+
31+
export function applyOpenCodeRuntimeBinaryEnv(
32+
env: NodeJS.ProcessEnv,
33+
discoveredBinaryPath: string | null | undefined
34+
): void {
35+
const existingBinaryPath = env[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim();
36+
const nextBinaryPath = existingBinaryPath || discoveredBinaryPath?.trim() || '';
37+
if (!nextBinaryPath) {
38+
return;
39+
}
40+
41+
if (!existingBinaryPath) {
42+
env[OPENCODE_RUNTIME_BINARY_PATH_ENV] = nextBinaryPath;
43+
}
44+
45+
if (!path.isAbsolute(nextBinaryPath)) {
46+
return;
47+
}
48+
49+
// Facts:
50+
// - The app-managed OpenCode status is resolved from the app runtime manifest.
51+
// - Older claude-multimodel readiness inventory still resolves "opencode" through PATH.
52+
// - Exposing the selected binary directory keeps both checks on the same runtime.
53+
prependPathEntry(env, path.dirname(nextBinaryPath));
54+
}

src/main/services/runtime/providerAwareCliEnv.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath } from '../infrastru
55

66
import { ensureAgentTeamsMcpLocalLaunchEnv } from './agentTeamsMcpLaunchEnv';
77
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
8+
import { applyOpenCodeRuntimeBinaryEnv } from './openCodeRuntimeBinaryEnv';
89
import { providerConnectionService } from './ProviderConnectionService';
910

1011
import type { CliProviderId, TeamProviderId } from '@shared/types';
@@ -43,13 +44,9 @@ export async function buildProviderAwareCliEnv(
4344
shellEnv,
4445
env: options.env,
4546
});
46-
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
47-
if (
48-
appManagedOpenCodeBinary &&
49-
!env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH &&
50-
(!resolvedProviderId || resolvedProviderId === 'opencode')
51-
) {
52-
env.CLAUDE_MULTIMODEL_OPENCODE_BIN_PATH = appManagedOpenCodeBinary;
47+
if (!resolvedProviderId || resolvedProviderId === 'opencode') {
48+
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
49+
applyOpenCodeRuntimeBinaryEnv(env, appManagedOpenCodeBinary);
5350
}
5451
const appManagedCodexBinary = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
5552
if (

src/main/services/team/TeamProvisioningService.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24376,6 +24376,15 @@ export class TeamProvisioningService {
2437624376
return Array.from(this.aliveRunByTeam.keys()).filter((name) => this.isTeamAlive(name));
2437724377
}
2437824378

24379+
/**
24380+
* True when shutdown has team runtime state that must not be left headless.
24381+
* Includes active leads, provisioning runs, runtime-adapter runs, secondary lanes,
24382+
* and in-flight team operations that may expose a runtime shortly.
24383+
*/
24384+
hasActiveTeamRuntimes(): boolean {
24385+
return this.getShutdownTrackedTeamNames().length > 0;
24386+
}
24387+
2437924388
async getRuntimeState(teamName: string): Promise<TeamRuntimeState> {
2438024389
const runId = this.getTrackedRunId(teamName);
2438124390
const run = runId ? (this.runs.get(runId) ?? null) : null;

src/renderer/components/team/members/MemberList.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -780,7 +780,10 @@ export const MemberList = memo(function MemberList({
780780
) {
781781
return false;
782782
}
783-
if (spawnEntry?.runtimeAlive === false && spawnEntry.status !== 'online') {
783+
if (spawnEntry?.runtimeAlive === false) {
784+
return false;
785+
}
786+
if (runtimeEntry?.alive === false) {
784787
return false;
785788
}
786789
if (

src/renderer/utils/memberHelpers.ts

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,7 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
797797
case 'starting_stale':
798798
return 'bg-amber-400';
799799
case 'registered_only':
800-
return SPAWN_DOT_COLORS.waiting;
800+
return STATUS_DOT_COLORS.terminated;
801801
case 'shell_only':
802802
return 'bg-amber-400';
803803
case 'stale_runtime':
@@ -807,6 +807,38 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
807807
}
808808
}
809809

810+
function getCurrentRuntimeOfflineVisualState(
811+
runtimeEntry: TeamAgentRuntimeEntry | undefined,
812+
spawnStatus: MemberSpawnStatus | undefined,
813+
spawnLaunchState: MemberLaunchState | undefined,
814+
spawnRuntimeAlive: boolean | undefined
815+
): MemberLaunchVisualState {
816+
if (runtimeEntry?.livenessKind === 'registered_only') {
817+
return 'registered_only';
818+
}
819+
if (
820+
runtimeEntry?.livenessKind === 'stale_metadata' ||
821+
runtimeEntry?.livenessKind === 'not_found'
822+
) {
823+
return 'stale_runtime';
824+
}
825+
if (
826+
runtimeEntry?.alive === false &&
827+
(runtimeEntry.livenessKind == null ||
828+
runtimeEntry.livenessKind === 'runtime_process' ||
829+
runtimeEntry.livenessKind === 'confirmed_bootstrap')
830+
) {
831+
return 'stale_runtime';
832+
}
833+
if (
834+
spawnRuntimeAlive === false &&
835+
(spawnStatus === 'online' || spawnLaunchState === 'confirmed_alive')
836+
) {
837+
return 'stale_runtime';
838+
}
839+
return null;
840+
}
841+
810842
export function shouldDisplayMemberCurrentTask({
811843
member,
812844
isTeamAlive,
@@ -846,10 +878,10 @@ export function shouldDisplayMemberCurrentTask({
846878
) {
847879
return false;
848880
}
849-
if (runtimeEntry?.alive === false && spawnStatus !== 'online') {
881+
if (runtimeEntry?.alive === false) {
850882
return false;
851883
}
852-
if (spawnRuntimeAlive === false && spawnStatus !== 'online') {
884+
if (spawnRuntimeAlive === false) {
853885
return false;
854886
}
855887
return true;
@@ -1039,13 +1071,26 @@ export function buildMemberLaunchPresentation({
10391071
leadActivity?: LeadActivityState;
10401072
nowMs?: number;
10411073
}): MemberLaunchPresentation {
1074+
const currentRuntimeOfflineVisualState = getCurrentRuntimeOfflineVisualState(
1075+
runtimeEntry,
1076+
spawnStatus,
1077+
spawnLaunchState,
1078+
spawnRuntimeAlive
1079+
);
10421080
const hasConfirmedSpawnLaunch =
10431081
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
10441082
const effectiveSpawnStatus =
1045-
hasConfirmedSpawnLaunch && (spawnStatus === 'waiting' || spawnStatus === 'spawning')
1083+
hasConfirmedSpawnLaunch &&
1084+
currentRuntimeOfflineVisualState == null &&
1085+
(spawnStatus === 'waiting' || spawnStatus === 'spawning')
10461086
? 'online'
10471087
: spawnStatus;
1048-
const effectiveSpawnRuntimeAlive = hasConfirmedSpawnLaunch ? true : spawnRuntimeAlive;
1088+
const effectiveSpawnRuntimeAlive =
1089+
currentRuntimeOfflineVisualState != null
1090+
? false
1091+
: hasConfirmedSpawnLaunch
1092+
? true
1093+
: spawnRuntimeAlive;
10491094
const presenceLabel = getLaunchAwarePresenceLabel(
10501095
member,
10511096
effectiveSpawnStatus,
@@ -1100,21 +1145,12 @@ export function buildMemberLaunchPresentation({
11001145
launchVisualState = 'permission_pending';
11011146
} else if (spawnBootstrapStalled === true) {
11021147
launchVisualState = 'bootstrap_stalled';
1103-
} else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'shell_only') {
1148+
} else if (currentRuntimeOfflineVisualState != null) {
1149+
launchVisualState = currentRuntimeOfflineVisualState;
1150+
} else if (runtimeEntry?.livenessKind === 'shell_only') {
11041151
launchVisualState = 'shell_only';
1105-
} else if (
1106-
!hasConfirmedSpawnLaunch &&
1107-
runtimeEntry?.livenessKind === 'runtime_process_candidate'
1108-
) {
1152+
} else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') {
11091153
launchVisualState = 'runtime_candidate';
1110-
} else if (!hasConfirmedSpawnLaunch && runtimeEntry?.livenessKind === 'registered_only') {
1111-
launchVisualState = 'registered_only';
1112-
} else if (
1113-
!hasConfirmedSpawnLaunch &&
1114-
(runtimeEntry?.livenessKind === 'stale_metadata' ||
1115-
runtimeEntry?.livenessKind === 'not_found')
1116-
) {
1117-
launchVisualState = 'stale_runtime';
11181154
} else if (!hasConfirmedSpawnLaunch && startingIsStale) {
11191155
launchVisualState = 'starting_stale';
11201156
} else if (

0 commit comments

Comments
 (0)