Skip to content

Commit 9cd5144

Browse files
committed
fix(codex): retry binary discovery after shell env loads
1 parent 554112a commit 9cd5144

6 files changed

Lines changed: 165 additions & 23 deletions

File tree

runtime.lock.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,27 @@
11
{
2-
"version": "0.0.38",
3-
"sourceRef": "v0.0.38",
2+
"version": "0.0.39",
3+
"sourceRef": "v0.0.39",
44
"sourceRepository": "777genius/agent_teams_orchestrator",
55
"releaseRepository": "777genius/agent-teams-ai",
66
"releaseTag": "v2.0.0",
77
"assets": {
88
"darwin-arm64": {
9-
"file": "agent-teams-runtime-darwin-arm64-v0.0.38.tar.gz",
9+
"file": "agent-teams-runtime-darwin-arm64-v0.0.39.tar.gz",
1010
"archiveKind": "tar.gz",
1111
"binaryName": "claude-multimodel"
1212
},
1313
"darwin-x64": {
14-
"file": "agent-teams-runtime-darwin-x64-v0.0.38.tar.gz",
14+
"file": "agent-teams-runtime-darwin-x64-v0.0.39.tar.gz",
1515
"archiveKind": "tar.gz",
1616
"binaryName": "claude-multimodel"
1717
},
1818
"linux-x64": {
19-
"file": "agent-teams-runtime-linux-x64-v0.0.38.tar.gz",
19+
"file": "agent-teams-runtime-linux-x64-v0.0.39.tar.gz",
2020
"archiveKind": "tar.gz",
2121
"binaryName": "claude-multimodel"
2222
},
2323
"win32-x64": {
24-
"file": "agent-teams-runtime-win32-x64-v0.0.38.zip",
24+
"file": "agent-teams-runtime-win32-x64-v0.0.39.zip",
2525
"archiveKind": "zip",
2626
"binaryName": "claude-multimodel.exe"
2727
}

src/features/codex-account/main/composition/createCodexAccountFeature.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
CodexBinaryResolver,
2323
JsonRpcStdioClient,
2424
} from '@main/services/infrastructure/codexAppServer';
25-
import { getCachedShellEnv } from '@main/utils/shellEnv';
25+
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
2626

2727
import { CodexAccountSnapshotPresenter } from '../adapters/output/presenters/CodexAccountSnapshotPresenter';
2828
import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient';
@@ -41,6 +41,7 @@ type LoggerPort = Pick<Logger, 'info' | 'warn' | 'error'>;
4141
const SNAPSHOT_CACHE_TTL_MS = 5_000;
4242
const RATE_LIMITS_CACHE_TTL_MS = 45_000;
4343
const LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS = 60_000;
44+
const CODEX_BINARY_COLD_RETRY_TIMEOUT_MS = 12_000;
4445

4546
interface CodexLastKnownAccount {
4647
payload: CodexAppServerGetAccountResponse;
@@ -251,6 +252,20 @@ function createDeferred(): { promise: Promise<void>; resolve: () => void } {
251252
};
252253
}
253254

255+
async function resolveCodexBinaryForAccountSnapshot(): Promise<string | null> {
256+
const binaryPath = await CodexBinaryResolver.resolve();
257+
if (binaryPath) {
258+
return binaryPath;
259+
}
260+
261+
await resolveInteractiveShellEnvBestEffort({
262+
timeoutMs: CODEX_BINARY_COLD_RETRY_TIMEOUT_MS,
263+
fallbackEnv: process.env,
264+
});
265+
CodexBinaryResolver.clearCache();
266+
return CodexBinaryResolver.resolve();
267+
}
268+
254269
export interface CodexAccountFeatureFacade {
255270
getSnapshot(): Promise<CodexAccountSnapshotDto>;
256271
refreshSnapshot(options?: {
@@ -351,7 +366,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
351366
}): Promise<CodexAccountSnapshotDto> {
352367
let binaryMissing = false;
353368
await this.runSerializedMutation(async () => {
354-
const binaryPath = await CodexBinaryResolver.resolve();
369+
const binaryPath = await resolveCodexBinaryForAccountSnapshot();
355370
if (!binaryPath) {
356371
binaryMissing = true;
357372
return;
@@ -380,7 +395,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
380395
await this.runSerializedMutation(async () => {
381396
await this.loginSessionManager.cancel().catch(() => undefined);
382397

383-
const binaryPath = await CodexBinaryResolver.resolve();
398+
const binaryPath = await resolveCodexBinaryForAccountSnapshot();
384399
if (!binaryPath) {
385400
throw new Error('Codex CLI is not available, so logout cannot be completed.');
386401
}
@@ -467,7 +482,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
467482
const localAccountState = await detectCodexLocalAccountState();
468483
const localAccountArtifactsPresent = localAccountState.hasArtifacts;
469484
const localActiveChatgptAccountPresent = localAccountState.hasActiveChatgptAccount;
470-
const binaryPath = await CodexBinaryResolver.resolve();
485+
const binaryPath = await resolveCodexBinaryForAccountSnapshot();
471486
const login = this.loginSessionManager.getState();
472487
const now = Date.now();
473488

src/main/services/infrastructure/codexAppServer/CodexBinaryResolver.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const BINARY_LAUNCH_VERIFY_TIMEOUT_MS = 3_000;
1515
let cachedBinaryPath: string | null | undefined;
1616
let cacheVerifiedAt = 0;
1717
let resolveInFlight: Promise<string | null> | null = null;
18+
let cachedMissHadShellEnv = false;
1819
const versionCache = new Map<string, { version: string | null; observedAt: number }>();
1920

2021
async function fileExists(filePath: string): Promise<boolean> {
@@ -121,24 +122,33 @@ export class CodexBinaryResolver {
121122
cachedBinaryPath = undefined;
122123
cacheVerifiedAt = 0;
123124
resolveInFlight = null;
125+
cachedMissHadShellEnv = false;
124126
versionCache.clear();
125127
}
126128

127129
static async resolve(): Promise<string | null> {
128130
if (cachedBinaryPath !== undefined) {
129131
if (cachedBinaryPath === null) {
130-
const verifiedAppManagedBinaryPath =
131-
await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
132-
if (verifiedAppManagedBinaryPath) {
133-
cachedBinaryPath = verifiedAppManagedBinaryPath;
134-
cacheVerifiedAt = Date.now();
135-
return verifiedAppManagedBinaryPath;
136-
}
137-
if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) {
138-
return null;
132+
if (!cachedMissHadShellEnv && getCachedShellEnv() !== null) {
133+
cachedBinaryPath = undefined;
134+
cacheVerifiedAt = 0;
135+
cachedMissHadShellEnv = false;
136+
} else {
137+
const verifiedAppManagedBinaryPath =
138+
await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
139+
if (verifiedAppManagedBinaryPath) {
140+
cachedBinaryPath = verifiedAppManagedBinaryPath;
141+
cacheVerifiedAt = Date.now();
142+
cachedMissHadShellEnv = false;
143+
return verifiedAppManagedBinaryPath;
144+
}
145+
if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) {
146+
return null;
147+
}
148+
cachedBinaryPath = undefined;
149+
cacheVerifiedAt = 0;
150+
cachedMissHadShellEnv = false;
139151
}
140-
cachedBinaryPath = undefined;
141-
cacheVerifiedAt = 0;
142152
} else {
143153
if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) {
144154
return cachedBinaryPath;
@@ -147,6 +157,7 @@ export class CodexBinaryResolver {
147157
const verified = await verifyBinary(cachedBinaryPath);
148158
if (verified) {
149159
cacheVerifiedAt = Date.now();
160+
cachedMissHadShellEnv = false;
150161
return verified;
151162
}
152163

@@ -178,12 +189,14 @@ export class CodexBinaryResolver {
178189
if (resolved) {
179190
cachedBinaryPath = resolved;
180191
cacheVerifiedAt = Date.now();
192+
cachedMissHadShellEnv = false;
181193
return resolved;
182194
}
183195
}
184196

185197
cachedBinaryPath = null;
186198
cacheVerifiedAt = Date.now();
199+
cachedMissHadShellEnv = getCachedShellEnv() !== null;
187200
return null;
188201
}
189202

src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,34 @@ describe('CodexBinaryResolver', () => {
205205
await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim);
206206
});
207207

208+
it('recovers a cold negative cache entry as soon as shell env becomes available', async () => {
209+
setPlatform('darwin');
210+
process.env.PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
211+
const shellPath = '/usr/local/bin:/usr/bin:/bin';
212+
const codexShim = path.posix.join('/usr/local/bin', 'codex');
213+
buildMergedCliPathMock.mockReturnValue('/usr/bin:/bin:/usr/sbin:/sbin');
214+
215+
accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
216+
217+
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
218+
CodexBinaryResolver.clearCache();
219+
220+
await expect(CodexBinaryResolver.resolve()).resolves.toBeNull();
221+
222+
getCachedShellEnvMock.mockReturnValue({
223+
HOME: '/Users/tester',
224+
PATH: shellPath,
225+
});
226+
accessMock.mockImplementation((filePath) => {
227+
if (filePath === codexShim) {
228+
return Promise.resolve();
229+
}
230+
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
231+
});
232+
233+
await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim);
234+
});
235+
208236
it('skips Windows PATH candidates that exist but cannot be launched', async () => {
209237
const blockedDir =
210238
'C:\\Program Files\\WindowsApps\\OpenAI.Codex_26.422.3464.0_x64__2p2nqsd0c76g0\\app\\resources';

src/main/services/team/TeamProvisioningService.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20061,14 +20061,28 @@ export class TeamProvisioningService {
2006120061
): Promise<string | null> {
2006220062
const { stdout } = await execCli(
2006320063
claudePath,
20064-
buildProviderCliCommandArgs(providerArgs, ['model', 'list', '--json', '--provider', 'all']),
20064+
buildProviderCliCommandArgs(providerArgs, [
20065+
'model',
20066+
'list',
20067+
'--json',
20068+
'--provider',
20069+
providerId,
20070+
]),
2006520071
{
2006620072
cwd,
2006720073
env,
2006820074
timeout: 10_000,
2006920075
}
2007020076
);
20071-
const parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(stdout);
20077+
let parsed: ProviderModelListCommandResponse;
20078+
try {
20079+
parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(stdout);
20080+
} catch (error) {
20081+
const message = error instanceof Error ? error.message : String(error);
20082+
throw new Error(
20083+
`Failed to parse runtime default model list for ${getTeamProviderLabel(providerId)} (${providerId}): ${message}`
20084+
);
20085+
}
2007220086
const defaultModel = parsed.providers?.[providerId]?.defaultModel;
2007320087
const normalizedDefaultModel =
2007420088
typeof defaultModel === 'string' && defaultModel.trim().length > 0

test/features/codex-account/main/createCodexAccountFeature.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type {
1111
const {
1212
apiKeyHasPreferredMock,
1313
apiKeyLookupMock,
14+
binaryClearCacheMock,
1415
binaryResolveMock,
1516
detectLocalAccountStateMock,
1617
getCachedShellEnvMock,
@@ -24,12 +25,15 @@ const {
2425
readAccountMock,
2526
readAccountSnapshotMock,
2627
readRateLimitsMock,
28+
resolveInteractiveShellEnvBestEffortMock,
2729
} = vi.hoisted(() => ({
2830
binaryResolveMock: vi.fn(),
31+
binaryClearCacheMock: vi.fn(),
2932
apiKeyHasPreferredMock: vi.fn(),
3033
apiKeyLookupMock: vi.fn(),
3134
detectLocalAccountStateMock: vi.fn(),
3235
getCachedShellEnvMock: vi.fn(),
36+
resolveInteractiveShellEnvBestEffortMock: vi.fn(),
3337
readAccountMock: vi.fn(),
3438
readAccountSnapshotMock: vi.fn(),
3539
readRateLimitsMock: vi.fn(),
@@ -71,12 +75,14 @@ vi.mock('../../../../src/main/utils/shellEnv', async (importOriginal) => {
7175
return {
7276
...actual,
7377
getCachedShellEnv: getCachedShellEnvMock,
78+
resolveInteractiveShellEnvBestEffort: resolveInteractiveShellEnvBestEffortMock,
7479
};
7580
});
7681

7782
vi.mock('../../../../src/main/services/infrastructure/codexAppServer', () => ({
7883
CodexBinaryResolver: {
7984
resolve: binaryResolveMock,
85+
clearCache: binaryClearCacheMock,
8086
},
8187
CodexAppServerSessionFactory: class MockCodexAppServerSessionFactory {},
8288
JsonRpcStdioClient: class MockJsonRpcStdioClient {},
@@ -231,6 +237,9 @@ describe('createCodexAccountFeature', () => {
231237
delete process.env.OPENAI_API_KEY;
232238
delete process.env.CODEX_API_KEY;
233239
binaryResolveMock.mockResolvedValue('/usr/local/bin/codex');
240+
binaryClearCacheMock.mockReset();
241+
resolveInteractiveShellEnvBestEffortMock.mockReset();
242+
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({});
234243
apiKeyHasPreferredMock.mockResolvedValue(false);
235244
apiKeyLookupMock.mockResolvedValue(null);
236245
detectLocalAccountStateMock.mockResolvedValue({
@@ -360,6 +369,69 @@ describe('createCodexAccountFeature', () => {
360369
}
361370
});
362371

372+
it('retries Codex binary discovery after cold shell env resolves before publishing runtime-missing', async () => {
373+
binaryResolveMock.mockResolvedValueOnce(null).mockResolvedValue('/usr/local/bin/codex');
374+
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
375+
PATH: '/usr/local/bin:/usr/bin:/bin',
376+
});
377+
readAccountMock.mockResolvedValue({
378+
account: createAccountResponse(),
379+
initialize: {
380+
codexHome: '/Users/test/.codex',
381+
platformFamily: 'unix',
382+
platformOs: 'macos',
383+
},
384+
});
385+
386+
const feature = createCodexAccountFeature({
387+
logger: createLoggerPort(),
388+
configManager: createConfigManager('chatgpt'),
389+
});
390+
391+
try {
392+
const snapshot = await feature.refreshSnapshot();
393+
394+
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith(
395+
expect.objectContaining({
396+
timeoutMs: 12_000,
397+
fallbackEnv: process.env,
398+
})
399+
);
400+
expect(binaryClearCacheMock).toHaveBeenCalledTimes(1);
401+
expect(binaryResolveMock).toHaveBeenCalledTimes(2);
402+
expect(snapshot.appServerState).toBe('healthy');
403+
expect(snapshot.launchReadinessState).toBe('ready_chatgpt');
404+
expect(snapshot.launchIssueMessage).toBeNull();
405+
} finally {
406+
await feature.dispose();
407+
}
408+
});
409+
410+
it('still reports runtime-missing after the cold binary retry cannot find Codex', async () => {
411+
binaryResolveMock.mockResolvedValue(null);
412+
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
413+
PATH: '/usr/bin:/bin',
414+
});
415+
416+
const feature = createCodexAccountFeature({
417+
logger: createLoggerPort(),
418+
configManager: createConfigManager('chatgpt'),
419+
});
420+
421+
try {
422+
const snapshot = await feature.refreshSnapshot();
423+
424+
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledTimes(1);
425+
expect(binaryClearCacheMock).toHaveBeenCalledTimes(1);
426+
expect(binaryResolveMock).toHaveBeenCalledTimes(2);
427+
expect(snapshot.appServerState).toBe('runtime-missing');
428+
expect(snapshot.launchReadinessState).toBe('runtime_missing');
429+
expect(snapshot.launchIssueMessage).toContain('Codex CLI not found');
430+
} finally {
431+
await feature.dispose();
432+
}
433+
});
434+
363435
it('reuses a fresh refresh snapshot when the request does not need stronger data', async () => {
364436
readAccountMock.mockResolvedValue({
365437
account: createAccountResponse(),

0 commit comments

Comments
 (0)