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 | 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 | 284 | scenario( |
| 217 | 285 | "MCP sessions · a paused approval survives the client reconnecting and resumes", |
| 218 | 286 | {}, |