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;
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue