@testdriverai/mcp 7.11.10-canary → 7.11.11-canary

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -58,7 +58,29 @@ export declare class NoActiveSessionError extends Error {
58
58
  * per async call via {@link als}.
59
59
  */
60
60
  export interface CoreContext {
61
+ /**
62
+ * The live SDK handle. Only ever assigned once its `connect()` has resolved —
63
+ * see {@link sdkGeneration}. Readers may assume `sdk.connected === true`.
64
+ */
61
65
  sdk: any;
66
+ /**
67
+ * Monotonic epoch, bumped by every flow that starts building a new SDK
68
+ * (`sessionStart` / `reconnectSession`).
69
+ *
70
+ * Why: a flow can be abandoned while its `connect()` is still in flight — eve's
71
+ * per-action deadline explicitly stops waiting on the SDK promise but cannot
72
+ * cancel it, so the abandoned action keeps running and races the retry that
73
+ * replaced it. Both then reach for `c.sdk`. Without an epoch the loser lands
74
+ * last and installs its (older, or half-built) SDK over the winner's, and every
75
+ * subsequent action fails against a connection nobody is maintaining.
76
+ *
77
+ * Each flow captures the epoch before it builds, and publishes only if the
78
+ * epoch is still its own. The loser closes the sandbox it built instead of
79
+ * leaking it — an orphaned sandbox pins a team concurrency slot for the whole
80
+ * keepAlive window (15 min for eve), which is what eventually starves the team
81
+ * and turns every later `connect()` into a slot-denial poll.
82
+ */
83
+ sdkGeneration: number;
62
84
  lastScreenshotBase64: string | null;
63
85
  reconnectResolver: ReconnectResolver | null;
64
86
  /** In-flight reconnect, so concurrent actions in one context share one rebuild. */
@@ -144,13 +166,41 @@ export declare function getElementRef(ref: string): {
144
166
  };
145
167
  } | undefined;
146
168
  /**
147
- * Cheap, side-effect-free check for a usable in-process session: a live SDK and
148
- * a current session that hasn't expired. Durable adapters call this *before*
149
- * doing any expensive reconnect prep (e.g. re-resolving an API key), so the
150
- * common warm-process path stays free. Note: unlike `requireActiveSession`, this
151
- * does NOT refresh the keepAlive window — it only reports liveness.
169
+ * Cheap, side-effect-free check for a usable in-process session: a *connected*
170
+ * SDK and a current session that is active and hasn't expired. Durable adapters
171
+ * call this *before* doing any expensive reconnect prep (e.g. re-resolving an API
172
+ * key), so the common warm-process path stays free. Note: unlike
173
+ * `requireActiveSession`, this does NOT refresh the keepAlive window — it only
174
+ * reports liveness.
175
+ *
176
+ * Both extra conditions matter, and neither used to be checked:
177
+ *
178
+ * - `sdk.connected` — the SDK sets this only when `connect()` resolves, and it
179
+ * is the flag `_ensureConnected()` gates every command on. A mere `!!c.sdk`
180
+ * accepted an SDK whose `connect()` was still in flight, so a caller could be
181
+ * told the session was live and then have the very next action throw "SDK is
182
+ * not connected. Call connect() first."
183
+ * - `status === "active"` — `isSessionValid()` deliberately accepts
184
+ * `"initializing"` (it only rejects `expired`/`error`), so a session that was
185
+ * created but never finished connecting counted as usable.
152
186
  */
153
187
  export declare function hasLiveSession(): boolean;
188
+ /**
189
+ * Drop the in-process session and SDK handle after the caller has released the
190
+ * sandbox out-of-band (eve's `session_end`, which calls `sdk.sandbox.close()`).
191
+ *
192
+ * Without this, ending a session left the context claiming a live one: `close()`
193
+ * tears down the sandbox's Ably client but never clears the SDK's own `connected`
194
+ * flag, and the session stayed `active` in the registry pointing at a sandbox that
195
+ * no longer exists — so `hasLiveSession()` answered true and `session_status`
196
+ * reported an active session for a dead VM.
197
+ *
198
+ * Bumping the epoch is the important part: any build still in flight (an abandoned
199
+ * action's reconnect) now loses its publish and closes the sandbox it opened,
200
+ * instead of resurrecting a session the caller explicitly ended — which would
201
+ * silently re-take a team concurrency slot.
202
+ */
203
+ export declare function clearSession(): void;
154
204
  export interface SessionStartHooks {
155
205
  /** Called before a long await; return a stop fn. Lets adapters heartbeat. */
156
206
  onProgress?: (message: string) => void;
@@ -39,6 +39,7 @@ export class NoActiveSessionError extends Error {
39
39
  export function createCoreContext() {
40
40
  return {
41
41
  sdk: null,
42
+ sdkGeneration: 0,
42
43
  lastScreenshotBase64: null,
43
44
  reconnectResolver: null,
44
45
  reconnecting: null,
@@ -110,16 +111,55 @@ export function getElementRef(ref) {
110
111
  return ctx().elementRefs.get(ref);
111
112
  }
112
113
  /**
113
- * Cheap, side-effect-free check for a usable in-process session: a live SDK and
114
- * a current session that hasn't expired. Durable adapters call this *before*
115
- * doing any expensive reconnect prep (e.g. re-resolving an API key), so the
116
- * common warm-process path stays free. Note: unlike `requireActiveSession`, this
117
- * does NOT refresh the keepAlive window — it only reports liveness.
114
+ * Cheap, side-effect-free check for a usable in-process session: a *connected*
115
+ * SDK and a current session that is active and hasn't expired. Durable adapters
116
+ * call this *before* doing any expensive reconnect prep (e.g. re-resolving an API
117
+ * key), so the common warm-process path stays free. Note: unlike
118
+ * `requireActiveSession`, this does NOT refresh the keepAlive window — it only
119
+ * reports liveness.
120
+ *
121
+ * Both extra conditions matter, and neither used to be checked:
122
+ *
123
+ * - `sdk.connected` — the SDK sets this only when `connect()` resolves, and it
124
+ * is the flag `_ensureConnected()` gates every command on. A mere `!!c.sdk`
125
+ * accepted an SDK whose `connect()` was still in flight, so a caller could be
126
+ * told the session was live and then have the very next action throw "SDK is
127
+ * not connected. Call connect() first."
128
+ * - `status === "active"` — `isSessionValid()` deliberately accepts
129
+ * `"initializing"` (it only rejects `expired`/`error`), so a session that was
130
+ * created but never finished connecting counted as usable.
118
131
  */
119
132
  export function hasLiveSession() {
120
133
  const c = ctx();
121
134
  const session = c.sessions.getCurrentSession();
122
- return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
135
+ if (!sdkIsConnected(c.sdk) || !session)
136
+ return false;
137
+ return session.status === "active" && c.sessions.isSessionValid(session.sessionId);
138
+ }
139
+ /**
140
+ * Drop the in-process session and SDK handle after the caller has released the
141
+ * sandbox out-of-band (eve's `session_end`, which calls `sdk.sandbox.close()`).
142
+ *
143
+ * Without this, ending a session left the context claiming a live one: `close()`
144
+ * tears down the sandbox's Ably client but never clears the SDK's own `connected`
145
+ * flag, and the session stayed `active` in the registry pointing at a sandbox that
146
+ * no longer exists — so `hasLiveSession()` answered true and `session_status`
147
+ * reported an active session for a dead VM.
148
+ *
149
+ * Bumping the epoch is the important part: any build still in flight (an abandoned
150
+ * action's reconnect) now loses its publish and closes the sandbox it opened,
151
+ * instead of resurrecting a session the caller explicitly ended — which would
152
+ * silently re-take a team concurrency slot.
153
+ */
154
+ export function clearSession() {
155
+ const c = ctx();
156
+ const current = c.sessions.getCurrentSession();
157
+ if (current)
158
+ c.sessions.endSession(current.sessionId);
159
+ c.sdk = null;
160
+ c.lastScreenshotBase64 = null;
161
+ c.elementRefs.clear();
162
+ c.sdkGeneration++;
123
163
  }
124
164
  // =============================================================================
125
165
  // Helpers
@@ -177,12 +217,68 @@ function bareBase64(s) {
177
217
  */
178
218
  function realtimeIsHealthy(s) {
179
219
  try {
180
- return s?.sandbox?._ably?.connection?.state === "connected";
220
+ return sdkIsConnected(s) && s?.sandbox?._ably?.connection?.state === "connected";
221
+ }
222
+ catch {
223
+ return false;
224
+ }
225
+ }
226
+ /**
227
+ * Whether `s` is an SDK that has finished connecting.
228
+ *
229
+ * `connected` flips to true only at the end of `connect()` and is exactly what
230
+ * the SDK's own `_ensureConnected()` guards every command on, so it is the one
231
+ * honest answer to "can I use this handle". Checking only for the *object* let a
232
+ * still-connecting SDK pass as usable. Optional-chained and defensive: an
233
+ * unusable handle must read as not-connected rather than throw.
234
+ */
235
+ function sdkIsConnected(s) {
236
+ try {
237
+ return s?.connected === true;
181
238
  }
182
239
  catch {
183
240
  return false;
184
241
  }
185
242
  }
243
+ /**
244
+ * Close a sandbox we built but are not going to publish (we lost the epoch race).
245
+ * Best-effort, but worth awaiting: `close()` publishes `end-session` and leaves
246
+ * presence, which releases the team concurrency slot immediately instead of
247
+ * letting the orphan pin it for the full keepAlive window.
248
+ */
249
+ async function discardSdk(sdk) {
250
+ try {
251
+ await sdk?.sandbox?.close?.();
252
+ }
253
+ catch {
254
+ /* best effort — the server reaps the orphan on lease expiry regardless */
255
+ }
256
+ }
257
+ /**
258
+ * Publish `sdk` as the context's handle, unless a newer flow superseded us while
259
+ * we were connecting. Returns false when superseded, having closed the sandbox we
260
+ * built so it can't linger as an orphan holding a concurrency slot.
261
+ *
262
+ * Call this immediately after `connect()` resolves and before touching `c.sdk`.
263
+ */
264
+ async function publishSdk(c, sdk, generation) {
265
+ if (c.sdkGeneration !== generation) {
266
+ await discardSdk(sdk);
267
+ return false;
268
+ }
269
+ c.sdk = sdk;
270
+ return true;
271
+ }
272
+ /** Result for a `sessionStart` that lost the epoch race to a newer session_start. */
273
+ function supersededResult() {
274
+ return {
275
+ ok: false,
276
+ text: "This session_start was superseded by a newer one that is already connected. " +
277
+ "The sandbox this call provisioned has been released. Use the active session, " +
278
+ "or call session_end and then session_start if you need a fresh sandbox.",
279
+ data: { action: "session_start", error: "SESSION_SUPERSEDED" },
280
+ };
281
+ }
186
282
  /**
187
283
  * Attempt to rebuild the singleton from the adapter-registered recovery hook.
188
284
  * Returns true if a usable session now exists, false if there was nothing to
@@ -272,10 +368,17 @@ async function requireActiveSession() {
272
368
  // Reset the keepAlive timer on each command so active use doesn't expire.
273
369
  c.sessions.refreshSession(current.sessionId);
274
370
  }
275
- /** Capture a fresh full-screen screenshot as bare base64 (or null on failure). */
276
- async function captureScreen() {
371
+ /**
372
+ * Capture a fresh full-screen screenshot as bare base64 (or null on failure).
373
+ *
374
+ * Pass an explicit `sdk` from any flow that owns a local handle (`sessionStart`):
375
+ * reading `ctx().sdk` there would let a concurrent rebuild swap the instance out
376
+ * between provisioning and the screenshot. Actions that have already been through
377
+ * `requireActiveSession()` can omit it — the context's SDK is the right one.
378
+ */
379
+ async function captureScreen(sdk = ctx().sdk) {
277
380
  try {
278
- const b64 = await ctx().sdk.agent.system.captureScreenBase64(1, false, true);
381
+ const b64 = await sdk.agent.system.captureScreenBase64(1, false, true);
279
382
  return b64 || null;
280
383
  }
281
384
  catch {
@@ -299,11 +402,13 @@ export async function sessionStart(params, resolved, hooks = {}) {
299
402
  return { ok: false, text: "electron type requires 'appPath' parameter", data: { error: "Missing required parameter: appPath" } };
300
403
  }
301
404
  }
302
- const newSession = c.sessions.createSession({
303
- os: resolved.os,
304
- keepAlive: params.keepAlive,
305
- testFile: params.testFile,
306
- });
405
+ // NOTE: the session is deliberately NOT created here. `createSession()` marks
406
+ // the new session *current* immediately, with status "initializing" — and
407
+ // `isSessionValid()` accepts that status — so creating it before `connect()`
408
+ // published a half-real session to every concurrent reader, and a `connect()`
409
+ // that threw or stalled (e.g. polling for a free concurrency slot) left it
410
+ // current forever. That is the `session_status: initializing` that never
411
+ // resolves. We create and activate it below, only once we have a live sandbox.
307
412
  const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
308
413
  const previewMode = process.env.TD_PREVIEW || "ide";
309
414
  const instanceIp = params.ip || process.env.TD_IP;
@@ -323,8 +428,16 @@ export async function sessionStart(params, resolved, hooks = {}) {
323
428
  },
324
429
  };
325
430
  }
431
+ // Build into a LOCAL handle and publish to `c.sdk` only once `connect()` has
432
+ // resolved. Assigning `c.sdk` up front exposed an SDK whose `connected` flag is
433
+ // still false to every concurrent reader, and — because each step below used to
434
+ // re-read `c.sdk` — let a concurrent rebuild swap the instance out *between*
435
+ // connect and provision, so provisioning ran against a handle that had never
436
+ // been connected and threw "SDK is not connected. Call connect() first."
437
+ // Everything below uses `sdk`; `c.sdk` is written exactly once, at publish.
438
+ const generation = ++c.sdkGeneration;
326
439
  const TestDriverSDK = (await loadTestDriverSdk()).default;
327
- c.sdk = new TestDriverSDK(apiKey, {
440
+ const sdk = new TestDriverSDK(apiKey, {
328
441
  os: resolved.os,
329
442
  logging: false,
330
443
  apiRoot,
@@ -335,11 +448,18 @@ export async function sessionStart(params, resolved, hooks = {}) {
335
448
  // Debug mode — attach to an existing sandbox, skip provisioning.
336
449
  if (params.sandboxId) {
337
450
  progress(`Connecting to existing sandbox ${params.sandboxId}...`);
338
- await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
339
- const instance = c.sdk.getInstance();
451
+ await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
452
+ if (!(await publishSdk(c, sdk, generation)))
453
+ return supersededResult();
454
+ const instance = sdk.getInstance();
455
+ const newSession = c.sessions.createSession({
456
+ os: resolved.os,
457
+ keepAlive: params.keepAlive,
458
+ testFile: params.testFile,
459
+ });
340
460
  c.sessions.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
341
461
  progress("Capturing screenshot...");
342
- const shot = await captureScreen();
462
+ const shot = await captureScreen(sdk);
343
463
  if (shot)
344
464
  c.lastScreenshotBase64 = shot;
345
465
  return {
@@ -351,36 +471,43 @@ export async function sessionStart(params, resolved, hooks = {}) {
351
471
  };
352
472
  }
353
473
  progress(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
354
- await c.sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
355
- const instance = c.sdk.getInstance();
474
+ await sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
475
+ if (!(await publishSdk(c, sdk, generation)))
476
+ return supersededResult();
477
+ const instance = sdk.getInstance();
478
+ const newSession = c.sessions.createSession({
479
+ os: resolved.os,
480
+ keepAlive: params.keepAlive,
481
+ testFile: params.testFile,
482
+ });
356
483
  c.sessions.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
357
484
  const provisionOptions = getProvisionOptions(params);
358
485
  let provisionCmd = "";
359
486
  progress(`Provisioning ${params.type}...`);
360
487
  switch (params.type) {
361
488
  case "chrome":
362
- await c.sdk.provision.chrome(provisionOptions);
489
+ await sdk.provision.chrome(provisionOptions);
363
490
  provisionCmd = "provision.chrome";
364
491
  break;
365
492
  case "chromeExtension":
366
- await c.sdk.provision.chromeExtension(provisionOptions);
493
+ await sdk.provision.chromeExtension(provisionOptions);
367
494
  provisionCmd = "provision.chromeExtension";
368
495
  break;
369
496
  case "vscode":
370
- await c.sdk.provision.vscode(provisionOptions);
497
+ await sdk.provision.vscode(provisionOptions);
371
498
  provisionCmd = "provision.vscode";
372
499
  break;
373
500
  case "installer":
374
- await c.sdk.provision.installer(provisionOptions);
501
+ await sdk.provision.installer(provisionOptions);
375
502
  provisionCmd = "provision.installer";
376
503
  break;
377
504
  case "electron":
378
- await c.sdk.provision.electron(provisionOptions);
505
+ await sdk.provision.electron(provisionOptions);
379
506
  provisionCmd = "provision.electron";
380
507
  break;
381
508
  }
382
509
  progress("Capturing screenshot...");
383
- const shot = await captureScreen();
510
+ const shot = await captureScreen(sdk);
384
511
  if (shot)
385
512
  c.lastScreenshotBase64 = shot;
386
513
  const debuggerUrl = instance?.debuggerUrl || (instanceIp ? `http://${instanceIp}:9222` : null);
@@ -422,13 +549,14 @@ export async function reconnectSession(params) {
422
549
  const c = ctx();
423
550
  const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
424
551
  const previewMode = process.env.TD_PREVIEW || "ide";
425
- const session = c.sessions.createSession({
426
- os: params.os,
427
- keepAlive: params.keepAlive,
428
- testFile: params.testFile,
429
- });
552
+ // Local handle + epoch, published only after connect() — see sessionStart and
553
+ // CoreContext.sdkGeneration. This path is the one an abandoned action reaches
554
+ // (eve stops waiting on a slow action but cannot cancel it, so the zombie keeps
555
+ // running and calls in here), which is exactly how a stale rebuild used to land
556
+ // on top of a newer, healthy SDK.
557
+ const generation = ++c.sdkGeneration;
430
558
  const TestDriverSDK = (await loadTestDriverSdk()).default;
431
- c.sdk = new TestDriverSDK(params.apiKey, {
559
+ const sdk = new TestDriverSDK(params.apiKey, {
432
560
  os: params.os,
433
561
  logging: false,
434
562
  apiRoot,
@@ -436,8 +564,23 @@ export async function reconnectSession(params) {
436
564
  ip: params.ip,
437
565
  e2bTemplateId: params.e2bTemplateId,
438
566
  });
439
- await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
440
- const instance = c.sdk.getInstance();
567
+ await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
568
+ // Superseded: a newer rebuild already published a live SDK. Release the sandbox
569
+ // we just attached (`publishSdk` closes it) and hand back the winner's session
570
+ // rather than clobbering it. The session is only created on the winning path, so
571
+ // a loser never leaves a stray "initializing" session current.
572
+ if (!(await publishSdk(c, sdk, generation))) {
573
+ const current = c.sessions.getCurrentSession();
574
+ if (current && c.sessions.isSessionValid(current.sessionId))
575
+ return current.sessionId;
576
+ throw new NoActiveSessionError("SESSION_EXPIRED", "The sandbox connection was superseded and no active session remains. Call session_start again to create a new sandbox session.");
577
+ }
578
+ const session = c.sessions.createSession({
579
+ os: params.os,
580
+ keepAlive: params.keepAlive,
581
+ testFile: params.testFile,
582
+ });
583
+ const instance = sdk.getInstance();
441
584
  c.sessions.activateSession(session.sessionId, instance?.instanceId || params.sandboxId);
442
585
  return session.sessionId;
443
586
  }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Unit tests for the session-liveness predicate.
3
+ *
4
+ * Regression cover for the "SDK is not connected. Call connect() first." cascade:
5
+ * `hasLiveSession()` used to answer `!!c.sdk && session valid`, which accepted
6
+ * - an SDK whose `connect()` was still in flight (`connected === false`), and
7
+ * - a session still in its `initializing` status (`isSessionValid()` only
8
+ * rejects `expired`/`error`).
9
+ *
10
+ * Both are reachable whenever a rebuild is racing an in-flight action, and either
11
+ * one made eve's `session_start` take its warm no-op path over a connection that
12
+ * could not serve a single command.
13
+ */
14
+
15
+ import { describe, it, expect } from "vitest";
16
+ import { clearSession, createIsolatedContext, hasLiveSession, runInContext, type CoreContext } from "./actions.js";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Helpers
20
+ // ---------------------------------------------------------------------------
21
+
22
+ /** A context holding `sdk`, with a session that is active unless told otherwise. */
23
+ function contextWith(sdk: unknown, opts: { activate?: boolean } = {}): CoreContext {
24
+ const c = createIsolatedContext();
25
+ c.sdk = sdk;
26
+ const session = c.sessions.createSession({ os: "linux", keepAlive: 60_000 });
27
+ if (opts.activate !== false) c.sessions.activateSession(session.sessionId, "sb-test");
28
+ return c;
29
+ }
30
+
31
+ const live = (c: CoreContext) => runInContext(c, () => hasLiveSession());
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Tests
35
+ // ---------------------------------------------------------------------------
36
+
37
+ describe("hasLiveSession", () => {
38
+ it("accepts a connected SDK on an active session", () => {
39
+ expect(live(contextWith({ connected: true }))).toBe(true);
40
+ });
41
+
42
+ it("rejects an SDK whose connect() has not resolved yet", () => {
43
+ // The half-built handle: the object exists, so the old `!!c.sdk` said "live",
44
+ // but every command would throw from _ensureConnected().
45
+ expect(live(contextWith({ connected: false }))).toBe(false);
46
+ });
47
+
48
+ it("rejects a session that was created but never activated", () => {
49
+ // status stays "initializing" — which isSessionValid() deliberately accepts.
50
+ expect(live(contextWith({ connected: true }, { activate: false }))).toBe(false);
51
+ });
52
+
53
+ it("rejects when there is no SDK at all", () => {
54
+ expect(live(contextWith(null))).toBe(false);
55
+ });
56
+
57
+ it("rejects an expired session even with a connected SDK", () => {
58
+ const c = createIsolatedContext();
59
+ c.sdk = { connected: true };
60
+ const session = c.sessions.createSession({ os: "linux", keepAlive: -1 });
61
+ c.sessions.activateSession(session.sessionId, "sb-test");
62
+ expect(live(c)).toBe(false);
63
+ });
64
+
65
+ it("does not throw on a hostile/exotic sdk handle", () => {
66
+ // The predicate must fail safe (unusable → "not live"), never propagate.
67
+ const hostile = {
68
+ get connected(): boolean {
69
+ throw new Error("boom");
70
+ },
71
+ };
72
+ expect(() => live(contextWith(hostile))).not.toThrow();
73
+ expect(live(contextWith(hostile))).toBe(false);
74
+ });
75
+ });
76
+
77
+ describe("clearSession", () => {
78
+ it("stops reporting a live session once the sandbox is released", () => {
79
+ // session_end's shape: sandbox.close() leaves sdk.connected true and the
80
+ // session 'active', so only clearSession() can make the context honest.
81
+ const c = contextWith({ connected: true });
82
+ expect(live(c)).toBe(true);
83
+
84
+ runInContext(c, () => clearSession());
85
+
86
+ expect(live(c)).toBe(false);
87
+ expect(c.sdk).toBeNull();
88
+ expect(c.sessions.getCurrentSession()).toBeNull();
89
+ });
90
+
91
+ it("bumps the epoch so an in-flight build can no longer publish", () => {
92
+ const c = contextWith({ connected: true });
93
+ const before = c.sdkGeneration;
94
+ runInContext(c, () => clearSession());
95
+ expect(c.sdkGeneration).toBeGreaterThan(before);
96
+ });
97
+
98
+ it("is safe to call with no session at all", () => {
99
+ const c = createIsolatedContext();
100
+ expect(() => runInContext(c, () => clearSession())).not.toThrow();
101
+ expect(live(c)).toBe(false);
102
+ });
103
+ });
104
+
105
+ describe("createIsolatedContext", () => {
106
+ it("starts at generation 0 so the first build wins its epoch", () => {
107
+ expect(createIsolatedContext().sdkGeneration).toBe(0);
108
+ });
109
+
110
+ it("gives each context its own SessionManager", () => {
111
+ const a = createIsolatedContext();
112
+ const b = createIsolatedContext();
113
+ a.sessions.createSession({ os: "linux", keepAlive: 60_000 });
114
+ expect(a.sessions.getCurrentSession()).not.toBeNull();
115
+ expect(b.sessions.getCurrentSession()).toBeNull();
116
+ });
117
+ });
@@ -81,7 +81,29 @@ export class NoActiveSessionError extends Error {
81
81
  * per async call via {@link als}.
82
82
  */
83
83
  export interface CoreContext {
84
+ /**
85
+ * The live SDK handle. Only ever assigned once its `connect()` has resolved —
86
+ * see {@link sdkGeneration}. Readers may assume `sdk.connected === true`.
87
+ */
84
88
  sdk: any;
89
+ /**
90
+ * Monotonic epoch, bumped by every flow that starts building a new SDK
91
+ * (`sessionStart` / `reconnectSession`).
92
+ *
93
+ * Why: a flow can be abandoned while its `connect()` is still in flight — eve's
94
+ * per-action deadline explicitly stops waiting on the SDK promise but cannot
95
+ * cancel it, so the abandoned action keeps running and races the retry that
96
+ * replaced it. Both then reach for `c.sdk`. Without an epoch the loser lands
97
+ * last and installs its (older, or half-built) SDK over the winner's, and every
98
+ * subsequent action fails against a connection nobody is maintaining.
99
+ *
100
+ * Each flow captures the epoch before it builds, and publishes only if the
101
+ * epoch is still its own. The loser closes the sandbox it built instead of
102
+ * leaking it — an orphaned sandbox pins a team concurrency slot for the whole
103
+ * keepAlive window (15 min for eve), which is what eventually starves the team
104
+ * and turns every later `connect()` into a slot-denial poll.
105
+ */
106
+ sdkGeneration: number;
85
107
  lastScreenshotBase64: string | null;
86
108
  reconnectResolver: ReconnectResolver | null;
87
109
  /** In-flight reconnect, so concurrent actions in one context share one rebuild. */
@@ -106,6 +128,7 @@ export interface CoreContext {
106
128
  export function createCoreContext(): CoreContext {
107
129
  return {
108
130
  sdk: null,
131
+ sdkGeneration: 0,
109
132
  lastScreenshotBase64: null,
110
133
  reconnectResolver: null,
111
134
  reconnecting: null,
@@ -210,16 +233,54 @@ export function getElementRef(ref: string) {
210
233
  }
211
234
 
212
235
  /**
213
- * Cheap, side-effect-free check for a usable in-process session: a live SDK and
214
- * a current session that hasn't expired. Durable adapters call this *before*
215
- * doing any expensive reconnect prep (e.g. re-resolving an API key), so the
216
- * common warm-process path stays free. Note: unlike `requireActiveSession`, this
217
- * does NOT refresh the keepAlive window — it only reports liveness.
236
+ * Cheap, side-effect-free check for a usable in-process session: a *connected*
237
+ * SDK and a current session that is active and hasn't expired. Durable adapters
238
+ * call this *before* doing any expensive reconnect prep (e.g. re-resolving an API
239
+ * key), so the common warm-process path stays free. Note: unlike
240
+ * `requireActiveSession`, this does NOT refresh the keepAlive window — it only
241
+ * reports liveness.
242
+ *
243
+ * Both extra conditions matter, and neither used to be checked:
244
+ *
245
+ * - `sdk.connected` — the SDK sets this only when `connect()` resolves, and it
246
+ * is the flag `_ensureConnected()` gates every command on. A mere `!!c.sdk`
247
+ * accepted an SDK whose `connect()` was still in flight, so a caller could be
248
+ * told the session was live and then have the very next action throw "SDK is
249
+ * not connected. Call connect() first."
250
+ * - `status === "active"` — `isSessionValid()` deliberately accepts
251
+ * `"initializing"` (it only rejects `expired`/`error`), so a session that was
252
+ * created but never finished connecting counted as usable.
218
253
  */
219
254
  export function hasLiveSession(): boolean {
220
255
  const c = ctx();
221
256
  const session = c.sessions.getCurrentSession();
222
- return !!c.sdk && !!session && c.sessions.isSessionValid(session.sessionId);
257
+ if (!sdkIsConnected(c.sdk) || !session) return false;
258
+ return session.status === "active" && c.sessions.isSessionValid(session.sessionId);
259
+ }
260
+
261
+ /**
262
+ * Drop the in-process session and SDK handle after the caller has released the
263
+ * sandbox out-of-band (eve's `session_end`, which calls `sdk.sandbox.close()`).
264
+ *
265
+ * Without this, ending a session left the context claiming a live one: `close()`
266
+ * tears down the sandbox's Ably client but never clears the SDK's own `connected`
267
+ * flag, and the session stayed `active` in the registry pointing at a sandbox that
268
+ * no longer exists — so `hasLiveSession()` answered true and `session_status`
269
+ * reported an active session for a dead VM.
270
+ *
271
+ * Bumping the epoch is the important part: any build still in flight (an abandoned
272
+ * action's reconnect) now loses its publish and closes the sandbox it opened,
273
+ * instead of resurrecting a session the caller explicitly ended — which would
274
+ * silently re-take a team concurrency slot.
275
+ */
276
+ export function clearSession(): void {
277
+ const c = ctx();
278
+ const current = c.sessions.getCurrentSession();
279
+ if (current) c.sessions.endSession(current.sessionId);
280
+ c.sdk = null;
281
+ c.lastScreenshotBase64 = null;
282
+ c.elementRefs.clear();
283
+ c.sdkGeneration++;
223
284
  }
224
285
 
225
286
  // =============================================================================
@@ -281,12 +342,71 @@ function bareBase64(s: string): string {
281
342
  */
282
343
  function realtimeIsHealthy(s: any): boolean {
283
344
  try {
284
- return s?.sandbox?._ably?.connection?.state === "connected";
345
+ return sdkIsConnected(s) && s?.sandbox?._ably?.connection?.state === "connected";
285
346
  } catch {
286
347
  return false;
287
348
  }
288
349
  }
289
350
 
351
+ /**
352
+ * Whether `s` is an SDK that has finished connecting.
353
+ *
354
+ * `connected` flips to true only at the end of `connect()` and is exactly what
355
+ * the SDK's own `_ensureConnected()` guards every command on, so it is the one
356
+ * honest answer to "can I use this handle". Checking only for the *object* let a
357
+ * still-connecting SDK pass as usable. Optional-chained and defensive: an
358
+ * unusable handle must read as not-connected rather than throw.
359
+ */
360
+ function sdkIsConnected(s: any): boolean {
361
+ try {
362
+ return s?.connected === true;
363
+ } catch {
364
+ return false;
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Close a sandbox we built but are not going to publish (we lost the epoch race).
370
+ * Best-effort, but worth awaiting: `close()` publishes `end-session` and leaves
371
+ * presence, which releases the team concurrency slot immediately instead of
372
+ * letting the orphan pin it for the full keepAlive window.
373
+ */
374
+ async function discardSdk(sdk: any): Promise<void> {
375
+ try {
376
+ await sdk?.sandbox?.close?.();
377
+ } catch {
378
+ /* best effort — the server reaps the orphan on lease expiry regardless */
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Publish `sdk` as the context's handle, unless a newer flow superseded us while
384
+ * we were connecting. Returns false when superseded, having closed the sandbox we
385
+ * built so it can't linger as an orphan holding a concurrency slot.
386
+ *
387
+ * Call this immediately after `connect()` resolves and before touching `c.sdk`.
388
+ */
389
+ async function publishSdk(c: CoreContext, sdk: any, generation: number): Promise<boolean> {
390
+ if (c.sdkGeneration !== generation) {
391
+ await discardSdk(sdk);
392
+ return false;
393
+ }
394
+ c.sdk = sdk;
395
+ return true;
396
+ }
397
+
398
+ /** Result for a `sessionStart` that lost the epoch race to a newer session_start. */
399
+ function supersededResult(): ActionResult {
400
+ return {
401
+ ok: false,
402
+ text:
403
+ "This session_start was superseded by a newer one that is already connected. " +
404
+ "The sandbox this call provisioned has been released. Use the active session, " +
405
+ "or call session_end and then session_start if you need a fresh sandbox.",
406
+ data: { action: "session_start", error: "SESSION_SUPERSEDED" },
407
+ };
408
+ }
409
+
290
410
  /**
291
411
  * Attempt to rebuild the singleton from the adapter-registered recovery hook.
292
412
  * Returns true if a usable session now exists, false if there was nothing to
@@ -390,10 +510,17 @@ async function requireActiveSession(): Promise<void> {
390
510
  c.sessions.refreshSession(current.sessionId);
391
511
  }
392
512
 
393
- /** Capture a fresh full-screen screenshot as bare base64 (or null on failure). */
394
- async function captureScreen(): Promise<string | null> {
513
+ /**
514
+ * Capture a fresh full-screen screenshot as bare base64 (or null on failure).
515
+ *
516
+ * Pass an explicit `sdk` from any flow that owns a local handle (`sessionStart`):
517
+ * reading `ctx().sdk` there would let a concurrent rebuild swap the instance out
518
+ * between provisioning and the screenshot. Actions that have already been through
519
+ * `requireActiveSession()` can omit it — the context's SDK is the right one.
520
+ */
521
+ async function captureScreen(sdk: any = ctx().sdk): Promise<string | null> {
395
522
  try {
396
- const b64 = await ctx().sdk.agent.system.captureScreenBase64(1, false, true);
523
+ const b64 = await sdk.agent.system.captureScreenBase64(1, false, true);
397
524
  return b64 || null;
398
525
  } catch {
399
526
  return null;
@@ -432,12 +559,13 @@ export async function sessionStart(
432
559
  }
433
560
  }
434
561
 
435
- const newSession = c.sessions.createSession({
436
- os: resolved.os,
437
- keepAlive: params.keepAlive,
438
- testFile: params.testFile,
439
- });
440
-
562
+ // NOTE: the session is deliberately NOT created here. `createSession()` marks
563
+ // the new session *current* immediately, with status "initializing" — and
564
+ // `isSessionValid()` accepts that status — so creating it before `connect()`
565
+ // published a half-real session to every concurrent reader, and a `connect()`
566
+ // that threw or stalled (e.g. polling for a free concurrency slot) left it
567
+ // current forever. That is the `session_status: initializing` that never
568
+ // resolves. We create and activate it below, only once we have a live sandbox.
441
569
  const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
442
570
  const previewMode = process.env.TD_PREVIEW || "ide";
443
571
  const instanceIp = params.ip || process.env.TD_IP;
@@ -459,8 +587,16 @@ export async function sessionStart(
459
587
  };
460
588
  }
461
589
 
590
+ // Build into a LOCAL handle and publish to `c.sdk` only once `connect()` has
591
+ // resolved. Assigning `c.sdk` up front exposed an SDK whose `connected` flag is
592
+ // still false to every concurrent reader, and — because each step below used to
593
+ // re-read `c.sdk` — let a concurrent rebuild swap the instance out *between*
594
+ // connect and provision, so provisioning ran against a handle that had never
595
+ // been connected and threw "SDK is not connected. Call connect() first."
596
+ // Everything below uses `sdk`; `c.sdk` is written exactly once, at publish.
597
+ const generation = ++c.sdkGeneration;
462
598
  const TestDriverSDK = (await loadTestDriverSdk()).default;
463
- c.sdk = new TestDriverSDK(apiKey, {
599
+ const sdk = new TestDriverSDK(apiKey, {
464
600
  os: resolved.os,
465
601
  logging: false,
466
602
  apiRoot,
@@ -472,12 +608,19 @@ export async function sessionStart(
472
608
  // Debug mode — attach to an existing sandbox, skip provisioning.
473
609
  if (params.sandboxId) {
474
610
  progress(`Connecting to existing sandbox ${params.sandboxId}...`);
475
- await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
476
- const instance = c.sdk.getInstance();
611
+ await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
612
+ if (!(await publishSdk(c, sdk, generation))) return supersededResult();
613
+
614
+ const instance = sdk.getInstance();
615
+ const newSession = c.sessions.createSession({
616
+ os: resolved.os,
617
+ keepAlive: params.keepAlive,
618
+ testFile: params.testFile,
619
+ });
477
620
  c.sessions.activateSession(newSession.sessionId, instance?.instanceId || params.sandboxId);
478
621
 
479
622
  progress("Capturing screenshot...");
480
- const shot = await captureScreen();
623
+ const shot = await captureScreen(sdk);
481
624
  if (shot) c.lastScreenshotBase64 = shot;
482
625
 
483
626
  return {
@@ -490,9 +633,15 @@ export async function sessionStart(
490
633
  }
491
634
 
492
635
  progress(instanceIp ? `Connecting to self-hosted instance ${instanceIp}...` : "Connecting to cloud sandbox...");
493
- await c.sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
636
+ await sdk.connect({ reconnect: params.reconnect, keepAlive: params.keepAlive, ip: instanceIp });
637
+ if (!(await publishSdk(c, sdk, generation))) return supersededResult();
494
638
 
495
- const instance = c.sdk.getInstance();
639
+ const instance = sdk.getInstance();
640
+ const newSession = c.sessions.createSession({
641
+ os: resolved.os,
642
+ keepAlive: params.keepAlive,
643
+ testFile: params.testFile,
644
+ });
496
645
  c.sessions.activateSession(newSession.sessionId, instance?.instanceId || "unknown");
497
646
 
498
647
  const provisionOptions = getProvisionOptions(params);
@@ -501,29 +650,29 @@ export async function sessionStart(
501
650
  progress(`Provisioning ${params.type}...`);
502
651
  switch (params.type) {
503
652
  case "chrome":
504
- await c.sdk.provision.chrome(provisionOptions);
653
+ await sdk.provision.chrome(provisionOptions);
505
654
  provisionCmd = "provision.chrome";
506
655
  break;
507
656
  case "chromeExtension":
508
- await c.sdk.provision.chromeExtension(provisionOptions);
657
+ await sdk.provision.chromeExtension(provisionOptions);
509
658
  provisionCmd = "provision.chromeExtension";
510
659
  break;
511
660
  case "vscode":
512
- await c.sdk.provision.vscode(provisionOptions);
661
+ await sdk.provision.vscode(provisionOptions);
513
662
  provisionCmd = "provision.vscode";
514
663
  break;
515
664
  case "installer":
516
- await c.sdk.provision.installer(provisionOptions);
665
+ await sdk.provision.installer(provisionOptions);
517
666
  provisionCmd = "provision.installer";
518
667
  break;
519
668
  case "electron":
520
- await c.sdk.provision.electron(provisionOptions);
669
+ await sdk.provision.electron(provisionOptions);
521
670
  provisionCmd = "provision.electron";
522
671
  break;
523
672
  }
524
673
 
525
674
  progress("Capturing screenshot...");
526
- const shot = await captureScreen();
675
+ const shot = await captureScreen(sdk);
527
676
  if (shot) c.lastScreenshotBase64 = shot;
528
677
 
529
678
  const debuggerUrl = instance?.debuggerUrl || (instanceIp ? `http://${instanceIp}:9222` : null);
@@ -590,14 +739,14 @@ export async function reconnectSession(params: ReconnectParams): Promise<string>
590
739
  const apiRoot = params.apiRoot || process.env.TD_API_ROOT || "https://api.testdriver.ai";
591
740
  const previewMode = process.env.TD_PREVIEW || "ide";
592
741
 
593
- const session = c.sessions.createSession({
594
- os: params.os,
595
- keepAlive: params.keepAlive,
596
- testFile: params.testFile,
597
- });
598
-
742
+ // Local handle + epoch, published only after connect() — see sessionStart and
743
+ // CoreContext.sdkGeneration. This path is the one an abandoned action reaches
744
+ // (eve stops waiting on a slow action but cannot cancel it, so the zombie keeps
745
+ // running and calls in here), which is exactly how a stale rebuild used to land
746
+ // on top of a newer, healthy SDK.
747
+ const generation = ++c.sdkGeneration;
599
748
  const TestDriverSDK = (await loadTestDriverSdk()).default;
600
- c.sdk = new TestDriverSDK(params.apiKey, {
749
+ const sdk = new TestDriverSDK(params.apiKey, {
601
750
  os: params.os,
602
751
  logging: false,
603
752
  apiRoot,
@@ -606,8 +755,27 @@ export async function reconnectSession(params: ReconnectParams): Promise<string>
606
755
  e2bTemplateId: params.e2bTemplateId,
607
756
  });
608
757
 
609
- await c.sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
610
- const instance = c.sdk.getInstance();
758
+ await sdk.connect({ sandboxId: params.sandboxId, keepAlive: params.keepAlive });
759
+
760
+ // Superseded: a newer rebuild already published a live SDK. Release the sandbox
761
+ // we just attached (`publishSdk` closes it) and hand back the winner's session
762
+ // rather than clobbering it. The session is only created on the winning path, so
763
+ // a loser never leaves a stray "initializing" session current.
764
+ if (!(await publishSdk(c, sdk, generation))) {
765
+ const current = c.sessions.getCurrentSession();
766
+ if (current && c.sessions.isSessionValid(current.sessionId)) return current.sessionId;
767
+ throw new NoActiveSessionError(
768
+ "SESSION_EXPIRED",
769
+ "The sandbox connection was superseded and no active session remains. Call session_start again to create a new sandbox session."
770
+ );
771
+ }
772
+
773
+ const session = c.sessions.createSession({
774
+ os: params.os,
775
+ keepAlive: params.keepAlive,
776
+ testFile: params.testFile,
777
+ });
778
+ const instance = sdk.getInstance();
611
779
  c.sessions.activateSession(session.sessionId, instance?.instanceId || params.sandboxId);
612
780
  return session.sessionId;
613
781
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@testdriverai/mcp",
3
- "version": "7.11.10-canary",
3
+ "version": "7.11.11-canary",
4
4
  "description": "Next generation autonomous AI agent for end-to-end testing of web & desktop",
5
5
  "main": "sdk.js",
6
6
  "types": "sdk.d.ts",