From 933e580d03a492b3f44aba51c4a67b54fd0c3c13 Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 19 May 2026 23:19:34 +0300 Subject: [PATCH] fix(opencode): preserve command preflight context --- runtime.lock.json | 12 +- scripts/lib/opencode-live-preflight.mjs | 29 ++- scripts/prove-provider-launch-stress.mjs | 4 + .../bridge/OpenCodeBridgeCommandClient.ts | 28 ++- .../providerPrepareRequestSignature.ts | 30 ++- .../team/OpenCodeBridgeCommandClient.test.ts | 45 +++- .../ProviderLaunchStress.live-e2e.test.ts | 10 + .../providerPrepareRequestSignature.test.ts | 207 ++++++++++++++++++ test/scripts/opencodeLivePreflight.test.ts | 9 + 9 files changed, 342 insertions(+), 32 deletions(-) diff --git a/runtime.lock.json b/runtime.lock.json index 1db56931..d64bd956 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.42", - "sourceRef": "v0.0.42", + "version": "0.0.43", + "sourceRef": "v0.0.43", "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.42.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.43.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.42.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.43.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.42.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.43.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.42.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.43.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/scripts/lib/opencode-live-preflight.mjs b/scripts/lib/opencode-live-preflight.mjs index 1fc7d734..176720fa 100644 --- a/scripts/lib/opencode-live-preflight.mjs +++ b/scripts/lib/opencode-live-preflight.mjs @@ -7,6 +7,7 @@ import path from 'node:path'; const CHILD_CLOSE_GRACE_MS = 3_000; const CHILD_FORCE_CLOSE_GRACE_MS = 1_000; const TASKKILL_TIMEOUT_MS = 5_000; +const OPENCODE_HEALTH_FETCH_TIMEOUT_MS = 1_000; export async function preflightOpenCodeLiveEnvironment(input) { const repoRoot = input.repoRoot; @@ -125,13 +126,12 @@ async function canStartOpenCodeHost(opencodeBin, cwd, env) { return { ok: false, reason: output || `process exited with code ${child.exitCode}` }; } try { - const response = await fetch(`http://127.0.0.1:${port}/global/health`); - if (response.ok) { - const data = await response.json().catch(() => ({})); - if (data?.healthy === true) { - return { ok: true }; - } + const response = await fetchOpenCodeHealth(port); + if (isHealthyOpenCodeHostResponse(response)) { + response.body?.cancel().catch(() => undefined); + return { ok: true }; } + response.body?.cancel().catch(() => undefined); } catch { // Host is still starting. } @@ -143,6 +143,22 @@ async function canStartOpenCodeHost(opencodeBin, cwd, env) { } } +async function fetchOpenCodeHealth(port) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), OPENCODE_HEALTH_FETCH_TIMEOUT_MS); + try { + return await fetch(`http://127.0.0.1:${port}/global/health`, { + signal: controller.signal, + }); + } finally { + clearTimeout(timeout); + } +} + +function isHealthyOpenCodeHostResponse(response) { + return response.ok; +} + async function stopChild(child, options = {}) { const platform = options.platform ?? process.platform; const killProcessTree = options.killProcessTree ?? taskkillProcessTree; @@ -271,6 +287,7 @@ function compactOutput(value) { } export const __opencodeLivePreflightTestHooks = { + isHealthyOpenCodeHostResponse, stopChild, taskkillProcessTree, }; diff --git a/scripts/prove-provider-launch-stress.mjs b/scripts/prove-provider-launch-stress.mjs index 397e87d7..b757e964 100644 --- a/scripts/prove-provider-launch-stress.mjs +++ b/scripts/prove-provider-launch-stress.mjs @@ -24,6 +24,10 @@ const env = { PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH: process.env.PROVIDER_LAUNCH_STRESS_ANTHROPIC_AUTH?.trim() || (process.env.ANTHROPIC_API_KEY?.trim() ? 'api-key' : 'subscription'), + CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS: + process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS?.trim() || '90000', + CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS: + process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS?.trim() || '30000', OPENCODE_E2E: '1', OPENCODE_E2E_USE_REAL_APP_CREDENTIALS: '1', OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1', diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts index cc8defff..acab846a 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts @@ -162,6 +162,7 @@ export class OpenCodeBridgeCommandClient { body, }; const inputPath = await this.writeInputFile(envelope); + const outputPath = `${inputPath}.output.json`; try { const maxAttempts = @@ -169,13 +170,22 @@ export class OpenCodeBridgeCommandClient { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const processResult = await this.processRunner.run({ binaryPath: this.binaryPath, - args: ['runtime', 'opencode-command', '--json', '--input', inputPath], + args: [ + 'runtime', + 'opencode-command', + '--json', + '--input', + inputPath, + '--output', + outputPath, + ], cwd: resolveOpenCodeBridgeProcessCwd(this.binaryPath, options.cwd), timeoutMs: options.timeoutMs, stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES, stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES, env: await this.resolveEnv(), }); + const stdout = await this.readBridgeOutput(processResult.stdout, outputPath); if (processResult.timedOut) { return this.contractFailure( @@ -204,7 +214,7 @@ export class OpenCodeBridgeCommandClient { ); } - const parsed = parseSingleBridgeJsonResult(processResult.stdout); + const parsed = parseSingleBridgeJsonResult(stdout); if (!parsed.ok) { if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) { await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS); @@ -212,7 +222,7 @@ export class OpenCodeBridgeCommandClient { } return this.contractFailure(envelope, 'contract_violation', parsed.error, false, { - stdoutPreview: redactBridgeDiagnosticText(processResult.stdout.slice(0, 2_000)), + stdoutPreview: redactBridgeDiagnosticText(stdout.slice(0, 2_000)), stderrPreview: redactBridgeDiagnosticText(processResult.stderr.slice(0, 2_000)), attempts: attempt, }); @@ -239,6 +249,18 @@ export class OpenCodeBridgeCommandClient { if (!this.keepInputFile) { await fs.unlink(inputPath).catch(() => undefined); } + await fs.unlink(outputPath).catch(() => undefined); + } + } + + private async readBridgeOutput(stdout: string, outputPath: string): Promise { + if (stdout.trim().length > 0) { + return stdout; + } + try { + return await fs.readFile(outputPath, 'utf8'); + } catch { + return stdout; } } diff --git a/src/renderer/components/team/dialogs/providerPrepareRequestSignature.ts b/src/renderer/components/team/dialogs/providerPrepareRequestSignature.ts index 473126c5..8682bc1b 100644 --- a/src/renderer/components/team/dialogs/providerPrepareRequestSignature.ts +++ b/src/renderer/components/team/dialogs/providerPrepareRequestSignature.ts @@ -14,6 +14,19 @@ type SelectedModelChecksByProvider = ReadonlyMap< readonly ProviderModelCheckSignatureInput[] >; +function getCodexPrepareRuntimeSignature( + codex: NonNullable['codex']> +): Record { + return { + preferredAuthMode: codex.preferredAuthMode, + effectiveAuthMode: codex.effectiveAuthMode, + managedAccountType: codex.managedAccount?.type ?? null, + requiresOpenaiAuth: codex.requiresOpenaiAuth ?? null, + launchAllowed: codex.launchAllowed, + launchReadinessState: codex.launchAllowed ? 'launchable' : codex.launchReadinessState, + }; +} + function normalizeModelIds(modelIds: readonly string[] | null | undefined): string[] { return Array.from( new Set((modelIds ?? []).map((modelId) => modelId.trim()).filter(Boolean)) @@ -100,22 +113,7 @@ export function buildProviderPrepareRuntimeStatusSignature( apiKeyConfigured: provider.connection.apiKeyConfigured, apiKeySource: provider.connection.apiKeySource ?? null, codex: provider.connection.codex - ? { - preferredAuthMode: provider.connection.codex.preferredAuthMode, - effectiveAuthMode: provider.connection.codex.effectiveAuthMode, - appServerState: provider.connection.codex.appServerState, - managedAccountType: provider.connection.codex.managedAccount?.type ?? null, - managedAccountEmail: provider.connection.codex.managedAccount?.email ?? null, - requiresOpenaiAuth: provider.connection.codex.requiresOpenaiAuth ?? null, - localAccountArtifactsPresent: - provider.connection.codex.localAccountArtifactsPresent ?? null, - localActiveChatgptAccountPresent: - provider.connection.codex.localActiveChatgptAccountPresent ?? null, - loginStatus: provider.connection.codex.login?.status ?? null, - launchAllowed: provider.connection.codex.launchAllowed, - launchIssueMessage: provider.connection.codex.launchIssueMessage ?? null, - launchReadinessState: provider.connection.codex.launchReadinessState, - } + ? getCodexPrepareRuntimeSignature(provider.connection.codex) : null, } : null, diff --git a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts index 544cd690..e7b13f6c 100644 --- a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts @@ -59,7 +59,15 @@ describe('OpenCodeBridgeCommandClient', () => { expect(runner.calls).toHaveLength(1); expect(runner.calls[0]).toMatchObject({ binaryPath: '/usr/local/bin/agent-teams-controller', - args: ['runtime', 'opencode-command', '--json', '--input', expect.any(String)], + args: [ + 'runtime', + 'opencode-command', + '--json', + '--input', + expect.any(String), + '--output', + expect.any(String), + ], cwd: '/tmp/project', timeoutMs: 10_000, env: expect.objectContaining({ @@ -68,6 +76,7 @@ describe('OpenCodeBridgeCommandClient', () => { }); const inputPath = runner.calls[0].args[4]; + const outputPath = runner.calls[0].args[6]; expect(JSON.parse(await runner.readInputEnvelope(0))).toMatchObject({ schemaVersion: 1, requestId: 'req-1', @@ -77,6 +86,33 @@ describe('OpenCodeBridgeCommandClient', () => { body: { runId: 'run-1' }, }); await expect(fs.access(inputPath)).rejects.toThrow(); + await expect(fs.access(outputPath)).rejects.toThrow(); + }); + + it('reads bridge JSON from the output file when stdout is empty', async () => { + runner.nextResult = { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }; + runner.nextOutputFileContents = `${JSON.stringify(bridgeSuccess({ data: { runId: 'run-1' } }))}\n`; + const client = createClient(); + + const result = await client.execute( + 'opencode.launchTeam', + { runId: 'run-1' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); + + expect(result).toMatchObject({ + ok: true, + requestId: 'req-1', + command: 'opencode.launchTeam', + }); }); it('fails closed when stdout contains logs plus json', async () => { @@ -455,10 +491,17 @@ class FakeBridgeProcessRunner implements OpenCodeBridgeProcessRunner { exitCode: 0, timedOut: false, }; + nextOutputFileContents: string | null = null; async run(input: OpenCodeBridgeProcessRunInput): Promise { this.calls.push(input); this.inputEnvelopes.push(await fs.readFile(input.args[4], 'utf8')); + const outputFlagIndex = input.args.indexOf('--output'); + const outputPath = outputFlagIndex >= 0 ? input.args[outputFlagIndex + 1] : undefined; + if (this.nextOutputFileContents !== null && outputPath) { + await fs.writeFile(outputPath, this.nextOutputFileContents, 'utf8'); + this.nextOutputFileContents = null; + } return this.nextResults.shift() ?? this.nextResult; } diff --git a/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts b/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts index 591bc9ac..c0863de4 100644 --- a/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts +++ b/test/main/services/team/ProviderLaunchStress.live-e2e.test.ts @@ -95,6 +95,8 @@ liveDescribe('provider launch stress live e2e', () => { let previousNodeEnv: string | undefined; let previousAnthropicApiKey: string | undefined; let previousAnthropicAuthToken: string | undefined; + let previousRuntimeReadyTimeout: string | undefined; + let previousInboxPollerReadyTimeout: string | undefined; let previousClaudeJsonConfig: string | null | undefined; const activeScenarios: ActiveScenario[] = []; @@ -136,10 +138,16 @@ liveDescribe('provider launch stress live e2e', () => { previousNodeEnv = process.env.NODE_ENV; previousAnthropicApiKey = process.env.ANTHROPIC_API_KEY; previousAnthropicAuthToken = process.env.ANTHROPIC_AUTH_TOKEN; + previousRuntimeReadyTimeout = process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS; + previousInboxPollerReadyTimeout = process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS; process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator'; + process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS = + process.env.CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS?.trim() || '90000'; + process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS = + process.env.CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS?.trim() || '30000'; process.env.CODEX_HOME = resolveConnectedCodexHome(previousCodexHome); process.env.HOME = usingAnthropicSubscriptionAuth() ? os.userInfo().homedir : tempHome; process.env.USERPROFILE = usingAnthropicSubscriptionAuth() ? os.userInfo().homedir : tempHome; @@ -170,6 +178,8 @@ liveDescribe('provider launch stress live e2e', () => { restoreEnv('NODE_ENV', previousNodeEnv); restoreEnv('ANTHROPIC_API_KEY', previousAnthropicApiKey); restoreEnv('ANTHROPIC_AUTH_TOKEN', previousAnthropicAuthToken); + restoreEnv('CLAUDE_TEAM_PROCESS_RUNTIME_READY_TIMEOUT_MS', previousRuntimeReadyTimeout); + restoreEnv('CLAUDE_TEAM_PROCESS_INBOX_POLLER_READY_TIMEOUT_MS', previousInboxPollerReadyTimeout); if (process.env.PROVIDER_LAUNCH_STRESS_KEEP_TEMP === '1') { process.stderr.write(`[ProviderLaunchStress.live] preserved temp dir: ${tempDir}\n`); diff --git a/test/renderer/components/team/dialogs/providerPrepareRequestSignature.test.ts b/test/renderer/components/team/dialogs/providerPrepareRequestSignature.test.ts index d418bbde..9b65eb4b 100644 --- a/test/renderer/components/team/dialogs/providerPrepareRequestSignature.test.ts +++ b/test/renderer/components/team/dialogs/providerPrepareRequestSignature.test.ts @@ -600,6 +600,213 @@ describe('providerPrepareRequestSignature', () => { expect(first).toBe(second); }); + it('ignores launchable Codex account telemetry churn', () => { + const providerIds = ['codex'] as const; + const first = buildProviderPrepareRuntimeStatusSignature( + providerIds, + providerStatusMap([ + [ + 'codex', + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'chatgpt', + apiKeyConfigured: false, + apiKeySource: null, + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'plus', + }, + requiresOpenaiAuth: false, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + }, + }, + }, + ], + ]) + ); + const second = buildProviderPrepareRuntimeStatusSignature( + providerIds, + providerStatusMap([ + [ + 'codex', + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'chatgpt', + apiKeyConfigured: false, + apiKeySource: null, + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + appServerState: 'degraded', + appServerStatusMessage: 'rate limits refresh failed', + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'plus', + }, + requiresOpenaiAuth: false, + localAccountArtifactsPresent: false, + localActiveChatgptAccountPresent: true, + login: { + status: 'pending', + error: null, + startedAt: '2026-05-19T00:00:00.000Z', + }, + rateLimits: { + limitId: 'codex', + limitName: null, + primary: { + usedPercent: 87, + windowDurationMins: 300, + resetsAt: 1_779_120_000_000, + }, + secondary: null, + credits: null, + planType: 'plus', + }, + launchAllowed: true, + launchIssueMessage: 'Ready with degraded account verification.', + launchReadinessState: 'warning_degraded_but_launchable', + }, + }, + }, + ], + ]) + ); + + expect(first).toBe(second); + }); + + it('changes the Codex runtime signature when launchability changes', () => { + const providerIds = ['codex'] as const; + const ready = buildProviderPrepareRuntimeStatusSignature( + providerIds, + providerStatusMap([ + [ + 'codex', + { + providerId: 'codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'chatgpt', + apiKeyConfigured: false, + apiKeySource: null, + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'plus', + }, + requiresOpenaiAuth: false, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: true, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + }, + }, + }, + ], + ]) + ); + const missingAuth = buildProviderPrepareRuntimeStatusSignature( + providerIds, + providerStatusMap([ + [ + 'codex', + { + providerId: 'codex', + supported: true, + authenticated: false, + authMethod: null, + selectedBackendId: 'codex-native', + resolvedBackendId: 'codex-native', + connection: { + supportsOAuth: false, + supportsApiKey: true, + configurableAuthModes: ['auto', 'chatgpt', 'api_key'], + configuredAuthMode: 'chatgpt', + apiKeyConfigured: false, + apiKeySource: null, + codex: { + preferredAuthMode: 'chatgpt', + effectiveAuthMode: null, + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + requiresOpenaiAuth: true, + localAccountArtifactsPresent: true, + localActiveChatgptAccountPresent: false, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + launchAllowed: false, + launchIssueMessage: 'Connect a ChatGPT account.', + launchReadinessState: 'missing_auth', + }, + }, + }, + ], + ]) + ); + + expect(ready).not.toBe(missingAuth); + }); + it('ignores volatile member draft ids in provider prepare signatures', () => { const first = buildProviderPrepareMembersSignature([ { diff --git a/test/scripts/opencodeLivePreflight.test.ts b/test/scripts/opencodeLivePreflight.test.ts index 0176d371..32f768fc 100644 --- a/test/scripts/opencodeLivePreflight.test.ts +++ b/test/scripts/opencodeLivePreflight.test.ts @@ -17,6 +17,7 @@ interface StopChildOptions { interface OpenCodeLivePreflightTestHooks { __opencodeLivePreflightTestHooks: { + isHealthyOpenCodeHostResponse(response: { ok: boolean }): boolean; stopChild(child: FakeChild, options?: StopChildOptions): Promise; taskkillProcessTree(pid: number): Promise; }; @@ -38,6 +39,14 @@ describe('opencode live preflight cleanup', () => { } }); + it('accepts an HTTP 2xx OpenCode health response without requiring a JSON body', async () => { + const { isHealthyOpenCodeHostResponse } = (await loadTestHooks()) + .__opencodeLivePreflightTestHooks; + + expect(isHealthyOpenCodeHostResponse({ ok: true })).toBe(true); + expect(isHealthyOpenCodeHostResponse({ ok: false })).toBe(false); + }); + it('waits for child close after Windows process-tree cleanup', async () => { const { stopChild } = (await loadTestHooks()).__opencodeLivePreflightTestHooks; const child = new FakeChild({ pid: 1234 });