From 50f876f8639e3ce9c39d114cb4661620084469c2 Mon Sep 17 00:00:00 2001 From: infiniti <52129260+developerInfiniti@users.noreply.github.com> Date: Sun, 24 May 2026 23:22:34 +0300 Subject: [PATCH] fix(opencode): retry read-only bridge no-output * fix(opencode): handle empty readiness bridge output * fix(opencode): retry read-only bridge no-output --------- Co-authored-by: iliya --- .../bridge/OpenCodeBridgeCommandClient.ts | 55 ++++++--- .../team/OpenCodeBridgeCommandClient.test.ts | 109 +++++++++++++++++- 2 files changed, 145 insertions(+), 19 deletions(-) diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts index 3b49ccca..f5d87a26 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts @@ -66,8 +66,9 @@ export interface OpenCodeBridgeCommandClientOptions { const DEFAULT_STDOUT_LIMIT_BYTES = 1_000_000; const DEFAULT_STDERR_LIMIT_BYTES = 256_000; const WINDOWS_BATCH_EXTENSIONS = new Set(['.cmd', '.bat']); -const EMPTY_STDOUT_READINESS_MAX_ATTEMPTS = 2; -const EMPTY_STDOUT_READINESS_RETRY_DELAY_MS = 250; +const EMPTY_STDOUT_READ_ONLY_MAX_ATTEMPTS = 2; +const EMPTY_STDOUT_READ_ONLY_STDOUT_FALLBACK_ATTEMPTS = 1; +const EMPTY_STDOUT_READ_ONLY_RETRY_DELAY_MS = 250; const SAFE_BRIDGE_INPUT_FILE_REQUEST_ID = /^[A-Za-z0-9._-]{1,120}$/; export function resolveOpenCodeBridgeProcessCwd( @@ -174,20 +175,22 @@ export class OpenCodeBridgeCommandClient { const outputPath = `${inputPath}.output.json`; try { - const maxAttempts = - command === 'opencode.readiness' ? EMPTY_STDOUT_READINESS_MAX_ATTEMPTS : 1; + const maxAttempts = isReadOnlyRetryableBridgeCommand(command) + ? EMPTY_STDOUT_READ_ONLY_MAX_ATTEMPTS + EMPTY_STDOUT_READ_ONLY_STDOUT_FALLBACK_ATTEMPTS + : 1; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const useStdoutOnlyFallback = shouldUseReadOnlyStdoutOnlyFallback( + command, + attempt, + maxAttempts + ); + const bridgeArgs = ['runtime', 'opencode-command', '--json', '--input', inputPath]; + if (!useStdoutOnlyFallback) { + bridgeArgs.push('--output', outputPath); + } const processResult = await this.processRunner.run({ binaryPath: this.binaryPath, - args: [ - 'runtime', - 'opencode-command', - '--json', - '--input', - inputPath, - '--output', - outputPath, - ], + args: bridgeArgs, cwd: resolveOpenCodeBridgeProcessCwd(this.binaryPath, options.cwd), timeoutMs: options.timeoutMs, stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES, @@ -235,8 +238,8 @@ export class OpenCodeBridgeCommandClient { const parsed = parseSingleBridgeJsonResult(bridgeOutput.content); if (!parsed.ok) { - if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) { - await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS); + if (shouldRetryEmptyReadOnlyStdout(command, parsed.error, attempt, maxAttempts)) { + await sleep(EMPTY_STDOUT_READ_ONLY_RETRY_DELAY_MS); continue; } @@ -402,14 +405,32 @@ export function redactBridgeDiagnosticText(value: string): string { .replace(/((?:api[_-]?key|token|password|secret)\s*[=:]\s*)[^\s"'`]+/gi, '$1[redacted]'); } -function shouldRetryEmptyReadinessStdout( +function shouldRetryEmptyReadOnlyStdout( command: OpenCodeBridgeCommandName, error: string, attempt: number, maxAttempts: number ): boolean { return ( - command === 'opencode.readiness' && error === 'Bridge stdout was empty' && attempt < maxAttempts + isReadOnlyRetryableBridgeCommand(command) && + error === 'Bridge stdout was empty' && + attempt < maxAttempts + ); +} + +function shouldUseReadOnlyStdoutOnlyFallback( + command: OpenCodeBridgeCommandName, + attempt: number, + maxAttempts: number +): boolean { + return isReadOnlyRetryableBridgeCommand(command) && attempt === maxAttempts && maxAttempts > 1; +} + +function isReadOnlyRetryableBridgeCommand(command: OpenCodeBridgeCommandName): boolean { + return ( + command === 'opencode.handshake' || + command === 'opencode.commandStatus' || + command === 'opencode.readiness' ); } diff --git a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts index 98385ee3..96b043a9 100644 --- a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts @@ -346,6 +346,104 @@ describe('OpenCodeBridgeCommandClient', () => { command: 'opencode.readiness', }); expect(runner.calls).toHaveLength(2); + expect(runner.calls[0].args).toContain('--output'); + expect(runner.calls[1].args).toContain('--output'); + }); + + it('falls back to stdout-only readiness when the output file contract returns no data', async () => { + runner.nextResults = [ + { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, + { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, + { + stdout: `${JSON.stringify( + bridgeSuccess({ + command: 'opencode.readiness', + data: { state: 'ready', launchAllowed: true }, + }) + )}\n`, + stderr: '', + exitCode: 0, + timedOut: false, + }, + ]; + const client = createClient(); + + const result = await client.execute( + 'opencode.readiness', + { projectPath: '/tmp/project' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); + + expect(result).toMatchObject({ + ok: true, + requestId: 'req-1', + command: 'opencode.readiness', + }); + expect(runner.calls).toHaveLength(3); + expect(runner.calls[0].args).toContain('--output'); + expect(runner.calls[1].args).toContain('--output'); + expect(runner.calls[2].args).not.toContain('--output'); + }); + + it('falls back to stdout-only handshake when the output file contract returns no data', async () => { + runner.nextResults = [ + { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, + { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, + { + stdout: `${JSON.stringify( + bridgeSuccess({ + command: 'opencode.handshake', + data: { acceptedCommands: ['opencode.launchTeam'] }, + }) + )}\n`, + stderr: '', + exitCode: 0, + timedOut: false, + }, + ]; + const client = createClient(); + + const result = await client.execute( + 'opencode.handshake', + { requiredCommand: 'opencode.launchTeam' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); + + expect(result).toMatchObject({ + ok: true, + requestId: 'req-1', + command: 'opencode.handshake', + }); + expect(runner.calls).toHaveLength(3); + expect(runner.calls[0].args).toContain('--output'); + expect(runner.calls[1].args).toContain('--output'); + expect(runner.calls[2].args).not.toContain('--output'); }); it('keeps empty readiness stdout diagnostics after the retry is exhausted', async () => { @@ -362,6 +460,12 @@ describe('OpenCodeBridgeCommandClient', () => { exitCode: 0, timedOut: false, }, + { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, ]; const client = createClient(); @@ -380,7 +484,7 @@ describe('OpenCodeBridgeCommandClient', () => { kind: 'contract_violation', message: 'Bridge stdout was empty', details: { - attempts: 2, + attempts: 3, stdoutBytes: 0, stderrBytes: 0, outputSource: 'none', @@ -389,7 +493,8 @@ describe('OpenCodeBridgeCommandClient', () => { }, }, }); - expect(runner.calls).toHaveLength(2); + expect(runner.calls).toHaveLength(3); + expect(runner.calls[2].args).not.toContain('--output'); }); it('does not retry empty stdout for state-changing bridge commands', async () => {