fix(opencode): retry empty readiness bridge stdout
Co-authored-by: iliya <iliyazelenkog@gmail.com>
This commit is contained in:
parent
c25ce62cea
commit
dc04cbfad7
2 changed files with 164 additions and 48 deletions
|
|
@ -58,6 +58,8 @@ 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_READINESS_RETRY_DELAY_MS = 250;
|
||||||
|
|
||||||
export function resolveOpenCodeBridgeProcessCwd(
|
export function resolveOpenCodeBridgeProcessCwd(
|
||||||
binaryPath: string,
|
binaryPath: string,
|
||||||
|
|
@ -162,54 +164,77 @@ export class OpenCodeBridgeCommandClient {
|
||||||
const inputPath = await this.writeInputFile(envelope);
|
const inputPath = await this.writeInputFile(envelope);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const processResult = await this.processRunner.run({
|
const maxAttempts =
|
||||||
binaryPath: this.binaryPath,
|
command === 'opencode.readiness' ? EMPTY_STDOUT_READINESS_MAX_ATTEMPTS : 1;
|
||||||
args: ['runtime', 'opencode-command', '--json', '--input', inputPath],
|
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||||
cwd: resolveOpenCodeBridgeProcessCwd(this.binaryPath, options.cwd),
|
const processResult = await this.processRunner.run({
|
||||||
timeoutMs: options.timeoutMs,
|
binaryPath: this.binaryPath,
|
||||||
stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES,
|
args: ['runtime', 'opencode-command', '--json', '--input', inputPath],
|
||||||
stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES,
|
cwd: resolveOpenCodeBridgeProcessCwd(this.binaryPath, options.cwd),
|
||||||
env: await this.resolveEnv(),
|
timeoutMs: options.timeoutMs,
|
||||||
});
|
stdoutLimitBytes: options.stdoutLimitBytes ?? DEFAULT_STDOUT_LIMIT_BYTES,
|
||||||
|
stderrLimitBytes: options.stderrLimitBytes ?? DEFAULT_STDERR_LIMIT_BYTES,
|
||||||
if (processResult.timedOut) {
|
env: await this.resolveEnv(),
|
||||||
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)),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
return this.contractFailure(
|
||||||
if (!validation.ok) {
|
envelope,
|
||||||
return this.contractFailure(envelope, 'contract_violation', validation.reason, false, {});
|
'contract_violation',
|
||||||
}
|
'Bridge stdout was empty after retry',
|
||||||
|
false,
|
||||||
return parsed.value;
|
{ attempts: maxAttempts }
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (!this.keepInputFile) {
|
if (!this.keepInputFile) {
|
||||||
await fs.unlink(inputPath).catch(() => undefined);
|
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]');
|
.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 {
|
function bufferToString(value: string | Buffer | undefined): string {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
return value;
|
return value;
|
||||||
|
|
|
||||||
|
|
@ -109,9 +109,9 @@ describe('OpenCodeBridgeCommandClient', () => {
|
||||||
type: 'opencode_bridge_contract_violation',
|
type: 'opencode_bridge_contract_violation',
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
runId: 'run-1',
|
runId: 'run-1',
|
||||||
data: {
|
data: expect.objectContaining({
|
||||||
stdoutPreview: 'debug token=[redacted]\n{"ok":true}\n',
|
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 () => {
|
it('rejects bridge result envelope mismatches before caller can mutate state', async () => {
|
||||||
runner.nextResult = {
|
runner.nextResult = {
|
||||||
stdout: `${JSON.stringify(bridgeSuccess({ requestId: 'other-req' }))}\n`,
|
stdout: `${JSON.stringify(bridgeSuccess({ requestId: 'other-req' }))}\n`,
|
||||||
|
|
@ -373,6 +448,7 @@ function bridgeSuccess(
|
||||||
class FakeBridgeProcessRunner implements OpenCodeBridgeProcessRunner {
|
class FakeBridgeProcessRunner implements OpenCodeBridgeProcessRunner {
|
||||||
calls: OpenCodeBridgeProcessRunInput[] = [];
|
calls: OpenCodeBridgeProcessRunInput[] = [];
|
||||||
inputEnvelopes: string[] = [];
|
inputEnvelopes: string[] = [];
|
||||||
|
nextResults: OpenCodeBridgeProcessRunResult[] = [];
|
||||||
nextResult: OpenCodeBridgeProcessRunResult = {
|
nextResult: OpenCodeBridgeProcessRunResult = {
|
||||||
stdout: '',
|
stdout: '',
|
||||||
stderr: '',
|
stderr: '',
|
||||||
|
|
@ -383,7 +459,7 @@ class FakeBridgeProcessRunner implements OpenCodeBridgeProcessRunner {
|
||||||
async run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult> {
|
async run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult> {
|
||||||
this.calls.push(input);
|
this.calls.push(input);
|
||||||
this.inputEnvelopes.push(await fs.readFile(input.args[4], 'utf8'));
|
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> {
|
async readInputEnvelope(index: number): Promise<string> {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue