From dc2de804418281a5214b1c5d10265079f082a7b9 Mon Sep 17 00:00:00 2001 From: AutoDev Bot Date: Fri, 4 Sep 2026 18:38:47 +0800 Subject: [PATCH 1/2] fix(gateway): health endpoint returns 503 until services ready MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway health server was bound before the channel manager, heartbeat, cron, and agent loop finished starting, and its handler always returned 200. When any downstream service failed to come up (or was still initializing), the process reported "started" while /health reported the gateway as healthy — producing the "start succeeds but /health returns 503" symptom inverted: 200 was returned even when the gateway was actually unavailable. Track a `gatewayReady` flag inside `gateway()`, thread it into `startGatewayHealthServer` as a readiness probe, and only flip it to true after cron.start, manager.startAll, transcriptMonitor.start, heartbeat.start, and loop.run() have all been invoked. The health handler now returns 503 {"status":"starting"} while readiness is false and 200 {"status":"ok"} once every dependency has started. Shutdown clears the flag first so /health reports 503 during teardown. Also add error listener handling to `listen()` so port-in-use errors reject the promise instead of hanging. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/entrypoints/cli/commands.ts | 34 ++++++++++++++--- .../tests/entrypoints/cli/commands.test.ts | 38 +++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/App/memmy-agent/src/entrypoints/cli/commands.ts b/App/memmy-agent/src/entrypoints/cli/commands.ts index 9b6fe77e7..bdb552975 100644 --- a/App/memmy-agent/src/entrypoints/cli/commands.ts +++ b/App/memmy-agent/src/entrypoints/cli/commands.ts @@ -969,11 +969,16 @@ export async function serve({ return server; } -export async function startGatewayHealthServer(host: string, port: number): Promise { +export async function startGatewayHealthServer( + host: string, + port: number, + isReady: () => boolean = () => true, +): Promise { const server = http.createServer((req, res) => { if (req.method === "GET" && req.url?.split("?", 1)[0] === "/health") { - const body = JSON.stringify({ status: "ok" }); - res.writeHead(200, { + const ready = Boolean(isReady()); + const body = JSON.stringify(ready ? { status: "ok" } : { status: "starting" }); + res.writeHead(ready ? 200 : 503, { "content-type": "application/json", "content-length": Buffer.byteLength(body), }); @@ -984,7 +989,18 @@ export async function startGatewayHealthServer(host: string, port: number): Prom res.writeHead(404, { "content-type": "text/plain", "content-length": Buffer.byteLength(body) }); res.end(body); }); - await new Promise((resolve) => server.listen(port, host, resolve)); + await new Promise((resolve, reject) => { + const onError = (error: Error): void => { + server.off("listening", onListening); + reject(error); + }; + const onListening = (): void => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.listen(port, host, onListening); + }); return server; } @@ -1236,7 +1252,10 @@ export async function gateway({ } const bindHost = host ?? loaded.gateway.host; const bindPort = port == null ? loaded.gateway.port : Number(port); - const healthServer = await startGatewayHealthServer(bindHost, bindPort); + // Track gateway readiness so the /health endpoint can accurately signal 503 during startup + // (or if startup stalls) and 200 only after every dependent service has started. + let gatewayReady = false; + const healthServer = await startGatewayHealthServer(bindHost, bindPort, () => gatewayReady); const deliverToChannelNow = async ( msg: OutboundMessage, @@ -1497,6 +1516,9 @@ export async function gateway({ transcriptMonitor?.start(); await heartbeat.start(); const inboundTask = loop.run(); + // All dependent services have completed startup; flip readiness so the /health endpoint + // reports 200. Until this point the endpoint responds with 503 to reflect actual state. + gatewayReady = true; console.log( `memmy gateway started (${manager.enabledChannels.join(", ") || "no channels enabled"})`, ); @@ -1522,6 +1544,8 @@ export async function gateway({ cron, healthServer, stop: async () => { + // Shutdown began; /health should immediately stop advertising readiness. + gatewayReady = false; modelCatalogWatcher.close(); heartbeat.stop(); cron.stop(); diff --git a/App/memmy-agent/tests/entrypoints/cli/commands.test.ts b/App/memmy-agent/tests/entrypoints/cli/commands.test.ts index 7be3b5920..026ae005b 100644 --- a/App/memmy-agent/tests/entrypoints/cli/commands.test.ts +++ b/App/memmy-agent/tests/entrypoints/cli/commands.test.ts @@ -860,6 +860,44 @@ describe("CLI command helpers", () => { } }); + it("gateway health helper reports 503 until readiness is signaled", async () => { + let ready = false; + const server = await startGatewayHealthServer("127.0.0.1", 0, () => ready); + try { + const address = server.address(); + const port = typeof address === "object" && address ? address.port : 0; + + const notReady = await fetch(`http://127.0.0.1:${port}/health`); + expect(notReady.status).toBe(503); + expect(await notReady.json()).toEqual({ status: "starting" }); + + ready = true; + const nowReady = await fetch(`http://127.0.0.1:${port}/health`); + expect(nowReady.status).toBe(200); + expect(await nowReady.json()).toEqual({ status: "ok" }); + + ready = false; + const shuttingDown = await fetch(`http://127.0.0.1:${port}/health`); + expect(shuttingDown.status).toBe(503); + expect(await shuttingDown.json()).toEqual({ status: "starting" }); + } finally { + await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))); + } + }); + + it("gateway health helper rejects when the port is already bound", async () => { + const first = await startGatewayHealthServer("127.0.0.1", 0); + try { + const address = first.address(); + const port = typeof address === "object" && address ? address.port : 0; + await expect(startGatewayHealthServer("127.0.0.1", port)).rejects.toMatchObject({ + code: "EADDRINUSE", + }); + } finally { + await new Promise((resolve, reject) => first.close((error) => (error ? reject(error) : resolve()))); + } + }); + async function startCronGatewayWithFakeLoop({ processDirect, isSessionBusy = vi.fn(() => false), From 796350afe9c128e5f125ec7e5a72b9e0fee6a2f1 Mon Sep 17 00:00:00 2001 From: AutoDev Bot Date: Fri, 4 Sep 2026 18:54:22 +0800 Subject: [PATCH 2/2] docs(gateway): explain why manager.startAll() is fire-and-forget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address OCR review feedback on PR #358: the `void manager.startAll()` call inside gateway() is deliberately not awaited because Telegram (grammy polling), Discord (gateway WebSocket lifetime), and Slack (Socket Mode) adapters return promises from start() that only settle on shutdown. Awaiting would deadlock startup. The reviewer's suggested `await manager.startAll()` fix would break gateway boot. Instead, take the reviewer's alternate suggestion: add a comment documenting the intentional fire-and-forget so future maintainers understand readiness semantics — gatewayReady reflects "channel init dispatched", not "every channel fully connected". Co-Authored-By: Claude Opus 4.7 (1M context) --- App/memmy-agent/src/entrypoints/cli/commands.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/App/memmy-agent/src/entrypoints/cli/commands.ts b/App/memmy-agent/src/entrypoints/cli/commands.ts index bdb552975..0864c38f0 100644 --- a/App/memmy-agent/src/entrypoints/cli/commands.ts +++ b/App/memmy-agent/src/entrypoints/cli/commands.ts @@ -1512,6 +1512,12 @@ export async function gateway({ }); await cron.start(); + // Fire-and-forget: several channel adapters (Telegram/Discord/Slack) return a promise + // from start() that only settles when the channel shuts down (grammy polling loop, + // Discord gateway lifetime, Slack Socket Mode connection). Awaiting startAll() here + // would deadlock gateway startup. Readiness therefore reflects "channel init dispatched", + // not "every channel has fully connected"; per-channel connection health is surfaced + // separately by the manager. void manager.startAll(); transcriptMonitor?.start(); await heartbeat.start();