From dd35a38b090bd31f90cc2e59bd57bb50a4ce795c Mon Sep 17 00:00:00 2001 From: Austin Kurpuis Date: Sat, 1 Aug 2026 14:50:11 -0700 Subject: [PATCH] fix: catch buildGraphInput rejection in /invoke fire-and-forget path A rejection from buildGraphInput (e.g. sessionStore.get hitting a Redis blip) was unhandled before the graph.invoke().catch() chain existed to catch it, crashing the process and wiping the in-memory invocations Map. That turned every in-flight poll into "poll failed: 404" for callers, which is what surfaced as the last comment on #185. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NynLp3kMJ5vMdvHDv3opGy --- apps/agent-orchestrator/src/server.test.ts | 36 ++++++++++++++++++++++ apps/agent-orchestrator/src/server.ts | 11 +++++++ 2 files changed, 47 insertions(+) diff --git a/apps/agent-orchestrator/src/server.test.ts b/apps/agent-orchestrator/src/server.test.ts index 6b8aec5..159714b 100644 --- a/apps/agent-orchestrator/src/server.test.ts +++ b/apps/agent-orchestrator/src/server.test.ts @@ -224,6 +224,42 @@ describe("InvokeServer", () => { await server.close(); }); + + /** + * `buildGraphInput` awaits `sessionStore.get` before the graph ever runs + * (see server.ts) -- a Redis blip there rejects that promise before the + * `this.graph.invoke(...).catch(...)` chain even exists to catch it. Left + * unhandled, that crashes the process, wiping every other in-flight + * invocation and turning their polls into "poll failed: 404" for the + * caller. This asserts the record still resolves to "failed" instead. + */ + it("marks the invocation failed (not an unhandled rejection) when buildGraphInput itself rejects", async () => { + const graph: AgentGraphLike = { invoke: vi.fn(), stream: vi.fn() }; + const failingSessionStore = { + get: vi.fn().mockRejectedValue(new Error("redis: connection reset")), + set: vi.fn(), + }; + const server = new InvokeServer(graph, failingSessionStore as unknown as InMemorySessionStore); + const port = await listenOn(server); + + const postRes = await fetch(`http://127.0.0.1:${port}/invoke`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ request: "do a thing", session_id: "session-1" }), + }); + const { id } = (await postRes.json()) as { id: string }; + + await new Promise((r) => setTimeout(r, 10)); + const res = await fetch(`http://127.0.0.1:${port}/invoke/${id}`); + expect(res.status).toBe(200); + expect((await res.json()) as { status: string; error: string }).toMatchObject({ + status: "failed", + error: "redis: connection reset", + }); + expect(graph.invoke).not.toHaveBeenCalled(); + + await server.close(); + }); }); describe("InvokeServer /invoke event field -> IntegrationRoute dispatch (ADR 0024)", () => { diff --git a/apps/agent-orchestrator/src/server.ts b/apps/agent-orchestrator/src/server.ts index 4a0530b..be95193 100644 --- a/apps/agent-orchestrator/src/server.ts +++ b/apps/agent-orchestrator/src/server.ts @@ -891,6 +891,17 @@ export class InvokeServer { ...(remoteControlUrl ? { remoteControlUrl } : {}), }); }); + }).catch((err: unknown) => { + // `buildGraphInput` itself can reject (e.g. `sessionStore.get` hitting + // Redis) before the graph ever runs. Without this, that rejection is + // unhandled -- Node terminates the process, wiping the in-memory + // `invocations` Map and turning every in-flight poll (not just this + // one) into a 404 the caller reports as "poll failed: 404". + this.invocations.set(id, { + id, + status: "failed", + error: err instanceof Error ? err.message : String(err), + }); }); res.writeHead(202, { "content-type": "application/json", location: `/invoke/${id}` }).end(