fix(codex): retry binary discovery after shell env loads

This commit is contained in:
777genius 2026-05-19 00:09:26 +03:00
parent 554112a3d8
commit 9cd5144e1a
6 changed files with 165 additions and 23 deletions

View file

@ -1,27 +1,27 @@
{
"version": "0.0.38",
"sourceRef": "v0.0.38",
"version": "0.0.39",
"sourceRef": "v0.0.39",
"sourceRepository": "777genius/agent_teams_orchestrator",
"releaseRepository": "777genius/agent-teams-ai",
"releaseTag": "v2.0.0",
"assets": {
"darwin-arm64": {
"file": "agent-teams-runtime-darwin-arm64-v0.0.38.tar.gz",
"file": "agent-teams-runtime-darwin-arm64-v0.0.39.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"darwin-x64": {
"file": "agent-teams-runtime-darwin-x64-v0.0.38.tar.gz",
"file": "agent-teams-runtime-darwin-x64-v0.0.39.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"linux-x64": {
"file": "agent-teams-runtime-linux-x64-v0.0.38.tar.gz",
"file": "agent-teams-runtime-linux-x64-v0.0.39.tar.gz",
"archiveKind": "tar.gz",
"binaryName": "claude-multimodel"
},
"win32-x64": {
"file": "agent-teams-runtime-win32-x64-v0.0.38.zip",
"file": "agent-teams-runtime-win32-x64-v0.0.39.zip",
"archiveKind": "zip",
"binaryName": "claude-multimodel.exe"
}

View file

@ -22,7 +22,7 @@ import {
CodexBinaryResolver,
JsonRpcStdioClient,
} from '@main/services/infrastructure/codexAppServer';
import { getCachedShellEnv } from '@main/utils/shellEnv';
import { getCachedShellEnv, resolveInteractiveShellEnvBestEffort } from '@main/utils/shellEnv';
import { CodexAccountSnapshotPresenter } from '../adapters/output/presenters/CodexAccountSnapshotPresenter';
import { CodexAccountAppServerClient } from '../infrastructure/CodexAccountAppServerClient';
@ -41,6 +41,7 @@ type LoggerPort = Pick<Logger, 'info' | 'warn' | 'error'>;
const SNAPSHOT_CACHE_TTL_MS = 5_000;
const RATE_LIMITS_CACHE_TTL_MS = 45_000;
const LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS = 60_000;
const CODEX_BINARY_COLD_RETRY_TIMEOUT_MS = 12_000;
interface CodexLastKnownAccount {
payload: CodexAppServerGetAccountResponse;
@ -251,6 +252,20 @@ function createDeferred(): { promise: Promise<void>; resolve: () => void } {
};
}
async function resolveCodexBinaryForAccountSnapshot(): Promise<string | null> {
const binaryPath = await CodexBinaryResolver.resolve();
if (binaryPath) {
return binaryPath;
}
await resolveInteractiveShellEnvBestEffort({
timeoutMs: CODEX_BINARY_COLD_RETRY_TIMEOUT_MS,
fallbackEnv: process.env,
});
CodexBinaryResolver.clearCache();
return CodexBinaryResolver.resolve();
}
export interface CodexAccountFeatureFacade {
getSnapshot(): Promise<CodexAccountSnapshotDto>;
refreshSnapshot(options?: {
@ -351,7 +366,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
}): Promise<CodexAccountSnapshotDto> {
let binaryMissing = false;
await this.runSerializedMutation(async () => {
const binaryPath = await CodexBinaryResolver.resolve();
const binaryPath = await resolveCodexBinaryForAccountSnapshot();
if (!binaryPath) {
binaryMissing = true;
return;
@ -380,7 +395,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
await this.runSerializedMutation(async () => {
await this.loginSessionManager.cancel().catch(() => undefined);
const binaryPath = await CodexBinaryResolver.resolve();
const binaryPath = await resolveCodexBinaryForAccountSnapshot();
if (!binaryPath) {
throw new Error('Codex CLI is not available, so logout cannot be completed.');
}
@ -467,7 +482,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade {
const localAccountState = await detectCodexLocalAccountState();
const localAccountArtifactsPresent = localAccountState.hasArtifacts;
const localActiveChatgptAccountPresent = localAccountState.hasActiveChatgptAccount;
const binaryPath = await CodexBinaryResolver.resolve();
const binaryPath = await resolveCodexBinaryForAccountSnapshot();
const login = this.loginSessionManager.getState();
const now = Date.now();

View file

@ -15,6 +15,7 @@ const BINARY_LAUNCH_VERIFY_TIMEOUT_MS = 3_000;
let cachedBinaryPath: string | null | undefined;
let cacheVerifiedAt = 0;
let resolveInFlight: Promise<string | null> | null = null;
let cachedMissHadShellEnv = false;
const versionCache = new Map<string, { version: string | null; observedAt: number }>();
async function fileExists(filePath: string): Promise<boolean> {
@ -121,24 +122,33 @@ export class CodexBinaryResolver {
cachedBinaryPath = undefined;
cacheVerifiedAt = 0;
resolveInFlight = null;
cachedMissHadShellEnv = false;
versionCache.clear();
}
static async resolve(): Promise<string | null> {
if (cachedBinaryPath !== undefined) {
if (cachedBinaryPath === null) {
const verifiedAppManagedBinaryPath =
await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
if (verifiedAppManagedBinaryPath) {
cachedBinaryPath = verifiedAppManagedBinaryPath;
cacheVerifiedAt = Date.now();
return verifiedAppManagedBinaryPath;
if (!cachedMissHadShellEnv && getCachedShellEnv() !== null) {
cachedBinaryPath = undefined;
cacheVerifiedAt = 0;
cachedMissHadShellEnv = false;
} else {
const verifiedAppManagedBinaryPath =
await resolveVerifiedAppManagedCodexRuntimeBinaryPath();
if (verifiedAppManagedBinaryPath) {
cachedBinaryPath = verifiedAppManagedBinaryPath;
cacheVerifiedAt = Date.now();
cachedMissHadShellEnv = false;
return verifiedAppManagedBinaryPath;
}
if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) {
return null;
}
cachedBinaryPath = undefined;
cacheVerifiedAt = 0;
cachedMissHadShellEnv = false;
}
if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) {
return null;
}
cachedBinaryPath = undefined;
cacheVerifiedAt = 0;
} else {
if (Date.now() - cacheVerifiedAt <= CACHE_VERIFY_TTL_MS) {
return cachedBinaryPath;
@ -147,6 +157,7 @@ export class CodexBinaryResolver {
const verified = await verifyBinary(cachedBinaryPath);
if (verified) {
cacheVerifiedAt = Date.now();
cachedMissHadShellEnv = false;
return verified;
}
@ -178,12 +189,14 @@ export class CodexBinaryResolver {
if (resolved) {
cachedBinaryPath = resolved;
cacheVerifiedAt = Date.now();
cachedMissHadShellEnv = false;
return resolved;
}
}
cachedBinaryPath = null;
cacheVerifiedAt = Date.now();
cachedMissHadShellEnv = getCachedShellEnv() !== null;
return null;
}

View file

@ -205,6 +205,34 @@ describe('CodexBinaryResolver', () => {
await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim);
});
it('recovers a cold negative cache entry as soon as shell env becomes available', async () => {
setPlatform('darwin');
process.env.PATH = '/usr/bin:/bin:/usr/sbin:/sbin';
const shellPath = '/usr/local/bin:/usr/bin:/bin';
const codexShim = path.posix.join('/usr/local/bin', 'codex');
buildMergedCliPathMock.mockReturnValue('/usr/bin:/bin:/usr/sbin:/sbin');
accessMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
const { CodexBinaryResolver } = await import('../CodexBinaryResolver');
CodexBinaryResolver.clearCache();
await expect(CodexBinaryResolver.resolve()).resolves.toBeNull();
getCachedShellEnvMock.mockReturnValue({
HOME: '/Users/tester',
PATH: shellPath,
});
accessMock.mockImplementation((filePath) => {
if (filePath === codexShim) {
return Promise.resolve();
}
return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
});
await expect(CodexBinaryResolver.resolve()).resolves.toBe(codexShim);
});
it('skips Windows PATH candidates that exist but cannot be launched', async () => {
const blockedDir =
'C:\\Program Files\\WindowsApps\\OpenAI.Codex_26.422.3464.0_x64__2p2nqsd0c76g0\\app\\resources';

View file

@ -20061,14 +20061,28 @@ export class TeamProvisioningService {
): Promise<string | null> {
const { stdout } = await execCli(
claudePath,
buildProviderCliCommandArgs(providerArgs, ['model', 'list', '--json', '--provider', 'all']),
buildProviderCliCommandArgs(providerArgs, [
'model',
'list',
'--json',
'--provider',
providerId,
]),
{
cwd,
env,
timeout: 10_000,
}
);
const parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(stdout);
let parsed: ProviderModelListCommandResponse;
try {
parsed = extractJsonObjectFromCli<ProviderModelListCommandResponse>(stdout);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to parse runtime default model list for ${getTeamProviderLabel(providerId)} (${providerId}): ${message}`
);
}
const defaultModel = parsed.providers?.[providerId]?.defaultModel;
const normalizedDefaultModel =
typeof defaultModel === 'string' && defaultModel.trim().length > 0

View file

@ -11,6 +11,7 @@ import type {
const {
apiKeyHasPreferredMock,
apiKeyLookupMock,
binaryClearCacheMock,
binaryResolveMock,
detectLocalAccountStateMock,
getCachedShellEnvMock,
@ -24,12 +25,15 @@ const {
readAccountMock,
readAccountSnapshotMock,
readRateLimitsMock,
resolveInteractiveShellEnvBestEffortMock,
} = vi.hoisted(() => ({
binaryResolveMock: vi.fn(),
binaryClearCacheMock: vi.fn(),
apiKeyHasPreferredMock: vi.fn(),
apiKeyLookupMock: vi.fn(),
detectLocalAccountStateMock: vi.fn(),
getCachedShellEnvMock: vi.fn(),
resolveInteractiveShellEnvBestEffortMock: vi.fn(),
readAccountMock: vi.fn(),
readAccountSnapshotMock: vi.fn(),
readRateLimitsMock: vi.fn(),
@ -71,12 +75,14 @@ vi.mock('../../../../src/main/utils/shellEnv', async (importOriginal) => {
return {
...actual,
getCachedShellEnv: getCachedShellEnvMock,
resolveInteractiveShellEnvBestEffort: resolveInteractiveShellEnvBestEffortMock,
};
});
vi.mock('../../../../src/main/services/infrastructure/codexAppServer', () => ({
CodexBinaryResolver: {
resolve: binaryResolveMock,
clearCache: binaryClearCacheMock,
},
CodexAppServerSessionFactory: class MockCodexAppServerSessionFactory {},
JsonRpcStdioClient: class MockJsonRpcStdioClient {},
@ -231,6 +237,9 @@ describe('createCodexAccountFeature', () => {
delete process.env.OPENAI_API_KEY;
delete process.env.CODEX_API_KEY;
binaryResolveMock.mockResolvedValue('/usr/local/bin/codex');
binaryClearCacheMock.mockReset();
resolveInteractiveShellEnvBestEffortMock.mockReset();
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({});
apiKeyHasPreferredMock.mockResolvedValue(false);
apiKeyLookupMock.mockResolvedValue(null);
detectLocalAccountStateMock.mockResolvedValue({
@ -360,6 +369,69 @@ describe('createCodexAccountFeature', () => {
}
});
it('retries Codex binary discovery after cold shell env resolves before publishing runtime-missing', async () => {
binaryResolveMock.mockResolvedValueOnce(null).mockResolvedValue('/usr/local/bin/codex');
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
PATH: '/usr/local/bin:/usr/bin:/bin',
});
readAccountMock.mockResolvedValue({
account: createAccountResponse(),
initialize: {
codexHome: '/Users/test/.codex',
platformFamily: 'unix',
platformOs: 'macos',
},
});
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
try {
const snapshot = await feature.refreshSnapshot();
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 12_000,
fallbackEnv: process.env,
})
);
expect(binaryClearCacheMock).toHaveBeenCalledTimes(1);
expect(binaryResolveMock).toHaveBeenCalledTimes(2);
expect(snapshot.appServerState).toBe('healthy');
expect(snapshot.launchReadinessState).toBe('ready_chatgpt');
expect(snapshot.launchIssueMessage).toBeNull();
} finally {
await feature.dispose();
}
});
it('still reports runtime-missing after the cold binary retry cannot find Codex', async () => {
binaryResolveMock.mockResolvedValue(null);
resolveInteractiveShellEnvBestEffortMock.mockResolvedValue({
PATH: '/usr/bin:/bin',
});
const feature = createCodexAccountFeature({
logger: createLoggerPort(),
configManager: createConfigManager('chatgpt'),
});
try {
const snapshot = await feature.refreshSnapshot();
expect(resolveInteractiveShellEnvBestEffortMock).toHaveBeenCalledTimes(1);
expect(binaryClearCacheMock).toHaveBeenCalledTimes(1);
expect(binaryResolveMock).toHaveBeenCalledTimes(2);
expect(snapshot.appServerState).toBe('runtime-missing');
expect(snapshot.launchReadinessState).toBe('runtime_missing');
expect(snapshot.launchIssueMessage).toContain('Codex CLI not found');
} finally {
await feature.dispose();
}
});
it('reuses a fresh refresh snapshot when the request does not need stronger data', async () => {
readAccountMock.mockResolvedValue({
account: createAccountResponse(),