History

Report executions lost to a session reset (#1978)

Rhys Sullivan committed 2 months ago

Commit f1d95f21 parent 6870f389 files · +984 −73Browse files

Unified · Split

9 files
  1. .changeset/lost-execution-visibility.md +5 −0
  2. apps/cloud/src/observability/observability.test.ts +12 −3
  3. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts +129 −2
  4. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +13 −7
  5. packages/hosts/cloudflare/src/mcp/agents-post-stream-loss.test.ts +337 −0
  6. packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts +8 −3
  7. packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts +14 −5
  8. packages/hosts/cloudflare/src/mcp/durable-object-errors.ts +37 −7
  9. patches/agents@0.17.3.patch +429 −46
.changeset/lost-execution-visibility.md+5−0
@@ -0,0 +1,5 @@
1---
2"executor": patch
3---
4
5Report an MCP `execute` call that dies with a session reset as a JSON-RPC error instead of a silently closed stream. The front worker answers outstanding request ids when the session socket closes abnormally or a response deadline passes, and a rebuilt session answers ids stranded by a previous incarnation on the next stream. The plain memory-limit reset is now classified as transient.
apps/cloud/src/observability/observability.test.ts+12−3
@@ -385,15 +385,24 @@ describe("Durable Object platform reset noise", () => { · 384 unmodified lines
385 expect(beforeSendWithOtelCorrelation(defect)).not.toBeNull();385 expect(beforeSendWithOtelCorrelation(defect)).not.toBeNull();
386 });386 });
387 387
388 // The memory-limit reset is deliberately absent from the classifier: the388 // The storage-cache memory-limit variant stays absent from the classifier:
389 // runtime blames the application for it, so it is a defect, not noise.389 // the runtime blames the application for it (un-awaited writes, an oversized
390 it("keeps the memory-limit reset the classifier deliberately excludes", () => {390 // read), so it is a defect, not noise. Its plain sibling is a platform reset
391 // and IS classified — the two are separated only by that qualifier.
392 it("keeps the memory-limit variant the classifier deliberately excludes", () => {
391 const memory = doInstrumentationEvent(393 const memory = doInstrumentationEvent(
392 "Durable Object's isolate exceeded its memory limit due to overflowing the storage cache. All objects in the isolate were reset.",394 "Durable Object's isolate exceeded its memory limit due to overflowing the storage cache. All objects in the isolate were reset.",
393 );395 );
394 expect(beforeSendWithOtelCorrelation(memory)).not.toBeNull();396 expect(beforeSendWithOtelCorrelation(memory)).not.toBeNull();
395 });397 });
396 398
399 it("drops the plain memory-limit reset as platform noise", () => {
400 const memory = doInstrumentationEvent(
401 "Durable Object's isolate exceeded its memory limit and was reset.",
402 );
403 expect(beforeSendWithOtelCorrelation(memory)).toBeNull();
404 });
405
397 it("the hook the worker and DOs install drops the deploy reset", () => {406 it("the hook the worker and DOs install drops the deploy reset", () => {
398 const options = cloudSentryOptions({ SENTRY_DSN: "https://public@example.invalid/1" } as Env);407 const options = cloudSentryOptions({ SENTRY_DSN: "https://public@example.invalid/1" } as Env);
399 const event = doInstrumentationEvent("Durable Object reset because its code was updated.");408 const event = doInstrumentationEvent("Durable Object reset because its code was updated.");
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.test.ts+129−2
@@ -153,6 +153,20 @@ type HarnessSession = { · 152 unmodified lines
153 >;153 >;
154 alarm: () => Promise<void>;154 alarm: () => Promise<void>;
155 ctx: MemoryStorage;155 ctx: MemoryStorage;
156 currentSessionEpoch: () => Promise<number>;
157 getStaleEpochStreamRequestIds: () => Promise<
158 ReadonlyArray<{
159 readonly streamId: string;
160 readonly requestIds: ReadonlyArray<string | number>;
161 readonly epoch: number;
162 readonly currentEpoch: number;
163 }>
164 >;
165 getStreamRequestIds: (streamId: string) => Promise<ReadonlyArray<string | number> | undefined>;
166 setStreamRequestIds: (
167 streamId: string,
168 requestIds: ReadonlyArray<string | number>,
169 ) => Promise<void>;
156 dbHandle: { readonly end: () => void } | null;170 dbHandle: { readonly end: () => void } | null;
157 engine: ExecutionEngine<Cause.YieldableError> | null;171 engine: ExecutionEngine<Cause.YieldableError> | null;
158 getConnections?: () => Iterable<unknown>;172 getConnections?: () => Iterable<unknown>;
@@ -266,7 +280,14 @@ const approval = { · 107 unmodified lines
266 content: { approved: true },280 content: { approved: true },
267} satisfies ResumeResponse;281} satisfies ResumeResponse;
268 282
269const makeHarnessSession = async (): Promise<HarnessSession> => {283/**
284 * `storage` is a parameter so a test can build a SECOND session on the same
285 * durable storage — that is exactly what a Durable Object reset looks like from
286 * storage's point of view: same keys, brand new instance.
287 */
288const makeHarnessSession = async (
289 storage: MemoryStorage = new MemoryStorage(),
290): Promise<HarnessSession> => {
270 const sessionId = "session-reconnect";291 const sessionId = "session-reconnect";
271 const sessionMeta: SessionMeta = {292 const sessionMeta: SessionMeta = {
272 organizationId: "org-1",293 organizationId: "org-1",
@@ -275,7 +296,6 @@ const makeHarnessSession = async (): Promise<HarnessSession> => { · 2 unmodified lines
275 userId: "user-1",296 userId: "user-1",
276 resource: defaultMcpResource,297 resource: defaultMcpResource,
277 };298 };
278 const storage = new MemoryStorage();
279 const server = makeServer();299 const server = makeServer();
280 await server.connect(new StaleCloseTransport());300 await server.connect(new StaleCloseTransport());
281 301
@@ -1766,3 +1786,110 @@ describe("McpAgentSessionDOBase residency cap eviction", () => { · 1484 unmodified lines
1766 });1786 });
1767 });1787 });
1768});1788});
1789
1790// The request-id ledger (`__mcp_stream_reqs__:<streamId>`, written by the
1791// patched McpAgent — see patches/agents@0.17.3.patch) is the only durable
1792// record that a POST is still owed a response. A row exists from the moment the
1793// request is accepted until its final response is written, so a row that
1794// outlives the incarnation which accepted it is a request nothing will ever
1795// answer: the isolate was reset mid-execute. Each row carries the epoch of the
1796// incarnation that wrote it, and that is what separates "stranded" from
1797// "legitimately still running" — a browser-approval pause holds a row open for
1798// minutes inside ONE incarnation and must never be swept.
1799describe("McpAgentSessionDOBase stranded-request ledger", () => {
1800 const ledgerKey = (streamId: string) => `__mcp_stream_reqs__:${streamId}`;
1801
1802 it("stamps the accepting incarnation on every ledger row", async () => {
1803 const session = await makeHarnessSession();
1804
1805 await session.setStreamRequestIds("stream-a", [1, "two"]);
1806
1807 expect(await session.ctx.storage.get(ledgerKey("stream-a"))).toEqual({
1808 epoch: await session.currentSessionEpoch(),
1809 requestIds: [1, "two"],
1810 });
1811 expect(
1812 await session.getStreamRequestIds("stream-a"),
1813 "readers still see a plain request-id list",
1814 ).toEqual([1, "two"]);
1815 });
1816
1817 it("does not treat a row from the running incarnation as stranded", async () => {
1818 const session = await makeHarnessSession();
1819
1820 // What a browser-approval pause looks like: accepted, unanswered, and
1821 // legitimately going to stay that way for minutes.
1822 await session.setStreamRequestIds("stream-paused", [9]);
1823
1824 expect(await session.getStaleEpochStreamRequestIds()).toEqual([]);
1825 });
1826
1827 it("reports a row left by a previous incarnation as stranded", async () => {
1828 const storage = new MemoryStorage();
1829 const beforeReset = await makeHarnessSession(storage);
1830 await beforeReset.setStreamRequestIds("stream-lost", [42]);
1831 await beforeReset.setStreamRequestIds("stream-also-lost", ["abc"]);
1832
1833 // The reset: same durable storage, a brand new Durable Object instance.
1834 const afterReset = await makeHarnessSession(storage);
1835 await afterReset.setStreamRequestIds("stream-live", [100]);
1836
1837 const stranded = await afterReset.getStaleEpochStreamRequestIds();
1838
1839 // Order follows storage's key order, which this fake does not model, so
1840 // the assertion is on the set.
1841 expect(
1842 [...stranded].sort((a, b) => a.streamId.localeCompare(b.streamId)),
1843 "only the rows the dead incarnation accepted",
1844 ).toMatchObject([
1845 { streamId: "stream-also-lost", requestIds: ["abc"] },
1846 { streamId: "stream-lost", requestIds: [42] },
1847 ]);
1848 for (const row of stranded) expect(row.epoch).toBeLessThan(row.currentEpoch);
1849 });
1850
1851 it("reports a pre-epoch ledger row as stranded", async () => {
1852 const session = await makeHarnessSession();
1853
1854 // The shape rows had before they carried an epoch. One can only have been
1855 // written by an earlier deployment, so it reads as epoch 0 and is swept.
1856 await session.ctx.storage.put(ledgerKey("stream-legacy"), [7]);
1857
1858 expect(await session.getStaleEpochStreamRequestIds()).toEqual([
1859 {
1860 currentEpoch: await session.currentSessionEpoch(),
1861 epoch: 0,
1862 requestIds: [7],
1863 streamId: "stream-legacy",
1864 },
1865 ]);
1866 expect(
1867 await session.getStreamRequestIds("stream-legacy"),
1868 "and it is still readable as a request-id list",
1869 ).toEqual([7]);
1870 });
1871
1872 it("holds the idle lease for a request the running incarnation still owes", async () => {
1873 const session = await makeHarnessSession();
1874 await session.setStreamRequestIds("stream-live", [1]);
1875
1876 await session.alarm();
1877
1878 expect(session.initialized, "live work keeps the runtime resident").toBe(true);
1879 expect(session.ctx.alarm, "and re-arms the lease").toBeGreaterThan(0);
1880 });
1881
1882 it("does not let a stranded row extend the idle lease", async () => {
1883 const storage = new MemoryStorage();
1884 const beforeReset = await makeHarnessSession(storage);
1885 await beforeReset.setStreamRequestIds("stream-lost", [1]);
1886
1887 const afterReset = await makeHarnessSession(storage);
1888 await afterReset.alarm();
1889
1890 expect(
1891 afterReset.initialized,
1892 "a request nothing will ever answer is dead work, not running work",
1893 ).toBe(false);
1894 });
1895});
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+13−7
@@ -203,7 +203,6 @@ const AGENTS_DESTROY_PENDING_KEY = "cf_agents_destroy_pending"; · 202 unmodified lines
203const MCP_HTTP_METHOD_HEADER = "cf-mcp-method";203const MCP_HTTP_METHOD_HEADER = "cf-mcp-method";
204const MCP_MESSAGE_HEADER = "cf-mcp-message";204const MCP_MESSAGE_HEADER = "cf-mcp-message";
205const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000;205const MODEL_RESUME_FORWARD_TIMEOUT_MS = 10_000;
206const MCP_STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";
207const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`;206const approvalResponseKey = (executionId: string) => `approval-response:${executionId}`;
208const BrowserApprovalDecisionStorage = Schema.Struct({207const BrowserApprovalDecisionStorage = Schema.Struct({
209 response: ResumeResponsePayload,208 response: ResumeResponsePayload,
@@ -752,13 +751,20 @@ export abstract class McpAgentSessionDOBase< · 542 unmodified lines
752 // which survives disposeIdleRuntime, so a later reconnect GET re-inits the751 // which survives disposeIdleRuntime, so a later reconnect GET re-inits the
753 // DO and replays it. Counting them would make every delivered-but-unacked752 // DO and replays it. Counting them would make every delivered-but-unacked
754 // POST response pin the runtime alive indefinitely.753 // POST response pin the runtime alive indefinitely.
755 const rows = await this.ctx.storage.list<readonly JsonRpcRequestId[]>({754 //
756 prefix: MCP_STREAM_REQS_KEY_PREFIX,755 // Rows stamped with an epoch older than this incarnation's are dead work,
757 limit: 1_000,756 // not running work: the isolate that was going to produce their response
758 });757 // was reset, so nothing will ever answer them and they must not hold the
758 // runtime open. The transport's orphan sweep tells the client and removes
759 // the row on the next GET; until then they simply do not count.
760 const [openStreams, currentEpoch] = await Promise.all([
761 this.getOpenStreamRequestIds(),
762 this.currentSessionEpoch(),
763 ]);
759 let count = 0;764 let count = 0;
760 for (const requestIds of rows.values()) {765 for (const stream of openStreams) {
761 if (Array.isArray(requestIds)) count += requestIds.length;766 if (stream.epoch < currentEpoch) continue;
767 count += stream.requestIds.length;
762 }768 }
763 return count;769 return count;
764 }770 }
packages/hosts/cloudflare/src/mcp/agents-post-stream-loss.test.ts+337−0
@@ -0,0 +1,337 @@
1// Unit coverage for the POST bridge's lost-execution handling (see
2// patches/agents@0.17.3.patch).
3//
4// The front worker answers a POST with HTTP 200 and an SSE body long before the
5// Durable Object produces a result. When the DO is reset mid-execute (isolate
6// memory/CPU limit, storage timeout, deploy) the bridge WebSocket closes, and
7// the bridge used to simply close the writer: the client saw a cleanly
8// terminated SSE stream carrying no JSON-RPC response, which is
9// indistinguishable from "still working" until its own timeout fires. Same
10// outcome if the DO never answers at all.
11//
12// Three properties are pinned here:
13// 1. An abnormal WS close answers everything still outstanding with the
14// -32010 session_reset error, then ends the stream.
15// 2. The happy path is byte-identical — a delivered response leaves nothing
16// outstanding, so nothing is synthesized.
17// 3. The response deadline answers a stream the DO never replied on, and the
18// constant that bounds it still clears the two source constants it has to
19// track (it lives in a vendored dist and cannot import them).
20import { readFileSync } from "node:fs";
21
22import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
23import { MCP_POST_RESPONSE_DEADLINE_MS, McpAgent } from "agents/mcp";
24import { Effect, Option, Schema } from "effect";
25
26import { PAUSED_APPROVAL_TIMEOUT_MS } from "@executor-js/host-mcp/tool-server";
27
28const SESSION_RESET_ERROR_CODE = -32010;
29/** Margin the patch adds on top of the two bounded waits. */
30const DEADLINE_MARGIN_MS = 60_000;
31
32type FakeWebSocket = EventTarget & {
33 accepted: boolean;
34 closeCode: number | undefined;
35 closeReason: string | undefined;
36 accept: () => void;
37 close: (code?: number, reason?: string) => void;
38 send: (message: string) => void;
39};
40
41const JsonRpcErrorFrame = Schema.Struct({
42 error: Schema.Struct({
43 code: Schema.Number,
44 data: Schema.Struct({ reason: Schema.String }),
45 message: Schema.String,
46 }),
47 id: Schema.Union([Schema.String, Schema.Number]),
48 jsonrpc: Schema.Literal("2.0"),
49});
50const decodeJsonRpcErrorFrame = Schema.decodeUnknownOption(
51 Schema.fromJsonString(JsonRpcErrorFrame),
52);
53
54/** Every `data:` payload in an SSE body that parses as a JSON-RPC error. */
55const errorFrames = (body: string): ReadonlyArray<typeof JsonRpcErrorFrame.Type> =>
56 body.split("\n").flatMap((line) => {
57 if (!line.startsWith("data: ")) return [];
58 const decoded = decodeJsonRpcErrorFrame(line.slice("data: ".length));
59 return Option.isSome(decoded) ? [decoded.value] : [];
60 });
61
62const flushMicrotasks = async (): Promise<void> => {
63 await Promise.resolve();
64 await Promise.resolve();
65 await Promise.resolve();
66};
67
68const drainResponse = async (response: Response): Promise<string> => {
69 const decoder = new TextDecoder();
70 let body = "";
71
72 await Effect.runPromise(
73 Effect.ignore(
74 Effect.tryPromise({
75 try: () =>
76 response.body?.pipeTo(
77 new WritableStream<Uint8Array>({
78 close: () => {
79 body += decoder.decode();
80 },
81 write: (chunk) => {
82 body += decoder.decode(chunk, { stream: true });
83 },
84 }),
85 ) ?? Promise.resolve(),
86 catch: () => undefined,
87 }),
88 ),
89 );
90
91 return body;
92};
93
94const makeExecutionContext = (): ExecutionContext => ({
95 passThroughOnException: () => {},
96 props: undefined,
97 waitUntil: () => {},
98});
99
100const makeWebSocket = (): FakeWebSocket => {
101 const ws = new EventTarget() as FakeWebSocket;
102 ws.accepted = false;
103 ws.closeCode = undefined;
104 ws.closeReason = undefined;
105 ws.accept = () => {
106 ws.accepted = true;
107 };
108 ws.close = (code?: number, reason?: string) => {
109 ws.closeCode = code;
110 ws.closeReason = reason;
111 };
112 ws.send = () => {};
113 return ws;
114};
115
116const makeNamespace = (ws: FakeWebSocket) => ({
117 newUniqueId: () => ({ toString: () => "generated-session" }),
118 idFromName: (name: string) => ({ equals: () => true, name, toString: () => name }),
119 get: () => ({
120 setName: async () => {},
121 getInitializeRequest: async () => ({}),
122 fetch: async () => ({ webSocket: ws }),
123 }),
124});
125
126const postToBridge = async (body: unknown) => {
127 const ws = makeWebSocket();
128 const handler = McpAgent.serve("/mcp", {
129 binding: "MCP_SESSION",
130 transport: "streamable-http",
131 });
132 const response = await handler.fetch(
133 new Request("https://executor.sh/mcp", {
134 body: JSON.stringify(body),
135 headers: {
136 accept: "application/json, text/event-stream",
137 "content-type": "application/json",
138 "mcp-session-id": "session-1",
139 },
140 method: "POST",
141 }),
142 { MCP_SESSION: makeNamespace(ws) },
143 makeExecutionContext(),
144 );
145 return { response, ws };
146};
147
148const toolCall = (id: number | string) => ({
149 id,
150 jsonrpc: "2.0" as const,
151 method: "tools/call",
152 params: { arguments: {}, name: "execute" },
153});
154
155/**
156 * Deliver one `cf_mcp_agent_event` envelope exactly as the patched
157 * `writeSSEEvent` builds it, including the `respondedIds` field the bridge uses
158 * to retire outstanding ids.
159 */
160const emitResponse = (ws: FakeWebSocket, message: { readonly id: number | string }, close = true) =>
161 ws.dispatchEvent(
162 new MessageEvent("message", {
163 data: JSON.stringify({
164 close: close ? true : undefined,
165 event: `event: message\ndata: ${JSON.stringify(message)}\n\n`,
166 respondedIds: [message.id],
167 type: "cf_mcp_agent_event",
168 }),
169 }),
170 );
171
172/**
173 * A Durable Object reset closes the bridge socket with no close code at all —
174 * `CloseEvent` is not reliably constructible across runtimes, so the shape the
175 * handler actually reads (`code` / `reason`) is attached directly.
176 */
177const emitAbnormalClose = (ws: FakeWebSocket, code?: number, reason?: string) =>
178 ws.dispatchEvent(Object.assign(new Event("close"), { code, reason }));
179
180describe("POST bridge: lost-execution visibility", () => {
181 beforeEach(() => {
182 vi.useFakeTimers();
183 vi.setSystemTime(0);
184 vi.spyOn(console, "warn").mockImplementation(() => {});
185 });
186
187 afterEach(() => {
188 vi.restoreAllMocks();
189 vi.useRealTimers();
190 });
191
192 it("answers every outstanding request when the bridge socket closes abnormally", async () => {
193 const { response, ws } = await postToBridge([toolCall(1), toolCall("two")]);
194 const drained = drainResponse(response);
195
196 emitAbnormalClose(ws);
197 const body = await drained;
198
199 const frames = errorFrames(body);
200 expect(
201 frames.map((frame) => frame.id),
202 "one error per unanswered request id, preserving the id's JSON type",
203 ).toEqual([1, "two"]);
204 for (const frame of frames) {
205 expect(frame.error.code).toBe(SESSION_RESET_ERROR_CODE);
206 expect(frame.error.data.reason).toBe("session_reset");
207 expect(frame.error.message).toContain("Execution lost");
208 }
209 });
210
211 it("writes nothing extra when the response was delivered before the close", async () => {
212 const { response, ws } = await postToBridge(toolCall(1));
213 const drained = drainResponse(response);
214
215 const result = { id: 1, jsonrpc: "2.0" as const, result: { ok: true } };
216 emitResponse(ws, result);
217 await flushMicrotasks();
218 emitAbnormalClose(ws);
219 const body = await drained;
220
221 expect(body, "the delivered result is the only frame on the wire").toBe(
222 `event: message\ndata: ${JSON.stringify(result)}\n\n`,
223 );
224 expect(errorFrames(body)).toEqual([]);
225 expect(ws.closeCode, "the happy-path close is unchanged").toBe(1000);
226 expect(ws.closeReason).toBe("SSE response delivered");
227 });
228
229 it("answers the request itself when the DO never responds before the deadline", async () => {
230 const { response, ws } = await postToBridge(toolCall(7));
231 const drained = drainResponse(response);
232
233 await vi.advanceTimersByTimeAsync(MCP_POST_RESPONSE_DEADLINE_MS - 1);
234 expect(errorFrames(await Promise.race([drained, Promise.resolve("")]))).toEqual([]);
235
236 await vi.advanceTimersByTimeAsync(2);
237 const body = await drained;
238
239 const frames = errorFrames(body);
240 expect(frames.map((frame) => frame.id)).toEqual([7]);
241 expect(frames[0]?.error.code).toBe(SESSION_RESET_ERROR_CODE);
242 expect(frames[0]?.error.data.reason).toBe("response_deadline");
243 expect(vi.getTimerCount(), "the deadline and the keepalive are both disarmed").toBe(0);
244
245 // A close arriving after the bridge already answered must not double-write.
246 emitAbnormalClose(ws);
247 await flushMicrotasks();
248 expect(errorFrames(body).length).toBe(1);
249 });
250
251 it("arms no deadline for a body that owes no response", async () => {
252 const { response } = await postToBridge({
253 jsonrpc: "2.0",
254 method: "notifications/initialized",
255 });
256
257 expect(response.status).toBe(202);
258 expect(vi.getTimerCount(), "a notifications-only POST leaves no timer behind").toBe(0);
259 });
260});
261
262describe("POST response deadline: drift against the waits it must cover", () => {
263 /**
264 * Evaluate the small `a * b + c` expressions these constants are declared
265 * with. Anything outside digits, `_`, `*`, `+` and the named substitutions is
266 * refused, so a declaration that grows a new shape fails the test loudly
267 * instead of silently reading as `NaN`.
268 */
269 const evaluate = (expression: string, known: Readonly<Record<string, number>>): number | null => {
270 let total = 0;
271 for (const term of expression.split("+")) {
272 let product = 1;
273 for (const rawFactor of term.split("*")) {
274 const factor = rawFactor.trim();
275 const named = known[factor];
276 if (named !== undefined) {
277 product *= named;
278 continue;
279 }
280 if (!/^\d[\d_]*$/.test(factor)) return null;
281 product *= Number(factor.replaceAll("_", ""));
282 }
283 total += product;
284 }
285 return total;
286 };
287
288 /**
289 * Read a constant out of another package's source. Deliberately a file read
290 * and not an import: `DEFAULT_TIMEOUT_MS` is private to the dynamic-worker
291 * runtime, the bridge constant that has to cover it lives in a vendored dist
292 * that can import neither, and this test exists precisely to notice when the
293 * two drift apart.
294 */
295 const readConstant = (
296 relativePath: string,
297 name: string,
298 known: Readonly<Record<string, number>> = {},
299 ): number | null => {
300 const source = readFileSync(new URL(relativePath, import.meta.url), "utf8");
301 const match = new RegExp(`\\b${name}\\s*=\\s*([^;\\n]+);`).exec(source);
302 return match?.[1] === undefined ? null : evaluate(match[1], known);
303 };
304
305 const RUNTIME_EXECUTOR = "../../../../kernel/runtime-dynamic-worker/src/executor.ts";
306 const TOOL_SERVER = "../../../mcp/src/tool-server.ts";
307
308 it("covers the sandbox execution ceiling plus the browser-approval wait plus margin", () => {
309 const sandboxTimeoutMs = readConstant(RUNTIME_EXECUTOR, "DEFAULT_TIMEOUT_MS");
310 expect(sandboxTimeoutMs, `DEFAULT_TIMEOUT_MS not readable from ${RUNTIME_EXECUTOR}`).not.toBe(
311 null,
312 );
313
314 // The scrape is kept honest by the one constant that IS a public export:
315 // if the regex ever stops reading these declarations correctly, this
316 // mismatch fires rather than the test passing on a wrong number.
317 const scrapedPausedApprovalMs = readConstant(TOOL_SERVER, "PAUSED_APPROVAL_TIMEOUT_MS");
318 expect(scrapedPausedApprovalMs, "scraped value must match the real export").toBe(
319 PAUSED_APPROVAL_TIMEOUT_MS,
320 );
321
322 const browserApprovalWaitMs = readConstant(TOOL_SERVER, "BROWSER_APPROVAL_WAIT_TIMEOUT_MS", {
323 PAUSED_APPROVAL_TIMEOUT_MS,
324 });
325 expect(
326 browserApprovalWaitMs,
327 `BROWSER_APPROVAL_WAIT_TIMEOUT_MS not readable from ${TOOL_SERVER}`,
328 ).not.toBe(null);
329
330 const required = (sandboxTimeoutMs ?? 0) + (browserApprovalWaitMs ?? 0) + DEADLINE_MARGIN_MS;
331 expect(
332 MCP_POST_RESPONSE_DEADLINE_MS,
333 "the bridge would answer for a call that is still legitimately running — raise " +
334 "MCP_POST_RESPONSE_DEADLINE_MS in patches/agents@0.17.3.patch",
335 ).toBeGreaterThanOrEqual(required);
336 });
337});
packages/hosts/cloudflare/src/mcp/agents-sse-max-age.test.ts+8−3
@@ -1,5 +1,5 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";1import { afterEach, beforeEach, describe, expect, it, vi } from "@effect/vitest";
2import { MAX_SSE_AGE_MS, McpAgent } from "agents/mcp";2import { MAX_SSE_AGE_MS, MCP_POST_RESPONSE_DEADLINE_MS, McpAgent } from "agents/mcp";
3import { Effect, Option, Schema } from "effect";3import { Effect, Option, Schema } from "effect";
4 4
5import { SESSION_TIMEOUT_MS } from "./session-alarm-policy";5import { SESSION_TIMEOUT_MS } from "./session-alarm-policy";
@@ -377,12 +377,17 @@ describe("agents SSE max-age rotation", () => { · 371 unmodified lines
377 await expect(drained).resolves.toContain(": max-age rotation, reconnect\n\n");377 await expect(drained).resolves.toContain(": max-age rotation, reconnect\n\n");
378 });378 });
379 379
380 it("does not rotate an in-flight POST response past max age", async () => {380 // Max-age rotation applies only to GET streams. A POST is bounded by its own,
381 // much shorter response deadline (MCP_POST_RESPONSE_DEADLINE_MS — see
382 // agents-post-stream-loss.test.ts), so max age is never reachable on one; this
383 // pins that rotation stays out of the way for as long as a POST may legitimately
384 // run.
385 it("does not rotate an in-flight POST response", async () => {
381 const { response, ws } = await openPostSse();386 const { response, ws } = await openPostSse();
382 const drained = drainResponse(response);387 const drained = drainResponse(response);
383 388
384 emitAgentEvent(ws, `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n`);389 emitAgentEvent(ws, `event: message\ndata: {"jsonrpc":"2.0","id":1,"result":{}}\n\n`);
385 await vi.advanceTimersByTimeAsync(MAX_SSE_AGE_MS + KEEPALIVE_INTERVAL_MS * 4);390 await vi.advanceTimersByTimeAsync(MCP_POST_RESPONSE_DEADLINE_MS - KEEPALIVE_INTERVAL_MS);
386 await flushMicrotasks();391 await flushMicrotasks();
387 392
388 expect(ws.closeCode).toBeUndefined();393 expect(ws.closeCode).toBeUndefined();
packages/hosts/cloudflare/src/mcp/durable-object-errors.test.ts+14−5
@@ -91,11 +91,20 @@ describe("classifyDurableObjectError", () => { · 90 unmodified lines
91 ).toEqual({ kind: "cpu_limit", disposition: "transient" });91 ).toEqual({ kind: "cpu_limit", disposition: "transient" });
92 });92 });
93 93
94 // The memory-limit reset is the CPU limit's sibling and is deliberately NOT94 it("reads a plain memory-limit reset as transient", () => {
95 // classified: the runtime names the application as the cause (un-awaited95 expect(
96 // writes, an oversized read), so a retry reproduces it. It has to keep being96 classifyDurableObjectError(
97 // rethrown and reported rather than disappearing into a 503.97 new Error("Durable Object's isolate exceeded its memory limit and was reset."),
98 it("refuses to classify the sibling memory-limit reset as retryable", () => {98 ),
99 ).toEqual({ kind: "memory_limit", disposition: "transient" });
100 });
101
102 // The storage-cache variant shares the "exceeded its memory limit" phrase but
103 // is a USER error the runtime names the cause of (un-awaited writes, an
104 // oversized read), so a retry reproduces it. It has to keep being rethrown and
105 // reported rather than disappearing into a 503 — the qualifier is the only
106 // thing separating it from the platform reset above.
107 it("refuses to classify the storage-cache memory-limit variant as retryable", () => {
99 expect(108 expect(
100 classifyDurableObjectError(109 classifyDurableObjectError(
101 new Error(110 new Error(
packages/hosts/cloudflare/src/mcp/durable-object-errors.ts+37−7
@@ -41,6 +41,8 @@ export type DurableObjectFailureKind = · 40 unmodified lines
41 | "concurrency_reset"41 | "concurrency_reset"
42 /** An invocation ran past the per-invocation CPU ceiling; the object was reset. */42 /** An invocation ran past the per-invocation CPU ceiling; the object was reset. */
43 | "cpu_limit"43 | "cpu_limit"
44 /** The isolate ran past its memory ceiling; every object in it was reset. */
45 | "memory_limit"
44 /** A generic platform blip: `internal error; reference = <id>`. */46 /** A generic platform blip: `internal error; reference = <id>`. */
45 | "internal_error"47 | "internal_error"
46 /** The runtime itself flagged the error as retryable. */48 /** The runtime itself flagged the error as retryable. */
@@ -68,6 +70,14 @@ export type DurableObjectFailure = { · 21 unmodified lines
68 */70 */
69const MESSAGE_PATTERNS: ReadonlyArray<{71const MESSAGE_PATTERNS: ReadonlyArray<{
70 readonly fragment: string;72 readonly fragment: string;
73 /**
74 * A second fragment that, when also present, *disqualifies* the match. One
75 * runtime phrase covers both a platform reset and an application defect, and
76 * the qualifier the runtime adds for the defect is the only thing separating
77 * them — so the entry names that qualifier rather than trying to spell out
78 * every benign variant of the shared phrase.
79 */
80 readonly excludeFragment?: string;
71 readonly failure: DurableObjectFailure;81 readonly failure: DurableObjectFailure;
72}> = [82}> = [
73 {83 {
@@ -125,6 +135,28 @@ const MESSAGE_PATTERNS: ReadonlyArray<{ · 51 unmodified lines
125 fragment: "exceeded its cpu time limit and was reset",135 fragment: "exceeded its cpu time limit and was reset",
126 failure: { kind: "cpu_limit", disposition: "transient" },136 failure: { kind: "cpu_limit", disposition: "transient" },
127 },137 },
138 {
139 // The CPU limit's sibling, matched on the short shared phrase rather than a
140 // full sentence because the runtime words its two memory-limit messages
141 // differently after it.
142 //
143 // `excludeFragment` carries what used to keep this bucket out of the list
144 // entirely: the runtime reuses "exceeded its memory limit" for a USER error
145 // that names its own cause — "… due to overflowing the storage cache. All
146 // objects in the isolate were reset." — too many un-awaited writes, or one
147 // oversized read. That variant reproduces on every retry, so calling it
148 // transient would bury an application defect behind a 503. It still falls
149 // through unclassified and keeps being rethrown and reported.
150 //
151 // What is left is the plain reset, transient for exactly the reasons the
152 // CPU limit is: nothing about the request caused it, durable storage is
153 // untouched, and the session id still routes. Leaving it unclassified is
154 // what let a memory-limit reset mid-execute fall out of the worker as an
155 // unhandled 500.
156 fragment: "exceeded its memory limit",
157 excludeFragment: "overflowing the storage cache",
158 failure: { kind: "memory_limit", disposition: "transient" },
159 },
128 {160 {
129 // Only the bare blip. A reference id at the end of the message is NOT the161 // Only the bare blip. A reference id at the end of the message is NOT the
130 // marker: the runtime also appends one to described faults such as the162 // marker: the runtime also appends one to described faults such as the
@@ -136,12 +168,6 @@ const MESSAGE_PATTERNS: ReadonlyArray<{ · 5 unmodified lines
136 fragment: "internal error; reference =",168 fragment: "internal error; reference =",
137 failure: { kind: "internal_error", disposition: "transient" },169 failure: { kind: "internal_error", disposition: "transient" },
138 },170 },
139 // Not listed, on purpose: the sibling memory-limit reset ("Durable Object's
140 // isolate exceeded its memory limit due to overflowing the storage cache …
141 // All objects in the isolate were reset."). The runtime tags that one as a
142 // user error, and it names its own cause — too many un-awaited writes, or one
143 // oversized read. Retrying reproduces it, so calling it transient would bury
144 // an application defect behind a 503 instead of surfacing it.
145];171];
146 172
147/**173/**
@@ -175,7 +201,11 @@ const classifyOne = (error: unknown): DurableObjectFailure | null => { · 27 unmodified lines
175 return { kind: "destroyed", disposition: "session_dead" };201 return { kind: "destroyed", disposition: "session_dead" };
176 }202 }
177 for (const pattern of MESSAGE_PATTERNS) {203 for (const pattern of MESSAGE_PATTERNS) {
178 if (normalized.includes(pattern.fragment)) return pattern.failure;204 if (!normalized.includes(pattern.fragment)) continue;
205 if (pattern.excludeFragment !== undefined && normalized.includes(pattern.excludeFragment)) {
206 continue;
207 }
208 return pattern.failure;
179 }209 }
180 }210 }
181 // Checked last: the message is the more specific signal, and the runtime sets211 // Checked last: the message is the more specific signal, and the runtime sets
patches/agents@0.17.3.patch+429−46
@@ -4,11 +4,17 @@ index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2 · 3 unmodified lines
4diff --git a/node_modules/agents/.bun-tag-61b2f1517ab5ced4 b/.bun-tag-61b2f1517ab5ced44diff --git a/node_modules/agents/.bun-tag-61b2f1517ab5ced4 b/.bun-tag-61b2f1517ab5ced4
5new file mode 1006445new file mode 100644
6index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c53916index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
7diff --git a/node_modules/agents/.bun-tag-61d29b56a6079f1d b/.bun-tag-61d29b56a6079f1d
8new file mode 100644
9index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
10diff --git a/node_modules/agents/.bun-tag-a6a47855632a2623 b/.bun-tag-a6a47855632a2623
11new file mode 100644
12index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
7diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e50213diff --git a/node_modules/agents/.bun-tag-c0c639aa2299e502 b/.bun-tag-c0c639aa2299e502
8new file mode 10064414new file mode 100644
9index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c539115index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
10diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.ts16diff --git a/dist/agent-tool-types-CNyE1iz_.d.ts b/dist/agent-tool-types-CNyE1iz_.d.ts
11index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df666253bf0b 10064417index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..010811ae74ef2d27f12f7fb2506848f1939d91d6 100644
12--- a/dist/agent-tool-types-CNyE1iz_.d.ts18--- a/dist/agent-tool-types-CNyE1iz_.d.ts
13+++ b/dist/agent-tool-types-CNyE1iz_.d.ts19+++ b/dist/agent-tool-types-CNyE1iz_.d.ts
14@@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore {20@@ -480,7 +480,10 @@ declare class DurableObjectEventStore implements EventStore {
@@ -23,31 +29,76 @@ index 571eececebd5a1eaf7f2fbf5278801c4d34728ba..317e526489fcd3fd477a50525da0df66 · 8 unmodified lines
23 getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined>;29 getStreamIdForEventId(eventId: EventId): Promise<StreamId | undefined>;
24 replayEventsAfter(30 replayEventsAfter(
25 lastEventId: EventId,31 lastEventId: EventId,
32@@ -538,6 +541,42 @@ declare abstract class McpAgent<
33 * @internal
34 */
35 private static readonly STREAM_REQS_KEY_PREFIX;
36+ /**
37+ * Monotonic id for THIS Durable Object incarnation, bumped once per live
38+ * instance. Ledger rows are stamped with it so a row that outlived the
39+ * isolate which was going to answer it is recognisable as stale.
40+ *
41+ * @internal
42+ */
43+ currentSessionEpoch(): Promise<number>;
44+ /**
45+ * Ledger rows stamped by an earlier incarnation: requests that nothing will
46+ * ever answer. Read-only — the caller writes the client's error first.
47+ *
48+ * @internal
49+ */
50+ getStaleEpochStreamRequestIds(): Promise<
51+ ReadonlyArray<{
52+ readonly streamId: string;
53+ readonly requestIds: RequestId[];
54+ readonly epoch: number;
55+ readonly currentEpoch: number;
56+ }>
57+ >;
58+ /**
59+ * Every POST stream still awaiting a response, with the incarnation that
60+ * accepted it. Callers that mean "work still running" must drop rows whose
61+ * `epoch` is below {@link currentSessionEpoch}.
62+ *
63+ * @internal
64+ */
65+ getOpenStreamRequestIds(): Promise<
66+ ReadonlyArray<{
67+ readonly streamId: string;
68+ readonly requestIds: RequestId[];
69+ readonly epoch: number;
70+ }>
71+ >;
72 /** Persist the `requestIds` for a POST stream. @internal */
73 setStreamRequestIds(streamId: string, requestIds: RequestId[]): Promise<void>;
74 /** Read the persisted `requestIds` for a POST stream. @internal */
26diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts75diff --git a/dist/mcp/index.d.ts b/dist/mcp/index.d.ts
27index c8fad448e8797b89690a99d93490d1363851b225..77f9fe3f6f2375eadc9f7a2974f0d202fe75b3bd 10064476index c8fad448e8797b89690a99d93490d1363851b225..d80f66f1532c29dda0a0477dc1ec9ef5fd1fca47 100644
28--- a/dist/mcp/index.d.ts77--- a/dist/mcp/index.d.ts
29+++ b/dist/mcp/index.d.ts78+++ b/dist/mcp/index.d.ts
30@@ -29,6 +29,7 @@ import {79@@ -29,6 +29,8 @@ import {
31 xt as MCPClientOAuthCallbackConfig,80 xt as MCPClientOAuthCallbackConfig,
32 zt as ElicitResult81 zt as ElicitResult
33 } from "../agent-tool-types-CNyE1iz_.js";82 } from "../agent-tool-types-CNyE1iz_.js";
34+declare const MAX_SSE_AGE_MS = 1800000;83+declare const MAX_SSE_AGE_MS = 1800000;
84+declare const MCP_POST_RESPONSE_DEADLINE_MS = 660000;
35 export {85 export {
36 type ClearableEventStore,86 type ClearableEventStore,
37 type CreateMcpHandlerOptions,87 type CreateMcpHandlerOptions,
38@@ -41,6 +42,7 @@ export {88@@ -41,6 +43,8 @@ export {
39 type MCPConnectionResult,89 type MCPConnectionResult,
40 type MCPDiscoverResult,90 type MCPDiscoverResult,
41 type MCPServerOptions,91 type MCPServerOptions,
42+ MAX_SSE_AGE_MS,92+ MAX_SSE_AGE_MS,
93+ MCP_POST_RESPONSE_DEADLINE_MS,
43 MCP_SERVER_ID_MAX_LENGTH,94 MCP_SERVER_ID_MAX_LENGTH,
44 McpAgent,95 McpAgent,
45 type McpAuthContext,96 type McpAuthContext,
46diff --git a/dist/mcp/index.js b/dist/mcp/index.js97diff --git a/dist/mcp/index.js b/dist/mcp/index.js
47index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5babd1219e17 10064498index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..231220c26b995a48e40dba993773c28786cca392 100644
48--- a/dist/mcp/index.js99--- a/dist/mcp/index.js
49+++ b/dist/mcp/index.js100+++ b/dist/mcp/index.js
50@@ -28,13 +28,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/101@@ -28,13 +28,60 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
51 const KEEPALIVE_INTERVAL_MS = 25e3;102 const KEEPALIVE_INTERVAL_MS = 25e3;
52 /** SSE comment frame the parser drops before any event dispatch. */103 /** SSE comment frame the parser drops before any event dispatch. */
53 const KEEPALIVE_FRAME = ": keepalive\n\n";104 const KEEPALIVE_FRAME = ": keepalive\n\n";
@@ -55,6 +106,49 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 1 unmodified line
55+// well above Executor's 5 minute session idle timeout so active clients rarely106+// well above Executor's 5 minute session idle timeout so active clients rarely
56+// rotate.107+// rotate.
57+const MAX_SSE_AGE_MS = 30 * 60 * 1000;108+const MAX_SSE_AGE_MS = 30 * 60 * 1000;
109+/**
110+* JSON-RPC error code for "the isolate that was running this call is gone".
111+* In the implementation-defined server range (-32000..-32099) and deliberately
112+* NOT the -32001 the worker already uses for session/transport failures, so a
113+* client can tell "the call you are waiting on died mid-flight, re-run it" from
114+* "this session id is dead, reconnect".
115+*/
116+const SESSION_RESET_ERROR_CODE = -32010;
117+const SESSION_RESET_ERROR_MESSAGE = "Execution lost: the session was reset before it produced a result. Re-run the call.";
118+/**
119+* How long the POST bridge waits for the Durable Object to answer a JSON-RPC
120+* request before answering for it.
121+*
122+* This file is a vendored dist and cannot import the two constants that
123+* actually bound how long an answer may legitimately take, so this MUST be kept
124+* at or above their sum:
125+*
126+* DEFAULT_TIMEOUT_MS packages/kernel/runtime-dynamic-worker/src/executor.ts
127+* (5 min sandbox execution ceiling)
128+* BROWSER_APPROVAL_WAIT_TIMEOUT_MS packages/hosts/mcp/src/tool-server.ts
129+* (PAUSED_APPROVAL_TIMEOUT_MS + 1s = 4 min 1 s)
130+*
131+* plus ~60s of margin for DO restore, storage and edge latency. 11 minutes
132+* clears 10 min 1 s. Drift is caught by
133+* packages/hosts/cloudflare/src/mcp/agents-post-stream-loss.test.ts,
134+* which recomputes the sum from those two source files.
135+*/
136+const MCP_POST_RESPONSE_DEADLINE_MS = 11 * 60 * 1000;
137+/**
138+* The JSON-RPC error response sent for a request that will never get a real
139+* answer. `reason` distinguishes the two ways we find out: `session_reset`
140+* (the DO end of the bridge went away, or a ledger row outlived its
141+* incarnation) and `response_deadline` (nothing came back in time).
142+*/
143+const sessionResetErrorResponse = (id, reason) => ({
144+ error: {
145+ code: SESSION_RESET_ERROR_CODE,
146+ data: { reason },
147+ message: SESSION_RESET_ERROR_MESSAGE
148+ },
149+ id,
150+ jsonrpc: "2.0"
151+});
58 /**152 /**
59 * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle153 * Start an SSE keepalive on `writer`. Returns a `clearInterval` handle
60 * that the stream cleanup must invoke when the stream closes.154 * that the stream cleanup must invoke when the stream closes.
@@ -67,7 +161,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 6 unmodified lines
67 }, KEEPALIVE_INTERVAL_MS);161 }, KEEPALIVE_INTERVAL_MS);
68 return handle;162 return handle;
69 }163 }
70@@ -180,10 +184,15 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {164@@ -180,10 +227,30 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
71 });165 });
72 return new Response(body, { status: 404 });166 return new Response(body, { status: 404 });
73 }167 }
@@ -83,11 +177,26 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines
83+ let __writeChain = Promise.resolve();177+ let __writeChain = Promise.resolve();
84+ let __sseClosed = false;178+ let __sseClosed = false;
85+ let keepAlive;179+ let keepAlive;
180+ let __responseDeadline;
181+ /**
182+ * JSON-RPC ids this POST still owes the client a response for.
183+ * Entries are removed as the DO reports responses (the
184+ * `respondedIds` field writeSSEEvent puts on the envelope).
185+ *
186+ * A non-empty set when the bridge finishes abnormally is exactly
187+ * the "lost execution" case: the client already holds a 200 with
188+ * an open SSE body, so a silently ended stream leaves it waiting
189+ * forever. Keyed by type+value because JSON-RPC ids may be
190+ * strings or numbers and 1 must not match "1".
191+ */
192+ const __outstanding = new Map();
193+ const __idKey = (id) => `${typeof id}:${String(id)}`;
194+ let __finished = false;
86+ const existingHeaders = {};195+ const existingHeaders = {};
87 request.headers.forEach((value, key) => {196 request.headers.forEach((value, key) => {
88 existingHeaders[key] = value;197 existingHeaders[key] = value;
89 });198 });
90@@ -206,45 +215,99 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {199@@ -206,47 +273,159 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
91 jsonrpc: "2.0"200 jsonrpc: "2.0"
92 });201 });
93 return new Response(body, { status: 500 });202 return new Response(body, { status: 500 });
@@ -101,8 +210,10 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 7 unmodified lines
101+ const __closeSse = () => {210+ const __closeSse = () => {
102+ if (__sseClosed) return;211+ if (__sseClosed) return;
103+ __sseClosed = true;212+ __sseClosed = true;
213+ __finished = true;
104+ try {214+ try {
105+ clearInterval(keepAlive);215+ clearInterval(keepAlive);
216+ clearTimeout(__responseDeadline);
106+ } catch {}217+ } catch {}
107+ try {218+ try {
108+ ws.close(1013, "SSE client not draining");219+ ws.close(1013, "SSE client not draining");
@@ -112,8 +223,10 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 3 unmodified lines
112+ const __markSseClientClosed = () => {223+ const __markSseClientClosed = () => {
113+ if (__sseClosed) return;224+ if (__sseClosed) return;
114+ __sseClosed = true;225+ __sseClosed = true;
226+ __finished = true;
115+ try {227+ try {
116+ clearInterval(keepAlive);228+ clearInterval(keepAlive);
229+ clearTimeout(__responseDeadline);
117+ } catch {}230+ } catch {}
118+ writer.abort(new Error("SSE client disconnected")).catch(() => {});231+ writer.abort(new Error("SSE client disconnected")).catch(() => {});
119+ };232+ };
@@ -148,6 +261,44 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 28 unmodified lines
148- ws.addEventListener("message", (event) => {261- ws.addEventListener("message", (event) => {
149+ });262+ });
150+ }263+ }
264+ for (const msg of messages) if (isJSONRPCRequest(msg)) __outstanding.set(__idKey(msg.id), msg.id);
265+ /**
266+ * End the response stream when it will not end on its own, and
267+ * answer whatever is still outstanding first.
268+ *
269+ * The Durable Object can be reset out from under an in-flight
270+ * execute (isolate memory/CPU limit, storage timeout, deploy). The
271+ * front worker has already returned HTTP 200 with an SSE body, so
272+ * before this the WS close simply closed the writer and the client
273+ * saw a cleanly terminated stream carrying no JSON-RPC response —
274+ * indistinguishable from "still working" until its own timeout.
275+ *
276+ * The happy path is byte-identical: every id has been reported
277+ * responded by then, so the set is empty and nothing is written.
278+ */
279+ const __finishAbnormally = async (reason) => {
280+ if (__finished) return;
281+ __finished = true;
282+ clearTimeout(__responseDeadline);
283+ clearInterval(keepAlive);
284+ if (__outstanding.size > 0) {
285+ console.warn(JSON.stringify({
286+ event: "mcp_post_stream_lost",
287+ outstandingCount: __outstanding.size,
288+ reason,
289+ sessionId
290+ }));
291+ for (const id of __outstanding.values()) __forwardSse(encoder.encode(`event: message\ndata: ${JSON.stringify(sessionResetErrorResponse(id, reason))}\n\n`));
292+ __outstanding.clear();
293+ }
294+ const pending = __writeChain;
295+ __sseClosed = true;
296+ await pending.catch(() => {});
297+ await writer.close().catch(() => {});
298+ };
299+ __responseDeadline = setTimeout(() => {
300+ __finishAbnormally("response_deadline").catch(console.error);
301+ }, MCP_POST_RESPONSE_DEADLINE_MS);
151+ ws.addEventListener("message", (event) => {302+ ws.addEventListener("message", (event) => {
152 async function onMessage(event) {303 async function onMessage(event) {
153 try {304 try {
@@ -163,8 +314,11 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines
163+ const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);314+ const data = typeof event.data === "string" ? event.data : new TextDecoder().decode(event.data);
164+ const message = JSON.parse(data);315+ const message = JSON.parse(data);
165+ if (message.type !== "cf_mcp_agent_event") return;316+ if (message.type !== "cf_mcp_agent_event") return;
317+ if (Array.isArray(message.respondedIds)) for (const respondedId of message.respondedIds) __outstanding.delete(__idKey(respondedId));
166+ const writePromise = __forwardSse(encoder.encode(message.event));318+ const writePromise = __forwardSse(encoder.encode(message.event));
167+ if (message.close) {319+ if (message.close) {
320+ __finished = true;
321+ clearTimeout(__responseDeadline);
168+ clearInterval(keepAlive);322+ clearInterval(keepAlive);
169+ await writePromise;323+ await writePromise;
170+ await writer.close();324+ await writer.close();
@@ -195,9 +349,9 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 24 unmodified lines
195- await writer.close().catch(() => {});349- await writer.close().catch(() => {});
196+ ws.addEventListener("error", (error) => {350+ ws.addEventListener("error", (error) => {
197+ async function onError(_error) {351+ async function onError(_error) {
198+ __sseClosed = true;352+ // The DO end of the bridge failed. Nothing still
199+ clearInterval(keepAlive);353+ // outstanding will ever be answered.
200+ await writer.close().catch(() => {});354+ await __finishAbnormally("session_reset");
201 }355 }
202 onError(error).catch(console.error);356 onError(error).catch(console.error);
203 });357 });
@@ -205,15 +359,31 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 1 unmodified line
205- async function onClose() {359- async function onClose() {
206- clearInterval(keepAlive);360- clearInterval(keepAlive);
207- await writer.close().catch(() => {});361- await writer.close().catch(() => {});
208+ ws.addEventListener("close", () => {362+ ws.addEventListener("close", (event) => {
209+ async function onClose() {363+ async function onClose(closeEvent) {
210+ __sseClosed = true;364+ // 1000/"SSE response delivered" is this bridge's OWN
211+ clearInterval(keepAlive);365+ // happy-path close coming back round. Every other code —
212+ await writer.close().catch(() => {});366+ // including no code at all, which is what a reset Durable
367+ // Object produces — means the far end went away, so
368+ // anything unanswered gets a session_reset error. The
369+ // outstanding set makes this safe either way: after a
370+ // normal delivery it is empty and nothing is written.
371+ if (closeEvent?.code === 1000 && closeEvent?.reason === "SSE response delivered") {
372+ __finished = true;
373+ clearTimeout(__responseDeadline);
374+ __sseClosed = true;
375+ clearInterval(keepAlive);
376+ await writer.close().catch(() => {});
377+ return;
378+ }
379+ await __finishAbnormally("session_reset");
213 }380 }
214 onClose().catch(console.error);381- onClose().catch(console.error);
382+ onClose(event).catch(console.error);
215 });383 });
216@@ -279,10 +342,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {384 return new Response(readable, {
385 headers: {
386@@ -279,10 +458,16 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
217 id: null,387 id: null,
218 jsonrpc: "2.0"388 jsonrpc: "2.0"
219 }), { status: 400 });389 }), { status: 400 });
@@ -234,7 +404,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 14 unmodified lines
234 props: ctx.props,404 props: ctx.props,
235 jurisdiction: options.jurisdiction405 jurisdiction: options.jurisdiction
236 });406 });
237@@ -306,27 +375,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {407@@ -306,27 +491,116 @@ const createStreamingHttpHandler = (basePath, namespace, options = {}) => {
238 if (!ws) {408 if (!ws) {
239 await writer.close();409 await writer.close();
240 return new Response("Failed to establish WS to DO", { status: 500 });410 return new Response("Failed to establish WS to DO", { status: 500 });
@@ -366,7 +536,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 125 unmodified lines
366 return new Response(readable, {536 return new Response(readable, {
367 headers: {537 headers: {
368 "Cache-Control": "no-cache",538 "Cache-Control": "no-cache",
369@@ -389,10 +547,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {539@@ -389,10 +663,16 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
370 const url = new URL(request.url);540 const url = new URL(request.url);
371 if (request.method === "GET" && basePattern.test(url)) {541 if (request.method === "GET" && basePattern.test(url)) {
372 const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString();542 const sessionId = url.searchParams.get("sessionId") || namespace.newUniqueId().toString();
@@ -387,7 +557,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 14 unmodified lines
387 endpointUrl.pathname = encodeURI(`${basePath}/message`);557 endpointUrl.pathname = encodeURI(`${basePath}/message`);
388 endpointUrl.searchParams.set("sessionId", sessionId);558 endpointUrl.searchParams.set("sessionId", sessionId);
389 const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`;559 const endpointMessage = `event: endpoint\ndata: ${endpointUrl.pathname + endpointUrl.search + endpointUrl.hash}\n\n`;
390@@ -414,35 +578,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {560@@ -414,35 +694,94 @@ const createLegacySseHandler = (basePath, namespace, options = {}) => {
391 console.error("Failed to establish WebSocket connection");561 console.error("Failed to establish WebSocket connection");
392 await writer.close();562 await writer.close();
393 return new Response("Failed to establish WebSocket connection", { status: 500 });563 return new Response("Failed to establish WebSocket connection", { status: 500 });
@@ -500,15 +670,16 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 106 unmodified lines
500 console.error("Error closing SSE connection:", error);670 console.error("Error closing SSE connection:", error);
501 }671 }
502 }672 }
503@@ -586,6 +809,7 @@ var StreamableHTTPServerTransport = class {673@@ -586,6 +925,8 @@ var StreamableHTTPServerTransport = class {
504 constructor(options) {674 constructor(options) {
505 this._started = false;675 this._started = false;
506 this._streamResponseIds = /* @__PURE__ */ new Map();676 this._streamResponseIds = /* @__PURE__ */ new Map();
507+ this._replayInFlight = /* @__PURE__ */ new Map();677+ this._replayInFlight = /* @__PURE__ */ new Map();
678+ this._sweptStaleStreams = /* @__PURE__ */ new Set();
508 const { agent } = getCurrentAgent();679 const { agent } = getCurrentAgent();
509 if (!agent) throw new Error("McpAgent was not found in Transport constructor");680 if (!agent) throw new Error("McpAgent was not found in Transport constructor");
510 this._agent = agent;681 this._agent = agent;
511@@ -627,23 +851,74 @@ var StreamableHTTPServerTransport = class {682@@ -627,23 +968,146 @@ var StreamableHTTPServerTransport = class {
512 const resumedStreamId = await this._eventStore.getStreamIdForEventId?.(lastEventId);683 const resumedStreamId = await this._eventStore.getStreamIdForEventId?.(lastEventId);
513 if (resumedStreamId) {684 if (resumedStreamId) {
514 const resumeState = { streamId: resumedStreamId };685 const resumeState = { streamId: resumedStreamId };
@@ -535,6 +706,14 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 20 unmodified lines
535+ const ackStreamIds = [];706+ const ackStreamIds = [];
536+ const replayedResponse = await this.replayEvents(lastEventId);707+ const replayedResponse = await this.replayEvents(lastEventId);
537+ if (resumedStreamId !== STANDALONE_STREAM_ID && replayedResponse) ackStreamIds.push(resumedStreamId);708+ if (resumedStreamId !== STANDALONE_STREAM_ID && replayedResponse) ackStreamIds.push(resumedStreamId);
709+ // Nothing to replay on a POST stream means one of two things: the
710+ // work is still running in THIS incarnation, in which case the
711+ // stream must stay attached so the response routes here, or the
712+ // incarnation that owned it is gone and no response will ever
713+ // exist. Only the second is swept, and only for the stream this
714+ // cursor names. Before this, that case fell through with no close
715+ // frame and the recovery GET hung open on keepalives.
716+ if (resumedStreamId !== STANDALONE_STREAM_ID && !replayedResponse && (await this.failStaleEpochRequests(agent, connection, resumedStreamId)).length > 0) ackStreamIds.push(resumedStreamId);
538+ // Last-Event-ID identifies one disconnected stream. Keep replay717+ // Last-Event-ID identifies one disconnected stream. Keep replay
539+ // scoped to that stream: mixing another POST's response into this718+ // scoped to that stream: mixing another POST's response into this
540+ // recovery stream can make a client stop reconnecting before the719+ // recovery stream can make a client stop reconnecting before the
@@ -565,12 +744,17 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 24 unmodified lines
565 };744 };
566 connection.setState(standaloneState);745 connection.setState(standaloneState);
567+ const replayedStreamIds = await this.replayUndeliveredResponsesOnFreshGet(agent, connection);746+ const replayedStreamIds = await this.replayUndeliveredResponsesOnFreshGet(agent, connection);
747+ // The standalone stream is also where orphans go. A POST stranded by a
748+ // reset may have no client left holding its cursor (its own recovery GET
749+ // never came), so every stale-epoch id is answered here instead. Both
750+ // sweeps delete the row, so an id is answered at most once.
751+ const staleStreamIds = await this.failStaleEpochRequests(agent, connection);
568+ // A GET without Last-Event-ID has no identified stream to preserve, so752+ // A GET without Last-Event-ID has no identified stream to preserve, so
569+ // retain the existing fallback that drains completed POST responses.753+ // retain the existing fallback that drains completed POST responses.
570+ // Same delivery-confirmed clearing as the resume branch above. When754+ // Same delivery-confirmed clearing as the resume branch above. When
571+ // nothing was replayed no close frame is sent and this stays the755+ // nothing was replayed or swept no close frame is sent and this stays
572+ // session's long-lived standalone listener.756+ // the session's long-lived standalone listener.
573+ if (replayedStreamIds.length > 0) this.sendReplayComplete(connection, replayedStreamIds);757+ if (replayedStreamIds.length > 0 || staleStreamIds.length > 0) this.sendReplayComplete(connection, [...replayedStreamIds, ...staleStreamIds]);
574+ }758+ }
575+ /**759+ /**
576+ * Ask the Worker bridge to flush everything queued on `connection`,760+ * Ask the Worker bridge to flush everything queued on `connection`,
@@ -586,10 +770,69 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines
586+ ackStreamIds,770+ ackStreamIds,
587+ close: true771+ close: true
588+ }));772+ }));
773+ }
774+ /**
775+ * Answer every request left stranded by a PREVIOUS Durable Object
776+ * incarnation, and retire its ledger row.
777+ *
778+ * A `__mcp_stream_reqs__:<streamId>` row exists only while a POST's requests
779+ * are unanswered — writing the final response deletes it. Each row is stamped
780+ * with the epoch of the incarnation that accepted the request, so a row whose
781+ * epoch is older than the running one is proof that the isolate which was
782+ * going to produce that response no longer exists (memory/CPU limit, storage
783+ * timeout, deploy). No code path will ever answer it.
784+ *
785+ * Same-epoch rows are deliberately left alone: a browser-approval pause
786+ * legitimately holds a row open for minutes inside one incarnation, and that
787+ * work is still live.
788+ *
789+ * `onlyStreamId` scopes the sweep to the stream a Last-Event-ID cursor
790+ * identifies, so a recovery GET is never handed an unrelated request's error
791+ * (see the replay-scoping note in handleGetRequest).
792+ *
793+ * Returns the stream ids it swept, so the caller can send the close frame
794+ * that makes the client's recovery GET end instead of hanging on keepalives.
795+ */
796+ async failStaleEpochRequests(agent, connection, onlyStreamId) {
797+ if (typeof agent.getStaleEpochStreamRequestIds !== "function") return [];
798+ const stale = await agent.getStaleEpochStreamRequestIds();
799+ const sweptStreamIds = [];
800+ for (const row of stale) {
801+ if (onlyStreamId !== void 0 && row.streamId !== onlyStreamId) continue;
802+ // Claimed synchronously, before the first await below: a client may
803+ // hold two listeners (mcp-remote does), so two GETs can read the same
804+ // rows and interleave at the delete. Without this the client would
805+ // receive the same error response twice for one request id.
806+ if (this._sweptStaleStreams.has(row.streamId)) continue;
807+ this._sweptStaleStreams.add(row.streamId);
808+ console.warn(JSON.stringify({
809+ event: "mcp_stale_epoch_requests_failed",
810+ currentEpoch: row.currentEpoch,
811+ requestIds: row.requestIds,
812+ rowEpoch: row.epoch,
813+ streamId: row.streamId
814+ }));
815+ for (const requestId of row.requestIds) try {
816+ // No event id on purpose: this error belongs to a stream whose
817+ // event log is not this connection's cursor space, and it must
818+ // not be replayed to a later reconnect as that stream's history.
819+ this.writeSSEEvent(connection, sessionResetErrorResponse(requestId, "session_reset"));
820+ } catch (error) {
821+ this.onerror?.(error);
822+ }
823+ // Dropped now rather than on the delivery ack (unlike a replayed
824+ // response, which is idempotent and worth retrying): a surviving row
825+ // would be re-swept and re-errored on every later GET. A client that
826+ // missed this write gets the same answer from the POST bridge's own
827+ // close/deadline synthesis.
828+ await agent.deleteStreamRequestIds(row.streamId);
829+ sweptStreamIds.push(row.streamId);
830+ }
831+ return sweptStreamIds;
589 }832 }
590 /**833 /**
591 * Close any connection (other than `selfId`) currently bound to834 * Close any connection (other than `selfId`) currently bound to
592@@ -651,6 +926,9 @@ var StreamableHTTPServerTransport = class {835@@ -651,6 +1115,9 @@ var StreamableHTTPServerTransport = class {
593 * Closing rather than mutating sibling state mirrors how the SDK's836 * Closing rather than mutating sibling state mirrors how the SDK's
594 * single `_streamMapping` entry gives last-writer-wins for free, and837 * single `_streamMapping` entry gives last-writer-wins for free, and
595 * keeps `send()` from routing to a stale bridge.838 * keeps `send()` from routing to a stale bridge.
@@ -599,7 +842,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 3 unmodified lines
599 */842 */
600 supersedePriorStreamConnections(agent, selfId, streamId) {843 supersedePriorStreamConnections(agent, selfId, streamId) {
601 for (const other of agent.getConnections()) {844 for (const other of agent.getConnections()) {
602@@ -664,12 +942,14 @@ var StreamableHTTPServerTransport = class {845@@ -664,12 +1131,14 @@ var StreamableHTTPServerTransport = class {
603 * Only used when resumability is enabled846 * Only used when resumability is enabled
604 */847 */
605 async replayEvents(lastEventId) {848 async replayEvents(lastEventId) {
@@ -615,7 +858,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 9 unmodified lines
615 this.writeSSEEvent(connection, message, eventId);858 this.writeSSEEvent(connection, message, eventId);
616 } catch (error) {859 } catch (error) {
617 this.onerror?.(error);860 this.onerror?.(error);
618@@ -678,6 +958,45 @@ var StreamableHTTPServerTransport = class {861@@ -678,6 +1147,45 @@ var StreamableHTTPServerTransport = class {
619 } catch (error) {862 } catch (error) {
620 this.onerror?.(error);863 this.onerror?.(error);
621 }864 }
@@ -661,12 +904,16 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 39 unmodified lines
661 }904 }
662 /**905 /**
663 * Writes an event to the SSE stream with proper formatting906 * Writes an event to the SSE stream with proper formatting
664@@ -689,10 +1008,65 @@ var StreamableHTTPServerTransport = class {907@@ -689,10 +1197,69 @@ var StreamableHTTPServerTransport = class {
665 return connection.send(JSON.stringify({908 return connection.send(JSON.stringify({
666 type: "cf_mcp_agent_event",909 type: "cf_mcp_agent_event",
667 event: eventData,910 event: eventData,
668+ eventId,911+ eventId,
669+ streamId: eventId ? eventId.slice(0, eventId.lastIndexOf(":")) : void 0,912+ streamId: eventId ? eventId.slice(0, eventId.lastIndexOf(":")) : void 0,
913+ // Lets the POST bridge retire the request ids it is holding without
914+ // re-parsing the SSE frame it is forwarding. An id still outstanding
915+ // when that bridge's WS dies is what it synthesizes an error for.
916+ respondedIds: isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message) ? [message.id] : void 0,
670 close917 close
671 }));918 }));
672 }919 }
@@ -727,7 +974,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 54 unmodified lines
727 * Handles POST requests containing JSON-RPC messages974 * Handles POST requests containing JSON-RPC messages
728 */975 */
729 async handlePostRequest(req, parsedBody) {976 async handlePostRequest(req, parsedBody) {
730@@ -733,6 +1107,22 @@ var StreamableHTTPServerTransport = class {977@@ -733,6 +1300,22 @@ var StreamableHTTPServerTransport = class {
731 };978 };
732 connection.setState(postState);979 connection.setState(postState);
733 if (this._eventStore) await agent.setStreamRequestIds(streamId, requestIds);980 if (this._eventStore) await agent.setStreamRequestIds(streamId, requestIds);
@@ -750,7 +997,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 16 unmodified lines
750 for (const message of messages) {997 for (const message of messages) {
751 if (this.messageInterceptor) {998 if (this.messageInterceptor) {
752 if (await this.messageInterceptor(message, {999 if (await this.messageInterceptor(message, {
753@@ -760,7 +1150,22 @@ var StreamableHTTPServerTransport = class {1000@@ -760,7 +1343,22 @@ var StreamableHTTPServerTransport = class {
754 * when the originating WS has dropped.1001 * when the originating WS has dropped.
755 */1002 */
756 async sendOnStream(agent, streamId, relatedIds, liveConnection, message, requestId) {1003 async sendOnStream(agent, streamId, relatedIds, liveConnection, message, requestId) {
@@ -774,7 +1021,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 17 unmodified lines
774 let shouldClose = false;1021 let shouldClose = false;
775 if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {1022 if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) {
776 let responseIds = this._streamResponseIds.get(streamId);1023 let responseIds = this._streamResponseIds.get(streamId);
777@@ -777,9 +1182,11 @@ var StreamableHTTPServerTransport = class {1024@@ -777,9 +1375,11 @@ var StreamableHTTPServerTransport = class {
778 } catch (error) {1025 } catch (error) {
779 this.onerror?.(error);1026 this.onerror?.(error);
780 }1027 }
@@ -788,7 +1035,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 7 unmodified lines
788 }1035 }
789 }1036 }
790 async send(message, options) {1037 async send(message, options) {
791@@ -798,14 +1205,19 @@ var StreamableHTTPServerTransport = class {1038@@ -798,14 +1398,19 @@ var StreamableHTTPServerTransport = class {
792 *1039 *
793 * Sent on exactly one stream, per MCP: "the server MUST send each of1040 * Sent on exactly one stream, per MCP: "the server MUST send each of
794 * its JSON-RPC messages on only one of the connected streams; it MUST1041 * its JSON-RPC messages on only one of the connected streams; it MUST
@@ -812,7 +1059,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 17 unmodified lines
812 if (standalone) this.writeSSEEvent(standalone, message, eventId);1059 if (standalone) this.writeSSEEvent(standalone, message, eventId);
813 }1060 }
814 /**1061 /**
815@@ -861,12 +1273,10 @@ var StreamableHTTPServerTransport = class {1062@@ -861,12 +1466,10 @@ var StreamableHTTPServerTransport = class {
816 *1063 *
817 * ## Lifecycle1064 * ## Lifecycle
818 *1065 *
@@ -829,7 +1076,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 10 unmodified lines
829 *1076 *
830 * Standalone GET stream events (`_GET_stream`) are *not* cleared1077 * Standalone GET stream events (`_GET_stream`) are *not* cleared
831 * automatically; they accumulate for the lifetime of the DO. Bounded1078 * automatically; they accumulate for the lifetime of the DO. Bounded
832@@ -893,12 +1303,34 @@ var DurableObjectEventStore = class DurableObjectEventStore {1079@@ -893,12 +1496,34 @@ var DurableObjectEventStore = class DurableObjectEventStore {
833 }1080 }
834 async storeEvent(streamId, message) {1081 async storeEvent(streamId, message) {
835 if (streamId.includes(":")) throw new Error(`DurableObjectEventStore: streamId must not contain ':' (got ${JSON.stringify(streamId)})`);1082 if (streamId.includes(":")) throw new Error(`DurableObjectEventStore: streamId must not contain ':' (got ${JSON.stringify(streamId)})`);
@@ -864,7 +1111,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 28 unmodified lines
864 return eventId;1111 return eventId;
865 }1112 }
866 async getStreamIdForEventId(eventId) {1113 async getStreamIdForEventId(eventId) {
867@@ -915,9 +1347,59 @@ var DurableObjectEventStore = class DurableObjectEventStore {1114@@ -915,9 +1540,59 @@ var DurableObjectEventStore = class DurableObjectEventStore {
868 start: startKey,1115 start: startKey,
869 limit: DurableObjectEventStore.REPLAY_LIMIT1116 limit: DurableObjectEventStore.REPLAY_LIMIT
870 });1117 });
@@ -925,7 +1172,7 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 54 unmodified lines
925 /**1172 /**
926 * Drop the event log for a single stream. Called by the transport1173 * Drop the event log for a single stream. Called by the transport
927 * immediately after a POST's final response has been written to the1174 * immediately after a POST's final response has been written to the
928@@ -973,6 +1455,13 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:";1175@@ -973,6 +1648,13 @@ DurableObjectEventStore.EVENT_KEY_PREFIX = "__mcp_event__:";
929 DurableObjectEventStore.SEQ_PAD = 16;1176 DurableObjectEventStore.SEQ_PAD = 16;
930 DurableObjectEventStore.DELETE_CHUNK = 128;1177 DurableObjectEventStore.DELETE_CHUNK = 128;
931 DurableObjectEventStore.REPLAY_LIMIT = 1e3;1178 DurableObjectEventStore.REPLAY_LIMIT = 1e3;
@@ -939,7 +1186,106 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 7 unmodified lines
939 //#endregion1186 //#endregion
940 //#region src/mcp/client-transports.ts1187 //#region src/mcp/client-transports.ts
941 /**1188 /**
942@@ -1381,6 +1870,48 @@ var McpAgent = class McpAgent extends Agent {1189@@ -1355,6 +2037,26 @@ function experimental_createMcpHandler(server, options = {}) {
1190 }
1191 //#endregion
1192 //#region src/mcp/index.ts
1193+/**
1194+* Normalize a `__mcp_stream_reqs__:` ledger row.
1195+*
1196+* Rows were a bare `requestIds` array before they carried an epoch. A row still
1197+* in that shape can only have been written by an earlier deployment — that is,
1198+* by an earlier incarnation — so epoch 0, below every real epoch (they start at
1199+* 1), is exactly the right reading: the orphan sweep retires it. No data
1200+* migration is needed because a row's whole lifetime is one unanswered POST.
1201+*/
1202+function readStreamRequestIdsRow(value) {
1203+ if (Array.isArray(value)) return {
1204+ epoch: 0,
1205+ requestIds: value
1206+ };
1207+ if (value !== null && typeof value === "object" && Array.isArray(value.requestIds)) return {
1208+ epoch: typeof value.epoch === "number" ? value.epoch : 0,
1209+ requestIds: value.requestIds
1210+ };
1211+ return void 0;
1212+}
1213 var McpAgent = class McpAgent extends Agent {
1214 constructor(..._args) {
1215 super(..._args);
1216@@ -1369,18 +2071,121 @@ var McpAgent = class McpAgent extends Agent {
1217 async getInitializeRequest() {
1218 return this.ctx.storage.get("initializeRequest");
1219 }
1220- /** Persist the `requestIds` for a POST stream. @internal */
1221+ /**
1222+ * Monotonic id for THIS Durable Object incarnation, bumped exactly once per
1223+ * live instance the first time anything asks for it (the promise is
1224+ * memoized on the instance, so concurrent first callers share one bump).
1225+ *
1226+ * A reset — isolate memory/CPU limit, storage timeout, deploy — throws the
1227+ * instance away and constructs a new one, so the next read bumps again and
1228+ * every ledger row written by the dead incarnation becomes recognisably
1229+ * stale.
1230+ *
1231+ * Deliberately NOT hung off onStart()/init(): both re-run inside a single
1232+ * incarnation (a restore-after-idle-dispose calls onStart again), which
1233+ * would strand still-running work as "stale". onStart does force the first
1234+ * read, so the stored value is current before any request handler runs and
1235+ * readers outside this class can compare against it.
1236+ * @internal
1237+ */
1238+ currentSessionEpoch() {
1239+ this._sessionEpoch ??= (async () => {
1240+ const stored = await this.ctx.storage.get(McpAgent.SESSION_EPOCH_KEY);
1241+ const next = (typeof stored === "number" && Number.isFinite(stored) ? stored : 0) + 1;
1242+ await this.ctx.storage.put(McpAgent.SESSION_EPOCH_KEY, next);
1243+ return next;
1244+ })();
1245+ return this._sessionEpoch;
1246+ }
1247+ /** Persist the `requestIds` for a POST stream, stamped with the incarnation
1248+ * that accepted them so a reset leaves a recognisable orphan. @internal */
1249 async setStreamRequestIds(streamId, requestIds) {
1250- await this.ctx.storage.put(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`, requestIds);
1251+ const epoch = await this.currentSessionEpoch();
1252+ await this.ctx.storage.put(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`, {
1253+ epoch,
1254+ requestIds
1255+ });
1256 }
1257 /** Read the persisted `requestIds` for a POST stream. @internal */
1258 async getStreamRequestIds(streamId) {
1259- return this.ctx.storage.get(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`);
1260+ return readStreamRequestIdsRow(await this.ctx.storage.get(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`))?.requestIds;
1261+ }
1262+ /**
1263+ * Ledger rows stamped by an earlier incarnation: requests dispatched into an
1264+ * isolate that no longer exists, so nothing will ever produce a response for
1265+ * them. Read-only — the caller must write the client's error before it
1266+ * deletes anything. @internal
1267+ */
1268+ async getStaleEpochStreamRequestIds() {
1269+ const currentEpoch = await this.currentSessionEpoch();
1270+ const rows = await this.ctx.storage.list({
1271+ prefix: McpAgent.STREAM_REQS_KEY_PREFIX,
1272+ limit: 1e3
1273+ });
1274+ const stale = [];
1275+ for (const [key, value] of rows) {
1276+ const row = readStreamRequestIdsRow(value);
1277+ if (!row || row.requestIds.length === 0) continue;
1278+ if (row.epoch >= currentEpoch) continue;
1279+ stale.push({
1280+ currentEpoch,
1281+ epoch: row.epoch,
1282+ requestIds: row.requestIds,
1283+ streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length)
1284+ });
1285+ }
1286+ return stale;
1287 }
1288 /** Drop the persisted `requestIds` for a POST stream. @internal */
943 async deleteStreamRequestIds(streamId) {1289 async deleteStreamRequestIds(streamId) {
944 await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`);1290 await this.ctx.storage.delete(`${McpAgent.STREAM_REQS_KEY_PREFIX}${streamId}`);
945 }1291 }
@@ -962,10 +1308,14 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 16 unmodified lines
962+ prefix: McpAgent.STREAM_REQS_KEY_PREFIX,1308+ prefix: McpAgent.STREAM_REQS_KEY_PREFIX,
963+ limit: 1e31309+ limit: 1e3
964+ });1310+ });
965+ return [...rows].flatMap(([key, requestIds]) => Array.isArray(requestIds) && requestIds.length > 0 ? [{1311+ return [...rows].flatMap(([key, value]) => {
966+ streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length),1312+ const row = readStreamRequestIdsRow(value);
967+ requestIds1313+ return row && row.requestIds.length > 0 ? [{
968+ }] : []);1314+ epoch: row.epoch,
1315+ streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length),
1316+ requestIds: row.requestIds
1317+ }] : [];
1318+ });
969+ }1319+ }
970+ async acknowledgeDeliveredStream(streamId) {1320+ async acknowledgeDeliveredStream(streamId) {
971+ await this.deleteStreamRequestIds(streamId);1321+ await this.deleteStreamRequestIds(streamId);
@@ -988,7 +1338,39 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 16 unmodified lines
988 /**1338 /**
989 * Reverse lookup: find which POST stream a given `requestId` belongs1339 * Reverse lookup: find which POST stream a given `requestId` belongs
990 * to, and return the stream's full `requestIds` list in the same1340 * to, and return the stream's full `requestIds` list in the same
991@@ -1516,23 +2047,36 @@ var McpAgent = class McpAgent extends Agent {1341@@ -1407,10 +2212,14 @@ var McpAgent = class McpAgent extends Agent {
1342 limit: STREAM_REQS_SCAN_LIMIT
1343 });
1344 if (rows.size === STREAM_REQS_SCAN_LIMIT) console.warn(`McpAgent: getStreamForRequestId hit the ${STREAM_REQS_SCAN_LIMIT}-key scan cap; stale __mcp_stream_reqs__ entries may be accumulating from abandoned POSTs`);
1345- for (const [key, requestIds] of rows) if (requestIds?.includes(requestId)) return {
1346- streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length),
1347- requestIds
1348- };
1349+ for (const [key, value] of rows) {
1350+ const row = readStreamRequestIdsRow(value);
1351+ if (!row?.requestIds.includes(requestId)) continue;
1352+ return {
1353+ streamId: key.slice(McpAgent.STREAM_REQS_KEY_PREFIX.length),
1354+ requestIds: row.requestIds
1355+ };
1356+ }
1357 }
1358 /** Read the transport type for this agent.
1359 * This relies on the naming scheme being `sse:${sessionId}`,
1360@@ -1498,6 +2307,12 @@ var McpAgent = class McpAgent extends Agent {
1361 }
1362 /** Sets up the MCP transport and server every time the Agent is started.*/
1363 async onStart(props) {
1364+ // Force the incarnation epoch before anything else can read or write the
1365+ // request-id ledger. partyserver runs onStart inside
1366+ // blockConcurrencyWhile at every entry point, so from here on the stored
1367+ // epoch IS this incarnation's, and readers outside this class (the DO's
1368+ // idle-lease accounting) can compare row epochs against storage directly.
1369+ await this.currentSessionEpoch();
1370 if (props) await this.updateProps(props);
1371 else this.props = await this.ctx.storage.get("props");
1372 await this.init();
1373@@ -1516,23 +2331,36 @@ var McpAgent = class McpAgent extends Agent {
992 return;1374 return;
993 }1375 }
994 break;1376 break;
@@ -1040,14 +1422,15 @@ index 1edcf0c8c9e67aa211ae515e7672cdf79912101e..cb04fc39d14db9f7fcdfa2613b1c5bab · 45 unmodified lines
1040 }1422 }
1041 }1423 }
1042 }1424 }
1043@@ -1697,7 +2241,8 @@ var McpAgent = class McpAgent extends Agent {1425@@ -1697,7 +2525,9 @@ var McpAgent = class McpAgent extends Agent {
1044 }1426 }
1045 };1427 };
1046 McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";1428 McpAgent.STREAM_REQS_KEY_PREFIX = "__mcp_stream_reqs__:";
1047+McpAgent.UNDELIVERED_STREAM_KEY_PREFIX = "__mcp_undelivered_stream__:";1429+McpAgent.UNDELIVERED_STREAM_KEY_PREFIX = "__mcp_undelivered_stream__:";
1430+McpAgent.SESSION_EPOCH_KEY = "__mcp_session_epoch__";
1048 //#endregion1431 //#endregion
1049-export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };1432-export { DurableObjectEventStore, ElicitRequestSchema, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
1050+export { DurableObjectEventStore, ElicitRequestSchema, MAX_SSE_AGE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };1433+export { DurableObjectEventStore, ElicitRequestSchema, MAX_SSE_AGE_MS, MCP_POST_RESPONSE_DEADLINE_MS, MCP_SERVER_ID_MAX_LENGTH, McpAgent, RPCClientTransport, RPCServerTransport, RPC_DO_PREFIX, SSEEdgeClientTransport, StreamableHTTPEdgeClientTransport, WorkerTransport, createMcpHandler, experimental_createMcpHandler, getMcpAuthContext, normalizeServerId };
1051 1434
1052 //# sourceMappingURL=index.js.map1435 //# sourceMappingURL=index.js.map
1053\ No newline at end of file1436\ No newline at end of file

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t