diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts index f5d87a26..f8ec382e 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandClient.ts @@ -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 { 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, diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index b8e11c19..fd67f0fa 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -96,6 +96,15 @@ function buildSendPayloadHash(input: OpenCodeSendMessageCommandBody): string { return stableHash(hashable); } +function isOpenCodeBridgeEmptyOutputFailure(result: OpenCodeBridgeResult): 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; diff --git a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts index a6fc5517..1cd123c8 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService.ts @@ -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): boolean { + return ( + !result.ok && (result.error.kind === 'timeout' || isOpenCodeBridgeEmptyOutputFailure(result)) + ); +} + +function isOpenCodeBridgeEmptyOutputFailure(result: OpenCodeBridgeResult): 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 { return new Promise((resolve) => setTimeout(resolve, delayMs)); } diff --git a/src/main/utils/childProcess.ts b/src/main/utils/childProcess.ts index 5c87cce1..e489b250 100644 --- a/src/main/utils/childProcess.ts +++ b/src/main/utils/childProcess.ts @@ -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, diff --git a/test/main/services/team/OpenCodeReadinessBridge.test.ts b/test/main/services/team/OpenCodeReadinessBridge.test.ts index 61a24612..c73e2ff5 100644 --- a/test/main/services/team/OpenCodeReadinessBridge.test.ts +++ b/test/main/services/team/OpenCodeReadinessBridge.test.ts @@ -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); diff --git a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts index 437ad3d1..54f17eb6 100644 --- a/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts +++ b/test/main/services/team/OpenCodeStateChangingBridgeCommandService.test.ts @@ -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); + 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({ diff --git a/test/main/utils/childProcess.test.ts b/test/main/utils/childProcess.test.ts index 3d0b48ca..3ed089db 100644 --- a/test/main/utils/childProcess.test.ts +++ b/test/main/utils/childProcess.test.ts @@ -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(); + }); + 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;