diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts index 87778ee2..cc8defff 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts @@ -58,6 +58,8 @@ 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; export function resolveOpenCodeBridgeProcessCwd( binaryPath: string, @@ -162,54 +164,77 @@ export class OpenCodeBridgeCommandClient { const inputPath = await this.writeInputFile(envelope); try { - const processResult = await this.processRunner.run({ - binaryPath: this.binaryPath, - args: ['runtime', 'opencode-command', '--json', '--input', inputPath], - 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(), - }); - - if (processResult.timedOut) { - return this.contractFailure( - envelope, - 'timeout', - 'OpenCode bridge command timed out', - true, - { - stderr: redactBridgeDiagnosticText(processResult.stderr), - } - ); - } - - if (processResult.exitCode !== 0) { - return this.contractFailure( - envelope, - 'provider_error', - 'OpenCode bridge command failed', - true, - { - exitCode: processResult.exitCode, - stderr: redactBridgeDiagnosticText(processResult.stderr), - } - ); - } - - const parsed = parseSingleBridgeJsonResult(processResult.stdout); - if (!parsed.ok) { - return this.contractFailure(envelope, 'contract_violation', parsed.error, false, { - stdoutPreview: redactBridgeDiagnosticText(processResult.stdout.slice(0, 2_000)), + const maxAttempts = + command === 'opencode.readiness' ? EMPTY_STDOUT_READINESS_MAX_ATTEMPTS : 1; + for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const processResult = await this.processRunner.run({ + binaryPath: this.binaryPath, + args: ['runtime', 'opencode-command', '--json', '--input', inputPath], + 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(), }); + + if (processResult.timedOut) { + return this.contractFailure( + envelope, + 'timeout', + 'OpenCode bridge command timed out', + true, + { + stderr: redactBridgeDiagnosticText(processResult.stderr), + attempts: attempt, + } + ); + } + + if (processResult.exitCode !== 0) { + return this.contractFailure( + envelope, + 'provider_error', + 'OpenCode bridge command failed', + true, + { + exitCode: processResult.exitCode, + stderr: redactBridgeDiagnosticText(processResult.stderr), + attempts: attempt, + } + ); + } + + const parsed = parseSingleBridgeJsonResult(processResult.stdout); + if (!parsed.ok) { + if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) { + await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS); + continue; + } + + return this.contractFailure(envelope, 'contract_violation', parsed.error, false, { + stdoutPreview: redactBridgeDiagnosticText(processResult.stdout.slice(0, 2_000)), + stderrPreview: redactBridgeDiagnosticText(processResult.stderr.slice(0, 2_000)), + attempts: attempt, + }); + } + + const validation = validateBridgeResultEnvelope(parsed.value, envelope); + if (!validation.ok) { + return this.contractFailure(envelope, 'contract_violation', validation.reason, false, { + attempts: attempt, + }); + } + + return parsed.value; } - const validation = validateBridgeResultEnvelope(parsed.value, envelope); - if (!validation.ok) { - return this.contractFailure(envelope, 'contract_violation', validation.reason, false, {}); - } - - return parsed.value; + return this.contractFailure( + envelope, + 'contract_violation', + 'Bridge stdout was empty after retry', + false, + { attempts: maxAttempts } + ); } finally { if (!this.keepInputFile) { await fs.unlink(inputPath).catch(() => undefined); @@ -285,6 +310,21 @@ export function redactBridgeDiagnosticText(value: string): string { .replace(/((?:api[_-]?key|token|password|secret)\s*[=:]\s*)[^\s"'`]+/gi, '$1[redacted]'); } +function shouldRetryEmptyReadinessStdout( + command: OpenCodeBridgeCommandName, + error: string, + attempt: number, + maxAttempts: number +): boolean { + return ( + command === 'opencode.readiness' && error === 'Bridge stdout was empty' && attempt < maxAttempts + ); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + function bufferToString(value: string | Buffer | undefined): string { if (typeof value === 'string') { return value; diff --git a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts index e5700370..544cd690 100644 --- a/test/main/services/team/OpenCodeBridgeCommandClient.test.ts +++ b/test/main/services/team/OpenCodeBridgeCommandClient.test.ts @@ -109,9 +109,9 @@ describe('OpenCodeBridgeCommandClient', () => { type: 'opencode_bridge_contract_violation', severity: 'error', runId: 'run-1', - data: { + data: expect.objectContaining({ stdoutPreview: 'debug token=[redacted]\n{"ok":true}\n', - }, + }), }) ); }); @@ -183,6 +183,81 @@ describe('OpenCodeBridgeCommandClient', () => { }); }); + it('retries empty stdout once for readiness because it is read-only', async () => { + runner.nextResults = [ + { + 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(2); + }); + + it('does not retry empty stdout for state-changing bridge commands', async () => { + runner.nextResults = [ + { + stdout: '', + stderr: '', + exitCode: 0, + timedOut: false, + }, + { + stdout: `${JSON.stringify(bridgeSuccess({ data: { runId: 'run-1' } }))}\n`, + stderr: '', + exitCode: 0, + timedOut: false, + }, + ]; + const client = createClient(); + + const result = await client.execute( + 'opencode.launchTeam', + { runId: 'run-1' }, + { + cwd: '/tmp/project', + timeoutMs: 10_000, + } + ); + + expect(result).toMatchObject({ + ok: false, + error: { + kind: 'contract_violation', + message: 'Bridge stdout was empty', + }, + }); + expect(runner.calls).toHaveLength(1); + }); + it('rejects bridge result envelope mismatches before caller can mutate state', async () => { runner.nextResult = { stdout: `${JSON.stringify(bridgeSuccess({ requestId: 'other-req' }))}\n`, @@ -373,6 +448,7 @@ function bridgeSuccess( class FakeBridgeProcessRunner implements OpenCodeBridgeProcessRunner { calls: OpenCodeBridgeProcessRunInput[] = []; inputEnvelopes: string[] = []; + nextResults: OpenCodeBridgeProcessRunResult[] = []; nextResult: OpenCodeBridgeProcessRunResult = { stdout: '', stderr: '', @@ -383,7 +459,7 @@ class FakeBridgeProcessRunner implements OpenCodeBridgeProcessRunner { async run(input: OpenCodeBridgeProcessRunInput): Promise { this.calls.push(input); this.inputEnvelopes.push(await fs.readFile(input.args[4], 'utf8')); - return this.nextResult; + return this.nextResults.shift() ?? this.nextResult; } async readInputEnvelope(index: number): Promise {