Skip to content

Commit ac7e871

Browse files
committed
Fix CI review findings
1 parent 1aff323 commit ac7e871

10 files changed

Lines changed: 161 additions & 57 deletions

File tree

.github/workflows/ci.yml

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ jobs:
6767
bun-version: 1.3.11
6868

6969
- name: Cache Bun package cache
70-
uses: useblacksmith/cache@v5
70+
uses: actions/cache@v4
7171
with:
7272
path: ~/.bun/install/cache
7373
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
@@ -90,7 +90,7 @@ jobs:
9090
bun-version: 1.3.11
9191

9292
- name: Cache Bun package cache
93-
uses: useblacksmith/cache@v5
93+
uses: actions/cache@v4
9494
with:
9595
path: ~/.bun/install/cache
9696
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
@@ -113,7 +113,7 @@ jobs:
113113
bun-version: 1.3.11
114114

115115
- name: Cache Bun package cache
116-
uses: useblacksmith/cache@v5
116+
uses: actions/cache@v4
117117
with:
118118
path: ~/.bun/install/cache
119119
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
@@ -127,15 +127,6 @@ jobs:
127127
with:
128128
node-version: 24
129129

130-
- name: Cache Turbo task cache
131-
uses: useblacksmith/cache@v5
132-
with:
133-
path: .turbo
134-
key: ${{ runner.os }}-turbo-typecheck-${{ hashFiles('bun.lock', 'turbo.json') }}
135-
restore-keys: |
136-
${{ runner.os }}-turbo-typecheck-
137-
${{ runner.os }}-turbo-
138-
139130
- run: bun install --frozen-lockfile
140131

141132
- run: bun run typecheck
@@ -155,7 +146,7 @@ jobs:
155146
bun-version: 1.3.11
156147

157148
- name: Cache Bun package cache
158-
uses: useblacksmith/cache@v5
149+
uses: actions/cache@v4
159150
with:
160151
path: ~/.bun/install/cache
161152
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
@@ -169,15 +160,6 @@ jobs:
169160
with:
170161
node-version: 24
171162

172-
- name: Cache Turbo task cache
173-
uses: useblacksmith/cache@v5
174-
with:
175-
path: .turbo
176-
key: ${{ runner.os }}-turbo-test-${{ hashFiles('bun.lock', 'turbo.json') }}
177-
restore-keys: |
178-
${{ runner.os }}-turbo-test-
179-
${{ runner.os }}-turbo-
180-
181163
- run: bun install --frozen-lockfile
182164

183165
- run: bun run test
@@ -199,7 +181,8 @@ jobs:
199181
runs-on: blacksmith-4vcpu-ubuntu-2404
200182
timeout-minutes: 30
201183
env:
202-
E2E_MCP_FAST_IDLE_TEARDOWN: "true"
184+
MCP_SESSION_TIMEOUT_MS: "3000"
185+
MCP_PAUSED_SESSION_IDLE_TIMEOUT_MS: "6000"
203186
steps:
204187
- uses: actions/checkout@v4
205188

@@ -208,7 +191,7 @@ jobs:
208191
bun-version: 1.3.11
209192

210193
- name: Cache Bun package cache
211-
uses: useblacksmith/cache@v5
194+
uses: actions/cache@v4
212195
with:
213196
path: ~/.bun/install/cache
214197
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
@@ -224,7 +207,7 @@ jobs:
224207
- run: bun install --frozen-lockfile
225208

226209
- name: Cache Playwright browsers
227-
uses: useblacksmith/cache@v5
210+
uses: actions/cache@v4
228211
with:
229212
path: ~/.cache/ms-playwright
230213
key: ${{ runner.os }}-playwright-1.60.0
@@ -271,7 +254,7 @@ jobs:
271254
bun-version: 1.3.11
272255

273256
- name: Cache Bun package cache
274-
uses: useblacksmith/cache@v5
257+
uses: actions/cache@v4
275258
with:
276259
path: ~/.bun/install/cache
277260
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
@@ -287,7 +270,7 @@ jobs:
287270
- run: bun install --frozen-lockfile
288271

289272
- name: Cache Playwright browsers
290-
uses: useblacksmith/cache@v5
273+
uses: actions/cache@v4
291274
with:
292275
path: ~/.cache/ms-playwright
293276
key: ${{ runner.os }}-playwright-1.60.0
@@ -328,7 +311,7 @@ jobs:
328311
bun-version: 1.3.11
329312

330313
- name: Cache Bun package cache
331-
uses: useblacksmith/cache@v5
314+
uses: actions/cache@v4
332315
with:
333316
path: ~/.bun/install/cache
334317
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}

apps/cloud/src/env-augment.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ declare global {
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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@ const LONG_LIVED_DB_IDLE_TIMEOUT_SECONDS = 5;
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) => {
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

Lines changed: 19 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
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,19 +217,19 @@ return JSON.stringify(result);
216217
const executionIdOf = (text: string): string | undefined =>
217218
/\bexecutionId:\s*(\S+)/.exec(text)?.[1];
218219

219-
const FAST_IDLE_TEARDOWN = process.env.E2E_MCP_FAST_IDLE_TEARDOWN === "true";
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;
220223

221-
// The session DO tears its runtime down after 5 minutes without a request
222-
// (SESSION_TIMEOUT_MS in McpSessionDOBase) and rebuilds it from storage on
223-
// the next one — the same engine-state wipe a workerd eviction or a deploy
224-
// causes. Paused approvals deliberately do NOT survive this (durable pause
225-
// state is out of scope); the contract is that an expired pause fails with
226-
// recovery guidance and the session keeps working.
227-
const IDLE_TEARDOWN_GAP = "6 minutes";
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.
228229

229230
scenario(
230231
"MCP sessions · an approval paused past the idle window expires with re-run guidance, not a dead end",
231-
{ timeout: 480_000 },
232+
{ timeout: IDLE_TEARDOWN_SCENARIO_TIMEOUT_MS },
232233
Effect.gen(function* () {
233234
const target = yield* Target;
234235
const { client } = yield* Api;
@@ -243,7 +244,9 @@ scenario(
243244

244245
yield* Effect.gen(function* () {
245246
const first = yield* Effect.promise(() => connectClient(target.mcpUrl, bearer));
246-
const sessionId = first.transport.sessionId ?? "";
247+
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");
247250
const paused = yield* Effect.promise(() =>
248251
first.client.callTool({ name: "execute", arguments: { code: GATED_CODE } }),
249252
).pipe(Effect.ensuring(closeQuietly(first)));
@@ -253,23 +256,18 @@ scenario(
253256
);
254257
const executionId = executionIdOf(pausedText);
255258
expect(executionId, "the paused result carries the executionId").toEqual(expect.any(String));
259+
if (executionId === undefined) return yield* Effect.die("missing paused execution id");
256260

257261
// The user thinks the approval over for longer than the session keeps
258-
// its runtime warm; the pause is gone when they come back. CI skips the
259-
// real idle wait by resuming a deliberately stale id, which exercises the
260-
// same recovery branch without spending six minutes in this shard.
261-
const expiredExecutionId =
262-
FAST_IDLE_TEARDOWN && executionId ? `${executionId}:expired-for-ci` : executionId;
263-
if (!FAST_IDLE_TEARDOWN) {
264-
yield* Effect.sleep(IDLE_TEARDOWN_GAP);
265-
}
262+
// its runtime warm; the pause is gone when they come back.
263+
yield* Effect.sleep(IDLE_TEARDOWN_GAP_MS);
266264

267265
const second = yield* Effect.promise(() => connectClient(target.mcpUrl, bearer, sessionId));
268266
yield* Effect.gen(function* () {
269267
const resumed = yield* Effect.promise(() =>
270268
second.client.callTool({
271269
name: "resume",
272-
arguments: { executionId: expiredExecutionId ?? "", action: "accept", content: "{}" },
270+
arguments: { executionId, action: "accept", content: "{}" },
273271
}),
274272
);
275273
const resumedText = textOf(resumed);
@@ -291,11 +289,12 @@ scenario(
291289
);
292290
const freshExecutionId = executionIdOf(reExecutedText);
293291
expect(freshExecutionId, "the re-run mints a different executionId").not.toBe(executionId);
292+
if (freshExecutionId === undefined) return yield* Effect.die("missing fresh execution id");
294293

295294
const resumedFresh = yield* Effect.promise(() =>
296295
second.client.callTool({
297296
name: "resume",
298-
arguments: { executionId: freshExecutionId ?? "", action: "accept", content: "{}" },
297+
arguments: { executionId: freshExecutionId, action: "accept", content: "{}" },
299298
}),
300299
);
301300
expect(resumedFresh.isError, "the fresh approval resumes to completion").not.toBe(true);

e2e/setup/cloud.boot.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ export const bootCloud = async (options: CloudBootOptions): Promise<CloudBooted>
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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { claimAndBoot } from "../src/ports";
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> {
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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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;

0 commit comments

Comments
 (0)