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:
parent
47cea58543
commit
50f876f863
2 changed files with 145 additions and 19 deletions
|
|
@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue