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.
Unified · Split
| @@ -213,6 +213,74 @@ const result = await tools.executor.coreTools.policies.list({}); · 212 unmodified lines | |||
| 213 | return JSON.stringify(result); | 213 | return JSON.stringify(result); |
| 214 | `; | 214 | `; |
| 215 | 215 | ||
| 216 | const 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. | ||
| 224 | const IDLE_TEARDOWN_GAP = "6 minutes"; | ||
| 225 | |||
| 226 | scenario( | ||
| 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 | |||
| 216 | scenario( | 284 | scenario( |
| 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 | {}, |