History

Speed up and tune CI wall-clock (#1296)

* Tune CI wall-clock and hygiene * Fix CI review findings * Scope cloud e2e idle overrides

Rhys Sullivan committed 4 months ago

Commit 87ed7c91 parent 892382710 files · +245 −69Browse files

Unified · Split

10 files
  1. .github/workflows/ci.yml +93 −47
  2. apps/cloud/src/env-augment.d.ts +2 −0
  3. apps/cloud/src/mcp/session-durable-object.ts +17 −0
  4. e2e/cloud/mcp-client-sessions.test.ts +21 −13
  5. e2e/setup/cloud.boot.ts +2 −0
  6. e2e/setup/cloud.globalsetup.ts +2 −0
  7. e2e/setup/mcp-session-timeouts.ts +34 −0
  8. packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts +47 −6
  9. packages/hosts/cloudflare/src/mcp/session-alarm-policy.test.ts +14 −0
  10. packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts +13 −3
.github/workflows/ci.yml+93−47
@@ -7,13 +7,58 @@ on: · 6 unmodified lines
77 - main
88
99 concurrency:
10− group: ci-${{ github.event_name == 'push' && format('{0}-{1}', github.ref, github.sha) || github.ref }}
11− cancel-in-progress: ${{ github.event_name == 'pull_request' }}
10+ group: ci-${{ github.ref }}
11+ cancel-in-progress: true
1212
1313 jobs:
14+ changes:
15+ name: Changed paths
16+ runs-on: blacksmith-4vcpu-ubuntu-2404
17+ timeout-minutes: 10
18+ outputs:
19+ desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }}
20+ selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }}
21+ steps:
22+ - uses: actions/checkout@v4
23+
24+ - name: Filter changed paths
25+ id: filter
26+ if: github.event_name == 'pull_request'
27+ uses: dorny/paths-filter@v3
28+ with:
29+ filters: |
30+ desktop_smoke:
31+ - ".github/workflows/**"
32+ - "bun.lock"
33+ - "package.json"
34+ - "turbo.json"
35+ - "apps/desktop/**"
36+ - "apps/local/**"
37+ - "apps/cli/**"
38+ - "packages/app/**"
39+ - "packages/core/**"
40+ - "packages/hosts/mcp/**"
41+ - "packages/kernel/runtime-quickjs/**"
42+ - "packages/plugins/**"
43+ - "packages/react/**"
44+ selfhost_docker_smoke:
45+ - ".github/workflows/**"
46+ - ".dockerignore"
47+ - "bun.lock"
48+ - "package.json"
49+ - "turbo.json"
50+ - "apps/host-selfhost/**"
51+ - "packages/app/**"
52+ - "packages/core/**"
53+ - "packages/hosts/mcp/**"
54+ - "packages/kernel/runtime-quickjs/**"
55+ - "packages/plugins/**"
56+ - "packages/react/**"
57+
1458 format:
1559 name: Format
1660 runs-on: blacksmith-4vcpu-ubuntu-2404
61+ timeout-minutes: 10
1762 steps:
1863 - uses: actions/checkout@v4
1964
@@ -29,20 +74,14 @@ jobs: · 9 unmodified lines
2974 restore-keys: |
3075 ${{ runner.os }}-bun-1.3.11-
3176
32− # No prebuilt better-sqlite3 binary matches this runner, so `bun install`
33− # builds it from source via node-gyp, whose undici needs Node 22.10+
34− # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use.
35− - uses: actions/setup-node@v4
36− with:
37− node-version: 22
38−
39− - run: bun install --frozen-lockfile
77+ - run: bun install --frozen-lockfile --ignore-scripts
4078
4179 - run: bun run format:check
4280
4381 lint:
4482 name: Lint
4583 runs-on: blacksmith-4vcpu-ubuntu-2404
84+ timeout-minutes: 10
4685 steps:
4786 - uses: actions/checkout@v4
4887
@@ -58,20 +97,14 @@ jobs: · 9 unmodified lines
5897 restore-keys: |
5998 ${{ runner.os }}-bun-1.3.11-
6099
61− # No prebuilt better-sqlite3 binary matches this runner, so `bun install`
62− # builds it from source via node-gyp, whose undici needs Node 22.10+
63− # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use.
64− - uses: actions/setup-node@v4
65− with:
66− node-version: 22
67−
68− - run: bun install --frozen-lockfile
100+ - run: bun install --frozen-lockfile --ignore-scripts
69101
70102 - run: bun run lint
71103
72104 typecheck:
73105 name: Typecheck
74106 runs-on: blacksmith-4vcpu-ubuntu-2404
107+ timeout-minutes: 15
75108 steps:
76109 - uses: actions/checkout@v4
77110
@@ -89,10 +122,10 @@ jobs: · 11 unmodified lines
89122
90123 # No prebuilt better-sqlite3 binary matches this runner, so `bun install`
91124 # builds it from source via node-gyp, whose undici needs Node 22.10+
92− # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use.
125+ # (webidl.markAsUncloneable). Pin the same Node 24 runtime used by release.
93126 - uses: actions/setup-node@v4
94127 with:
95− node-version: 22
128+ node-version: 24
96129
97130 - run: bun install --frozen-lockfile
98131
@@ -101,8 +134,10 @@ jobs: · 2 unmodified lines
101134 test:
102135 name: Test
103136 runs-on: blacksmith-4vcpu-ubuntu-2404
137+ timeout-minutes: 15
138+ # Tuned for Blacksmith's 4 vCPU runners.
104139 env:
105− TURBO_TEST_CONCURRENCY: 3
140+ TURBO_TEST_CONCURRENCY: 4
106141 steps:
107142 - uses: actions/checkout@v4
108143
@@ -120,10 +155,10 @@ jobs: · 11 unmodified lines
120155
121156 # apps/cloud's test script invokes `node` directly; undici 8.x (pulled
122157 # in by @cloudflare/vitest-pool-workers) calls webidl.markAsUncloneable
123− # which only exists in Node 22.10+. Pin a known-good runtime.
158+ # which only exists in Node 22.10+. Pin the Node 24 CI runtime.
124159 - uses: actions/setup-node@v4
125160 with:
126− node-version: 22
161+ node-version: 24
127162
128163 - run: bun install --frozen-lockfile
129164
@@ -135,21 +170,13 @@ jobs: · 5 unmodified lines
135170 fail-fast: false
136171 matrix:
137172 include:
138− # Cloud is SHARDED: each shard boots its own fresh dev stack. The
139− # cloud dev server degrades after a few minutes of sustained suite
140− # load on 2-core runners (the SSE/OTel memory growth being
141− # instrumented on main) — requests start failing partway through and
142− # everything after dies with connection errors. Short shards on
143− # fresh boots stay under that threshold; re-merge into fewer jobs
144− # once the degradation is fixed.
145− - { target: cloud, shard: 1/8, shard-name: 1of8 }
146− - { target: cloud, shard: 2/8, shard-name: 2of8 }
147− - { target: cloud, shard: 3/8, shard-name: 3of8 }
148− - { target: cloud, shard: 4/8, shard-name: 4of8 }
149− - { target: cloud, shard: 5/8, shard-name: 5of8 }
150− - { target: cloud, shard: 6/8, shard-name: 6of8 }
151− - { target: cloud, shard: 7/8, shard-name: 7of8 }
152− - { target: cloud, shard: 8/8, shard-name: 8of8 }
173+ # Each cloud shard boots its own fresh dev stack. On 4 vCPU runners,
174+ # four fatter shards keep the longest shard below selfhost while saving
175+ # four runner boots and four warm cache restores.
176+ - { target: cloud, shard: 1/4, shard-name: 1of4 }
177+ - { target: cloud, shard: 2/4, shard-name: 2of4 }
178+ - { target: cloud, shard: 3/4, shard-name: 3of4 }
179+ - { target: cloud, shard: 4/4, shard-name: 4of4 }
153180 - target: selfhost
154181 runs-on: blacksmith-4vcpu-ubuntu-2404
155182 timeout-minutes: 30
@@ -169,10 +196,10 @@ jobs: · 13 unmodified lines
169196 ${{ runner.os }}-bun-1.3.11-
170197
171198 # The dev stacks spawn Node sidecars (vite/workerd tooling); pin the
172− # same known-good runtime the unit-test job uses.
199+ # same Node 24 runtime the release and publish workflows use.
173200 - uses: actions/setup-node@v4
174201 with:
175− node-version: 22
202+ node-version: 24
176203
177204 - run: bun install --frozen-lockfile
178205
@@ -181,6 +208,8 @@ jobs: · 2 unmodified lines
181208 with:
182209 path: ~/.cache/ms-playwright
183210 key: ${{ runner.os }}-playwright-1.60.0
211+ restore-keys: |
212+ ${{ runner.os }}-playwright-
184213
185214 # Install from e2e so bunx resolves ITS pinned playwright (the version
186215 # the tests run against) rather than floating to the latest.
@@ -190,11 +219,20 @@ jobs: · 3 unmodified lines
190219
191220 # The globalsetup boots the target's own dev server (ports are claimed
192221 # per checkout, so this is hermetic) and tears it down after the run.
193− # --retry=2: browser scenarios time out sporadically on 2-core runners
222+ # --retry=2: browser scenarios can still hit isolated waitFor timeouts
194223 # (single-test waitFor timeouts, not systemic failures); a retry on the
195224 # same booted stack clears them.
196− - name: Run ${{ matrix.target }} scenarios
197− run: bunx vitest run --project ${{ matrix.target }} --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }}
225+ - name: Run cloud scenarios
226+ if: matrix.target == 'cloud'
227+ env:
228+ MCP_SESSION_TIMEOUT_MS: "3000"
229+ MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000"
230+ run: bunx vitest run --project cloud --retry=2 ${{ matrix.shard && format('--shard={0}', matrix.shard) || '' }}
231+ working-directory: e2e
232+
233+ - name: Run selfhost scenarios
234+ if: matrix.target == 'selfhost'
235+ run: bunx vitest run --project selfhost --retry=2
198236 working-directory: e2e
199237
200238 # Failed runs keep their trace.zip / session.mp4 / step screenshots in
@@ -230,10 +268,10 @@ jobs: · 29 unmodified lines
230268 ${{ runner.os }}-bun-1.3.11-
231269
232270 # The local scenarios boot a real `executor web` (which spawns a Node
233− # sidecar) and some drive a browser, so pin Node 22 and install Chromium.
271+ # sidecar) and some drive a browser, so pin Node 24 and install Chromium.
234272 - uses: actions/setup-node@v4
235273 with:
236− node-version: 22
274+ node-version: 24
237275
238276 - run: bun install --frozen-lockfile
239277
@@ -242,6 +280,8 @@ jobs: · 2 unmodified lines
242280 with:
243281 path: ~/.cache/ms-playwright
244282 key: ${{ runner.os }}-playwright-1.60.0
283+ restore-keys: |
284+ ${{ runner.os }}-playwright-
245285
246286 # `chromium` and the new `chromium-headless-shell` ship as separate
247287 # downloads; the browser-driven scenarios launch the headless shell.
@@ -265,7 +305,10 @@ jobs: · 17 unmodified lines
265305
266306 desktop-smoke:
267307 name: Desktop smoke build
308+ needs: changes
309+ if: needs.changes.outputs.desktop_smoke == 'true'
268310 runs-on: blacksmith-4vcpu-ubuntu-2404
311+ timeout-minutes: 20
269312 steps:
270313 - uses: actions/checkout@v4
271314
@@ -283,10 +326,10 @@ jobs: · 11 unmodified lines
283326
284327 # No prebuilt better-sqlite3 binary matches this runner, so `bun install`
285328 # builds it from source via node-gyp, whose undici needs Node 22.10+
286− # (webidl.markAsUncloneable). Pin the same runtime the test/e2e jobs use.
329+ # (webidl.markAsUncloneable). Pin the same Node 24 CI runtime.
287330 - uses: actions/setup-node@v4
288331 with:
289− node-version: 22
332+ node-version: 24
290333
291334 - run: bun install --frozen-lockfile
292335
@@ -305,7 +348,10 @@ jobs: · 12 unmodified lines
305348
306349 selfhost-docker-smoke:
307350 name: Self-host Docker image
351+ needs: changes
352+ if: needs.changes.outputs.selfhost_docker_smoke == 'true'
308353 runs-on: blacksmith-4vcpu-ubuntu-2404
354+ timeout-minutes: 20
309355 steps:
310356 - uses: actions/checkout@v4
311357
apps/cloud/src/env-augment.d.ts+2−0
@@ -58,6 +58,8 @@ declare global { · 57 unmodified lines
5858 EXECUTOR_MCP_DEBUG?: string;
5959 MCP_AUTHKIT_DOMAIN?: string;
6060 MCP_RESOURCE_ORIGIN?: string;
61+ MCP_SESSION_TIMEOUT_MS?: string;
62+ MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS?: string;
6163 NODE_ENV?: string;
6264
6365 // Shared with frontend
apps/cloud/src/mcp/session-durable-object.ts+17−0
@@ -77,6 +77,13 @@ const LONG_LIVED_DB_IDLE_TIMEOUT_SECONDS = 5; · 76 unmodified lines
7777 const LONG_LIVED_DB_MAX_LIFETIME_SECONDS = 120;
7878 const TELEMETRY_FLUSH_TIMEOUT_MS = 1_000;
7979
80+const positiveMilliseconds = (raw: string | undefined): number | undefined => {
81+ if (!raw) return undefined;
82+ const parsed = Number(raw);
83+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
84+ return Math.floor(parsed);
85+};
86+
8087 type CloudSessionDbHandle = DbServiceShape & {
8188 readonly sql: Sql;
8289 readonly end: () => Promise<void>;
@@ -158,6 +165,16 @@ const makeSessionServices = (dbHandle: CloudSessionDbHandle) => { · 75 unmodified lines
158165 // ---------------------------------------------------------------------------
159166
160167 export class McpSessionDOSqlite extends McpAgentSessionDOBase<Env, CloudSessionDbHandle> {
168+ protected override sessionTimeoutMs(): number {
169+ return positiveMilliseconds(env.MCP_SESSION_TIMEOUT_MS) ?? super.sessionTimeoutMs();
170+ }
171+
172+ protected override maxPausedSessionIdleMs(): number {
173+ return (
174+ positiveMilliseconds(env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS) ?? super.maxPausedSessionIdleMs()
175+ );
176+ }
177+
161178 protected override openSessionDb(): CloudSessionDbHandle {
162179 return makeDbHandle({
163180 idleTimeout: LONG_LIVED_DB_IDLE_TIMEOUT_SECONDS,
e2e/cloud/mcp-client-sessions.test.ts+21−13
@@ -18,6 +18,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ · 17 unmodified lines
1818 import { scenario } from "../src/scenario";
1919 import { Api, Mcp, Target } from "../src/services";
2020 import type { Identity } from "../src/target";
21+import { configuredMcpPausedSessionIdleTimeoutMs } from "../setup/mcp-session-timeouts";
2122
2223 const coreApi = composePluginApi([] as const);
2324
@@ -216,17 +217,19 @@ return JSON.stringify(result); · 192 unmodified lines
216217 const executionIdOf = (text: string): string | undefined =>
217218 /\bexecutionId:\s*(\S+)/.exec(text)?.[1];
218219
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. Paused approvals deliberately do NOT survive this (durable pause
223−// state is out of scope); the contract is that an expired pause fails with
224−// recovery guidance and the session keeps working.
225−const IDLE_TEARDOWN_GAP = "6 minutes";
220+const IDLE_TEARDOWN_BUFFER_MS = 2_000;
221+const IDLE_TEARDOWN_GAP_MS = configuredMcpPausedSessionIdleTimeoutMs() + IDLE_TEARDOWN_BUFFER_MS;
222+const IDLE_TEARDOWN_SCENARIO_TIMEOUT_MS = IDLE_TEARDOWN_GAP_MS + 120_000;
223+
224+// The session DO tears its runtime down after the configured idle ceiling and
225+// rebuilds it from storage on the next request. The e2e harness shortens that
226+// ceiling when it owns the cloud dev stack. Paused approvals deliberately do
227+// not survive this (durable pause state is out of scope); the contract is that
228+// an expired pause fails with recovery guidance and the session keeps working.
226229
227230 scenario(
228231 "MCP sessions · an approval paused past the idle window expires with re-run guidance, not a dead end",
229− { timeout: 480_000 },
232+ { timeout: IDLE_TEARDOWN_SCENARIO_TIMEOUT_MS },
230233 Effect.gen(function* () {
231234 const target = yield* Target;
232235 const { client } = yield* Api;
@@ -242,6 +245,8 @@ scenario( · 9 unmodified lines
242245 yield* Effect.gen(function* () {
243246 const first = yield* Effect.promise(() => connectClient(target.mcpUrl, bearer));
244247 const sessionId = first.transport.sessionId;
248+ expect(sessionId, "the first client got a session id").toEqual(expect.any(String));
249+ if (sessionId === undefined) return yield* Effect.die("missing session id");
245250 const paused = yield* Effect.promise(() =>
246251 first.client.callTool({ name: "execute", arguments: { code: GATED_CODE } }),
247252 ).pipe(Effect.ensuring(closeQuietly(first)));
@@ -251,24 +256,26 @@ scenario( · 3 unmodified lines
251256 );
252257 const executionId = executionIdOf(pausedText);
253258 expect(executionId, "the paused result carries the executionId").toEqual(expect.any(String));
259+ if (executionId === undefined) return yield* Effect.die("missing paused execution id");
254260
255261 // The user thinks the approval over for longer than the session keeps
256262 // its runtime warm; the pause is gone when they come back.
257− yield* Effect.sleep(IDLE_TEARDOWN_GAP);
263+ yield* Effect.sleep(IDLE_TEARDOWN_GAP_MS);
258264
259265 const second = yield* Effect.promise(() => connectClient(target.mcpUrl, bearer, sessionId));
260266 yield* Effect.gen(function* () {
261267 const resumed = yield* Effect.promise(() =>
262268 second.client.callTool({
263269 name: "resume",
264− arguments: { executionId: executionId ?? "", action: "accept", content: "{}" },
270+ arguments: { executionId, action: "accept", content: "{}" },
265271 }),
266272 );
267− expect(resumed.isError, "the expired resume is an error, not a silent success").toBe(true);
273+ const resumedText = textOf(resumed);
268274 expect(
269− textOf(resumed),
275+ resumedText,
270276 "the error tells the model how to recover, not just that the pause is gone",
271277 ).toContain("run the execute tool again");
278+ expect(resumed.isError, "the expired resume is an error, not a silent success").toBe(true);
272279
273280 // The advertised recovery actually works on the same session: a fresh
274281 // execute pauses with a NEW id (stale ids are never reused), and
@@ -282,11 +289,12 @@ scenario( · 7 unmodified lines
282289 );
283290 const freshExecutionId = executionIdOf(reExecutedText);
284291 expect(freshExecutionId, "the re-run mints a different executionId").not.toBe(executionId);
292+ if (freshExecutionId === undefined) return yield* Effect.die("missing fresh execution id");
285293
286294 const resumedFresh = yield* Effect.promise(() =>
287295 second.client.callTool({
288296 name: "resume",
289− arguments: { executionId: freshExecutionId ?? "", action: "accept", content: "{}" },
297+ arguments: { executionId: freshExecutionId, action: "accept", content: "{}" },
290298 }),
291299 );
292300 expect(resumedFresh.isError, "the fresh approval resumes to completion").not.toBe(true);
e2e/setup/cloud.boot.ts+2−0
@@ -90,6 +90,8 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted> · 89 unmodified lines
9090 // The AuthKit domain (MCP OAuth metadata + JWKS) is the emulator too.
9191 MCP_AUTHKIT_DOMAIN: workosUrl,
9292 MCP_RESOURCE_ORIGIN: options.publicUrl,
93+ MCP_SESSION_TIMEOUT_MS: process.env.MCP_SESSION_TIMEOUT_MS,
94+ MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: process.env.MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS,
9395 ALLOW_LOCAL_NETWORK: "true",
9496 // Shrink the per-org hourly execution cap (prod default 1000) to a number
9597 // the rate-limit-backstop scenario can actually exhaust with real
e2e/setup/cloud.globalsetup.ts+2−0
@@ -10,6 +10,7 @@ import { claimAndBoot } from "../src/ports"; · 9 unmodified lines
1010 import { E2E_COOKIE_PASSWORD, E2E_WORKOS_CLIENT_ID } from "../targets/cloud";
1111 import { waitForHttp } from "./boot";
1212 import { bootCloud } from "./cloud.boot";
13+import { ensureE2eMcpSessionTimeoutEnv } from "./mcp-session-timeouts";
1314 import { bootMotel, motelExporterEnv } from "./motel";
1415 import { RUNS_DIR } from "../src/scenario";
1516
@@ -31,6 +32,7 @@ export default async function setup(): Promise<(() => Promise<void>) | void> { · 15 unmodified lines
3132 }
3233
3334 if (bootLogFile) mkdirSync(resolve(bootLogFile, ".."), { recursive: true });
35+ ensureE2eMcpSessionTimeoutEnv();
3436
3537 // Suite-owned trace store — every run captures distributed traces. Booted
3638 // once outside the retry: it binds its own OS-assigned port (not a claimed
e2e/setup/mcp-session-timeouts.ts+34−0
@@ -0,0 +1,34 @@
1+const DEFAULT_E2E_MCP_SESSION_TIMEOUT_MS = 3_000;
2+const DEFAULT_E2E_MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS = 6_000;
3+const PRODUCTION_MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS = 9 * 60 * 1000;
4+
5+export const MCP_SESSION_TIMEOUT_ENV = "MCP_SESSION_TIMEOUT_MS";
6+export const MCP_PAUSED_SESSION_IDLE_TIMEOUT_ENV = "MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS";
7+
8+const positiveMilliseconds = (raw: string | undefined): number | undefined => {
9+ if (!raw) return undefined;
10+ const parsed = Number(raw);
11+ if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
12+ return Math.floor(parsed);
13+};
14+
15+export const ensureE2eMcpSessionTimeoutEnv = (): {
16+ readonly sessionTimeoutMs: number;
17+ readonly pausedSessionIdleTimeoutMs: number;
18+} => {
19+ const sessionTimeoutMs =
20+ positiveMilliseconds(process.env[MCP_SESSION_TIMEOUT_ENV]) ??
21+ DEFAULT_E2E_MCP_SESSION_TIMEOUT_MS;
22+ const pausedSessionIdleTimeoutMs =
23+ positiveMilliseconds(process.env[MCP_PAUSED_SESSION_IDLE_TIMEOUT_ENV]) ??
24+ DEFAULT_E2E_MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS;
25+
26+ process.env[MCP_SESSION_TIMEOUT_ENV] = String(sessionTimeoutMs);
27+ process.env[MCP_PAUSED_SESSION_IDLE_TIMEOUT_ENV] = String(pausedSessionIdleTimeoutMs);
28+
29+ return { sessionTimeoutMs, pausedSessionIdleTimeoutMs };
30+};
31+
32+export const configuredMcpPausedSessionIdleTimeoutMs = (): number =>
33+ positiveMilliseconds(process.env[MCP_PAUSED_SESSION_IDLE_TIMEOUT_ENV]) ??
34+ PRODUCTION_MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS;
packages/hosts/cloudflare/src/mcp/agent-session-durable-object.ts+47−6
@@ -18,6 +18,7 @@ import { defaultMcpResource, type McpResource } from "@executor-js/host-mcp"; · 17 unmodified lines
1818
1919 import type { IncomingPropagationHeaders, McpElicitationMode } from "./do-headers";
2020 import {
21+ MAX_PAUSED_SESSION_IDLE_MS,
2122 SESSION_TIMEOUT_MS,
2223 decideSessionAlarm,
2324 pausedLeaseExtensionLog,
@@ -234,6 +235,14 @@ export abstract class McpAgentSessionDOBase< · 210 unmodified lines
234235 return undefined;
235236 }
236237
238+ protected sessionTimeoutMs(): number {
239+ return SESSION_TIMEOUT_MS;
240+ }
241+
242+ protected maxPausedSessionIdleMs(): number {
243+ return MAX_PAUSED_SESSION_IDLE_MS;
244+ }
245+
237246 protected readonly browserApprovalStore: BrowserApprovalStore = {
238247 takeResponse: (executionId) => this.takeApprovalResponse(executionId),
239248 waitForResponse: (executionId) => this.waitForApprovalResponse(executionId),
@@ -310,7 +319,7 @@ export abstract class McpAgentSessionDOBase< · 70 unmodified lines
310319 this.lastActivityMs = now;
311320 await Promise.all([
312321 this.ctx.storage.put(LAST_ACTIVITY_KEY, now),
313− this.ctx.storage.setAlarm(now + SESSION_TIMEOUT_MS),
322+ this.ctx.storage.setAlarm(now + this.sessionTimeoutMs()),
314323 ]);
315324 }
316325
@@ -337,6 +346,27 @@ export abstract class McpAgentSessionDOBase< · 20 unmodified lines
337346 );
338347 }
339348
349+ private async disposeIdleRuntime(input: {
350+ readonly idleMs: number;
351+ readonly pausedExecutionCount: number;
352+ }): Promise<void> {
353+ console.info(
354+ JSON.stringify({
355+ event: "mcp_session_idle_runtime_dispose",
356+ sessionId: this.sessionId,
357+ idleMs: input.idleMs,
358+ pausedExecutionCount: input.pausedExecutionCount,
359+ }),
360+ );
361+ await Effect.runPromise(this.closeRuntime());
362+ await Effect.runPromise(
363+ Effect.all([
364+ Effect.ignore(Effect.tryPromise(() => this.ctx.storage.deleteAlarm())),
365+ Effect.ignore(Effect.tryPromise(() => this.ctx.storage.delete(LAST_ACTIVITY_KEY))),
366+ ]),
367+ );
368+ }
369+
340370 private resolveAndStoreSessionMeta(token: McpSessionInit) {
341371 const self = this;
342372 return Effect.gen(function* () {
@@ -476,9 +506,15 @@ export abstract class McpAgentSessionDOBase< · 133 unmodified lines
476506 Effect.gen(function* () {
477507 const sessionMeta = yield* self.loadSessionMeta();
478508 if (!sessionMeta) return "not_found" as const;
479− yield* Effect.promise(() => self.markActivity()).pipe(
480− Effect.withSpan("McpSessionDO.markActivity"),
481− );
509+ if (self.initialized) {
510+ yield* Effect.promise(() => self.markActivity()).pipe(
511+ Effect.withSpan("McpSessionDO.markActivity"),
512+ );
513+ } else {
514+ yield* Effect.promise(() => self.onStart()).pipe(
515+ Effect.withSpan("McpSessionDO.restore_transport_runtime"),
516+ );
517+ }
482518 return identity.accountId === sessionMeta.userId &&
483519 identity.organizationId === sessionMeta.organizationId
484520 ? ("ok" as const)
@@ -576,7 +612,12 @@ export abstract class McpAgentSessionDOBase< · 91 unmodified lines
576612 const lastActivityMs = await this.loadLastActivity();
577613 const idleMs = lastActivityMs > 0 ? Date.now() - lastActivityMs : 0;
578614 const pausedExecutionCount = await this.pausedExecutionCount();
579− const decision = decideSessionAlarm({ idleMs, pausedExecutionCount });
615+ const decision = decideSessionAlarm({
616+ idleMs,
617+ pausedExecutionCount,
618+ sessionTimeoutMs: this.sessionTimeoutMs(),
619+ maxPausedSessionIdleMs: this.maxPausedSessionIdleMs(),
620+ });
580621
581622 if (decision.kind === "idle_within_timeout") {
582623 await super.alarm();
@@ -598,7 +639,7 @@ export abstract class McpAgentSessionDOBase< · 15 unmodified lines
598639 return;
599640 }
600641
601− await this.destroy();
642+ await this.disposeIdleRuntime({ idleMs, pausedExecutionCount });
602643 }
603644
604645 private validateApprovalIdentity(
packages/hosts/cloudflare/src/mcp/session-alarm-policy.test.ts+14−0
@@ -47,6 +47,20 @@ describe("decideSessionAlarm", () => { · 46 unmodified lines
4747 });
4848 });
4949
50+ it("caps the extension at the configured paused idle ceiling", () => {
51+ expect(
52+ decideSessionAlarm({
53+ idleMs: 3_000,
54+ pausedExecutionCount: 1,
55+ sessionTimeoutMs: 3_000,
56+ maxPausedSessionIdleMs: 6_000,
57+ }),
58+ ).toEqual({
59+ kind: "extend_paused_lease",
60+ leaseMs: 3_000,
61+ });
62+ });
63+
5064 it("destroys a paused session once the lease cap elapses", () => {
5165 expect(
5266 decideSessionAlarm({
packages/hosts/cloudflare/src/mcp/session-alarm-policy.ts+13−3
@@ -23,12 +23,22 @@ export type SessionAlarmDecision = · 22 unmodified lines
2323 export const decideSessionAlarm = (input: {
2424 readonly idleMs: number;
2525 readonly pausedExecutionCount: number;
26+ readonly sessionTimeoutMs?: number;
27+ readonly maxPausedSessionIdleMs?: number;
2628 }): SessionAlarmDecision => {
27− if (input.idleMs < SESSION_TIMEOUT_MS) {
29+ const sessionTimeoutMs = input.sessionTimeoutMs ?? SESSION_TIMEOUT_MS;
30+ const maxPausedSessionIdleMs = input.maxPausedSessionIdleMs ?? MAX_PAUSED_SESSION_IDLE_MS;
31+ if (input.idleMs < sessionTimeoutMs) {
2832 return { kind: "idle_within_timeout" };
2933 }
30− if (input.pausedExecutionCount > 0 && input.idleMs < MAX_PAUSED_SESSION_IDLE_MS) {
31− return { kind: "extend_paused_lease", leaseMs: PAUSED_EXECUTION_LEASE_MS };
34+ if (input.pausedExecutionCount > 0 && input.idleMs < maxPausedSessionIdleMs) {
35+ return {
36+ kind: "extend_paused_lease",
37+ leaseMs: Math.max(
38+ 1,
39+ Math.min(PAUSED_EXECUTION_LEASE_MS, maxPausedSessionIdleMs - input.idleMs),
40+ ),
41+ };
3242 }
3343 return { kind: "destroy_idle_session" };
3444 };