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:
parent
7e5fa14b75
commit
2b3a184bef
7 changed files with 207 additions and 8 deletions
|
|
@ -89,6 +89,22 @@ export function resolveOpenCodeBridgeProcessCwd(
|
||||||
return launcherDirectory && launcherDirectory !== '.' ? launcherDirectory : requestedCwd;
|
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 {
|
export class ExecCliOpenCodeBridgeProcessRunner implements OpenCodeBridgeProcessRunner {
|
||||||
async run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult> {
|
async run(input: OpenCodeBridgeProcessRunInput): Promise<OpenCodeBridgeProcessRunResult> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -97,6 +113,10 @@ export class ExecCliOpenCodeBridgeProcessRunner implements OpenCodeBridgeProcess
|
||||||
timeout: input.timeoutMs,
|
timeout: input.timeoutMs,
|
||||||
maxBuffer: input.stdoutLimitBytes + input.stderrLimitBytes,
|
maxBuffer: input.stdoutLimitBytes + input.stderrLimitBytes,
|
||||||
env: input.env,
|
env: input.env,
|
||||||
|
preferShellForWindowsBatch: shouldPreferShellForOpenCodeBridgeCommand(
|
||||||
|
input.binaryPath,
|
||||||
|
input.args
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
stdout: result.stdout,
|
stdout: result.stdout,
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,15 @@ function buildSendPayloadHash(input: OpenCodeSendMessageCommandBody): string {
|
||||||
return stableHash(hashable);
|
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 {
|
export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
private readonly lastRuntimeSnapshotsByProjectPath = new Map<
|
private readonly lastRuntimeSnapshotsByProjectPath = new Map<
|
||||||
string,
|
string,
|
||||||
|
|
@ -384,12 +393,17 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
? withOpenCodeObservedFallbackDiagnostic(result.data)
|
? withOpenCodeObservedFallbackDiagnostic(result.data)
|
||||||
: 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({
|
const recovered = await this.recoverSendMessageOutcome({
|
||||||
originalRequestId: activeRequestId,
|
originalRequestId: activeRequestId,
|
||||||
body: activeBody,
|
body: activeBody,
|
||||||
diagnosticCode: 'opencode_send_recovered_after_bridge_timeout',
|
diagnosticCode: recoveredAfterEmptyOutput
|
||||||
diagnosticMessage: 'OpenCode bridge outcome recovered after timeout.',
|
? '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) {
|
if (recovered) {
|
||||||
return usedObservedFallback ? withOpenCodeObservedFallbackDiagnostic(recovered) : recovered;
|
return usedObservedFallback ? withOpenCodeObservedFallbackDiagnostic(recovered) : recovered;
|
||||||
|
|
|
||||||
|
|
@ -209,7 +209,7 @@ export class OpenCodeStateChangingBridgeCommandService {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
if (result.error.kind === 'timeout') {
|
if (isOpenCodeBridgeUnknownOutcomeFailure(result)) {
|
||||||
await this.ledger.markUnknownAfterTimeout({
|
await this.ledger.markUnknownAfterTimeout({
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
error: result.error.message,
|
error: result.error.message,
|
||||||
|
|
@ -331,7 +331,9 @@ export class OpenCodeStateChangingBridgeCommandService {
|
||||||
}),
|
}),
|
||||||
runId: input.runId ?? extractRunId(input.result) ?? undefined,
|
runId: input.runId ?? extractRunId(input.result) ?? undefined,
|
||||||
severity: 'warning',
|
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,
|
createdAt: completedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -393,6 +395,21 @@ function isActiveOpenCodeBridgeCommandLeaseError(error: OpenCodeBridgeCommandLea
|
||||||
return error.message.startsWith('OpenCode bridge command lease already active:');
|
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> {
|
function sleep(delayMs: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
return new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -355,10 +355,18 @@ function withCliProcessDefaults<
|
||||||
* The return value matches the shape of Node's `execFile` promise: an
|
* The return value matches the shape of Node's `execFile` promise: an
|
||||||
* object with `stdout` and `stderr` strings.
|
* 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(
|
export async function execCli(
|
||||||
binaryPath: string | null,
|
binaryPath: string | null,
|
||||||
args: string[],
|
args: string[],
|
||||||
options: ExecFileOptions = {}
|
options: ExecCliOptions = {}
|
||||||
): Promise<{ stdout: string; stderr: string }> {
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
if (!binaryPath) {
|
if (!binaryPath) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -366,8 +374,12 @@ export async function execCli(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const target = binaryPath;
|
const target = binaryPath;
|
||||||
const opts = withCliProcessDefaults(options);
|
const { preferShellForWindowsBatch = false, ...execOptions } = options;
|
||||||
const directLauncher = resolveDirectWindowsLauncher(target);
|
const opts = withCliProcessDefaults(execOptions);
|
||||||
|
const directLauncher =
|
||||||
|
preferShellForWindowsBatch && isWindowsBatchLauncher(target)
|
||||||
|
? null
|
||||||
|
: resolveDirectWindowsLauncher(target);
|
||||||
if (directLauncher) {
|
if (directLauncher) {
|
||||||
const result = await execFileAsync(
|
const result = await execFileAsync(
|
||||||
directLauncher.command,
|
directLauncher.command,
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it('does not query commandStatus for non-timeout OpenCode sendMessage failures', async () => {
|
||||||
const executor = fakeExecutor(bridgeFailure('provider_error', 'OpenCode send failed', []));
|
const executor = fakeExecutor(bridgeFailure('provider_error', 'OpenCode send failed', []));
|
||||||
const bridge = new OpenCodeReadinessBridge(executor);
|
const bridge = new OpenCodeReadinessBridge(executor);
|
||||||
|
|
|
||||||
|
|
@ -299,6 +299,50 @@ describe('OpenCodeStateChangingBridgeCommandService', () => {
|
||||||
await expect(leaseStore.getActive('team-a')).resolves.toBeNull();
|
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 () => {
|
it('marks result precondition mismatch as failed and does not leave active lease', async () => {
|
||||||
bridge.resultFactory = ({ body, options }) =>
|
bridge.resultFactory = ({ body, options }) =>
|
||||||
bridgeSuccess({
|
bridgeSuccess({
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it('executes extensionless npm node cmd launchers directly', async () => {
|
||||||
setPlatform('win32');
|
setPlatform('win32');
|
||||||
const execFileMock = child.execFile as unknown as Mock;
|
const execFileMock = child.execFile as unknown as Mock;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue