feat: add same-team native delivery dedup for lead inbox relay
3-layer dedup to prevent duplicate message delivery when CLI delivers teammate messages both natively (<teammate-message>) and via inbox file: Layer 1: Grace window — defer recent source-less messages (15s) Layer 2: Fingerprint cache + one-to-one pairing — marks specific messageIds as read Layer 3: Deduped retry timers — ensures delivery if native path fails
This commit is contained in:
parent
5686d60d99
commit
7c6837eacd
1 changed files with 363 additions and 41 deletions
|
|
@ -34,7 +34,10 @@ import { isInboxNoiseMessage } from '@shared/utils/inboxNoise';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
import {
|
||||||
|
parseAllTeammateMessages,
|
||||||
|
type ParsedTeammateContent,
|
||||||
|
} from '@shared/utils/teammateMessageParser';
|
||||||
import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
import { createCliAutoSuffixNameGuard, parseNumericSuffixName } from '@shared/utils/teamMemberName';
|
||||||
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
import { extractToolPreview, formatToolSummaryFromCalls } from '@shared/utils/toolSummary';
|
||||||
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
import * as agentTeamsControllerModule from 'agent-teams-controller';
|
||||||
|
|
@ -1154,10 +1157,27 @@ interface PendingInboxRelayCandidate {
|
||||||
queuedAtMs: number;
|
queuedAtMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface NativeSameTeamFingerprint {
|
||||||
|
id: string;
|
||||||
|
from: string;
|
||||||
|
text: string;
|
||||||
|
summary: string;
|
||||||
|
seenAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSameTeamText(text: string): string {
|
||||||
|
return text.trim().replace(/\r\n/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamProvisioningService {
|
export class TeamProvisioningService {
|
||||||
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
private static readonly CLAUDE_LOG_LINES_LIMIT = 50_000;
|
||||||
private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000;
|
private static readonly RECENT_CROSS_TEAM_DELIVERY_TTL_MS = 10 * 60 * 1000;
|
||||||
private static readonly PENDING_INBOX_RELAY_TTL_MS = 2 * 60 * 1000;
|
private static readonly PENDING_INBOX_RELAY_TTL_MS = 2 * 60 * 1000;
|
||||||
|
private static readonly SAME_TEAM_NATIVE_DELIVERY_GRACE_MS = 15_000;
|
||||||
|
private static readonly SAME_TEAM_NATIVE_FINGERPRINT_TTL_MS = 60_000;
|
||||||
|
private static readonly SAME_TEAM_MATCH_WINDOW_MS = 30_000;
|
||||||
|
private static readonly SAME_TEAM_RUN_START_SKEW_MS = 1_000;
|
||||||
|
private static readonly SAME_TEAM_PERSIST_RETRY_MS = 2_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>();
|
||||||
|
|
@ -1170,6 +1190,10 @@ export class TeamProvisioningService {
|
||||||
private readonly pendingCrossTeamFirstReplies = new Map<string, Map<string, number>>();
|
private readonly pendingCrossTeamFirstReplies = new Map<string, Map<string, number>>();
|
||||||
private readonly recentCrossTeamLeadDeliveryMessageIds = new Map<string, Map<string, number>>();
|
private readonly recentCrossTeamLeadDeliveryMessageIds = new Map<string, Map<string, number>>();
|
||||||
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
|
private readonly liveLeadProcessMessages = new Map<string, InboxMessage[]>();
|
||||||
|
private readonly recentSameTeamNativeFingerprints = new Map<
|
||||||
|
string,
|
||||||
|
NativeSameTeamFingerprint[]
|
||||||
|
>();
|
||||||
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||||
private helpOutputCache: string | null = null;
|
private helpOutputCache: string | null = null;
|
||||||
private helpOutputCacheTime = 0;
|
private helpOutputCacheTime = 0;
|
||||||
|
|
@ -1673,35 +1697,44 @@ export class TeamProvisioningService {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
if (crossTeamBlocks.length === 0) return;
|
// Cross-team reconciliation (existing logic)
|
||||||
|
if (crossTeamBlocks.length > 0) {
|
||||||
const leadName = this.getRunLeadName(run);
|
const leadName = this.getRunLeadName(run);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const matches = await this.matchCrossTeamLeadInboxMessages(
|
const matches = await this.matchCrossTeamLeadInboxMessages(
|
||||||
run.teamName,
|
run.teamName,
|
||||||
leadName,
|
leadName,
|
||||||
crossTeamBlocks
|
crossTeamBlocks
|
||||||
);
|
);
|
||||||
const unreadMatches = matches.filter((match) => !match.wasRead);
|
const unreadMatches = matches.filter((match) => !match.wasRead);
|
||||||
if (unreadMatches.length > 0) {
|
if (unreadMatches.length > 0) {
|
||||||
try {
|
try {
|
||||||
await this.markInboxMessagesRead(run.teamName, leadName, unreadMatches);
|
await this.markInboxMessagesRead(run.teamName, leadName, unreadMatches);
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort
|
// best-effort
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
const freshMatches = matches.filter(
|
||||||
const freshMatches = matches.filter(
|
(match) => !this.wasRecentlyDeliveredToLead(run.teamName, match.messageId)
|
||||||
(match) => !this.wasRecentlyDeliveredToLead(run.teamName, match.messageId)
|
);
|
||||||
);
|
this.rememberRecentCrossTeamLeadDeliveryMessageIds(
|
||||||
this.rememberRecentCrossTeamLeadDeliveryMessageIds(
|
run.teamName,
|
||||||
run.teamName,
|
freshMatches.map((match) => match.messageId)
|
||||||
freshMatches.map((match) => match.messageId)
|
);
|
||||||
);
|
run.activeCrossTeamReplyHints = freshMatches.map((match) => ({
|
||||||
run.activeCrossTeamReplyHints = freshMatches.map((match) => ({
|
toTeam: match.toTeam,
|
||||||
toTeam: match.toTeam,
|
conversationId: match.conversationId,
|
||||||
conversationId: match.conversationId,
|
}));
|
||||||
}));
|
})();
|
||||||
})();
|
}
|
||||||
|
|
||||||
|
// Same-team reconciliation: record fingerprints for native delivery dedup
|
||||||
|
const sameTeamBlocks = blocks.filter((block) => !parseCrossTeamPrefix(block.content));
|
||||||
|
if (sameTeamBlocks.length > 0) {
|
||||||
|
this.rememberSameTeamNativeFingerprints(run.teamName, sameTeamBlocks);
|
||||||
|
const leadName = this.getRunLeadName(run);
|
||||||
|
void this.reconcileSameTeamNativeDeliveries(run.teamName, leadName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private persistSentMessage(teamName: string, message: InboxMessage): void {
|
private persistSentMessage(teamName: string, message: InboxMessage): void {
|
||||||
|
|
@ -3577,21 +3610,18 @@ export class TeamProvisioningService {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Ignore (and auto-mark read) internal coordination noise like idle/shutdown messages.
|
// Category 1: permanently ignored → mark as read.
|
||||||
// Also ignore local sender-copy rows for cross-team traffic: those exist only so the UI
|
// Includes noise (idle/shutdown), cross-team sender copies, cross-team reply dedup.
|
||||||
// can show outbound activity and must not be re-injected into the live lead as new work.
|
const permanentlyIgnored = unread.filter(
|
||||||
// If the same cross-team delivery already arrived via a raw <teammate-message> turn,
|
|
||||||
// suppress the duplicate relay here and simply mark the inbox row as read.
|
|
||||||
const ignoredUnread = unread.filter(
|
|
||||||
(m) =>
|
(m) =>
|
||||||
isInboxNoiseMessage(m.text) ||
|
isInboxNoiseMessage(m.text) ||
|
||||||
m.source === CROSS_TEAM_SENT_SOURCE ||
|
m.source === CROSS_TEAM_SENT_SOURCE ||
|
||||||
isCrossTeamReplyToOwnOutbound(m) ||
|
isCrossTeamReplyToOwnOutbound(m) ||
|
||||||
wasRecentlyDeliveredCrossTeam(m)
|
wasRecentlyDeliveredCrossTeam(m)
|
||||||
);
|
);
|
||||||
if (ignoredUnread.length > 0) {
|
if (permanentlyIgnored.length > 0) {
|
||||||
try {
|
try {
|
||||||
await this.markInboxMessagesRead(teamName, leadName, ignoredUnread);
|
await this.markInboxMessagesRead(teamName, leadName, permanentlyIgnored);
|
||||||
} catch {
|
} catch {
|
||||||
// best-effort
|
// best-effort
|
||||||
}
|
}
|
||||||
|
|
@ -3603,13 +3633,38 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Category 2: same-team native delivery confirmation (one-to-one pairing).
|
||||||
|
const { nativeMatchedMessageIds, persisted: sameTeamPersisted } =
|
||||||
|
await this.confirmSameTeamNativeMatches(teamName, leadName, unread);
|
||||||
|
|
||||||
|
// Category 3: deferred by age — source-less messages within grace window of CURRENT run.
|
||||||
|
// NOT marked read (crash safety: if native delivery fails, retry will relay).
|
||||||
|
const runStartedAtMs = Date.parse(run.startedAt);
|
||||||
|
const permanentlyIgnoredIds = new Set(permanentlyIgnored.map((m) => m.messageId));
|
||||||
|
const deferredByAge = unread.filter(
|
||||||
|
(m) =>
|
||||||
|
!permanentlyIgnoredIds.has(m.messageId) &&
|
||||||
|
!nativeMatchedMessageIds.has(m.messageId) &&
|
||||||
|
this.shouldDeferSameTeamMessage(m, leadName, runStartedAtMs)
|
||||||
|
);
|
||||||
|
const deferredIds = new Set(deferredByAge.map((m) => m.messageId));
|
||||||
|
|
||||||
|
// Actionable: everything not in any category.
|
||||||
const actionableUnread = unread.filter(
|
const actionableUnread = unread.filter(
|
||||||
(m) =>
|
(m) =>
|
||||||
!isInboxNoiseMessage(m.text) &&
|
!permanentlyIgnoredIds.has(m.messageId) &&
|
||||||
m.source !== CROSS_TEAM_SENT_SOURCE &&
|
!nativeMatchedMessageIds.has(m.messageId) &&
|
||||||
!isCrossTeamReplyToOwnOutbound(m) &&
|
!deferredIds.has(m.messageId)
|
||||||
!wasRecentlyDeliveredCrossTeam(m)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Layer 3: schedule retry timers.
|
||||||
|
if (nativeMatchedMessageIds.size > 0 && !sameTeamPersisted) {
|
||||||
|
this.scheduleSameTeamPersistRetry(teamName);
|
||||||
|
}
|
||||||
|
if (deferredByAge.length > 0) {
|
||||||
|
this.scheduleSameTeamDeferredRetry(teamName);
|
||||||
|
}
|
||||||
|
|
||||||
if (actionableUnread.length === 0) return 0;
|
if (actionableUnread.length === 0) return 0;
|
||||||
|
|
||||||
const MAX_RELAY = 10;
|
const MAX_RELAY = 10;
|
||||||
|
|
@ -5426,6 +5481,263 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Same-team native delivery dedup (Layer 2)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private collectConfirmedSameTeamPairs(
|
||||||
|
messages: InboxMessage[],
|
||||||
|
fingerprints: NativeSameTeamFingerprint[],
|
||||||
|
leadName: string
|
||||||
|
): { confirmedMessageIds: Set<string>; matchedFingerprintIds: Set<string> } {
|
||||||
|
const confirmedMessageIds = new Set<string>();
|
||||||
|
const matchedFingerprintIds = new Set<string>();
|
||||||
|
|
||||||
|
if (fingerprints.length === 0) {
|
||||||
|
return { confirmedMessageIds, matchedFingerprintIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build group key: from + normalizedText (summary checked during pairing, not grouping)
|
||||||
|
const groupKey = (from: string, text: string) => `${from}\0${text}`;
|
||||||
|
|
||||||
|
// Group fingerprints by (from, text), sorted FIFO by seenAt within each group
|
||||||
|
const fpByGroup = new Map<string, NativeSameTeamFingerprint[]>();
|
||||||
|
for (const fp of fingerprints) {
|
||||||
|
const key = groupKey(fp.from, fp.text);
|
||||||
|
let group = fpByGroup.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = [];
|
||||||
|
fpByGroup.set(key, group);
|
||||||
|
}
|
||||||
|
group.push(fp);
|
||||||
|
}
|
||||||
|
for (const group of fpByGroup.values()) {
|
||||||
|
group.sort((a, b) => a.seenAt - b.seenAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect eligible inbox messages, grouped by (from, text), sorted FIFO by timestamp
|
||||||
|
type EligibleMsg = InboxMessage & { messageId: string; parsedTs: number };
|
||||||
|
const msgByGroup = new Map<string, EligibleMsg[]>();
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.read) continue;
|
||||||
|
if (m.source) continue;
|
||||||
|
if (!this.hasStableMessageId(m)) continue;
|
||||||
|
const fromName = m.from?.trim() ?? '';
|
||||||
|
if (!fromName || fromName === leadName || fromName === 'user') continue;
|
||||||
|
const parsedTs = Date.parse(m.timestamp);
|
||||||
|
if (!Number.isFinite(parsedTs)) continue;
|
||||||
|
|
||||||
|
const key = groupKey(fromName, normalizeSameTeamText(m.text));
|
||||||
|
let group = msgByGroup.get(key);
|
||||||
|
if (!group) {
|
||||||
|
group = [];
|
||||||
|
msgByGroup.set(key, group);
|
||||||
|
}
|
||||||
|
group.push({ ...m, parsedTs } as EligibleMsg);
|
||||||
|
}
|
||||||
|
for (const group of msgByGroup.values()) {
|
||||||
|
group.sort((a, b) => a.parsedTs - b.parsedTs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIFO pair within each group: first fingerprint → first message, second → second, etc.
|
||||||
|
// This prevents delayed native delivery from pairing with the wrong inbox row
|
||||||
|
// when identical messages (e.g. "Done") are sent close together.
|
||||||
|
for (const [key, fps] of fpByGroup) {
|
||||||
|
const msgs = msgByGroup.get(key);
|
||||||
|
if (!msgs || msgs.length === 0) continue;
|
||||||
|
|
||||||
|
const limit = Math.min(fps.length, msgs.length);
|
||||||
|
for (let i = 0; i < limit; i++) {
|
||||||
|
const fp = fps[i];
|
||||||
|
const m = msgs[i];
|
||||||
|
// Summary validation: if both sides have summary, they must match
|
||||||
|
if (fp.summary && m.summary?.trim() && fp.summary !== m.summary.trim()) continue;
|
||||||
|
// Time window validation
|
||||||
|
if (Math.abs(m.parsedTs - fp.seenAt) > TeamProvisioningService.SAME_TEAM_MATCH_WINDOW_MS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
confirmedMessageIds.add(m.messageId);
|
||||||
|
matchedFingerprintIds.add(fp.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { confirmedMessageIds, matchedFingerprintIds };
|
||||||
|
}
|
||||||
|
|
||||||
|
private rememberSameTeamNativeFingerprints(
|
||||||
|
teamName: string,
|
||||||
|
blocks: ParsedTeammateContent[]
|
||||||
|
): void {
|
||||||
|
const teamKey = teamName.trim();
|
||||||
|
const existing = this.recentSameTeamNativeFingerprints.get(teamKey) ?? [];
|
||||||
|
const now = Date.now();
|
||||||
|
const cutoff = now - TeamProvisioningService.SAME_TEAM_NATIVE_FINGERPRINT_TTL_MS;
|
||||||
|
const fresh = existing.filter((fp) => fp.seenAt > cutoff);
|
||||||
|
|
||||||
|
for (const block of blocks) {
|
||||||
|
fresh.push({
|
||||||
|
id: randomUUID(),
|
||||||
|
from: block.teammateId.trim(),
|
||||||
|
text: normalizeSameTeamText(block.content),
|
||||||
|
summary: (block.summary ?? '').trim(),
|
||||||
|
seenAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recentSameTeamNativeFingerprints.set(teamKey, fresh);
|
||||||
|
}
|
||||||
|
|
||||||
|
private consumeMatchedSameTeamFingerprints(teamName: string, matchedIds: Set<string>): void {
|
||||||
|
if (matchedIds.size === 0) return;
|
||||||
|
const current = this.recentSameTeamNativeFingerprints.get(teamName.trim()) ?? [];
|
||||||
|
if (current.length === 0) return;
|
||||||
|
const remaining = current.filter((fp) => !matchedIds.has(fp.id));
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
this.recentSameTeamNativeFingerprints.set(teamName.trim(), remaining);
|
||||||
|
} else {
|
||||||
|
this.recentSameTeamNativeFingerprints.delete(teamName.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getFreshSameTeamNativeFingerprints(teamName: string): NativeSameTeamFingerprint[] {
|
||||||
|
const all = this.recentSameTeamNativeFingerprints.get(teamName) ?? [];
|
||||||
|
if (all.length === 0) return [];
|
||||||
|
const cutoff = Date.now() - TeamProvisioningService.SAME_TEAM_NATIVE_FINGERPRINT_TTL_MS;
|
||||||
|
const fresh = all.filter((fp) => fp.seenAt > cutoff);
|
||||||
|
if (fresh.length !== all.length) {
|
||||||
|
if (fresh.length > 0) {
|
||||||
|
this.recentSameTeamNativeFingerprints.set(teamName, fresh);
|
||||||
|
} else {
|
||||||
|
this.recentSameTeamNativeFingerprints.delete(teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPotentialSameTeamCliMessage(m: InboxMessage, leadName: string): boolean {
|
||||||
|
if (m.source) return false;
|
||||||
|
const fromName = m.from?.trim() ?? '';
|
||||||
|
if (!fromName || fromName === leadName || fromName === 'user') return false;
|
||||||
|
const toName = m.to?.trim();
|
||||||
|
if (toName && toName !== leadName) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private shouldDeferSameTeamMessage(
|
||||||
|
m: InboxMessage,
|
||||||
|
leadName: string,
|
||||||
|
runStartedAtMs: number
|
||||||
|
): boolean {
|
||||||
|
if (!this.isPotentialSameTeamCliMessage(m, leadName)) return false;
|
||||||
|
const messageTs = Date.parse(m.timestamp);
|
||||||
|
if (!Number.isFinite(messageTs) || messageTs < 0) return false;
|
||||||
|
if (
|
||||||
|
Number.isFinite(runStartedAtMs) &&
|
||||||
|
messageTs < runStartedAtMs - TeamProvisioningService.SAME_TEAM_RUN_START_SKEW_MS
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const ageMs = Date.now() - messageTs;
|
||||||
|
if (ageMs < 0) return false;
|
||||||
|
return ageMs < TeamProvisioningService.SAME_TEAM_NATIVE_DELIVERY_GRACE_MS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async confirmSameTeamNativeMatches(
|
||||||
|
teamName: string,
|
||||||
|
leadName: string,
|
||||||
|
messages: InboxMessage[]
|
||||||
|
): Promise<{ nativeMatchedMessageIds: Set<string>; persisted: boolean }> {
|
||||||
|
const fingerprints = this.getFreshSameTeamNativeFingerprints(teamName);
|
||||||
|
const { confirmedMessageIds, matchedFingerprintIds } = this.collectConfirmedSameTeamPairs(
|
||||||
|
messages,
|
||||||
|
fingerprints,
|
||||||
|
leadName
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmedMessageIds.size === 0) {
|
||||||
|
return { nativeMatchedMessageIds: confirmedMessageIds, persisted: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMarkRead = Array.from(confirmedMessageIds, (messageId) => ({ messageId }));
|
||||||
|
let persisted = false;
|
||||||
|
try {
|
||||||
|
await this.markInboxMessagesRead(teamName, leadName, toMarkRead);
|
||||||
|
persisted = true;
|
||||||
|
} catch {
|
||||||
|
// keep fingerprints alive for next attempt
|
||||||
|
}
|
||||||
|
|
||||||
|
if (persisted) {
|
||||||
|
// Durable: inbox says read=true. Safe to add in-memory dedup and consume fingerprints.
|
||||||
|
const relayedIds = this.relayedLeadInboxMessageIds.get(teamName) ?? new Set<string>();
|
||||||
|
for (const messageId of confirmedMessageIds) {
|
||||||
|
relayedIds.add(messageId);
|
||||||
|
}
|
||||||
|
this.relayedLeadInboxMessageIds.set(teamName, this.trimRelayedSet(relayedIds));
|
||||||
|
this.consumeMatchedSameTeamFingerprints(teamName, matchedFingerprintIds);
|
||||||
|
}
|
||||||
|
// If NOT persisted: don't add to relayedIds, don't consume fingerprints.
|
||||||
|
// Next relay cycle will see the message in unread, re-match, and retry persist.
|
||||||
|
|
||||||
|
return { nativeMatchedMessageIds: confirmedMessageIds, persisted };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reconcileSameTeamNativeDeliveries(
|
||||||
|
teamName: string,
|
||||||
|
leadName: string
|
||||||
|
): Promise<void> {
|
||||||
|
let leadInboxMessages: Awaited<ReturnType<TeamInboxReader['getMessagesFor']>> = [];
|
||||||
|
try {
|
||||||
|
leadInboxMessages = await this.inboxReader.getMessagesFor(teamName, leadName);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nativeMatchedMessageIds, persisted } = await this.confirmSameTeamNativeMatches(
|
||||||
|
teamName,
|
||||||
|
leadName,
|
||||||
|
leadInboxMessages
|
||||||
|
);
|
||||||
|
// If native was matched but persist failed, schedule a quick retry
|
||||||
|
// so we don't wait for the 16s deferred timer to retry the disk write.
|
||||||
|
if (nativeMatchedMessageIds.size > 0 && !persisted) {
|
||||||
|
this.scheduleSameTeamPersistRetry(teamName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleSameTeamDeferredRetry(teamName: string): void {
|
||||||
|
const key = `same-team-deferred:${teamName}`;
|
||||||
|
if (this.pendingTimeouts.has(key)) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingTimeouts.delete(key);
|
||||||
|
void this.relayLeadInboxMessages(teamName).catch((e: unknown) =>
|
||||||
|
logger.warn(`[${teamName}] same-team deferred retry failed: ${String(e)}`)
|
||||||
|
);
|
||||||
|
}, TeamProvisioningService.SAME_TEAM_NATIVE_DELIVERY_GRACE_MS + 1_000);
|
||||||
|
|
||||||
|
this.pendingTimeouts.set(key, timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort durable follow-up after native delivery was matched but inbox read-state
|
||||||
|
* could not be persisted. If the run dies before this retry succeeds, a later reconnect
|
||||||
|
* may still relay the row once because in-memory dedupe is not durable.
|
||||||
|
*/
|
||||||
|
private scheduleSameTeamPersistRetry(teamName: string): void {
|
||||||
|
const key = `same-team-persist:${teamName}`;
|
||||||
|
if (this.pendingTimeouts.has(key)) return;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
this.pendingTimeouts.delete(key);
|
||||||
|
void this.relayLeadInboxMessages(teamName).catch((e: unknown) =>
|
||||||
|
logger.warn(`[${teamName}] same-team persist retry failed: ${String(e)}`)
|
||||||
|
);
|
||||||
|
}, TeamProvisioningService.SAME_TEAM_PERSIST_RETRY_MS);
|
||||||
|
|
||||||
|
this.pendingTimeouts.set(key, timer);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a run from tracking maps.
|
* Remove a run from tracking maps.
|
||||||
*/
|
*/
|
||||||
|
|
@ -5457,6 +5769,16 @@ export class TeamProvisioningService {
|
||||||
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
this.relayedLeadInboxMessageIds.delete(run.teamName);
|
||||||
this.pendingCrossTeamFirstReplies.delete(run.teamName);
|
this.pendingCrossTeamFirstReplies.delete(run.teamName);
|
||||||
this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName);
|
this.recentCrossTeamLeadDeliveryMessageIds.delete(run.teamName);
|
||||||
|
this.recentSameTeamNativeFingerprints.delete(run.teamName);
|
||||||
|
// Clear same-team retry timers
|
||||||
|
for (const suffix of ['deferred', 'persist']) {
|
||||||
|
const key = `same-team-${suffix}:${run.teamName}`;
|
||||||
|
const timer = this.pendingTimeouts.get(key);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.pendingTimeouts.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
run.activeCrossTeamReplyHints = [];
|
run.activeCrossTeamReplyHints = [];
|
||||||
run.pendingInboxRelayCandidates = [];
|
run.pendingInboxRelayCandidates = [];
|
||||||
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
for (const key of Array.from(this.memberInboxRelayInFlight.keys())) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue