fix(codex): retry binary discovery after shell env loads
This commit is contained in:
parent
554112a3d8
commit
9cd5144e1a
6 changed files with 165 additions and 23 deletions
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue