fix(team): bound inbox relay waits
This commit is contained in:
parent
6d0b06e2b1
commit
5366dbb34a
2 changed files with 368 additions and 7 deletions
|
|
@ -3286,6 +3286,17 @@ interface OpenCodeMemberInboxDelivery {
|
||||||
userVisibleImpact?: OpenCodeRuntimeDeliveryUserVisibleImpact;
|
userVisibleImpact?: OpenCodeRuntimeDeliveryUserVisibleImpact;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class InboxRelayInFlightTimeoutError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'InboxRelayInFlightTimeoutError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInboxRelayInFlightTimeoutError(error: unknown): error is InboxRelayInFlightTimeoutError {
|
||||||
|
return error instanceof InboxRelayInFlightTimeoutError;
|
||||||
|
}
|
||||||
|
|
||||||
type OpenCodeVisibleReplyCorrelation = NonNullable<
|
type OpenCodeVisibleReplyCorrelation = NonNullable<
|
||||||
OpenCodePromptDeliveryLedgerRecord['visibleReplyCorrelation']
|
OpenCodePromptDeliveryLedgerRecord['visibleReplyCorrelation']
|
||||||
>;
|
>;
|
||||||
|
|
@ -3444,6 +3455,15 @@ function getOpenCodeInboxRelayPriority(
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMemberInboxRelayPriority(
|
||||||
|
message: Pick<InboxMessage, 'messageKind' | 'source'>
|
||||||
|
): number {
|
||||||
|
if (message.messageKind === 'member_work_sync_nudge') {
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
function getLeadInboxRelayPriority(message: Pick<InboxMessage, 'messageKind'>): number {
|
function getLeadInboxRelayPriority(message: Pick<InboxMessage, 'messageKind'>): number {
|
||||||
if (message.messageKind === 'member_work_sync_nudge') {
|
if (message.messageKind === 'member_work_sync_nudge') {
|
||||||
return 30;
|
return 30;
|
||||||
|
|
@ -3478,6 +3498,13 @@ function compareOpenCodeInboxRelayMessagesByPriority(
|
||||||
return compareInboxRelayMessages(a, b, getOpenCodeInboxRelayPriority);
|
return compareInboxRelayMessages(a, b, getOpenCodeInboxRelayPriority);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareMemberInboxRelayMessagesByPriority(
|
||||||
|
a: Pick<InboxMessage, 'messageKind' | 'source' | 'timestamp'> & { messageId: string },
|
||||||
|
b: Pick<InboxMessage, 'messageKind' | 'source' | 'timestamp'> & { messageId: string }
|
||||||
|
): number {
|
||||||
|
return compareInboxRelayMessages(a, b, getMemberInboxRelayPriority);
|
||||||
|
}
|
||||||
|
|
||||||
function compareLeadInboxRelayMessagesByPriority(
|
function compareLeadInboxRelayMessagesByPriority(
|
||||||
a: Pick<InboxMessage, 'messageKind' | 'source' | 'timestamp'> & { messageId: string },
|
a: Pick<InboxMessage, 'messageKind' | 'source' | 'timestamp'> & { messageId: string },
|
||||||
b: Pick<InboxMessage, 'messageKind' | 'source' | 'timestamp'> & { messageId: string }
|
b: Pick<InboxMessage, 'messageKind' | 'source' | 'timestamp'> & { messageId: string }
|
||||||
|
|
@ -3527,6 +3554,7 @@ export class TeamProvisioningService {
|
||||||
private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000;
|
private static readonly RETAINED_PROVISIONING_PROGRESS_TTL_MS = 5 * 60_000;
|
||||||
private static readonly OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS = 24 * 60 * 60_000;
|
private static readonly OPENCODE_RUNTIME_DELIVERY_ADVISORY_EVENT_TTL_MS = 24 * 60 * 60_000;
|
||||||
private static readonly OPENCODE_RUNTIME_DELIVERY_LEAD_NOTICE_TTL_MS = 24 * 60 * 60_000;
|
private static readonly OPENCODE_RUNTIME_DELIVERY_LEAD_NOTICE_TTL_MS = 24 * 60 * 60_000;
|
||||||
|
private static readonly INBOX_RELAY_IN_FLIGHT_TIMEOUT_MS = 2 * 60_000;
|
||||||
|
|
||||||
private readonly runs = new Map<string, ProvisioningRun>();
|
private readonly runs = new Map<string, ProvisioningRun>();
|
||||||
private readonly provisioningRunByTeam = new Map<string, string>();
|
private readonly provisioningRunByTeam = new Map<string, string>();
|
||||||
|
|
@ -11661,6 +11689,33 @@ export class TeamProvisioningService {
|
||||||
return `opencode:${this.getMemberRelayKey(teamName, memberName)}`;
|
return `opencode:${this.getMemberRelayKey(teamName, memberName)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async waitForInboxRelayInFlight<T>(input: {
|
||||||
|
promise: Promise<T>;
|
||||||
|
relayName: string;
|
||||||
|
relayKey: string;
|
||||||
|
}): Promise<T> {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
input.promise,
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
reject(
|
||||||
|
new InboxRelayInFlightTimeoutError(
|
||||||
|
`${input.relayName} timed out after ${TeamProvisioningService.INBOX_RELAY_IN_FLIGHT_TIMEOUT_MS}ms: ${input.relayKey}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, TeamProvisioningService.INBOX_RELAY_IN_FLIGHT_TIMEOUT_MS);
|
||||||
|
timer.unref?.();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeRelayCandidateText(text: string): string {
|
private normalizeRelayCandidateText(text: string): string {
|
||||||
return stripAgentBlocks(String(text)).trim().replace(/\r\n/g, '\n');
|
return stripAgentBlocks(String(text)).trim().replace(/\r\n/g, '\n');
|
||||||
}
|
}
|
||||||
|
|
@ -23201,7 +23256,23 @@ export class TeamProvisioningService {
|
||||||
const relayKey = this.getMemberRelayKey(teamName, memberName);
|
const relayKey = this.getMemberRelayKey(teamName, memberName);
|
||||||
const existing = this.memberInboxRelayInFlight.get(relayKey);
|
const existing = this.memberInboxRelayInFlight.get(relayKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
try {
|
||||||
|
return await this.waitForInboxRelayInFlight({
|
||||||
|
promise: existing,
|
||||||
|
relayName: 'member_inbox_relay',
|
||||||
|
relayKey,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.warn(`[${teamName}] member_inbox_relay_timed_out: ${getErrorMessage(error)}`);
|
||||||
|
return 0;
|
||||||
|
} finally {
|
||||||
|
if (this.memberInboxRelayInFlight.get(relayKey) === existing) {
|
||||||
|
this.memberInboxRelayInFlight.delete(relayKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const work = (async (): Promise<number> => {
|
const work = (async (): Promise<number> => {
|
||||||
|
|
@ -23274,7 +23345,9 @@ export class TeamProvisioningService {
|
||||||
if (actionableUnread.length === 0) return 0;
|
if (actionableUnread.length === 0) return 0;
|
||||||
|
|
||||||
const MAX_RELAY = 10;
|
const MAX_RELAY = 10;
|
||||||
const batch = actionableUnread.slice(0, MAX_RELAY);
|
const batch = [...actionableUnread]
|
||||||
|
.sort(compareMemberInboxRelayMessagesByPriority)
|
||||||
|
.slice(0, MAX_RELAY);
|
||||||
|
|
||||||
this.armSilentTeammateForward(run, memberName, 'member_inbox_relay');
|
this.armSilentTeammateForward(run, memberName, 'member_inbox_relay');
|
||||||
const rememberedRelayIds = this.rememberPendingInboxRelayCandidates(run, memberName, batch);
|
const rememberedRelayIds = this.rememberPendingInboxRelayCandidates(run, memberName, batch);
|
||||||
|
|
@ -23355,7 +23428,17 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
this.memberInboxRelayInFlight.set(relayKey, work);
|
this.memberInboxRelayInFlight.set(relayKey, work);
|
||||||
try {
|
try {
|
||||||
return await work;
|
return await this.waitForInboxRelayInFlight({
|
||||||
|
promise: work,
|
||||||
|
relayName: 'member_inbox_relay',
|
||||||
|
relayKey,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.warn(`[${teamName}] member_inbox_relay_timed_out: ${getErrorMessage(error)}`);
|
||||||
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
if (this.memberInboxRelayInFlight.get(relayKey) === work) {
|
if (this.memberInboxRelayInFlight.get(relayKey) === work) {
|
||||||
this.memberInboxRelayInFlight.delete(relayKey);
|
this.memberInboxRelayInFlight.delete(relayKey);
|
||||||
|
|
@ -23521,7 +23604,37 @@ export class TeamProvisioningService {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const onlyMessageId = options.onlyMessageId?.trim();
|
const onlyMessageId = options.onlyMessageId?.trim();
|
||||||
if (!onlyMessageId) {
|
if (!onlyMessageId) {
|
||||||
return existing;
|
try {
|
||||||
|
return await this.waitForInboxRelayInFlight({
|
||||||
|
promise: existing,
|
||||||
|
relayName: 'opencode_member_inbox_relay',
|
||||||
|
relayKey,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const diagnostic = `opencode_member_inbox_relay_timed_out: ${getErrorMessage(error)}`;
|
||||||
|
logger.warn(`[${teamName}] ${diagnostic}`);
|
||||||
|
return {
|
||||||
|
relayed: 0,
|
||||||
|
attempted: 0,
|
||||||
|
delivered: 0,
|
||||||
|
failed: 1,
|
||||||
|
lastDelivery: {
|
||||||
|
delivered: false,
|
||||||
|
accepted: false,
|
||||||
|
responsePending: false,
|
||||||
|
reason: 'opencode_member_inbox_relay_timed_out',
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
},
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
if (this.openCodeMemberInboxRelayInFlight.get(relayKey) === existing) {
|
||||||
|
this.openCodeMemberInboxRelayInFlight.delete(relayKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const inboxMessages = await this.inboxReader
|
const inboxMessages = await this.inboxReader
|
||||||
.getMessagesFor(teamName, memberName)
|
.getMessagesFor(teamName, memberName)
|
||||||
|
|
@ -24012,7 +24125,31 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
this.openCodeMemberInboxRelayInFlight.set(relayKey, work);
|
this.openCodeMemberInboxRelayInFlight.set(relayKey, work);
|
||||||
try {
|
try {
|
||||||
return await work;
|
return await this.waitForInboxRelayInFlight({
|
||||||
|
promise: work,
|
||||||
|
relayName: 'opencode_member_inbox_relay',
|
||||||
|
relayKey,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const diagnostic = `opencode_member_inbox_relay_timed_out: ${getErrorMessage(error)}`;
|
||||||
|
logger.warn(`[${teamName}] ${diagnostic}`);
|
||||||
|
return {
|
||||||
|
relayed: 0,
|
||||||
|
attempted: options.onlyMessageId ? 1 : 0,
|
||||||
|
delivered: 0,
|
||||||
|
failed: 1,
|
||||||
|
lastDelivery: {
|
||||||
|
delivered: false,
|
||||||
|
accepted: false,
|
||||||
|
responsePending: false,
|
||||||
|
reason: 'opencode_member_inbox_relay_timed_out',
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
},
|
||||||
|
diagnostics: [diagnostic],
|
||||||
|
};
|
||||||
} finally {
|
} finally {
|
||||||
if (this.openCodeMemberInboxRelayInFlight.get(relayKey) === work) {
|
if (this.openCodeMemberInboxRelayInFlight.get(relayKey) === work) {
|
||||||
this.openCodeMemberInboxRelayInFlight.delete(relayKey);
|
this.openCodeMemberInboxRelayInFlight.delete(relayKey);
|
||||||
|
|
@ -24321,7 +24458,23 @@ export class TeamProvisioningService {
|
||||||
async relayLeadInboxMessages(teamName: string): Promise<number> {
|
async relayLeadInboxMessages(teamName: string): Promise<number> {
|
||||||
const existing = this.leadInboxRelayInFlight.get(teamName);
|
const existing = this.leadInboxRelayInFlight.get(teamName);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing;
|
try {
|
||||||
|
return await this.waitForInboxRelayInFlight({
|
||||||
|
promise: existing,
|
||||||
|
relayName: 'lead_inbox_relay',
|
||||||
|
relayKey: teamName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.warn(`[${teamName}] lead_inbox_relay_timed_out: ${getErrorMessage(error)}`);
|
||||||
|
return 0;
|
||||||
|
} finally {
|
||||||
|
if (this.leadInboxRelayInFlight.get(teamName) === existing) {
|
||||||
|
this.leadInboxRelayInFlight.delete(teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const work = (async (): Promise<number> => {
|
const work = (async (): Promise<number> => {
|
||||||
|
|
@ -24877,7 +25030,17 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
this.leadInboxRelayInFlight.set(teamName, work);
|
this.leadInboxRelayInFlight.set(teamName, work);
|
||||||
try {
|
try {
|
||||||
return await work;
|
return await this.waitForInboxRelayInFlight({
|
||||||
|
promise: work,
|
||||||
|
relayName: 'lead_inbox_relay',
|
||||||
|
relayKey: teamName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (!isInboxRelayInFlightTimeoutError(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
logger.warn(`[${teamName}] lead_inbox_relay_timed_out: ${getErrorMessage(error)}`);
|
||||||
|
return 0;
|
||||||
} finally {
|
} finally {
|
||||||
if (this.leadInboxRelayInFlight.get(teamName) === work) {
|
if (this.leadInboxRelayInFlight.get(teamName) === work) {
|
||||||
this.leadInboxRelayInFlight.delete(teamName);
|
this.leadInboxRelayInFlight.delete(teamName);
|
||||||
|
|
|
||||||
|
|
@ -1630,6 +1630,81 @@ Messages:
|
||||||
expect(payload).toContain('Please retry with logging enabled.');
|
expect(payload).toContain('Please retry with logging enabled.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prioritizes member work-sync nudges over older ordinary member relay rows', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
seedConfig(teamName);
|
||||||
|
seedMemberInbox(teamName, 'alice', [
|
||||||
|
...Array.from({ length: 11 }, (_, index) => ({
|
||||||
|
from: 'team-lead',
|
||||||
|
text: `Routine relay row ${index + 1}.`,
|
||||||
|
timestamp: `2026-02-23T10:${String(index).padStart(2, '0')}:00.000Z`,
|
||||||
|
read: false,
|
||||||
|
messageId: `m-ordinary-${index + 1}`,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
from: 'system',
|
||||||
|
text: 'Call member_work_sync_status, then member_work_sync_report.',
|
||||||
|
timestamp: '2026-02-23T10:30:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'm-work-sync-late',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
workSyncIntent: 'agenda_sync',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { writeSpy } = attachAliveRun(service, teamName);
|
||||||
|
const relayed = await service.relayMemberInboxMessages(teamName, 'alice');
|
||||||
|
|
||||||
|
expect(relayed).toBe(10);
|
||||||
|
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
|
||||||
|
expect(payload).toContain('1) From: system');
|
||||||
|
expect(payload).toContain('MessageId: m-work-sync-late');
|
||||||
|
expect(payload).toContain('Message kind: member_work_sync_nudge');
|
||||||
|
expect(payload).not.toContain('MessageId: m-ordinary-11');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a work-sync nudge after member relay times out before stdin write completes', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
try {
|
||||||
|
seedConfig(teamName);
|
||||||
|
seedMemberInbox(teamName, 'alice', [
|
||||||
|
{
|
||||||
|
from: 'system',
|
||||||
|
text: 'Call member_work_sync_status, then member_work_sync_report.',
|
||||||
|
timestamp: '2026-02-23T10:00:00.000Z',
|
||||||
|
read: false,
|
||||||
|
messageId: 'm-work-sync-retry',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
workSyncIntent: 'agenda_sync',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const { writeSpy } = attachAliveRun(service, teamName);
|
||||||
|
writeSpy.mockImplementationOnce(() => true);
|
||||||
|
|
||||||
|
const firstRelay = service.relayMemberInboxMessages(teamName, 'alice');
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
await expect(firstRelay).resolves.toBe(0);
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
|
||||||
|
const secondRelay = await service.relayMemberInboxMessages(teamName, 'alice');
|
||||||
|
|
||||||
|
expect(secondRelay).toBe(1);
|
||||||
|
expect(writeSpy).toHaveBeenCalledTimes(2);
|
||||||
|
const secondPayload = String(writeSpy.mock.calls[1]?.[0] ?? '');
|
||||||
|
expect(secondPayload).toContain('MessageId: m-work-sync-retry');
|
||||||
|
expect(secondPayload).toContain('Message kind: member_work_sync_nudge');
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('marks exact teammate relay copies with relayOfMessageId', async () => {
|
it('marks exact teammate relay copies with relayOfMessageId', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
|
|
@ -3831,6 +3906,129 @@ Messages:
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('times out a hung existing OpenCode member relay in-flight lock', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
const relayKey = `opencode:${teamName}:jack`;
|
||||||
|
try {
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
openCodeMemberInboxRelayInFlight: Map<string, Promise<unknown>>;
|
||||||
|
}
|
||||||
|
).openCodeMemberInboxRelayInFlight.set(relayKey, new Promise(() => undefined));
|
||||||
|
|
||||||
|
const relay = service.relayOpenCodeMemberInboxMessages(teamName, 'jack');
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
|
||||||
|
await expect(relay).resolves.toMatchObject({
|
||||||
|
attempted: 0,
|
||||||
|
delivered: 0,
|
||||||
|
failed: 1,
|
||||||
|
lastDelivery: {
|
||||||
|
delivered: false,
|
||||||
|
accepted: false,
|
||||||
|
responsePending: false,
|
||||||
|
reason: 'opencode_member_inbox_relay_timed_out',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
openCodeMemberInboxRelayInFlight: Map<string, Promise<unknown>>;
|
||||||
|
}
|
||||||
|
).openCodeMemberInboxRelayInFlight.has(relayKey)
|
||||||
|
).toBe(false);
|
||||||
|
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||||
|
'opencode_member_inbox_relay_timed_out'
|
||||||
|
);
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('times out a hung existing lead relay in-flight lock', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
try {
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
leadInboxRelayInFlight: Map<string, Promise<number>>;
|
||||||
|
}
|
||||||
|
).leadInboxRelayInFlight.set(teamName, new Promise(() => undefined));
|
||||||
|
|
||||||
|
const relay = service.relayLeadInboxMessages(teamName);
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
|
||||||
|
await expect(relay).resolves.toBe(0);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
leadInboxRelayInFlight: Map<string, Promise<number>>;
|
||||||
|
}
|
||||||
|
).leadInboxRelayInFlight.has(teamName)
|
||||||
|
).toBe(false);
|
||||||
|
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||||
|
'lead_inbox_relay_timed_out'
|
||||||
|
);
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('times out a hung existing member relay in-flight lock', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
const relayKey = `${teamName}:alice`;
|
||||||
|
try {
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
memberInboxRelayInFlight: Map<string, Promise<number>>;
|
||||||
|
}
|
||||||
|
).memberInboxRelayInFlight.set(relayKey, new Promise(() => undefined));
|
||||||
|
|
||||||
|
const relay = service.relayMemberInboxMessages(teamName, 'alice');
|
||||||
|
await vi.advanceTimersByTimeAsync(120_000);
|
||||||
|
|
||||||
|
await expect(relay).resolves.toBe(0);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
memberInboxRelayInFlight: Map<string, Promise<number>>;
|
||||||
|
}
|
||||||
|
).memberInboxRelayInFlight.has(relayKey)
|
||||||
|
).toBe(false);
|
||||||
|
expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain(
|
||||||
|
'member_inbox_relay_timed_out'
|
||||||
|
);
|
||||||
|
vi.mocked(console.warn).mockClear();
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not convert non-timeout member relay failures into timeout results', async () => {
|
||||||
|
const service = new TeamProvisioningService();
|
||||||
|
const teamName = 'my-team';
|
||||||
|
const relayKey = `${teamName}:alice`;
|
||||||
|
const rejected = Promise.reject(new Error('relay failed'));
|
||||||
|
rejected.catch(() => undefined);
|
||||||
|
(
|
||||||
|
service as unknown as {
|
||||||
|
memberInboxRelayInFlight: Map<string, Promise<number>>;
|
||||||
|
}
|
||||||
|
).memberInboxRelayInFlight.set(relayKey, rejected);
|
||||||
|
|
||||||
|
await expect(service.relayMemberInboxMessages(teamName, 'alice')).rejects.toThrow(
|
||||||
|
'relay failed'
|
||||||
|
);
|
||||||
|
expect(vi.mocked(console.warn)).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('treats an already-read specific OpenCode inbox row as delivered for UI-send relay', async () => {
|
it('treats an already-read specific OpenCode inbox row as delivered for UI-send relay', async () => {
|
||||||
const service = new TeamProvisioningService();
|
const service = new TeamProvisioningService();
|
||||||
const teamName = 'my-team';
|
const teamName = 'my-team';
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue