Skip to content

fix(mcp): settle approval pauses under hibernation - #56

Merged
aryasaatvik merged 7 commits into
devfrom
feat/mcp-conditional-timeout
Jun 24, 2026
Merged

aryasaatvik merged 7 commits into
devfrom
feat/mcp-conditional-timeout

Conversation

@aryasaatvik

@aryasaatvik aryasaatvik commented Jun 23, 2026 •

Copy link
Copy Markdown
Owner

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() => undefined workaround.

Changes

  • Add pausedExecutionCount() / hasPausedExecutions() on ExecutionEngine.
  • Add shared Cloudflare MCP session alarm policy for idle sessions with paused continuations.
  • Add PausedExecutionHooks to the MCP host so hosts can observe pause and resume boundaries.
  • In 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.
  • Wire pausedExecutionHooks through host-cloudflare MCP sessions.
  • Align browser approval wait with the bounded approval lease.
  • Keep pause lifecycle telemetry aligned with existing conventions: MCP host code uses debug logging and span annotations, while Cloudflare DO logs stay focused on exceptional boundary events.
  • Compact execution-history interaction payloads so pause creation does not serialize arbitrary full tool context.
  • Resolve OpenAPI-backed annotations with getOperation(...) point lookups instead of scanning every operation in large specs.
  • Include the lockfile update from the working tree.

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:

  • pause returned: yes
  • executionId: exec_5df462bc-3502-4ef7-9472-7488ec7f524f
  • pause wall time: 2.0445s
  • decline resume wall time: 1.0295s
  • outcome: expected declined-tool error
  • timeout or memory reset: none

Local validation

  • packages/hosts/mcp: bun run test src/tool-server.test.ts, 38 passed
  • packages/hosts/cloudflare: bun run test src/mcp/session-alarm-policy.test.ts, 5 passed
  • packages/plugins/execution-history: bun run test src/sdk/store.test.ts src/sdk/store-list.test.ts, 6 passed
  • packages/plugins/openapi: bun run test src/sdk/store.test.ts src/sdk/plugin.test.ts, 26 passed
  • bun run typecheck for packages/hosts/mcp, packages/hosts/cloudflare, packages/core/sdk, packages/plugins/execution-history, and packages/plugins/openapi
  • touched-file bunx oxfmt --check
  • touched-file bunx oxlint --deny-warnings

Some package typechecks still print existing unrelated suggestions or warnings, but exited successfully.

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.
@aryasaatvik
aryasaatvik force-pushed the feat/mcp-conditional-timeout branch from 8206d5d to c03fb17 Compare June 23, 2026 10:54
@greptile-apps

greptile-apps Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Greptile Summary

This 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 PausedExecutionHooks interface lets the host observe pause and resume boundaries, a new session-alarm-policy module encodes the alarm decision logic (with a hard cap at SESSION_TIMEOUT_MS + PAUSED_EXECUTION_LEASE_MS), and auto-decline fires after a 4-minute lease if no user response arrives.

  • Pending-approval lease (startPendingApprovalLease): calls keepAlive() and sets a setTimeout(4 min) that records a decline and resolves the approval deferred; expirePendingApproval skips direct engine.resume when a waiter exists, letting the browser flow drive resumption.
  • Alarm policy (decideSessionAlarm): deterministic pure function covering idle_within_timeout, extend_paused_lease (single extension, bounded by MAX_PAUSED_SESSION_IDLE_MS), and destroy_idle_session; fully unit-tested with the cap assertion.
  • Payload compaction in execution-history (interactionPayloadOf): truncates per-string values to 300 chars and limits to 25 arg keys, preventing large tool contexts from bloating interaction records.

Confidence Score: 4/5

The 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

Filename Overview
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts Major addition: pending-approval lease system (keepAlive, setTimeout expiry, auto-decline), onConnect override with active POST response drain, alarm() rewritten with decideSessionAlarm policy, and cleanupUnaddressableSessionAlarm guard. Core logic is sound with careful single-thread reasoning; the expiry/waiter coordination handles the waiter-present vs. waiter-absent paths correctly.
packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts New pure policy module with well-defined constants: SESSION_TIMEOUT_MS (5 min), PAUSED_EXECUTION_LEASE_MS (4 min), MAX_PAUSED_SESSION_IDLE_MS (9 min cap). decideSessionAlarm is deterministic and fully covered by the new test suite; the cap correctly limits the extension to one lease window.
packages/hosts/cloudflare/src/mcp/session-alarm-policy.test.ts New test file covering all five branches of decideSessionAlarm including the idle_within_timeout case with paused work (addressing previous review feedback), the cap boundary, and normal destroy. Full coverage, no gaps.
packages/hosts/mcp/src/tool-server.ts PausedExecutionHooks interface added, PAUSED_APPROVAL_TIMEOUT_MS exported, BROWSER_APPROVAL_WAIT_TIMEOUT_MS set 1 s beyond the lease timer to ensure expiry fires first. resumeWithLifecycle wraps onResumeStarted/onResumeSettled correctly for model and browser resume paths.
packages/hosts/mcp/src/tool-server.test.ts New test verifies complete pause/resume lifecycle event sequence covering the multi-pause continuation path.
packages/core/execution/src/engine.ts Adds pausedExecutionCount() and hasPausedExecutions() as thin wrappers over the existing pausedExecutions Map; straightforward and correct.
packages/plugins/execution-history/src/sdk/store.ts interactionPayloadOf compacts args to at most 25 keys / 300 chars per string, preventing large tool contexts from bloating the interaction record. requestedSchema is still stored in full for FormElicitation (intentional for UI rendering).
packages/plugins/openapi/src/sdk/backing.ts resolveOpenApiBackedAnnotations switches from listOperations (bulk scan) to getOperation point lookups per tool, trading N sequential queries for avoiding full-spec materialization on large OpenAPI specs.
packages/hosts/cloudflare/src/mcp/session-durable-object.ts Integrates decideSessionAlarm into runAlarm, adding extend_paused_lease branch alongside the existing destroy/heartbeat paths. SESSION_TIMEOUT_MS de-duplicated by importing from session-alarm-policy.

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
Loading
%%{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
Loading

Reviews (5): Last reviewed commit: "fix(mcp): guard approval timeout races" | Re-trigger Greptile

Comment thread packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts Outdated
Comment thread packages/hosts/cloudflare/src/mcp/session-alarm-policy.test.ts
…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.
@aryasaatvik aryasaatvik changed the title feat(cloudflare): conditional MCP session timeout for paused continuations fix(mcp): settle approval pauses under hibernation Jun 23, 2026
Comment thread packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts Outdated
aryasaatvik added a commit that referenced this pull request Jun 23, 2026
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.
Comment thread packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts
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.
@aryasaatvik
aryasaatvik merged commit 4826d55 into dev Jun 24, 2026
8 checks passed
@aryasaatvik
aryasaatvik deleted the feat/mcp-conditional-timeout branch June 24, 2026 06:05
RhysSullivan added a commit to UsefulSoftwareCo/executor that referenced this pull request Jun 28, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant