Skip to content

Commit 5e0d552

Browse files
committed
fix(opencode): improve runtime preflight diagnostics
1 parent 88e01ae commit 5e0d552

29 files changed

Lines changed: 1573 additions & 171 deletions

src/main/index.ts

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,7 +226,7 @@ import {
226226
TeamTaskStallSnapshotSource,
227227
TeamTranscriptSourceLocator,
228228
UpdaterService,
229-
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
229+
resolveVerifiedOpenCodeRuntimeBinaryPath,
230230
} from './services';
231231

232232
import type { FileChangeEvent } from '@main/types';
@@ -343,10 +343,8 @@ function describeMemberWorkSyncReviewPickupEscalationReason(reason: string): str
343343
}
344344

345345
async function resolveOpenCodeRuntimeBinaryForBridgeEnv(): Promise<string | null> {
346-
const manifestBinaryPath = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
347-
if (manifestBinaryPath) {
348-
return manifestBinaryPath;
349-
}
346+
const resolvedBinaryPath = await resolveVerifiedOpenCodeRuntimeBinaryPath();
347+
if (resolvedBinaryPath) return resolvedBinaryPath;
350348

351349
try {
352350
const status = await openCodeRuntimeInstallerService?.getStatus();
@@ -435,7 +433,7 @@ async function createOpenCodeRuntimeAdapterRegistry(
435433
await ensureOpenCodeBridgeRuntimeBinaryEnv({
436434
targetEnv,
437435
bridgeEnv,
438-
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv,
436+
resolveVerifiedOpenCodeRuntimeBinaryPath: resolveOpenCodeRuntimeBinaryForBridgeEnv,
439437
onWarning: (message) => logger.warn(message),
440438
});
441439
};

src/main/services/infrastructure/OpenCodeRuntimeInstallerService.ts

Lines changed: 72 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { execCli } from '@main/utils/childProcess';
22
import { getAppDataPath } from '@main/utils/pathDecoder';
33
import { safeSendToRenderer } from '@main/utils/safeWebContentsSend';
4-
import { getCachedShellEnv } from '@main/utils/shellEnv';
4+
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
55
import { getErrorMessage } from '@shared/utils/errorHandling';
66
import { createLogger } from '@shared/utils/logger';
77
import { createHash, randomUUID } from 'crypto';
@@ -22,6 +22,7 @@ const MAX_TARBALL_BYTES = 250 * 1024 * 1024;
2222
const MAX_BINARY_BYTES = 350 * 1024 * 1024;
2323
const FETCH_TIMEOUT_MS = 60_000;
2424
const VERSION_TIMEOUT_MS = 10_000;
25+
const PATH_SHELL_ENV_TIMEOUT_MS = 1_500;
2526

2627
interface NpmPackageMetadata {
2728
name?: string;
@@ -134,9 +135,15 @@ function splitPathEnv(pathValue: string | undefined): string[] {
134135
.filter(Boolean);
135136
}
136137

137-
function resolvePathOpenCodeBinary(): string | null {
138+
function resolvePathOpenCodeBinary(
139+
additionalEnvSources: (NodeJS.ProcessEnv | null | undefined)[] = []
140+
): string | null {
138141
const shellEnv = getCachedShellEnv() ?? {};
139-
const pathEntries = [...splitPathEnv(shellEnv.PATH), ...splitPathEnv(process.env.PATH)];
142+
const pathEntries = [
143+
...additionalEnvSources.flatMap((env) => splitPathEnv(env?.PATH)),
144+
...splitPathEnv(shellEnv.PATH),
145+
...splitPathEnv(process.env.PATH),
146+
];
140147
const seen = new Set<string>();
141148
for (const entry of pathEntries) {
142149
const normalizedEntry = path.resolve(entry);
@@ -154,6 +161,57 @@ function resolvePathOpenCodeBinary(): string | null {
154161
return null;
155162
}
156163

164+
type OpenCodeBinaryVersionProbe =
165+
| { ok: true; version: string | null }
166+
| { ok: false; error: string };
167+
168+
async function probeOpenCodeBinaryVersion(binaryPath: string): Promise<OpenCodeBinaryVersionProbe> {
169+
try {
170+
const { stdout } = await execCli(binaryPath, ['--version'], {
171+
timeout: VERSION_TIMEOUT_MS,
172+
windowsHide: true,
173+
});
174+
return { ok: true, version: stdout.trim() || null };
175+
} catch (error) {
176+
return { ok: false, error: getErrorMessage(error) };
177+
}
178+
}
179+
180+
async function resolvePathOpenCodeBinaryWithBestEffortEnv(
181+
options: { shellEnvTimeoutMs?: number } = {}
182+
): Promise<string | null> {
183+
const cachedCandidate = resolvePathOpenCodeBinary();
184+
if (cachedCandidate) {
185+
return cachedCandidate;
186+
}
187+
188+
const shellEnv = await resolveInteractiveShellEnvBestEffort({
189+
timeoutMs: options.shellEnvTimeoutMs ?? PATH_SHELL_ENV_TIMEOUT_MS,
190+
fallbackEnv: process.env,
191+
});
192+
return resolvePathOpenCodeBinary([shellEnv]);
193+
}
194+
195+
async function resolveVerifiedPathOpenCodeBinaryPath(
196+
options: { shellEnvTimeoutMs?: number } = {}
197+
): Promise<string | null> {
198+
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv(options);
199+
if (!binaryPath) {
200+
return null;
201+
}
202+
203+
return (await probeOpenCodeBinaryVersion(binaryPath)).ok ? binaryPath : null;
204+
}
205+
206+
export async function resolveVerifiedOpenCodeRuntimeBinaryPath(
207+
options: { shellEnvTimeoutMs?: number } = {}
208+
): Promise<string | null> {
209+
return (
210+
(await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath()) ??
211+
(await resolveVerifiedPathOpenCodeBinaryPath(options))
212+
);
213+
}
214+
157215
function isLinuxMuslRuntime(): boolean {
158216
if (process.platform !== 'linux') {
159217
return false;
@@ -466,31 +524,27 @@ export class OpenCodeRuntimeInstallerService {
466524
}
467525

468526
private async getPathStatus(): Promise<OpenCodeRuntimeStatus> {
469-
const binaryPath = resolvePathOpenCodeBinary();
527+
const binaryPath = await resolvePathOpenCodeBinaryWithBestEffortEnv();
470528
if (!binaryPath) {
471529
return { installed: false, source: 'missing', state: 'idle' };
472530
}
473-
try {
474-
const { stdout } = await execCli(binaryPath, ['--version'], {
475-
timeout: VERSION_TIMEOUT_MS,
476-
windowsHide: true,
477-
});
478-
return {
479-
installed: true,
480-
binaryPath,
481-
version: stdout.trim() || undefined,
482-
source: 'path',
483-
state: 'ready',
484-
};
485-
} catch (error) {
531+
const version = await probeOpenCodeBinaryVersion(binaryPath);
532+
if (!version.ok) {
486533
return {
487534
installed: false,
488535
binaryPath,
489536
source: 'path',
490537
state: 'failed',
491-
error: getErrorMessage(error),
538+
error: version.error,
492539
};
493540
}
541+
return {
542+
installed: true,
543+
binaryPath,
544+
version: version.version ?? undefined,
545+
source: 'path',
546+
state: 'ready',
547+
};
494548
}
495549

496550
private async installInternal(): Promise<OpenCodeRuntimeStatus> {
Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { existsSync, statSync } from 'node:fs';
2+
import path from 'node:path';
3+
14
import { getErrorMessage } from '@shared/utils/errorHandling';
25

36
import {
@@ -9,16 +12,76 @@ import {
912
export interface EnsureOpenCodeBridgeRuntimeBinaryEnvOptions {
1013
targetEnv: NodeJS.ProcessEnv;
1114
bridgeEnv?: NodeJS.ProcessEnv;
12-
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath: () => Promise<string | null>;
15+
resolveVerifiedOpenCodeRuntimeBinaryPath: () => Promise<string | null>;
1316
onWarning?: (message: string) => void;
1417
}
1518

19+
function resolveExistingFilePath(filePath: string): string | null {
20+
const resolvedPath = path.resolve(filePath.trim());
21+
if (!existsSync(resolvedPath)) {
22+
return null;
23+
}
24+
try {
25+
return statSync(resolvedPath).isFile() ? resolvedPath : null;
26+
} catch {
27+
return null;
28+
}
29+
}
30+
31+
function getOpenCodeRuntimeBinaryEnvValues(env: NodeJS.ProcessEnv): string[] {
32+
return [
33+
env[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim(),
34+
env[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim(),
35+
].filter((value): value is string => Boolean(value));
36+
}
37+
38+
function resolveExistingOpenCodeRuntimeBinaryEnvPath(env: NodeJS.ProcessEnv): string | null {
39+
for (const value of getOpenCodeRuntimeBinaryEnvValues(env)) {
40+
const resolvedPath = resolveExistingFilePath(value);
41+
if (resolvedPath) {
42+
return resolvedPath;
43+
}
44+
}
45+
return null;
46+
}
47+
48+
function clearOpenCodeRuntimeBinaryEnvValues(
49+
env: NodeJS.ProcessEnv,
50+
invalidValues: Set<string>
51+
): void {
52+
for (const key of [OPENCODE_RUNTIME_BINARY_PATH_ENV, OPENCODE_LEGACY_BINARY_PATH_ENV]) {
53+
const value = env[key]?.trim();
54+
if (value && invalidValues.has(value)) {
55+
delete env[key];
56+
}
57+
}
58+
}
59+
1660
export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
1761
targetEnv,
1862
bridgeEnv = targetEnv,
19-
resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath,
63+
resolveVerifiedOpenCodeRuntimeBinaryPath,
2064
onWarning,
2165
}: EnsureOpenCodeBridgeRuntimeBinaryEnvOptions): Promise<void> {
66+
if (
67+
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim() ||
68+
targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim()
69+
) {
70+
const existingBinaryPath = resolveExistingOpenCodeRuntimeBinaryEnvPath(targetEnv);
71+
if (!existingBinaryPath) {
72+
const invalidValues = new Set(getOpenCodeRuntimeBinaryEnvValues(targetEnv));
73+
clearOpenCodeRuntimeBinaryEnvValues(targetEnv, invalidValues);
74+
if (targetEnv !== bridgeEnv) {
75+
clearOpenCodeRuntimeBinaryEnvValues(bridgeEnv, invalidValues);
76+
}
77+
} else {
78+
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV] = existingBinaryPath;
79+
targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV] = existingBinaryPath;
80+
applyOpenCodeRuntimeBinaryEnv(targetEnv, existingBinaryPath);
81+
return;
82+
}
83+
}
84+
2285
if (
2386
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]?.trim() ||
2487
targetEnv[OPENCODE_LEGACY_BINARY_PATH_ENV]?.trim()
@@ -28,8 +91,8 @@ export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
2891
}
2992

3093
try {
31-
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
32-
applyOpenCodeRuntimeBinaryEnv(targetEnv, appManagedOpenCodeBinary);
94+
const openCodeBinary = await resolveVerifiedOpenCodeRuntimeBinaryPath();
95+
applyOpenCodeRuntimeBinaryEnv(targetEnv, openCodeBinary);
3396
if (
3497
targetEnv !== bridgeEnv &&
3598
targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV] &&
@@ -38,8 +101,6 @@ export async function ensureOpenCodeBridgeRuntimeBinaryEnv({
38101
applyOpenCodeRuntimeBinaryEnv(bridgeEnv, targetEnv[OPENCODE_RUNTIME_BINARY_PATH_ENV]);
39102
}
40103
} catch (error) {
41-
onWarning?.(
42-
`[OpenCode] Runtime adapter bundled OpenCode binary unresolved: ${getErrorMessage(error)}`
43-
);
104+
onWarning?.(`[OpenCode] Runtime adapter OpenCode binary unresolved: ${getErrorMessage(error)}`);
44105
}
45106
}

src/main/services/runtime/providerAwareCliEnv.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { resolveVerifiedAppManagedCodexRuntimeBinaryPath } from '@features/codex-runtime-installer/main';
22
import { getCachedShellEnv } from '@main/utils/shellEnv';
33

4-
import { resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
4+
import { resolveVerifiedOpenCodeRuntimeBinaryPath } from '../infrastructure/OpenCodeRuntimeInstallerService';
55

66
import { ensureAgentTeamsMcpLocalLaunchEnv } from './agentTeamsMcpLaunchEnv';
77
import { buildRuntimeBaseEnv } from './buildRuntimeBaseEnv';
@@ -45,8 +45,8 @@ export async function buildProviderAwareCliEnv(
4545
env: options.env,
4646
});
4747
if (!resolvedProviderId || resolvedProviderId === 'opencode') {
48-
const appManagedOpenCodeBinary = await resolveVerifiedAppManagedOpenCodeRuntimeBinaryPath();
49-
applyOpenCodeRuntimeBinaryEnv(env, appManagedOpenCodeBinary);
48+
const openCodeBinary = await resolveVerifiedOpenCodeRuntimeBinaryPath();
49+
applyOpenCodeRuntimeBinaryEnv(env, openCodeBinary);
5050
}
5151
const appManagedCodexBinary = await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
5252
if (

src/main/services/team/AgentTeamsMcpHttpServer.ts

Lines changed: 39 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const logger = createLogger('Service:AgentTeamsMcpHttpServer');
1212
const MCP_HTTP_HOST = '127.0.0.1';
1313
const MCP_HTTP_ENDPOINT = '/mcp';
1414
const MCP_HTTP_READY_TIMEOUT_MS = 5_000;
15+
const MCP_HTTP_EXISTING_HANDLE_READY_TIMEOUT_MS = 3_000;
1516
const MCP_HTTP_READY_POLL_MS = 100;
1617

1718
export interface AgentTeamsMcpHttpServerHandle {
@@ -120,18 +121,18 @@ export class AgentTeamsMcpHttpServer {
120121
private startPromise: Promise<AgentTeamsMcpHttpServerHandle> | null = null;
121122
private child: ChildProcess | null = null;
122123
private handle: AgentTeamsMcpHttpServerHandle | null = null;
124+
private readonly expectedStopChildren = new WeakSet<ChildProcess>();
123125

124126
constructor(private readonly deps: AgentTeamsMcpHttpServerDeps = {}) {}
125127

126128
async ensureStarted(): Promise<AgentTeamsMcpHttpServerHandle> {
127-
if (this.handle) {
128-
return this.handle;
129-
}
130129
if (this.startPromise) {
131130
return this.startPromise;
132131
}
133132

134-
this.startPromise = this.startOnce().finally(() => {
133+
this.startPromise = (
134+
this.handle ? this.reuseOrRestartExistingHandle(this.handle) : this.startOnce()
135+
).finally(() => {
135136
this.startPromise = null;
136137
});
137138
return this.startPromise;
@@ -142,10 +143,34 @@ export class AgentTeamsMcpHttpServer {
142143
this.child = null;
143144
this.handle = null;
144145
if (child) {
146+
this.expectedStopChildren.add(child);
145147
killProcessTree(child, 'SIGKILL');
146148
}
147149
}
148150

151+
private async reuseOrRestartExistingHandle(
152+
handle: AgentTeamsMcpHttpServerHandle
153+
): Promise<AgentTeamsMcpHttpServerHandle> {
154+
const waitForPort = this.deps.waitForPort ?? waitForLoopbackPort;
155+
try {
156+
await waitForPort(MCP_HTTP_HOST, handle.port, MCP_HTTP_EXISTING_HANDLE_READY_TIMEOUT_MS);
157+
if (this.handle === handle) {
158+
return handle;
159+
}
160+
} catch (error) {
161+
if (this.handle === handle) {
162+
logger.warn(
163+
`Agent Teams MCP HTTP server at ${handle.url} failed health reuse check, restarting: ${
164+
error instanceof Error ? error.message : String(error)
165+
}`
166+
);
167+
await this.stop();
168+
}
169+
}
170+
171+
return this.startOnce();
172+
}
173+
149174
private async startOnce(): Promise<AgentTeamsMcpHttpServerHandle> {
150175
const resolveLaunchSpec = this.deps.resolveLaunchSpec ?? resolveAgentTeamsMcpLaunchSpec;
151176
const allocatePort = this.deps.allocatePort ?? allocateLoopbackPort;
@@ -181,14 +206,21 @@ export class AgentTeamsMcpHttpServer {
181206
let startupSettled = false;
182207
const startupFailure = new Promise<never>((_, reject) => {
183208
child.once('exit', (code, signal) => {
209+
const expectedStop = this.expectedStopChildren.delete(child);
184210
clearIfCurrent();
185211
const codeSuffix = typeof code === 'number' ? ` with code ${code}` : '';
186212
const signalSuffix = signal ? ` (${signal})` : '';
187213
const message = `Agent Teams MCP HTTP server exited before startup completed${codeSuffix}${signalSuffix}`;
188-
if (!startupSettled) {
214+
if (!startupSettled && !expectedStop) {
189215
reject(new Error(message));
216+
logger.warn(message);
217+
return;
218+
}
219+
if (startupSettled && !expectedStop) {
220+
logger.warn(
221+
`Agent Teams MCP HTTP server exited after startup${codeSuffix}${signalSuffix}`
222+
);
190223
}
191-
logger.warn(message);
192224
});
193225
child.once('error', (error) => {
194226
clearIfCurrent();
@@ -216,6 +248,7 @@ export class AgentTeamsMcpHttpServer {
216248
this.child = null;
217249
this.handle = null;
218250
}
251+
this.expectedStopChildren.add(child);
219252
killProcessTree(child, 'SIGKILL');
220253
throw error;
221254
}

0 commit comments

Comments
 (0)