Skip to content

Commit 27f5411

Browse files
yarin-magclaude
andcommitted
fix(server): add Discord notification debouncing and rate limit handling
- Pre-filter notifiable statuses in EventService to avoid unnecessary getAgent DB lookups and notification calls for non-actionable statuses (working, starting, etc.) - Add 60s per-agent+status debounce in NotificationService to prevent spam when agents rapidly cycle through the same status - Respect Discord's Retry-After header on 429 responses instead of blind exponential backoff - Demote noisy logger.info calls to logger.debug for skipped/debounced events - Bump version to v1.3.9 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b956270 commit 27f5411

5 files changed

Lines changed: 28 additions & 9 deletions

File tree

apps/server/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "marionette-server",
3-
"version": "1.3.8",
3+
"version": "1.3.9",
44
"private": true,
55
"type": "module",
66
"bin": {

apps/server/src/services/event.service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ const VALID_AGENT_STATUSES = new Set<string>([
1111
'finished', 'crashed', 'disconnected', 'awaiting_input', 'delegating'
1212
]);
1313

14+
const NOTIFIABLE_STATUSES = new Set<string>([
15+
'idle', 'finished', 'crashed', 'error', 'awaiting_input',
16+
]);
17+
1418
export interface BatchResult {
1519
processed: MarionetteEvent[];
1620
failed: Array<{ event: MarionetteEvent; error: string }>;
@@ -73,7 +77,7 @@ export class EventService {
7377
await this.repository.insert({ ...effectiveEvent, ts });
7478

7579
// Fire Discord notification for terminal statuses (fire-and-forget).
76-
if (effectiveEvent.agent_id && effectiveEvent.status) {
80+
if (effectiveEvent.agent_id && effectiveEvent.status && NOTIFIABLE_STATUSES.has(effectiveEvent.status)) {
7781
this.agentService.getAgent(effectiveEvent.agent_id).then((agent) => {
7882
if (agent && !agent.is_subagent && !agent.parent_agent_id) {
7983
this.notificationService.notifyAgentStatus(agent, effectiveEvent);

apps/server/src/services/notification.service.ts

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,23 +17,31 @@ function formatDuration(ms: number): string {
1717
return `${s}s`;
1818
}
1919

20+
// How long to suppress repeat notifications for the same agent+status (ms).
21+
const DEBOUNCE_MS = 60_000;
22+
2023
export class NotificationService {
2124
private prefsRepo = new PreferencesRepository();
25+
// key: `${agentId}:${status}` → timestamp of last successful send
26+
private readonly sentAt = new Map<string, number>();
2227

2328
async notifyAgentStatus(agent: AgentSnapshot, event: MarionetteEvent): Promise<void> {
2429
const status = event.status;
25-
logger.info(`[discord] called — status=${status} agent=${agent.agent_id.slice(0, 12)}`);
2630

2731
const isDone = status === "idle" || status === "finished";
2832
const isError = status === "crashed" || status === "error";
2933
const isAwaitingInput = status === "awaiting_input";
30-
if (!isDone && !isError && !isAwaitingInput) {
31-
logger.info(`[discord] skipping — status not actionable`);
34+
if (!isDone && !isError && !isAwaitingInput) return;
35+
36+
// Debounce: skip if the same agent+status was sent recently.
37+
const debounceKey = `${agent.agent_id}:${status}`;
38+
const lastSent = this.sentAt.get(debounceKey) ?? 0;
39+
if (Date.now() - lastSent < DEBOUNCE_MS) {
40+
logger.debug(`[discord] debounced — status=${status} agent=${agent.agent_id.slice(0, 12)}`);
3241
return;
3342
}
3443

3544
const webhookUrl = await this.getWebhookUrl();
36-
logger.info(`[discord] webhookUrl=${webhookUrl ? "SET" : "NOT SET"}`);
3745
if (!webhookUrl) return;
3846

3947
const embed = isDone
@@ -42,7 +50,8 @@ export class NotificationService {
4250
? this.buildAwaitingInputEmbed(agent)
4351
: this.buildErrorEmbed(agent, event);
4452

45-
logger.info(`[discord] firing POST`);
53+
this.sentAt.set(debounceKey, Date.now());
54+
logger.info(`[discord] firing POST — status=${status} agent=${agent.agent_id.slice(0, 12)}`);
4655
withRetry(() => this.post(webhookUrl, embed), `discord-webhook agent=${agent.agent_id.slice(0, 12)}`);
4756
}
4857

@@ -115,6 +124,12 @@ export class NotificationService {
115124
body: JSON.stringify({ embeds: [embed] }),
116125
});
117126
if (!res.ok) {
127+
if (res.status === 429) {
128+
const retryAfter = res.headers.get("retry-after");
129+
const waitMs = retryAfter ? parseFloat(retryAfter) * 1000 : 5_000;
130+
logger.warn(`[discord] rate limited, waiting ${waitMs}ms before retry`);
131+
await new Promise<void>((resolve) => setTimeout(resolve, waitMs));
132+
}
118133
throw new Error(`Discord webhook responded with ${res.status}`);
119134
}
120135
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "marionette",
33
"private": true,
4-
"version": "1.3.8",
4+
"version": "1.3.9",
55
"packageManager": "pnpm@9.0.0",
66
"workspaces": [
77
"apps/*",

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@marionette-app/cli",
3-
"version": "1.3.8",
3+
"version": "1.3.9",
44
"description": "Real-time monitoring for Claude Code sessions",
55
"bin": {
66
"marionette": "./bin/marionette.js"

0 commit comments

Comments
 (0)