Skip to content

Commit 2b3a184

Browse files
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>
1 parent 7e5fa14 commit 2b3a184

7 files changed

Lines changed: 207 additions & 8 deletions

File tree

src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,22 @@ export function resolveOpenCodeBridgeProcessCwd(
8989
return launcherDirectory && launcherDirectory !== '.' ? launcherDirectory : requestedCwd;
9090
}
9191

92+
function shouldPreferShellForOpenCodeBridgeCommand(
93+
binaryPath: string,
94+
args: string[],
95+
platform: NodeJS.Platform = process.platform
96+
): boolean {
97+
if (platform !== 'win32') {
98+
return false;
99+
}
100+
const extension = path.win32.extname(binaryPath).toLowerCase();
101+
return (
102+
WINDOWS_BATCH_EXTENSIONS.has(extension) &&
103+
args[0] === 'runtime' &&
104+
args[1] === 'opencode-command'
105+
);
106+
}
107+
92108
export class ExecCliOpenCodeBridgeProcessRunner implements OpenCodeBridgeProcessRunner {
93109
async run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult> {
94110
try {
@@ -97,6 +113,10 @@ export class ExecCliOpenCodeBridgeProcessRunner implements OpenCodeBridgeProcess
97113
timeout: input.timeoutMs,
98114
maxBuffer: input.stdoutLimitBytes + input.stderrLimitBytes,
99115
env: input.env,
116+
preferShellForWindowsBatch: shouldPreferShellForOpenCodeBridgeCommand(
117+
input.binaryPath,
118+
input.args
119+
),
100120
});
101121
return {
102122
stdout: result.stdout,

src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ function buildSendPayloadHash(input: OpenCodeSendMessageCommandBody): string {
9696
return stableHash(hashable);
9797
}
9898

99+
function isOpenCodeBridgeEmptyOutputFailure(result: OpenCodeBridgeResult<unknown>): boolean {
100+
return (
101+
!result.ok &&
102+
result.error.kind === 'contract_violation' &&
103+
(result.error.message === 'Bridge stdout was empty' ||
104+
result.error.message === 'Bridge stdout was empty after retry')
105+
);
106+
}
107+
99108
export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
100109
private readonly lastRuntimeSnapshotsByProjectPath = new Map<
101110
string,
@@ -384,12 +393,17 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
384393
? withOpenCodeObservedFallbackDiagnostic(result.data)
385394
: result.data;
386395
}
387-
if (result.error.kind === 'timeout') {
396+
if (result.error.kind === 'timeout' || isOpenCodeBridgeEmptyOutputFailure(result)) {
397+
const recoveredAfterEmptyOutput = isOpenCodeBridgeEmptyOutputFailure(result);
388398
const recovered = await this.recoverSendMessageOutcome({
389399
originalRequestId: activeRequestId,
390400
body: activeBody,
391-
diagnosticCode: 'opencode_send_recovered_after_bridge_timeout',
392-
diagnosticMessage: 'OpenCode bridge outcome recovered after timeout.',
401+
diagnosticCode: recoveredAfterEmptyOutput
402+
? 'opencode_send_recovered_after_bridge_empty_output'
403+
: 'opencode_send_recovered_after_bridge_timeout',
404+
diagnosticMessage: recoveredAfterEmptyOutput
405+
? 'OpenCode bridge outcome recovered after empty bridge output.'
406+
: 'OpenCode bridge outcome recovered after timeout.',
393407
});
394408
if (recovered) {
395409
return usedObservedFallback ? withOpenCodeObservedFallbackDiagnostic(recovered) : recovered;

src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,7 @@ export class OpenCodeStateChangingBridgeCommandService {
209209
);
210210

211211
if (!result.ok) {
212-
if (result.error.kind === 'timeout') {
212+
if (isOpenCodeBridgeUnknownOutcomeFailure(result)) {
213213
await this.ledger.markUnknownAfterTimeout({
214214
idempotencyKey,
215215
error: result.error.message,
@@ -331,7 +331,9 @@ export class OpenCodeStateChangingBridgeCommandService {
331331
}),
332332
runId: input.runId ?? extractRunId(input.result) ?? undefined,
333333
severity: 'warning',
334-
message: 'OpenCode bridge command timed out; outcome must be reconciled before retry',
334+
message: isOpenCodeBridgeEmptyOutputFailure(input.result)
335+
? 'OpenCode bridge command exited without output; outcome must be reconciled before retry'
336+
: 'OpenCode bridge command timed out; outcome must be reconciled before retry',
335337
createdAt: completedAt,
336338
});
337339
}
@@ -393,6 +395,21 @@ function isActiveOpenCodeBridgeCommandLeaseError(error: OpenCodeBridgeCommandLea
393395
return error.message.startsWith('OpenCode bridge command lease already active:');
394396
}
395397

398+
function isOpenCodeBridgeUnknownOutcomeFailure(result: OpenCodeBridgeResult<unknown>): boolean {
399+
return (
400+
!result.ok && (result.error.kind === 'timeout' || isOpenCodeBridgeEmptyOutputFailure(result))
401+
);
402+
}
403+
404+
function isOpenCodeBridgeEmptyOutputFailure(result: OpenCodeBridgeResult<unknown>): boolean {
405+
return (
406+
!result.ok &&
407+
result.error.kind === 'contract_violation' &&
408+
(result.error.message === 'Bridge stdout was empty' ||
409+
result.error.message === 'Bridge stdout was empty after retry')
410+
);
411+
}
412+
396413
function sleep(delayMs: number): Promise<void> {
397414
return new Promise((resolve) => setTimeout(resolve, delayMs));
398415
}

src/main/utils/childProcess.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -355,19 +355,31 @@ function withCliProcessDefaults<
355355
* The return value matches the shape of Node's `execFile` promise: an
356356
* object with `stdout` and `stderr` strings.
357357
*/
358+
export interface ExecCliOptions extends ExecFileOptions {
359+
/**
360+
* Some generated Windows launchers are safe to run directly, but callers can
361+
* force the .cmd/.bat path when they need the launcher environment exactly.
362+
*/
363+
preferShellForWindowsBatch?: boolean;
364+
}
365+
358366
export async function execCli(
359367
binaryPath: string | null,
360368
args: string[],
361-
options: ExecFileOptions = {}
369+
options: ExecCliOptions = {}
362370
): Promise<{ stdout: string; stderr: string }> {
363371
if (!binaryPath) {
364372
throw new Error(
365373
'Claude CLI binary path is null. Resolve the binary via ClaudeBinaryResolver before calling execCli.'
366374
);
367375
}
368376
const target = binaryPath;
369-
const opts = withCliProcessDefaults(options);
370-
const directLauncher = resolveDirectWindowsLauncher(target);
377+
const { preferShellForWindowsBatch = false, ...execOptions } = options;
378+
const opts = withCliProcessDefaults(execOptions);
379+
const directLauncher =
380+
preferShellForWindowsBatch && isWindowsBatchLauncher(target)
381+
? null
382+
: resolveDirectWindowsLauncher(target);
371383
if (directLauncher) {
372384
const result = await execFileAsync(
373385
directLauncher.command,

test/main/services/team/OpenCodeReadinessBridge.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,75 @@ describe('OpenCodeReadinessBridge', () => {
585585
]);
586586
});
587587

588+
it('recovers accepted OpenCode sendMessage after empty bridge output through commandStatus', async () => {
589+
const executor = fakeSequenceExecutor([
590+
bridgeFailure('contract_violation', 'Bridge stdout was empty', [
591+
{
592+
id: 'diag-empty-output',
593+
type: 'opencode_bridge_contract_violation',
594+
providerId: 'opencode',
595+
severity: 'error',
596+
message: 'Bridge stdout was empty',
597+
data: {
598+
command: 'opencode.sendMessage',
599+
outputSource: 'none',
600+
outputReadError: 'ENOENT',
601+
},
602+
createdAt: '2026-04-21T12:00:00.000Z',
603+
},
604+
]),
605+
bridgeCommandSuccess({
606+
command: 'opencode.commandStatus',
607+
requestId: 'status-req-empty-output',
608+
data: {
609+
status: 'prompt_accepted',
610+
safeToRetry: false,
611+
accepted: true,
612+
sessionId: 'session-bob',
613+
runtimePromptMessageId: 'msg_prompt_1',
614+
diagnostics: ['OpenCode prompt acceptance recovered from command status.'],
615+
},
616+
}),
617+
]);
618+
const bridge = new OpenCodeReadinessBridge(executor);
619+
620+
await expect(
621+
bridge.sendOpenCodeTeamMessage({
622+
teamId: 'team-a',
623+
teamName: 'team-a',
624+
laneId: 'secondary:opencode:bob',
625+
projectPath: '/repo',
626+
memberName: 'bob',
627+
text: 'hello',
628+
messageId: 'message-1',
629+
deliveryAttemptId: 'ledger-1:1:payload',
630+
})
631+
).resolves.toMatchObject({
632+
accepted: true,
633+
sessionId: 'session-bob',
634+
diagnostics: expect.arrayContaining([
635+
expect.objectContaining({
636+
code: 'opencode_send_recovered_after_bridge_empty_output',
637+
}),
638+
]),
639+
});
640+
641+
expect(executor.execute).toHaveBeenCalledTimes(2);
642+
expect(executor.execute.mock.calls[1]).toEqual([
643+
'opencode.commandStatus',
644+
expect.objectContaining({
645+
originalCommand: 'opencode.sendMessage',
646+
originalRequestId: 'req-1',
647+
deliveryAttemptId: 'ledger-1:1:payload',
648+
payloadHash: expect.any(String),
649+
}),
650+
{
651+
cwd: '/repo',
652+
timeoutMs: 5_000,
653+
},
654+
]);
655+
});
656+
588657
it('does not query commandStatus for non-timeout OpenCode sendMessage failures', async () => {
589658
const executor = fakeExecutor(bridgeFailure('provider_error', 'OpenCode send failed', []));
590659
const bridge = new OpenCodeReadinessBridge(executor);

test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,50 @@ describe('OpenCodeStateChangingBridgeCommandService', () => {
299299
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
300300
});
301301

302+
it('records empty bridge output as unknown outcome and blocks duplicate retry', async () => {
303+
bridge.resultFactory = ({ body, command, options }) => ({
304+
ok: false,
305+
schemaVersion: 1,
306+
requestId: options.requestId,
307+
command,
308+
completedAt: '2026-04-21T12:00:10.000Z',
309+
durationMs: 100,
310+
error: {
311+
kind: 'contract_violation',
312+
message: 'Bridge stdout was empty',
313+
retryable: false,
314+
},
315+
diagnostics: [],
316+
data: body,
317+
} as OpenCodeBridgeResult<unknown>);
318+
const service = createService();
319+
320+
const first = await service.execute(buildLaunchInput());
321+
322+
expect(first).toMatchObject({
323+
ok: false,
324+
error: { kind: 'contract_violation' },
325+
});
326+
const idempotencyKey = bridge.calls[0].body.preconditions.idempotencyKey;
327+
await expect(ledger.getByIdempotencyKey(idempotencyKey)).resolves.toMatchObject({
328+
status: 'unknown_after_timeout',
329+
retryable: false,
330+
lastError: 'Bridge stdout was empty',
331+
});
332+
expect(diagnostics.append).toHaveBeenCalledWith(
333+
expect.objectContaining({
334+
type: 'opencode_bridge_unknown_outcome',
335+
message: 'OpenCode bridge command exited without output; outcome must be reconciled before retry',
336+
})
337+
);
338+
339+
await expect(service.execute(buildLaunchInput())).rejects.toThrow(
340+
'OpenCode bridge command outcome must be reconciled before retry'
341+
);
342+
expect(bridge.calls).toHaveLength(1);
343+
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
344+
});
345+
302346
it('marks result precondition mismatch as failed and does not leave active lease', async () => {
303347
bridge.resultFactory = ({ body, options }) =>
304348
bridgeSuccess({

test/main/utils/childProcess.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,29 @@ describe('cli child process helpers', () => {
425425
}
426426
});
427427

428+
it('can force generated Bun cmd launchers through shell', async () => {
429+
setPlatform('win32');
430+
const execFileMock = child.execFile as unknown as Mock;
431+
const execMock = child.exec as unknown as Mock;
432+
execMock.mockImplementation((_cmd: string, _opts: unknown, cb: ExecCallback) => {
433+
cb(null, 'ok', '');
434+
return createMockProcess<ExecChild>();
435+
});
436+
const { dir, launcher } = createGeneratedBunLauncher();
437+
try {
438+
const result = await execCli(launcher, ['runtime', 'opencode-command'], {
439+
preferShellForWindowsBatch: true,
440+
});
441+
expect(execFileMock).not.toHaveBeenCalled();
442+
expect(execMock).toHaveBeenCalledTimes(1);
443+
expect(execMock.mock.calls[0][0]).toContain('runtime');
444+
expect(execMock.mock.calls[0][0]).toContain('opencode-command');
445+
expect(result.stdout).toBe('ok');
446+
} finally {
447+
rmSync(dir, { recursive: true, force: true });
448+
}
449+
});
450+
428451
it('executes extensionless npm node cmd launchers directly', async () => {
429452
setPlatform('win32');
430453
const execFileMock = child.execFile as unknown as Mock;

0 commit comments

Comments
 (0)