Autonomous message routing between Agent Teams via MCP with cascade protection. Inbox-first canonical delivery with cross-process file lock (O_CREAT|O_EXCL) and best-effort relay for online teams. - CascadeGuard: rate limit (10/min), chain depth (max 5), pair cooldown (3s) - FileLock: cross-process safe via kernel-level atomic lock files - CrossTeamService: validate → cascade → file-lock → inbox write → relay → outbox - Unified lead resolver with members.meta.json normalization (trim+dedup) - 3 MCP tools: cross_team_send, cross_team_list_targets, cross_team_get_outbox - Controller module with sync file lock, same protocol as main - IPC adapter with 3 Electron handlers - 64 new tests across 8 test files
59 lines
1.8 KiB
TypeScript
59 lines
1.8 KiB
TypeScript
const MAX_PER_MINUTE = 10;
|
|
const PAIR_COOLDOWN_MS = 3_000;
|
|
const MAX_CHAIN_DEPTH = 5;
|
|
const WINDOW_MS = 60_000;
|
|
|
|
export class CascadeGuard {
|
|
private teamCounters = new Map<string, number[]>();
|
|
private pairTimestamps = new Map<string, number>();
|
|
|
|
check(fromTeam: string, toTeam: string, chainDepth: number): void {
|
|
if (chainDepth >= MAX_CHAIN_DEPTH) {
|
|
throw new Error(`Cross-team chain depth limit exceeded (max ${MAX_CHAIN_DEPTH})`);
|
|
}
|
|
|
|
const now = Date.now();
|
|
this.cleanup(now);
|
|
|
|
const counts = this.teamCounters.get(fromTeam) ?? [];
|
|
const recentCount = counts.filter((ts) => ts > now - WINDOW_MS).length;
|
|
if (recentCount >= MAX_PER_MINUTE) {
|
|
throw new Error(`Cross-team rate limit exceeded for ${fromTeam} (max ${MAX_PER_MINUTE}/min)`);
|
|
}
|
|
|
|
const pairKey = `${fromTeam}→${toTeam}`;
|
|
const lastPairTs = this.pairTimestamps.get(pairKey);
|
|
if (lastPairTs !== undefined && now - lastPairTs < PAIR_COOLDOWN_MS) {
|
|
throw new Error(`Cross-team pair cooldown active: ${fromTeam} → ${toTeam}`);
|
|
}
|
|
}
|
|
|
|
record(fromTeam: string, toTeam: string): void {
|
|
const now = Date.now();
|
|
const counts = this.teamCounters.get(fromTeam) ?? [];
|
|
counts.push(now);
|
|
this.teamCounters.set(fromTeam, counts);
|
|
this.pairTimestamps.set(`${fromTeam}→${toTeam}`, now);
|
|
}
|
|
|
|
reset(): void {
|
|
this.teamCounters.clear();
|
|
this.pairTimestamps.clear();
|
|
}
|
|
|
|
private cleanup(now: number): void {
|
|
for (const [team, timestamps] of this.teamCounters) {
|
|
const fresh = timestamps.filter((ts) => ts > now - WINDOW_MS);
|
|
if (fresh.length === 0) {
|
|
this.teamCounters.delete(team);
|
|
} else {
|
|
this.teamCounters.set(team, fresh);
|
|
}
|
|
}
|
|
for (const [key, ts] of this.pairTimestamps) {
|
|
if (now - ts > WINDOW_MS) {
|
|
this.pairTimestamps.delete(key);
|
|
}
|
|
}
|
|
}
|
|
}
|