fix(opencode): recover empty bridge output sends

* fix(opencode): handle empty readiness bridge output

* fix(opencode): retry read-only bridge no-output

* fix(opencode): recover empty bridge output sends

---------

Co-authored-by: iliya <iliyazelenkog@gmail.com>
This commit is contained in:
infiniti 2026-05-25 00:41:54 +03:00 committed by GitHub
parent 7e5fa14b75
commit 2b3a184bef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 207 additions and 8 deletions

View file

@ -89,6 +89,22 @@ export function resolveOpenCodeBridgeProcessCwd(
return launcherDirectory && launcherDirectory !== '.' ? launcherDirectory : requestedCwd;
}
function shouldPreferShellForOpenCodeBridgeCommand(
binaryPath: string,
args: string[],
platform: NodeJS.Platform = process.platform
): boolean {
if (platform !== 'win32') {
return false;
}
const extension = path.win32.extname(binaryPath).toLowerCase();
return (
WINDOWS_BATCH_EXTENSIONS.has(extension) &&
args[0] === 'runtime' &&
args[1] === 'opencode-command'
);
}
export class ExecCliOpenCodeBridgeProcessRunner implements OpenCodeBridgeProcessRunner {
async run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult> {
try {
@ -97,6 +113,10 @@ export class ExecCliOpenCodeBridgeProcessRunner implements OpenCodeBridgeProcess
timeout: input.timeoutMs,
maxBuffer: input.stdoutLimitBytes + input.stderrLimitBytes,
env: input.env,
preferShellForWindowsBatch: shouldPreferShellForOpenCodeBridgeCommand(
input.binaryPath,
input.args
),
});
return {
stdout: result.stdout,

View file

@ -96,6 +96,15 @@ function buildSendPayloadHash(input: OpenCodeSendMessageCommandBody): string {
return stableHash(hashable);
}
function isOpenCodeBridgeEmptyOutputFailure(result: OpenCodeBridgeResult<unknown>): boolean {
return (
!result.ok &&
result.error.kind === 'contract_violation' &&
(result.error.message === 'Bridge stdout was empty' ||
result.error.message === 'Bridge stdout was empty after retry')
);
}
export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
private readonly lastRuntimeSnapshotsByProjectPath = new Map<
string,
@ -384,12 +393,17 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
? withOpenCodeObservedFallbackDiagnostic(result.data)
: result.data;
}
if (result.error.kind === 'timeout') {
if (result.error.kind === 'timeout' || isOpenCodeBridgeEmptyOutputFailure(result)) {
const recoveredAfterEmptyOutput = isOpenCodeBridgeEmptyOutputFailure(result);
const recovered = await this.recoverSendMessageOutcome({
originalRequestId: activeRequestId,
body: activeBody,
diagnosticCode: 'opencode_send_recovered_after_bridge_timeout',
diagnosticMessage: 'OpenCode bridge outcome recovered after timeout.',
diagnosticCode: recoveredAfterEmptyOutput
? 'opencode_send_recovered_after_bridge_empty_output'
: 'opencode_send_recovered_after_bridge_timeout',
diagnosticMessage: recoveredAfterEmptyOutput
? 'OpenCode bridge outcome recovered after empty bridge output.'
: 'OpenCode bridge outcome recovered after timeout.',
});
if (recovered) {
return usedObservedFallback ? withOpenCodeObservedFallbackDiagnostic(recovered) : recovered;

View file

@ -209,7 +209,7 @@ export class OpenCodeStateChangingBridgeCommandService {
);
if (!result.ok) {
if (result.error.kind === 'timeout') {
if (isOpenCodeBridgeUnknownOutcomeFailure(result)) {
await this.ledger.markUnknownAfterTimeout({
idempotencyKey,
error: result.error.message,
@ -331,7 +331,9 @@ export class OpenCodeStateChangingBridgeCommandService {
}),
runId: input.runId ?? extractRunId(input.result) ?? undefined,
severity: 'warning',
message: 'OpenCode bridge command timed out; outcome must be reconciled before retry',
message: isOpenCodeBridgeEmptyOutputFailure(input.result)
? 'OpenCode bridge command exited without output; outcome must be reconciled before retry'
: 'OpenCode bridge command timed out; outcome must be reconciled before retry',
createdAt: completedAt,
});
}
@ -393,6 +395,21 @@ function isActiveOpenCodeBridgeCommandLeaseError(error: OpenCodeBridgeCommandLea
return error.message.startsWith('OpenCode bridge command lease already active:');
}
function isOpenCodeBridgeUnknownOutcomeFailure(result: OpenCodeBridgeResult<unknown>): boolean {
return (
!result.ok && (result.error.kind === 'timeout' || isOpenCodeBridgeEmptyOutputFailure(result))
);
}
function isOpenCodeBridgeEmptyOutputFailure(result: OpenCodeBridgeResult<unknown>): boolean {
return (
!result.ok &&
result.error.kind === 'contract_violation' &&
(result.error.message === 'Bridge stdout was empty' ||
result.error.message === 'Bridge stdout was empty after retry')
);
}
function sleep(delayMs: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, delayMs));
}

View file

@ -355,10 +355,18 @@ function withCliProcessDefaults<
* The return value matches the shape of Node's `execFile` promise: an
* object with `stdout` and `stderr` strings.
*/
export interface ExecCliOptions extends ExecFileOptions {
/**
* Some generated Windows launchers are safe to run directly, but callers can
* force the .cmd/.bat path when they need the launcher environment exactly.
*/
preferShellForWindowsBatch?: boolean;
}
export async function execCli(
binaryPath: string | null,
args: string[],
options: ExecFileOptions = {}
options: ExecCliOptions = {}
): Promise<{ stdout: string; stderr: string }> {
if (!binaryPath) {
throw new Error(
@ -366,8 +374,12 @@ export async function execCli(
);
}
const target = binaryPath;
const opts = withCliProcessDefaults(options);
const directLauncher = resolveDirectWindowsLauncher(target);
const { preferShellForWindowsBatch = false, ...execOptions } = options;
const opts = withCliProcessDefaults(execOptions);
const directLauncher =
preferShellForWindowsBatch && isWindowsBatchLauncher(target)
? null
: resolveDirectWindowsLauncher(target);
if (directLauncher) {
const result = await execFileAsync(
directLauncher.command,

View file

@ -585,6 +585,75 @@ describe('OpenCodeReadinessBridge', () => {
]);
});
it('recovers accepted OpenCode sendMessage after empty bridge output through commandStatus', async () => {
const executor = fakeSequenceExecutor([
bridgeFailure('contract_violation', 'Bridge stdout was empty', [
{
id: 'diag-empty-output',
type: 'opencode_bridge_contract_violation',
providerId: 'opencode',
severity: 'error',
message: 'Bridge stdout was empty',
data: {
command: 'opencode.sendMessage',
outputSource: 'none',
outputReadError: 'ENOENT',
},
createdAt: '2026-04-21T12:00:00.000Z',
},
]),
bridgeCommandSuccess({
command: 'opencode.commandStatus',
requestId: 'status-req-empty-output',
data: {
status: 'prompt_accepted',
safeToRetry: false,
accepted: true,
sessionId: 'session-bob',
runtimePromptMessageId: 'msg_prompt_1',
diagnostics: ['OpenCode prompt acceptance recovered from command status.'],
},
}),
]);
const bridge = new OpenCodeReadinessBridge(executor);
await expect(
bridge.sendOpenCodeTeamMessage({
teamId: 'team-a',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
projectPath: '/repo',
memberName: 'bob',
text: 'hello',
messageId: 'message-1',
deliveryAttemptId: 'ledger-1:1:payload',
})
).resolves.toMatchObject({
accepted: true,
sessionId: 'session-bob',
diagnostics: expect.arrayContaining([
expect.objectContaining({
code: 'opencode_send_recovered_after_bridge_empty_output',
}),
]),
});
expect(executor.execute).toHaveBeenCalledTimes(2);
expect(executor.execute.mock.calls[1]).toEqual([
'opencode.commandStatus',
expect.objectContaining({
originalCommand: 'opencode.sendMessage',
originalRequestId: 'req-1',
deliveryAttemptId: 'ledger-1:1:payload',
payloadHash: expect.any(String),
}),
{
cwd: '/repo',
timeoutMs: 5_000,
},
]);
});
it('does not query commandStatus for non-timeout OpenCode sendMessage failures', async () => {
const executor = fakeExecutor(bridgeFailure('provider_error', 'OpenCode send failed', []));
const bridge = new OpenCodeReadinessBridge(executor);

View file

@ -299,6 +299,50 @@ describe('OpenCodeStateChangingBridgeCommandService', () => {
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
});
it('records empty bridge output as unknown outcome and blocks duplicate retry', async () => {
bridge.resultFactory = ({ body, command, options }) => ({
ok: false,
schemaVersion: 1,
requestId: options.requestId,
command,
completedAt: '2026-04-21T12:00:10.000Z',
durationMs: 100,
error: {
kind: 'contract_violation',
message: 'Bridge stdout was empty',
retryable: false,
},
diagnostics: [],
data: body,
} as OpenCodeBridgeResult<unknown>);
const service = createService();
const first = await service.execute(buildLaunchInput());
expect(first).toMatchObject({
ok: false,
error: { kind: 'contract_violation' },
});
const idempotencyKey = bridge.calls[0].body.preconditions.idempotencyKey;
await expect(ledger.getByIdempotencyKey(idempotencyKey)).resolves.toMatchObject({
status: 'unknown_after_timeout',
retryable: false,
lastError: 'Bridge stdout was empty',
});
expect(diagnostics.append).toHaveBeenCalledWith(
expect.objectContaining({
type: 'opencode_bridge_unknown_outcome',
message: 'OpenCode bridge command exited without output; outcome must be reconciled before retry',
})
);
await expect(service.execute(buildLaunchInput())).rejects.toThrow(
'OpenCode bridge command outcome must be reconciled before retry'
);
expect(bridge.calls).toHaveLength(1);
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
});
it('marks result precondition mismatch as failed and does not leave active lease', async () => {
bridge.resultFactory = ({ body, options }) =>
bridgeSuccess({

View file

@ -425,6 +425,29 @@ describe('cli child process helpers', () => {
}
});
it('can force generated Bun cmd launchers through shell', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;
const execMock = child.exec as unknown as Mock;
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
cb(null, 'ok', '');
return createMockProcess<ExecChild>();
});
const { dir, launcher } = createGeneratedBunLauncher();
try {
const result = await execCli(launcher, ['runtime', 'opencode-command'], {
preferShellForWindowsBatch: true,
});
expect(execFileMock).not.toHaveBeenCalled();
expect(execMock).toHaveBeenCalledTimes(1);
expect(execMock.mock.calls[0][0]).toContain('runtime');
expect(execMock.mock.calls[0][0]).toContain('opencode-command');
expect(result.stdout).toBe('ok');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('executes extensionless npm node cmd launchers directly', async () => {
setPlatform('win32');
const execFileMock = child.execFile as unknown as Mock;