fix(opencode): retry empty readiness bridge stdout

Co-authored-by: iliya <iliyazelenkog@gmail.com>
This commit is contained in:
infiniti 2026-05-19 12:33:13 +03:00 committed by GitHub
parent c25ce62cea
commit dc04cbfad7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 164 additions and 48 deletions

View file

@ -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<TData>(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<TData>(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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function bufferToString(value: string | Buffer | undefined): string {
if (typeof value === 'string') {
return value;

View file

@ -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<OpenCodeBridgeProcessRunResult> {
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<string> {