From 8b17baae96350044521c9f9a817672310cfb8d50 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Wed, 1 Jul 2026 01:52:46 +0200 Subject: [PATCH] =?UTF-8?q?fix(sse):=20/events=20keepalive=20heartbeat=20?= =?UTF-8?q?=E2=80=94=20keeps=20idle=20agenthub=5Fwork=20alive=20across=20s?= =?UTF-8?q?hort=20MCP=20client=20timeouts=20(TSK-0042)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/server/routes.ts | 12 ++++++++---- tests/sse.test.ts | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/server/routes.ts b/src/server/routes.ts index 09e5a6f..915f764 100644 --- a/src/server/routes.ts +++ b/src/server/routes.ts @@ -18,6 +18,8 @@ import { eventBus, emitChange } from './events.js'; import type { AgentHubEvent } from './events.js'; import type { Task, Handoff, Decision, Memory, Message } from '../core/schema.js'; +const SSE_KEEPALIVE_MS = 10_000; + function notFound(reply: FastifyReply, resource: string) { return reply.status(404).send({ error: `${resource} not found` }); } @@ -58,8 +60,9 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise // GET /events?role= // // Keeps the connection open and streams JSON-encoded AgentHubEvent objects - // as SSE data lines. Sends a keepalive comment (": \n\n") every 25 s so - // proxies and clients detect the connection is still alive. + // as SSE data lines. Sends an immediate keepalive comment and then another + // one every 10 s so proxies, fetch(), and MCP clients don't treat an idle + // work-loop request as a dead connection. // // Optional ?role= filter: tasks whose role doesn't match are dropped // server-side. All handoff / decision / memory events are always forwarded. @@ -81,6 +84,7 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise Connection: 'keep-alive', }); raw.flushHeaders(); + raw.write(': connected\n\n'); const listener = (event: AgentHubEvent) => { // Server-side role filter: skip tasks that belong to a different role. @@ -94,8 +98,8 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise eventBus.on('change', listener); const keepAliveTimer = setInterval(() => { - raw.write(':\n\n'); - }, 25_000); + raw.write(': keepalive\n\n'); + }, SSE_KEEPALIVE_MS); // Clean up when the client disconnects (or the server closes). request.raw.on('close', () => { diff --git a/tests/sse.test.ts b/tests/sse.test.ts index 35b2fd4..6c70b94 100644 --- a/tests/sse.test.ts +++ b/tests/sse.test.ts @@ -261,6 +261,24 @@ describe('SSE stream e2e', () => { rmSync(cwd, { recursive: true, force: true }); }); + it('sends an immediate keepalive comment when /events connects', async () => { + const controller = new AbortController(); + const res = await fetch(`${server.url}/events`, { signal: controller.signal }); + expect(res.status).toBe(200); + expect(res.headers.get('content-type')).toContain('text/event-stream'); + expect(res.body).toBeTruthy(); + + const reader = res.body!.getReader(); + const timeout = new Promise((_, reject) => { + setTimeout(() => reject(new Error('SSE: no keepalive frame within 1 s')), 1000); + }); + const chunk = await Promise.race([reader.read(), timeout]); + controller.abort(); + + expect(chunk.done).toBe(false); + expect(new TextDecoder().decode(chunk.value)).toContain(': connected\n\n'); + }, 3000); + it('delivers task/created to a connected SSE subscriber', async () => { const controller = new AbortController();