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 <iliyazelenkog@gmail.com>
This commit is contained in:
infiniti 2026-05-24 23:22:34 +03:00 committed by GitHub
parent 47cea58543
commit 50f876f863
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 145 additions and 19 deletions

View file

@ -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<TData>(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'
);
}

View file

@ -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 () => {