fix(opencode): stabilize runtime message delivery
This commit is contained in:
parent
c49d6c373e
commit
6b8ab10414
6 changed files with 570 additions and 27 deletions
|
|
@ -242,7 +242,17 @@ import type {
|
|||
import type { CliArgsValidationResult } from '@shared/utils/cliArgsParser';
|
||||
|
||||
const logger = createLogger('IPC:teams');
|
||||
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 12_000;
|
||||
// Runtime relay continues in the background after this race; keep sendMessage IPC off the
|
||||
// 25s OpenCode turn-settled guard while still giving prompt acceptance/reconcile time.
|
||||
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS = 6_000;
|
||||
const OPENCODE_RUNTIME_DELIVERY_STATUS_AFTER_UI_TIMEOUT_MS = 1_000;
|
||||
const OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON =
|
||||
'opencode_runtime_delivery_ui_timeout_pending';
|
||||
|
||||
type OpenCodeMemberInboxRelayResult = Awaited<
|
||||
ReturnType<TeamProvisioningService['relayOpenCodeMemberInboxMessages']>
|
||||
>;
|
||||
type OpenCodeMemberInboxDelivery = NonNullable<OpenCodeMemberInboxRelayResult['lastDelivery']>;
|
||||
|
||||
type VisibleDirectReplyProtocol = 'send_message' | 'agent_teams_message_send';
|
||||
|
||||
|
|
@ -320,6 +330,158 @@ async function withTimeoutValue<T>(
|
|||
}
|
||||
}
|
||||
|
||||
async function waitForOpenCodeRuntimeRelayForUi(input: {
|
||||
provisioning: TeamProvisioningService;
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
messageId: string;
|
||||
relayPromise: Promise<OpenCodeMemberInboxRelayResult>;
|
||||
timeoutMs?: number;
|
||||
}): Promise<OpenCodeMemberInboxRelayResult> {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let timedOut = false;
|
||||
|
||||
void input.relayPromise.then(
|
||||
(relay) => {
|
||||
if (!timedOut) return;
|
||||
const delivery = relay.lastDelivery;
|
||||
if (delivery && !delivery.delivered && delivery.reason !== 'recipient_is_not_opencode') {
|
||||
logger.warn(
|
||||
`OpenCode runtime delivery after sendMessage completed after UI timeout for teammate "${input.memberName}" with failure: ${
|
||||
delivery.reason ?? 'unknown error'
|
||||
}`
|
||||
);
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (!timedOut) return;
|
||||
logger.warn(
|
||||
`OpenCode runtime delivery after sendMessage rejected after UI timeout for teammate "${input.memberName}": ${getErrorMessage(error)}`
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const outcome = await Promise.race<
|
||||
{ kind: 'relay'; relay: OpenCodeMemberInboxRelayResult } | { kind: 'timeout' }
|
||||
>([
|
||||
input.relayPromise.then((relay) => ({ kind: 'relay' as const, relay })),
|
||||
new Promise<{ kind: 'timeout' }>((resolve) => {
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
resolve({ kind: 'timeout' });
|
||||
}, input.timeoutMs ?? OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS);
|
||||
timer.unref?.();
|
||||
}),
|
||||
]);
|
||||
|
||||
if (outcome.kind === 'relay') {
|
||||
return outcome.relay;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await withTimeoutValue(
|
||||
input.provisioning.getOpenCodeRuntimeDeliveryStatus(input.teamName, input.messageId),
|
||||
OPENCODE_RUNTIME_DELIVERY_STATUS_AFTER_UI_TIMEOUT_MS,
|
||||
null
|
||||
);
|
||||
if (status) {
|
||||
return openCodeRuntimeDeliveryStatusToRelayResult(status);
|
||||
}
|
||||
} catch (error) {
|
||||
const reason = getErrorMessage(error);
|
||||
logger.warn(
|
||||
`OpenCode runtime delivery status after UI timeout failed for teammate "${input.memberName}": ${reason}`
|
||||
);
|
||||
return buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult([
|
||||
`${OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON}: status lookup failed: ${reason}`,
|
||||
]);
|
||||
}
|
||||
|
||||
return buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult();
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openCodeRuntimeDeliveryStatusToRelayResult(
|
||||
status: OpenCodeRuntimeDeliveryStatus
|
||||
): OpenCodeMemberInboxRelayResult {
|
||||
const lastDelivery: OpenCodeMemberInboxDelivery = {
|
||||
delivered: status.delivered,
|
||||
...(typeof status.responsePending === 'boolean'
|
||||
? { responsePending: status.responsePending }
|
||||
: {}),
|
||||
...(typeof status.acceptanceUnknown === 'boolean'
|
||||
? { acceptanceUnknown: status.acceptanceUnknown }
|
||||
: {}),
|
||||
...(status.responseState ? { responseState: status.responseState } : {}),
|
||||
...(status.ledgerStatus ? { ledgerStatus: status.ledgerStatus } : {}),
|
||||
...(status.visibleReplyMessageId
|
||||
? { visibleReplyMessageId: status.visibleReplyMessageId }
|
||||
: {}),
|
||||
...(status.visibleReplyCorrelation
|
||||
? { visibleReplyCorrelation: status.visibleReplyCorrelation }
|
||||
: {}),
|
||||
...(status.queuedBehindMessageId
|
||||
? { queuedBehindMessageId: status.queuedBehindMessageId }
|
||||
: {}),
|
||||
...(status.reason ? { reason: status.reason } : {}),
|
||||
...(status.diagnostics ? { diagnostics: status.diagnostics } : {}),
|
||||
...(shouldPreserveOpenCodeRuntimeDeliveryStatusImpact(status)
|
||||
? { userVisibleImpact: status.userVisibleImpact }
|
||||
: {}),
|
||||
};
|
||||
return {
|
||||
relayed: 0,
|
||||
attempted: 1,
|
||||
delivered: status.delivered && status.responsePending !== true ? 1 : 0,
|
||||
failed: status.delivered ? 0 : 1,
|
||||
lastDelivery,
|
||||
diagnostics: status.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldPreserveOpenCodeRuntimeDeliveryStatusImpact(
|
||||
status: OpenCodeRuntimeDeliveryStatus
|
||||
): boolean {
|
||||
if (!status.userVisibleImpact) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
status.userVisibleImpact.state === 'none' &&
|
||||
(status.responsePending === true ||
|
||||
status.acceptanceUnknown === true ||
|
||||
Boolean(status.queuedBehindMessageId))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildOpenCodeRuntimeDeliveryUiTimeoutRelayResult(
|
||||
extraDiagnostics: string[] = []
|
||||
): OpenCodeMemberInboxRelayResult {
|
||||
const diagnostics = [OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON, ...extraDiagnostics];
|
||||
return {
|
||||
relayed: 0,
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 1,
|
||||
lastDelivery: {
|
||||
delivered: true,
|
||||
accepted: false,
|
||||
responsePending: true,
|
||||
acceptanceUnknown: true,
|
||||
responseState: 'not_observed',
|
||||
reason: OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_PENDING_REASON,
|
||||
diagnostics,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function noteHeavyTeamDataWorkerFallback(operation: string): void {
|
||||
if (!app.isPackaged) {
|
||||
return;
|
||||
|
|
@ -3077,8 +3239,12 @@ async function handleSendMessage(
|
|||
// }
|
||||
if (isOpenCodeRecipient) {
|
||||
try {
|
||||
const relay = await withTimeoutValue(
|
||||
provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, {
|
||||
const relay = await waitForOpenCodeRuntimeRelayForUi({
|
||||
provisioning,
|
||||
teamName: tn,
|
||||
memberName,
|
||||
messageId: result.messageId,
|
||||
relayPromise: provisioning.relayOpenCodeMemberInboxMessages(tn, memberName, {
|
||||
onlyMessageId: result.messageId,
|
||||
source: 'ui-send',
|
||||
deliveryMetadata: {
|
||||
|
|
@ -3087,23 +3253,7 @@ async function handleSendMessage(
|
|||
taskRefs: validatedTaskRefs.value,
|
||||
},
|
||||
}),
|
||||
OPENCODE_RUNTIME_DELIVERY_UI_TIMEOUT_MS,
|
||||
{
|
||||
relayed: 0,
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 1,
|
||||
lastDelivery: {
|
||||
delivered: true,
|
||||
accepted: false,
|
||||
responsePending: true,
|
||||
acceptanceUnknown: true,
|
||||
responseState: 'not_observed',
|
||||
reason: 'opencode_runtime_delivery_ui_timeout_pending',
|
||||
diagnostics: ['opencode_runtime_delivery_ui_timeout_pending'],
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
const delivery = relay.lastDelivery ?? {
|
||||
delivered: relay.relayed > 0,
|
||||
reason: relay.relayed > 0 ? undefined : 'opencode_message_delivery_not_attempted',
|
||||
|
|
|
|||
|
|
@ -8540,14 +8540,23 @@ export class TeamProvisioningService {
|
|||
}
|
||||
if (active && active.inboxMessageId !== messageId) {
|
||||
const activeDueMs = active.nextAttemptAt ? Date.parse(active.nextAttemptAt) : NaN;
|
||||
const activeRetryDelayMs = Number.isFinite(activeDueMs)
|
||||
? Math.max(500, activeDueMs - Date.now())
|
||||
: OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS;
|
||||
this.scheduleOpenCodePromptDeliveryWatchdog({
|
||||
teamName,
|
||||
memberName: canonicalMemberName,
|
||||
messageId: active.inboxMessageId,
|
||||
delayMs: Number.isFinite(activeDueMs)
|
||||
? Math.max(500, activeDueMs - Date.now())
|
||||
: OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS,
|
||||
delayMs: activeRetryDelayMs,
|
||||
});
|
||||
if (messageId) {
|
||||
this.scheduleOpenCodeMemberInboxDeliveryWake({
|
||||
teamName,
|
||||
memberName: canonicalMemberName,
|
||||
messageId,
|
||||
delayMs: activeRetryDelayMs + 500,
|
||||
});
|
||||
}
|
||||
return {
|
||||
delivered: true,
|
||||
accepted: false,
|
||||
|
|
@ -22892,6 +22901,18 @@ export class TeamProvisioningService {
|
|||
break;
|
||||
}
|
||||
if (delivery.responsePending) {
|
||||
if (
|
||||
typeof delivery.queuedBehindMessageId === 'string' &&
|
||||
delivery.queuedBehindMessageId.trim() &&
|
||||
delivery.queuedBehindMessageId !== message.messageId
|
||||
) {
|
||||
this.scheduleOpenCodeMemberInboxDeliveryWake({
|
||||
teamName,
|
||||
memberName,
|
||||
messageId: message.messageId,
|
||||
delayMs: OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS,
|
||||
});
|
||||
}
|
||||
result.diagnostics = [
|
||||
...(result.diagnostics ?? []),
|
||||
...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_delivery_response_pending']),
|
||||
|
|
|
|||
|
|
@ -48,6 +48,11 @@ const MANAGED_ENV_IDENTITY_MARKERS = [
|
|||
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_ENTRY=',
|
||||
'CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_URL=',
|
||||
] as const;
|
||||
const MANAGED_INLINE_OPENCODE_CONFIG_PATTERNS = [
|
||||
/OPENCODE_CONFIG_CONTENT=[\s\S]*"mcp"\s*:\s*\{[\s\S]*"agent-teams(?:-runtime-\d+)?"/i,
|
||||
/OPENCODE_CONFIG_CONTENT=[\s\S]*"claude-multimodel runtime orchestration"/i,
|
||||
/OPENCODE_CONFIG_CONTENT=[\s\S]*"(?:agent-teams|agent_teams|mcp__agent-teams|mcp__agent_teams)_\*"/i,
|
||||
] as const;
|
||||
|
||||
export async function cleanupManagedOpenCodeServeProcesses(
|
||||
options: OpenCodeManagedHostProcessCleanupOptions
|
||||
|
|
@ -202,7 +207,8 @@ export function isAppManagedWindowsOpenCodeServeCommand(command: string): boolea
|
|||
export function isManagedOpenCodeServeProcessDetails(details: string): boolean {
|
||||
return (
|
||||
processDetailsIncludeMarkers(details, MANAGED_ENV_MARKERS) &&
|
||||
MANAGED_ENV_IDENTITY_MARKERS.some((marker) => processDetailsIncludeMarker(details, marker))
|
||||
(MANAGED_ENV_IDENTITY_MARKERS.some((marker) => processDetailsIncludeMarker(details, marker)) ||
|
||||
MANAGED_INLINE_OPENCODE_CONFIG_PATTERNS.some((pattern) => pattern.test(details)))
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
BoardTaskLogStreamResponse,
|
||||
InboxMessage,
|
||||
MessagesPage,
|
||||
OpenCodeRuntimeDeliveryStatus,
|
||||
SendMessageResult,
|
||||
TeamCreateRequest,
|
||||
TeamLaunchRequest,
|
||||
|
|
@ -318,6 +319,7 @@ describe('ipc teams handlers', () => {
|
|||
}
|
||||
| undefined,
|
||||
})),
|
||||
getOpenCodeRuntimeDeliveryStatus: vi.fn(async () => null as OpenCodeRuntimeDeliveryStatus | null),
|
||||
buildOpenCodeRuntimeDeliveryUserVisibleImpact: vi.fn(() => ({ state: 'none' })),
|
||||
getLiveLeadProcessMessages: vi.fn(() => [] as InboxMessage[]),
|
||||
getCurrentLeadSessionId: vi.fn(() => null as string | null),
|
||||
|
|
@ -868,6 +870,7 @@ describe('ipc teams handlers', () => {
|
|||
attempted: true,
|
||||
delivered: true,
|
||||
});
|
||||
expect(provisioningService.getOpenCodeRuntimeDeliveryStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns runtimeDelivery failure without hiding the persisted OpenCode message', async () => {
|
||||
|
|
@ -950,6 +953,7 @@ describe('ipc teams handlers', () => {
|
|||
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(
|
||||
new Promise(() => undefined)
|
||||
);
|
||||
provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce(null);
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
||||
|
|
@ -958,7 +962,7 @@ describe('ipc teams handlers', () => {
|
|||
text: 'Ping bob',
|
||||
}) as Promise<{ success: boolean; data?: SendMessageResult }>;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(12_000);
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
|
@ -971,6 +975,281 @@ describe('ipc teams handlers', () => {
|
|||
responseState: 'not_observed',
|
||||
reason: 'opencode_runtime_delivery_ui_timeout_pending',
|
||||
});
|
||||
expect(provisioningService.getOpenCodeRuntimeDeliveryStatus).toHaveBeenCalledWith(
|
||||
'my-team',
|
||||
result.data?.messageId
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('uses durable OpenCode delivery status when UI relay timeout fires after prompt acceptance', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
||||
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(
|
||||
new Promise(() => undefined)
|
||||
);
|
||||
provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce({
|
||||
messageId: 'msg-123',
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'not_observed',
|
||||
ledgerStatus: 'pending',
|
||||
reason: 'opencode_delivery_response_pending',
|
||||
diagnostics: ['prompt accepted'],
|
||||
userVisibleImpact: { state: 'none' },
|
||||
});
|
||||
provisioningService.buildOpenCodeRuntimeDeliveryUserVisibleImpact.mockReturnValueOnce({
|
||||
state: 'checking',
|
||||
});
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
||||
const resultPromise = sendHandler!({} as never, 'my-team', {
|
||||
member: 'bob',
|
||||
text: 'Ping bob',
|
||||
}) as Promise<{ success: boolean; data?: SendMessageResult }>;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.runtimeDelivery).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
responseState: 'not_observed',
|
||||
ledgerStatus: 'pending',
|
||||
reason: 'opencode_delivery_response_pending',
|
||||
diagnostics: ['prompt accepted'],
|
||||
userVisibleImpact: { state: 'checking' },
|
||||
});
|
||||
expect(result.data?.runtimeDelivery?.acceptanceUnknown).toBeUndefined();
|
||||
const impactCalls = provisioningService.buildOpenCodeRuntimeDeliveryUserVisibleImpact.mock
|
||||
.calls as unknown as Array<[Partial<NonNullable<SendMessageResult['runtimeDelivery']>>]>;
|
||||
const impactInput = impactCalls.at(-1)?.[0];
|
||||
expect(impactInput).toMatchObject({
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
});
|
||||
expect(impactInput).not.toHaveProperty('userVisibleImpact');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves terminal durable OpenCode delivery status after UI relay timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
||||
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(
|
||||
new Promise(() => undefined)
|
||||
);
|
||||
provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce({
|
||||
messageId: 'msg-responded',
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: true,
|
||||
responsePending: false,
|
||||
responseState: 'responded_visible_message',
|
||||
ledgerStatus: 'responded',
|
||||
visibleReplyMessageId: 'reply-1',
|
||||
visibleReplyCorrelation: 'relayOfMessageId',
|
||||
acceptanceUnknown: false,
|
||||
diagnostics: [],
|
||||
userVisibleImpact: { state: 'none' },
|
||||
});
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
||||
const resultPromise = sendHandler!({} as never, 'my-team', {
|
||||
member: 'bob',
|
||||
text: 'Ping bob',
|
||||
}) as Promise<{ success: boolean; data?: SendMessageResult }>;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.runtimeDelivery).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: true,
|
||||
responsePending: false,
|
||||
responseState: 'responded_visible_message',
|
||||
ledgerStatus: 'responded',
|
||||
visibleReplyMessageId: 'reply-1',
|
||||
visibleReplyCorrelation: 'relayOfMessageId',
|
||||
acceptanceUnknown: false,
|
||||
userVisibleImpact: { state: 'none' },
|
||||
});
|
||||
expect(provisioningService.buildOpenCodeRuntimeDeliveryUserVisibleImpact).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a slow OpenCode delivery status lookup extend the UI timeout indefinitely', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
||||
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(
|
||||
new Promise(() => undefined)
|
||||
);
|
||||
provisioningService.getOpenCodeRuntimeDeliveryStatus.mockReturnValueOnce(
|
||||
new Promise(() => undefined)
|
||||
);
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
||||
const resultPromise = sendHandler!({} as never, 'my-team', {
|
||||
member: 'bob',
|
||||
text: 'Ping bob',
|
||||
}) as Promise<{ success: boolean; data?: SendMessageResult }>;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
await flushMicrotasks();
|
||||
let settled = false;
|
||||
void resultPromise.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await flushMicrotasks();
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.runtimeDelivery).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
acceptanceUnknown: true,
|
||||
reason: 'opencode_runtime_delivery_ui_timeout_pending',
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to acceptance-unknown when OpenCode delivery status lookup rejects after UI timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
||||
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(
|
||||
new Promise(() => undefined)
|
||||
);
|
||||
provisioningService.getOpenCodeRuntimeDeliveryStatus.mockRejectedValueOnce(
|
||||
new Error('status read failed')
|
||||
);
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
||||
const resultPromise = sendHandler!({} as never, 'my-team', {
|
||||
member: 'bob',
|
||||
text: 'Ping bob',
|
||||
}) as Promise<{ success: boolean; data?: SendMessageResult }>;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.data?.runtimeDelivery).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
attempted: true,
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
acceptanceUnknown: true,
|
||||
reason: 'opencode_runtime_delivery_ui_timeout_pending',
|
||||
diagnostics: [
|
||||
'opencode_runtime_delivery_ui_timeout_pending',
|
||||
'opencode_runtime_delivery_ui_timeout_pending: status lookup failed: status read failed',
|
||||
],
|
||||
});
|
||||
expect(vi.mocked(console.warn).mock.calls.some((call) =>
|
||||
call.join(' ').includes('status after UI timeout failed')
|
||||
)).toBe(true);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs OpenCode relay rejection that happens after the UI timeout fallback', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const deferredRelay = createDeferred<Awaited<
|
||||
ReturnType<typeof provisioningService.relayOpenCodeMemberInboxMessages>
|
||||
>>();
|
||||
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
||||
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(deferredRelay.promise);
|
||||
provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce(null);
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
||||
const resultPromise = sendHandler!({} as never, 'my-team', {
|
||||
member: 'bob',
|
||||
text: 'Ping bob',
|
||||
}) as Promise<{ success: boolean; data?: SendMessageResult }>;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
await expect(resultPromise).resolves.toMatchObject({ success: true });
|
||||
|
||||
deferredRelay.reject(new Error('late bridge failure'));
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(vi.mocked(console.warn).mock.calls.some((call) =>
|
||||
call.join(' ').includes('rejected after UI timeout')
|
||||
)).toBe(true);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('logs OpenCode relay failure result that resolves after the UI timeout fallback', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const deferredRelay = createDeferred<Awaited<
|
||||
ReturnType<typeof provisioningService.relayOpenCodeMemberInboxMessages>
|
||||
>>();
|
||||
provisioningService.resolveRuntimeRecipientProviderId.mockResolvedValueOnce('opencode');
|
||||
provisioningService.relayOpenCodeMemberInboxMessages.mockReturnValueOnce(deferredRelay.promise);
|
||||
provisioningService.getOpenCodeRuntimeDeliveryStatus.mockResolvedValueOnce(null);
|
||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||
expect(sendHandler).toBeDefined();
|
||||
|
||||
const resultPromise = sendHandler!({} as never, 'my-team', {
|
||||
member: 'bob',
|
||||
text: 'Ping bob',
|
||||
}) as Promise<{ success: boolean; data?: SendMessageResult }>;
|
||||
|
||||
await vi.advanceTimersByTimeAsync(6_000);
|
||||
await expect(resultPromise).resolves.toMatchObject({ success: true });
|
||||
|
||||
deferredRelay.resolve({
|
||||
relayed: 0,
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 1,
|
||||
lastDelivery: {
|
||||
delivered: false,
|
||||
reason: 'late_runtime_failure',
|
||||
diagnostics: ['late_runtime_failure'],
|
||||
},
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(vi.mocked(console.warn).mock.calls.some((call) =>
|
||||
call.join(' ').includes('completed after UI timeout')
|
||||
)).toBe(true);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
cleanupManagedOpenCodeServeProcesses,
|
||||
getOpenCodeServeLoopbackBaseUrl,
|
||||
|
|
@ -7,6 +5,7 @@ import {
|
|||
isManagedOpenCodeServeProcessDetails,
|
||||
isOpenCodeServeCommand,
|
||||
} from '@main/services/team/opencode/bridge/OpenCodeManagedHostProcessCleanup';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const MANAGED_DETAILS = [
|
||||
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
|
||||
|
|
@ -27,6 +26,16 @@ const MANAGED_DETAILS_WITH_WORKSPACE_MCP = [
|
|||
'OPENCODE_CONFIG_CONTENT={}',
|
||||
'AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude',
|
||||
].join(' ');
|
||||
const MANAGED_DETAILS_WITH_INLINE_OPENCODE_CONFIG_MCP = [
|
||||
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
|
||||
'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime',
|
||||
'OPENCODE_CONFIG_CONTENT={"mcp":{"agent-teams":{"type":"local","command":["node","mcp-server/dist/index.js"],"environment":{"AGENT_TEAMS_MCP_CLAUDE_DIR":"/tmp/claude"},"enabled":true}}}',
|
||||
].join(' ');
|
||||
const MANAGED_DETAILS_WITH_INLINE_OPENCODE_AGENT_PERMISSIONS = [
|
||||
'/opt/homebrew/bin/opencode serve --hostname 127.0.0.1 --port 54171',
|
||||
'CLAUDE_MULTIMODEL_DATA_HOME=/tmp/agent-teams-runtime',
|
||||
'OPENCODE_CONFIG_CONTENT={"agent":{"teammate":{"description":"Managed teammate agent for claude-multimodel runtime orchestration.","permission":{"agent-teams_*":"allow","mcp__agent-teams__*":"allow"}}}}',
|
||||
].join(' ');
|
||||
|
||||
function resolved<T>(value: T): Promise<T> {
|
||||
return Promise.resolve(value);
|
||||
|
|
@ -63,6 +72,12 @@ describe('OpenCodeManagedHostProcessCleanup', () => {
|
|||
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS)).toBe(true);
|
||||
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_REMOTE_MCP)).toBe(true);
|
||||
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_WORKSPACE_MCP)).toBe(true);
|
||||
expect(isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_INLINE_OPENCODE_CONFIG_MCP)).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isManagedOpenCodeServeProcessDetails(MANAGED_DETAILS_WITH_INLINE_OPENCODE_AGENT_PERMISSIONS)
|
||||
).toBe(true);
|
||||
expect(
|
||||
isManagedOpenCodeServeProcessDetails(
|
||||
'opencode serve CLAUDE_MULTIMODEL_DATA_HOME=/tmp OPENCODE_CONFIG_CONTENT={}'
|
||||
|
|
@ -78,6 +93,16 @@ describe('OpenCodeManagedHostProcessCleanup', () => {
|
|||
'opencode serve NOT_CLAUDE_MULTIMODEL_DATA_HOME=/tmp OPENCODE_CONFIG_CONTENT={} AGENT_TEAMS_MCP_CLAUDE_DIR=/tmp/claude'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isManagedOpenCodeServeProcessDetails(
|
||||
'opencode serve OPENCODE_CONFIG_CONTENT={"mcp":{"agent-teams":{"enabled":true}}}'
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
isManagedOpenCodeServeProcessDetails(
|
||||
'opencode serve OPENCODE_CONFIG_CONTENT={"agent":{"teammate":{"permission":{"agent-teams_*":"allow"}}}}'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('extracts only loopback OpenCode serve base URLs for disposal', () => {
|
||||
|
|
|
|||
|
|
@ -3721,6 +3721,68 @@ Messages:
|
|||
}
|
||||
});
|
||||
|
||||
it('reschedules a queued OpenCode inbox row when delivery is blocked behind an active prompt', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
hoisted.files.set(
|
||||
`/mock/teams/${teamName}/config.json`,
|
||||
JSON.stringify({
|
||||
name: teamName,
|
||||
projectPath: '/mock/my-team',
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'jack', role: 'developer', providerId: 'opencode', model: 'openrouter/test' },
|
||||
],
|
||||
})
|
||||
);
|
||||
seedMemberInbox(teamName, 'jack', [
|
||||
{
|
||||
from: 'user',
|
||||
to: 'jack',
|
||||
text: 'Queued UI message.',
|
||||
timestamp: '2026-02-23T17:00:01.000Z',
|
||||
read: false,
|
||||
messageId: 'opencode-queued-current',
|
||||
},
|
||||
]);
|
||||
const wakeSpy = vi
|
||||
.spyOn(service, 'scheduleOpenCodeMemberInboxDeliveryWake')
|
||||
.mockImplementation(() => undefined);
|
||||
vi.spyOn(service, 'deliverOpenCodeMemberMessage').mockResolvedValue({
|
||||
delivered: true,
|
||||
accepted: false,
|
||||
responsePending: true,
|
||||
queuedBehindMessageId: 'opencode-active-blocker',
|
||||
reason: 'opencode_delivery_response_pending',
|
||||
diagnostics: ['OpenCode delivery is queued behind opencode-active-blocker.'],
|
||||
});
|
||||
|
||||
const relay = await service.relayOpenCodeMemberInboxMessages(teamName, 'jack', {
|
||||
onlyMessageId: 'opencode-queued-current',
|
||||
source: 'watchdog',
|
||||
deliveryMetadata: { replyRecipient: 'user' },
|
||||
});
|
||||
|
||||
expect(relay).toMatchObject({
|
||||
attempted: 1,
|
||||
delivered: 0,
|
||||
failed: 0,
|
||||
lastDelivery: {
|
||||
delivered: true,
|
||||
responsePending: true,
|
||||
queuedBehindMessageId: 'opencode-active-blocker',
|
||||
},
|
||||
});
|
||||
expect(wakeSpy).toHaveBeenCalledWith({
|
||||
teamName,
|
||||
memberName: 'jack',
|
||||
messageId: 'opencode-queued-current',
|
||||
delayMs: 3_000,
|
||||
});
|
||||
const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
|
||||
expect(rows[0]).toMatchObject({ messageId: 'opencode-queued-current', read: false });
|
||||
});
|
||||
|
||||
it('treats an already-read specific OpenCode inbox row as delivered for UI-send relay', async () => {
|
||||
const service = new TeamProvisioningService();
|
||||
const teamName = 'my-team';
|
||||
|
|
|
|||
Loading…
Reference in a new issue