Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 35 additions & 5 deletions App/memmy-agent/src/entrypoints/cli/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -969,11 +969,16 @@ export async function serve({
return server;
}

export async function startGatewayHealthServer(host: string, port: number): Promise<http.Server> {
export async function startGatewayHealthServer(
host: string,
port: number,
isReady: () => boolean = () => true,
): Promise<http.Server> {
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),
});
Expand All @@ -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<void>((resolve) => server.listen(port, host, resolve));
await new Promise<void>((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;
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1493,10 +1512,19 @@ 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();
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"})`,
);
Expand All @@ -1522,6 +1550,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();
Expand Down
38 changes: 38 additions & 0 deletions App/memmy-agent/tests/entrypoints/cli/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((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<void>((resolve, reject) => first.close((error) => (error ? reject(error) : resolve())));
}
});

async function startCronGatewayWithFakeLoop({
processDirect,
isSessionBusy = vi.fn(() => false),
Expand Down
Loading