fix(team): guard opencode acceptance sends
This commit is contained in:
parent
55e1c8f3c4
commit
9dc7858e22
3 changed files with 214 additions and 13 deletions
|
|
@ -231,6 +231,7 @@ export interface OpenCodeDeliveryResponseObservation {
|
||||||
|
|
||||||
export interface OpenCodeSendMessageCommandData {
|
export interface OpenCodeSendMessageCommandData {
|
||||||
accepted: boolean;
|
accepted: boolean;
|
||||||
|
runId?: string;
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
runtimePid?: number;
|
runtimePid?: number;
|
||||||
|
|
@ -238,6 +239,9 @@ export interface OpenCodeSendMessageCommandData {
|
||||||
runtimePromptMessageId?: string;
|
runtimePromptMessageId?: string;
|
||||||
responseObservation?: OpenCodeDeliveryResponseObservation;
|
responseObservation?: OpenCodeDeliveryResponseObservation;
|
||||||
diagnostics: OpenCodeTeamBridgeDiagnostic[];
|
diagnostics: OpenCodeTeamBridgeDiagnostic[];
|
||||||
|
idempotencyKey?: string;
|
||||||
|
manifestHighWatermark?: number | null;
|
||||||
|
runtimeStoreManifestHighWatermark?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeCommandStatusCommandBody {
|
export interface OpenCodeCommandStatusCommandBody {
|
||||||
|
|
|
||||||
|
|
@ -235,20 +235,47 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
let activeRequestId = commandRequestId;
|
let activeRequestId = commandRequestId;
|
||||||
let activeBody = body;
|
let activeBody = body;
|
||||||
let usedObservedFallback = false;
|
let usedObservedFallback = false;
|
||||||
const executeSend = (nextBody: OpenCodeSendMessageCommandBody, requestId: string) =>
|
const executeSend = async (
|
||||||
this.bridge.execute<OpenCodeSendMessageCommandBody, OpenCodeSendMessageCommandData>(
|
nextBody: OpenCodeSendMessageCommandBody,
|
||||||
'opencode.sendMessage',
|
requestId: string
|
||||||
nextBody,
|
): Promise<{
|
||||||
{
|
result: OpenCodeBridgeResult<OpenCodeSendMessageCommandData>;
|
||||||
|
requestId: string;
|
||||||
|
}> => {
|
||||||
|
if (this.options.stateChangingCommands && nextBody.settlementMode === 'acceptance') {
|
||||||
|
const result = await this.options.stateChangingCommands.execute<
|
||||||
|
OpenCodeSendMessageCommandBody,
|
||||||
|
OpenCodeSendMessageCommandData
|
||||||
|
>({
|
||||||
|
command: 'opencode.sendMessage',
|
||||||
|
teamName: nextBody.teamName,
|
||||||
|
laneId: nextBody.laneId,
|
||||||
|
runId: nextBody.runId ?? null,
|
||||||
|
capabilitySnapshotId: null,
|
||||||
|
behaviorFingerprint: null,
|
||||||
|
body: nextBody,
|
||||||
cwd: nextBody.projectPath,
|
cwd: nextBody.projectPath,
|
||||||
timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS,
|
timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS,
|
||||||
requestId,
|
});
|
||||||
}
|
return { result, requestId: result.requestId || requestId };
|
||||||
);
|
}
|
||||||
|
|
||||||
|
const result = await this.bridge.execute<
|
||||||
|
OpenCodeSendMessageCommandBody,
|
||||||
|
OpenCodeSendMessageCommandData
|
||||||
|
>('opencode.sendMessage', nextBody, {
|
||||||
|
cwd: nextBody.projectPath,
|
||||||
|
timeoutMs: this.options.sendTimeoutMs ?? DEFAULT_SEND_TIMEOUT_MS,
|
||||||
|
requestId,
|
||||||
|
});
|
||||||
|
return { result, requestId: result.requestId || requestId };
|
||||||
|
};
|
||||||
|
|
||||||
let result: OpenCodeBridgeResult<OpenCodeSendMessageCommandData>;
|
let result: OpenCodeBridgeResult<OpenCodeSendMessageCommandData>;
|
||||||
try {
|
try {
|
||||||
result = await executeSend(activeBody, activeRequestId);
|
const executed = await executeSend(activeBody, activeRequestId);
|
||||||
|
result = executed.result;
|
||||||
|
activeRequestId = executed.requestId;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
body.settlementMode !== 'acceptance' ||
|
body.settlementMode !== 'acceptance' ||
|
||||||
|
|
@ -262,7 +289,25 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
settlementMode: 'observed',
|
settlementMode: 'observed',
|
||||||
};
|
};
|
||||||
usedObservedFallback = true;
|
usedObservedFallback = true;
|
||||||
result = await executeSend(activeBody, activeRequestId);
|
const executed = await executeSend(activeBody, activeRequestId);
|
||||||
|
result = executed.result;
|
||||||
|
activeRequestId = executed.requestId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!result.ok &&
|
||||||
|
activeBody.settlementMode === 'acceptance' &&
|
||||||
|
isOpenCodeAcceptanceContractMissingError(result.error.message)
|
||||||
|
) {
|
||||||
|
activeRequestId = `${commandRequestId}-observed`;
|
||||||
|
activeBody = {
|
||||||
|
...body,
|
||||||
|
settlementMode: 'observed',
|
||||||
|
};
|
||||||
|
usedObservedFallback = true;
|
||||||
|
const executed = await executeSend(activeBody, activeRequestId);
|
||||||
|
result = executed.result;
|
||||||
|
activeRequestId = executed.requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
|
@ -487,7 +532,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
|
|
||||||
type OpenCodeStateChangingTeamCommandName = Extract<
|
type OpenCodeStateChangingTeamCommandName = Extract<
|
||||||
OpenCodeBridgeCommandName,
|
OpenCodeBridgeCommandName,
|
||||||
'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam'
|
'opencode.launchTeam' | 'opencode.reconcileTeam' | 'opencode.stopTeam' | 'opencode.sendMessage'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
function blockedLaunchData(
|
function blockedLaunchData(
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
OpenCodeBridgeResult,
|
OpenCodeBridgeResult,
|
||||||
OpenCodeBridgeSuccess,
|
OpenCodeBridgeSuccess,
|
||||||
OpenCodeLaunchTeamCommandData,
|
OpenCodeLaunchTeamCommandData,
|
||||||
|
OpenCodeSendMessageCommandData,
|
||||||
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
} from '../../../../src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract';
|
||||||
|
|
||||||
describe('OpenCodeReadinessBridge', () => {
|
describe('OpenCodeReadinessBridge', () => {
|
||||||
|
|
@ -405,12 +406,11 @@ describe('OpenCodeReadinessBridge', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(executor.execute).toHaveBeenCalledTimes(2);
|
expect(executor.execute).toHaveBeenCalledTimes(2);
|
||||||
const sendOptions = executor.execute.mock.calls[0]?.[2] as { requestId?: string } | undefined;
|
|
||||||
expect(executor.execute.mock.calls[1]).toEqual([
|
expect(executor.execute.mock.calls[1]).toEqual([
|
||||||
'opencode.commandStatus',
|
'opencode.commandStatus',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
originalCommand: 'opencode.sendMessage',
|
originalCommand: 'opencode.sendMessage',
|
||||||
originalRequestId: sendOptions?.requestId,
|
originalRequestId: 'req-1',
|
||||||
deliveryAttemptId: 'ledger-1:1:payload',
|
deliveryAttemptId: 'ledger-1:1:payload',
|
||||||
payloadHash: expect.any(String),
|
payloadHash: expect.any(String),
|
||||||
}),
|
}),
|
||||||
|
|
@ -560,6 +560,136 @@ describe('OpenCodeReadinessBridge', () => {
|
||||||
expect(executor.execute).toHaveBeenCalledTimes(2);
|
expect(executor.execute).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('routes send-message commands through the guarded command service when configured', async () => {
|
||||||
|
const executor = fakeExecutor(
|
||||||
|
bridgeFailure('internal_error', 'direct bridge must not run', [])
|
||||||
|
);
|
||||||
|
const stateChangingExecute = vi.fn();
|
||||||
|
const stateChangingCommands = {
|
||||||
|
async execute<TBody, TData>(input: {
|
||||||
|
command: OpenCodeBridgeCommandName;
|
||||||
|
body: TBody;
|
||||||
|
teamName: string;
|
||||||
|
laneId?: string | null;
|
||||||
|
runId: string | null;
|
||||||
|
}): Promise<OpenCodeBridgeResult<TData>> {
|
||||||
|
stateChangingExecute(input);
|
||||||
|
return bridgeCommandSuccess<OpenCodeSendMessageCommandData>({
|
||||||
|
command: input.command,
|
||||||
|
requestId: 'guarded-send-req-1',
|
||||||
|
data: {
|
||||||
|
accepted: true,
|
||||||
|
memberName: 'bob',
|
||||||
|
sessionId: 'session-bob',
|
||||||
|
runtimePromptMessageId: 'msg_prompt_1',
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
}) as unknown as OpenCodeBridgeResult<TData>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const bridge = new OpenCodeReadinessBridge(executor, { stateChangingCommands });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bridge.sendOpenCodeTeamMessage({
|
||||||
|
runId: 'run-1',
|
||||||
|
teamId: 'team-a',
|
||||||
|
teamName: 'team-a',
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
projectPath: '/repo',
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'hello',
|
||||||
|
messageId: 'message-1',
|
||||||
|
deliveryAttemptId: 'ledger-1:1:payload',
|
||||||
|
settlementMode: 'acceptance',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
accepted: true,
|
||||||
|
memberName: 'bob',
|
||||||
|
sessionId: 'session-bob',
|
||||||
|
runtimePromptMessageId: 'msg_prompt_1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stateChangingExecute).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
command: 'opencode.sendMessage',
|
||||||
|
teamName: 'team-a',
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
runId: 'run-1',
|
||||||
|
cwd: '/repo',
|
||||||
|
body: expect.objectContaining({
|
||||||
|
settlementMode: 'acceptance',
|
||||||
|
payloadHash: expect.any(String),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(executor.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to observed send mode when guarded acceptance contract validation fails', async () => {
|
||||||
|
const executor = fakeExecutor(
|
||||||
|
bridgeCommandSuccess<OpenCodeSendMessageCommandData>({
|
||||||
|
command: 'opencode.sendMessage',
|
||||||
|
requestId: 'legacy-observed-send',
|
||||||
|
data: {
|
||||||
|
accepted: true,
|
||||||
|
memberName: 'bob',
|
||||||
|
sessionId: 'session-bob',
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const stateChangingExecute = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
bridgeCommandFailure({
|
||||||
|
command: 'opencode.sendMessage',
|
||||||
|
requestId: 'guarded-send-acceptance',
|
||||||
|
kind: 'internal_error',
|
||||||
|
message:
|
||||||
|
'OpenCode delivery acceptance mode is required, but the orchestrator does not advertise contract version 1.',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const stateChangingCommands = {
|
||||||
|
execute: stateChangingExecute,
|
||||||
|
};
|
||||||
|
const bridge = new OpenCodeReadinessBridge(executor, { stateChangingCommands });
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bridge.sendOpenCodeTeamMessage({
|
||||||
|
runId: 'run-1',
|
||||||
|
teamId: 'team-a',
|
||||||
|
teamName: 'team-a',
|
||||||
|
laneId: 'secondary:opencode:bob',
|
||||||
|
projectPath: '/repo',
|
||||||
|
memberName: 'bob',
|
||||||
|
text: 'hello',
|
||||||
|
messageId: 'message-1',
|
||||||
|
deliveryAttemptId: 'ledger-1:1:payload',
|
||||||
|
settlementMode: 'acceptance',
|
||||||
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
|
accepted: true,
|
||||||
|
diagnostics: [
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'opencode_accept_fast_capability_missing',
|
||||||
|
severity: 'warning',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(stateChangingExecute).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stateChangingExecute.mock.calls[0]?.[0]?.body).toMatchObject({
|
||||||
|
settlementMode: 'acceptance',
|
||||||
|
});
|
||||||
|
expect(executor.execute).toHaveBeenCalledWith(
|
||||||
|
'opencode.sendMessage',
|
||||||
|
expect.objectContaining({ settlementMode: 'observed' }),
|
||||||
|
expect.objectContaining({
|
||||||
|
cwd: '/repo',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('routes state-changing launch commands through the guarded command service when configured', async () => {
|
it('routes state-changing launch commands through the guarded command service when configured', async () => {
|
||||||
const executor = fakeExecutor(
|
const executor = fakeExecutor(
|
||||||
bridgeFailure('internal_error', 'direct bridge must not run', [])
|
bridgeFailure('internal_error', 'direct bridge must not run', [])
|
||||||
|
|
@ -690,6 +820,28 @@ function bridgeFailure(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bridgeCommandFailure(input: {
|
||||||
|
command: OpenCodeBridgeCommandName;
|
||||||
|
requestId: string;
|
||||||
|
kind: OpenCodeBridgeFailureKind;
|
||||||
|
message: string;
|
||||||
|
}): OpenCodeBridgeResult<unknown> {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
schemaVersion: 1,
|
||||||
|
requestId: input.requestId,
|
||||||
|
command: input.command,
|
||||||
|
completedAt: '2026-04-21T12:00:01.000Z',
|
||||||
|
durationMs: 1000,
|
||||||
|
error: {
|
||||||
|
kind: input.kind,
|
||||||
|
message: input.message,
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function bridgeCommandSuccess<TData>(input: {
|
function bridgeCommandSuccess<TData>(input: {
|
||||||
command: OpenCodeBridgeCommandName;
|
command: OpenCodeBridgeCommandName;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue