Repository navigation
fix(mcp): settle approval pauses under hibernation - #56
Conversation
Expose pausedExecutionCount and hasPausedExecutions on ExecutionEngine so Cloudflare MCP session DOs can distinguish idle cleanup from paused continuations.
… exist Add shared alarm policy so idle sessions still destroy after 5 minutes, but sessions with in-memory paused executions reschedule a 10-minute lease instead of tearing down the runtime. Preserves the legacy unaddressable-alarm guard.
8206d5d to
c03fb17
Compare
Greptile SummaryThis PR adds bounded pending-approval leases to Cloudflare Durable Object MCP sessions so that paused, approval-required executions return a pause envelope promptly and the DO can safely hibernate between requests. A
Confidence Score: 4/5The core lease lifecycle and alarm policy are well-reasoned and thoroughly tested; the main subtlety is a non-operational early keepAlive release in the expiry-with-waiter path. All three previously flagged issues from earlier review rounds (unbounded lease extension, missing alarm-policy test, and the atob crash) are addressed. The dual-dispose handling in releasePendingApprovalLease is idempotent and correct. The one remaining observation — that expirePendingApproval's Effect.ensuring disposes the keepAlive before the browser-flow engine.resume completes — is non-blocking because the active HTTP request independently keeps the DO alive during that window. No correctness regressions were found in the alarm policy refactor, the payload compaction, or the OpenAPI point-lookup change. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts — specifically the interplay between expirePendingApproval's Effect.ensuring and the browser-flow onResumeSettled in the waiter-present code path. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Model
participant McpToolServer
participant Engine
participant McpAgentSessionDO
participant BrowserUser
Model->>McpToolServer: execute(code)
McpToolServer->>Engine: executeWithPause(code)
Engine-->>McpToolServer: paused(executionId)
McpToolServer->>McpAgentSessionDO: onExecutionPaused(executionId)
McpAgentSessionDO->>McpAgentSessionDO: keepAlive() + setTimeout(4min, expire)
McpToolServer->>McpAgentSessionDO: waitForBrowserApprovalResponse(executionId)
McpAgentSessionDO->>McpAgentSessionDO: create approvalWaiter Deferred
McpToolServer-->>Model: pause envelope (browser approval URL)
alt User approves within 4 min
BrowserUser->>McpAgentSessionDO: resumeExecutionForApproval(accept)
McpAgentSessionDO->>McpAgentSessionDO: recordApprovalResponse(accept)
McpAgentSessionDO->>McpAgentSessionDO: Deferred.succeed(waiter, accept)
McpAgentSessionDO-->>McpToolServer: accept response
McpToolServer->>McpAgentSessionDO: onResumeStarted → clearTimeout
McpToolServer->>Engine: resume(executionId, accept)
Engine-->>McpToolServer: completed result
McpToolServer->>McpAgentSessionDO: onResumeSettled → releasePendingApprovalLease
McpToolServer-->>Model: tool result
else Approval timeout (4 min)
McpAgentSessionDO->>McpAgentSessionDO: expirePendingApproval(executionId)
McpAgentSessionDO->>McpAgentSessionDO: recordApprovalResponse(decline) + Deferred.succeed(waiter, decline)
McpAgentSessionDO-->>McpToolServer: decline response (via waiter)
McpToolServer->>Engine: resume(executionId, decline)
Engine-->>McpToolServer: completed/error result
McpAgentSessionDO->>McpAgentSessionDO: releasePendingApprovalLease (via ensuring)
McpToolServer-->>Model: declined tool error
end
Note over McpAgentSessionDO: Session alarm: idleMs >= SESSION_TIMEOUT_MS<br/>+ pausedExecutionCount → extend_paused_lease<br/>capped at MAX_PAUSED_SESSION_IDLE_MS
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Model
participant McpToolServer
participant Engine
participant McpAgentSessionDO
participant BrowserUser
Model->>McpToolServer: execute(code)
McpToolServer->>Engine: executeWithPause(code)
Engine-->>McpToolServer: paused(executionId)
McpToolServer->>McpAgentSessionDO: onExecutionPaused(executionId)
McpAgentSessionDO->>McpAgentSessionDO: keepAlive() + setTimeout(4min, expire)
McpToolServer->>McpAgentSessionDO: waitForBrowserApprovalResponse(executionId)
McpAgentSessionDO->>McpAgentSessionDO: create approvalWaiter Deferred
McpToolServer-->>Model: pause envelope (browser approval URL)
alt User approves within 4 min
BrowserUser->>McpAgentSessionDO: resumeExecutionForApproval(accept)
McpAgentSessionDO->>McpAgentSessionDO: recordApprovalResponse(accept)
McpAgentSessionDO->>McpAgentSessionDO: Deferred.succeed(waiter, accept)
McpAgentSessionDO-->>McpToolServer: accept response
McpToolServer->>McpAgentSessionDO: onResumeStarted → clearTimeout
McpToolServer->>Engine: resume(executionId, accept)
Engine-->>McpToolServer: completed result
McpToolServer->>McpAgentSessionDO: onResumeSettled → releasePendingApprovalLease
McpToolServer-->>Model: tool result
else Approval timeout (4 min)
McpAgentSessionDO->>McpAgentSessionDO: expirePendingApproval(executionId)
McpAgentSessionDO->>McpAgentSessionDO: recordApprovalResponse(decline) + Deferred.succeed(waiter, decline)
McpAgentSessionDO-->>McpToolServer: decline response (via waiter)
McpToolServer->>Engine: resume(executionId, decline)
Engine-->>McpToolServer: completed/error result
McpAgentSessionDO->>McpAgentSessionDO: releasePendingApprovalLease (via ensuring)
McpToolServer-->>Model: declined tool error
end
Note over McpAgentSessionDO: Session alarm: idleMs >= SESSION_TIMEOUT_MS<br/>+ pausedExecutionCount → extend_paused_lease<br/>capped at MAX_PAUSED_SESSION_IDLE_MS
Reviews (5): Last reviewed commit: "fix(mcp): guard approval timeout races" | Re-trigger Greptile |
…ptile) The extend_paused_lease branch rescheduled the alarm unconditionally, so a session whose paused execution was never resumed (browser tab closed, client disconnect) would extend the lease forever and keep the Durable Object alive and billable indefinitely. Bound the lease to a single approval window: once idle exceeds SESSION_TIMEOUT_MS + PAUSED_EXECUTION_LEASE_MS (15 min), the session is torn down regardless of outstanding paused work. By that point the browser approval wait (tool-server, 10 min) has already timed out, so the paused execution is no longer resumable. idleMs grows across extensions because activity is not marked during the wait, so decideSessionAlarm stays pure. Also add the missing coverage: idle_within_timeout wins regardless of paused work, and the new cap forces destroy_idle_session.
Add host-owned paused execution hooks so Cloudflare MCP sessions can hold a bounded pending-approval lease, keep active POST requests alive until their response drains, and release hibernation blockers on resume or timeout. Also bound the approval wait window, compact execution-history interaction payloads, and resolve OpenAPI-backed annotations with point lookups so large specs do not reset the Durable Object before returning a pause envelope.
Add host-owned paused execution hooks so Cloudflare MCP sessions can hold a bounded pending-approval lease, keep active POST requests alive until their response drains, and release hibernation blockers on resume or timeout. Also bound the approval wait window and resolve OpenAPI-backed annotations with point lookups so large specs do not reset the Durable Object before returning a pause envelope. This is the upstream port of the core runtime pieces from #56. The execution-history payload compaction from that PR is omitted because this upstream branch does not contain that plugin.
Remove MCP host console logging wrappers and rely on existing debug logging plus span annotations for pause lifecycle state. Keep Cloudflare Durable Object logs focused on exceptional boundary events by dropping happy-path active POST and pending approval lease messages.
Handle malformed active POST metadata without failing the Agent connection. Avoid overwriting a stored browser approval on timeout, and only resume timed-out executions directly when no MCP waiter is already registered.
…1178) * feat(cloudflare): serve MCP sessions with Agents * feat(execution): add paused execution introspection API Expose pausedExecutionCount and hasPausedExecutions on ExecutionEngine so Cloudflare MCP session DOs can distinguish idle cleanup from paused continuations. * feat(cloudflare): extend MCP session lease while paused continuations exist Add shared alarm policy so idle sessions still destroy after 5 minutes, but sessions with in-memory paused executions reschedule a 10-minute lease instead of tearing down the runtime. Preserves the legacy unaddressable-alarm guard. * fix(cloud): forward paused execution introspection * fix(cloudflare): cap paused-session lease at one approval window (greptile) The extend_paused_lease branch rescheduled the alarm unconditionally, so a session whose paused execution was never resumed (browser tab closed, client disconnect) would extend the lease forever and keep the Durable Object alive and billable indefinitely. Bound the lease to a single approval window: once idle exceeds SESSION_TIMEOUT_MS + PAUSED_EXECUTION_LEASE_MS (15 min), the session is torn down regardless of outstanding paused work. By that point the browser approval wait (tool-server, 10 min) has already timed out, so the paused execution is no longer resumable. idleMs grows across extensions because activity is not marked during the wait, so decideSessionAlarm stays pure. Also add the missing coverage: idle_within_timeout wins regardless of paused work, and the new cap forces destroy_idle_session. * chore: update lockfile * fix(mcp): settle approval pauses under hibernation Add host-owned paused execution hooks so Cloudflare MCP sessions can hold a bounded pending-approval lease, keep active POST requests alive until their response drains, and release hibernation blockers on resume or timeout. Also bound the approval wait window and resolve OpenAPI-backed annotations with point lookups so large specs do not reset the Durable Object before returning a pause envelope. This is the upstream port of the core runtime pieces from aryasaatvik#56. The execution-history payload compaction from that PR is omitted because this upstream branch does not contain that plugin. * refactor(mcp): align pause lifecycle telemetry Remove MCP host console logging wrappers and rely on existing debug logging plus span annotations for pause lifecycle state. Keep Cloudflare Durable Object logs focused on exceptional boundary events by dropping happy-path active POST and pending approval lease messages. * fix(mcp): guard approval timeout races Handle malformed active POST metadata without failing the Agent connection. Avoid overwriting a stored browser approval on timeout, and only resume timed-out executions directly when no MCP waiter is already registered. * refactor(cloudflare): remove legacy MCP session DO and finish cloud migration The hibernatable Agent-based MCP Durable Object now fully replaces the old bespoke session DO. This removes the superseded implementation instead of leaving it alongside the new one: - delete the old McpSessionDOBase + its worker transport and request-id queue (nothing extends it anymore; both hosts' concrete McpSessionDO already extend McpAgentSessionDOBase) - finish the cloud migration: cloud only kept the legacy McpServingRoutes session-store dispatch to publish its OAuth discovery docs. Make mcp.sessions optional and add a discovery-only McpDiscoveryRoutes layer so a host whose /mcp transport lives outside the envelope (the Agent bridge) can serve just the discovery docs. Cloud now wires only the auth seam. - drop the now-dead cloud session-store, seams, response-peek, and reporter wiring plus the stale package exports Net: the old impl and the half-migrated cloud envelope path are gone. Co-authored-by: Saatvik Arya <aryasaatvik@gmail.com> * style: oxfmt two react files left unformatted by the merge * fix: regenerate bun.lock to match merged manifests (frozen install was failing) * fix: reconcile bun.lock against main pins plus branch deps (frozen install passes) --------- Co-authored-by: Saatvik Arya <aryasaatvik@gmail.com>
Summary
Make approval-required MCP executions return a pause envelope promptly while preserving Cloudflare Durable Object hibernation. Normal tool calls can hibernate after the active POST response drains. Paused approvals hold a bounded pending-approval lease, then release it on approve, decline, or timeout.
This keeps the Cloudflare Agents event-store path intact. It does not use the earlier
getEventStore() => undefinedworkaround.Changes
pausedExecutionCount()/hasPausedExecutions()onExecutionEngine.PausedExecutionHooksto the MCP host so hosts can observe pause and resume boundaries.McpAgentSessionDOBase, add active POST response wait around Cloudflare Agents streamable HTTP dispatch, bounded pending-approval leases, auto-decline on approval timeout, and lease cleanup on resume and runtime close.pausedExecutionHooksthrough host-cloudflare MCP sessions.getOperation(...)point lookups instead of scanning every operation in large specs.Live verification
Final deployed Worker version tested:
0aa27611-e865-4fbe-8576-48df95ee1230.A subagent called
tools.cloudflare_api.org.aryalabs.user.userUserDetails({})through Executor MCP with approval required:exec_5df462bc-3502-4ef7-9472-7488ec7f524f2.0445s1.0295sLocal validation
packages/hosts/mcp:bun run test src/tool-server.test.ts, 38 passedpackages/hosts/cloudflare:bun run test src/mcp/session-alarm-policy.test.ts, 5 passedpackages/plugins/execution-history:bun run test src/sdk/store.test.ts src/sdk/store-list.test.ts, 6 passedpackages/plugins/openapi:bun run test src/sdk/store.test.ts src/sdk/plugin.test.ts, 26 passedbun run typecheckforpackages/hosts/mcp,packages/hosts/cloudflare,packages/core/sdk,packages/plugins/execution-history, andpackages/plugins/openapibunx oxfmt --checkbunx oxlint --deny-warningsSome package typechecks still print existing unrelated suggestions or warnings, but exited successfully.