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_STDOUT_LIMIT_BYTES = 1_000_000;
|
||||||
const DEFAULT_STDERR_LIMIT_BYTES = 256_000;
|
const DEFAULT_STDERR_LIMIT_BYTES = 256_000;
|
||||||
const WINDOWS_BATCH_EXTENSIONS = new Set(['.cmd', '.bat']);
|
const WINDOWS_BATCH_EXTENSIONS = new Set(['.cmd', '.bat']);
|
||||||
const EMPTY_STDOUT_READINESS_MAX_ATTEMPTS = 2;
|
const EMPTY_STDOUT_READ_ONLY_MAX_ATTEMPTS = 2;
|
||||||
const EMPTY_STDOUT_READINESS_RETRY_DELAY_MS = 250;
|
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}$/;
|
const SAFE_BRIDGE_INPUT_FILE_REQUEST_ID = /^[A-Za-z0-9._-]{1,120}$/;
|
||||||
|
|
||||||
export function resolveOpenCodeBridgeProcessCwd(
|
export function resolveOpenCodeBridgeProcessCwd(
|
||||||
|
|
@ -174,20 +175,22 @@ export class OpenCodeBridgeCommandClient {
|
||||||
const outputPath = `${inputPath}.output.json`;
|
const outputPath = `${inputPath}.output.json`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const maxAttempts =
|
const maxAttempts = isReadOnlyRetryableBridgeCommand(command)
|
||||||
command === 'opencode.readiness' ? EMPTY_STDOUT_READINESS_MAX_ATTEMPTS : 1;
|
? EMPTY_STDOUT_READ_ONLY_MAX_ATTEMPTS + EMPTY_STDOUT_READ_ONLY_STDOUT_FALLBACK_ATTEMPTS
|
||||||
|
: 1;
|
||||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 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({
|
const processResult = await this.processRunner.run({
|
||||||
binaryPath: this.binaryPath,
|
binaryPath: this.binaryPath,
|
||||||
args: [
|
args: bridgeArgs,
|
||||||
'runtime',
|
|
||||||
'opencode-command',
|
|
||||||
'--json',
|
|
||||||
'--input',
|
|
||||||
inputPath,
|
|
||||||
'--output',
|
|
||||||
outputPath,
|
|
||||||
],
|
|
||||||
cwd: resolveOpenCodeBridgeProcessCwd(this.binaryPath, options.cwd),
|
cwd: resolveOpenCodeBridgeProcessCwd(this.binaryPath, options.cwd),
|
||||||
timeoutMs: options.timeoutMs,
|
timeoutMs: options.timeoutMs,
|
||||||
stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES,
|
stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES,
|
||||||
|
|
@ -235,8 +238,8 @@ export class OpenCodeBridgeCommandClient {
|
||||||
|
|
||||||
const parsed = parseSingleBridgeJsonResult<TData>(bridgeOutput.content);
|
const parsed = parseSingleBridgeJsonResult<TData>(bridgeOutput.content);
|
||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
if (shouldRetryEmptyReadinessStdout(command, parsed.error, attempt, maxAttempts)) {
|
if (shouldRetryEmptyReadOnlyStdout(command, parsed.error, attempt, maxAttempts)) {
|
||||||
await sleep(EMPTY_STDOUT_READINESS_RETRY_DELAY_MS);
|
await sleep(EMPTY_STDOUT_READ_ONLY_RETRY_DELAY_MS);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -402,14 +405,32 @@ export function redactBridgeDiagnosticText(value: string): string {
|
||||||
.replace(/((?:api[_-]?key|token|password|secret)\s*[=:]\s*)[^\s"'`]+/gi, '$1[redacted]');
|
.replace(/((?:api[_-]?key|token|password|secret)\s*[=:]\s*)[^\s"'`]+/gi, '$1[redacted]');
|
||||||
}
|
}
|
||||||
|
|
||||||
function shouldRetryEmptyReadinessStdout(
|
function shouldRetryEmptyReadOnlyStdout(
|
||||||
command: OpenCodeBridgeCommandName,
|
command: OpenCodeBridgeCommandName,
|
||||||
error: string,
|
error: string,
|
||||||
attempt: number,
|
attempt: number,
|
||||||
maxAttempts: number
|
maxAttempts: number
|
||||||
): boolean {
|
): boolean {
|
||||||
return (
|
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',
|
command: 'opencode.readiness',
|
||||||
});
|
});
|
||||||
expect(runner.calls).toHaveLength(2);
|
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 () => {
|
it('keeps empty readiness stdout diagnostics after the retry is exhausted', async () => {
|
||||||
|
|
@ -362,6 +460,12 @@ describe('OpenCodeBridgeCommandClient', () => {
|
||||||
exitCode: 0,
|
exitCode: 0,
|
||||||
timedOut: false,
|
timedOut: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
stdout: '',
|
||||||
|
stderr: '',
|
||||||
|
exitCode: 0,
|
||||||
|
timedOut: false,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
const client = createClient();
|
const client = createClient();
|
||||||
|
|
||||||
|
|
@ -380,7 +484,7 @@ describe('OpenCodeBridgeCommandClient', () => {
|
||||||
kind: 'contract_violation',
|
kind: 'contract_violation',
|
||||||
message: 'Bridge stdout was empty',
|
message: 'Bridge stdout was empty',
|
||||||
details: {
|
details: {
|
||||||
attempts: 2,
|
attempts: 3,
|
||||||
stdoutBytes: 0,
|
stdoutBytes: 0,
|
||||||
stderrBytes: 0,
|
stderrBytes: 0,
|
||||||
outputSource: 'none',
|
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 () => {
|
it('does not retry empty stdout for state-changing bridge commands', async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue