History

Add failing e2e: paused approval must survive the session runtime idle teardown

Reproduces the reported bug: with model elicitation over streamable HTTP, execute returns a paused executionId, but the session DO's 5-minute idle alarm calls closeRuntime() and rebuilds the engine from storage on the next request — the in-memory pause map (fiber + Deferred) is gone, so resume answers 'No paused execution: exec_1'. A 20-second gap passes (the runtime is still warm); the 6-minute gap fails deterministically, so the loss is the app's own idle teardown, not a workerd eviction race.

Rhys Sullivan committed 5 months ago

Commit f3ed1ab1 parent 018e4961 file · +68 −0Browse files

Unified · Split

e2e/cloud/mcp-client-sessions.test.ts+68−0
@@ -213,6 +213,74 @@ const result = await tools.executor.coreTools.policies.list({}); · 212 unmodified lines
213return JSON.stringify(result);213return JSON.stringify(result);
214`;214`;
215 215
216const executionIdOf = (text: string): string | undefined =>
217 /\bexecutionId:\s*(\S+)/.exec(text)?.[1];
218
219// The session DO tears its runtime down after 5 minutes without a request
220// (SESSION_TIMEOUT_MS in McpSessionDOBase) and rebuilds it from storage on
221// the next one — the same engine-state wipe a workerd eviction or a deploy
222// causes. A user who takes a few minutes to answer an approval crosses this
223// gap on every paused execution.
224const IDLE_TEARDOWN_GAP = "6 minutes";
225
226scenario(
227 "MCP sessions · a paused approval survives the session runtime idle teardown and resumes",
228 { timeout: 480_000 },
229 Effect.gen(function* () {
230 const target = yield* Target;
231 const { client } = yield* Api;
232 const mcp = yield* Mcp;
233 const identity = yield* target.newIdentity();
234 const api = yield* client(coreApi, identity);
235 const bearer = yield* mcp.mintBearer(emailOf(identity));
236
237 const policy = yield* api.policies.create({
238 payload: { owner: "org", pattern: APPROVAL_TARGET_TOOL, action: "require_approval" },
239 });
240
241 yield* Effect.gen(function* () {
242 const first = yield* Effect.promise(() => connectClient(target.mcpUrl, bearer));
243 const sessionId = first.transport.sessionId;
244 const paused = yield* Effect.promise(() =>
245 first.client.callTool({ name: "execute", arguments: { code: GATED_CODE } }),
246 ).pipe(Effect.ensuring(closeQuietly(first)));
247 const pausedText = textOf(paused);
248 expect(pausedText, "the gated call pauses instead of completing").toContain(
249 "Execution paused",
250 );
251 const executionId = executionIdOf(pausedText);
252 expect(executionId, "the paused result carries the executionId").toEqual(expect.any(String));
253
254 // The user thinks the approval over. No client is connected and no
255 // request is in flight, so the session DO instance gets evicted and the
256 // next request cold-restores it from storage.
257 yield* Effect.sleep(IDLE_TEARDOWN_GAP);
258
259 const second = yield* Effect.promise(() => connectClient(target.mcpUrl, bearer, sessionId));
260 const resumed = yield* Effect.promise(() =>
261 second.client.callTool({
262 name: "resume",
263 arguments: { executionId: executionId ?? "", action: "accept", content: "{}" },
264 }),
265 ).pipe(Effect.ensuring(closeQuietly(second)));
266 expect(
267 textOf(resumed),
268 "the paused execution is still resumable after the eviction gap",
269 ).not.toContain("No paused execution");
270 expect(resumed.isError, "the resumed execution completes").not.toBe(true);
271 expect(textOf(resumed), "the gated tool's result comes back after approval").toContain(
272 "connections",
273 );
274 }).pipe(
275 Effect.ensuring(
276 api.policies
277 .remove({ params: { policyId: policy.id }, payload: { owner: "org" } })
278 .pipe(Effect.ignore),
279 ),
280 );
281 }),
282);
283
216scenario(284scenario(
217 "MCP sessions · a paused approval survives the client reconnecting and resumes",285 "MCP sessions · a paused approval survives the client reconnecting and resumes",
218 {},286 {},