testdriverai 7.8.0-test.6 → 7.8.0-test.60

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/agent/index.js +10 -5
  3. package/agent/lib/commands.js +3 -2
  4. package/agent/lib/http.js +144 -0
  5. package/agent/lib/logger.js +15 -0
  6. package/agent/lib/sandbox.js +513 -207
  7. package/agent/lib/sdk.js +4 -2
  8. package/agent/lib/system.js +25 -65
  9. package/ai/skills/testdriver-cache/SKILL.md +221 -0
  10. package/ai/skills/testdriver-errors/SKILL.md +246 -0
  11. package/ai/skills/testdriver-events/SKILL.md +356 -0
  12. package/ai/skills/testdriver-find/SKILL.md +14 -20
  13. package/ai/skills/testdriver-mcp/SKILL.md +7 -0
  14. package/ai/skills/testdriver-provision/SKILL.md +331 -0
  15. package/ai/skills/testdriver-redraw/SKILL.md +214 -0
  16. package/ai/skills/testdriver-running-tests/SKILL.md +1 -1
  17. package/ai/skills/testdriver-screenshots/SKILL.md +184 -0
  18. package/docs/_data/examples-manifest.json +46 -46
  19. package/docs/changelog.mdx +151 -5
  20. package/docs/docs.json +44 -37
  21. package/docs/images/content/vscode/v7-chat.png +0 -0
  22. package/docs/images/content/vscode/v7-choose-agent.png +0 -0
  23. package/docs/images/content/vscode/v7-full.png +0 -0
  24. package/docs/images/content/vscode/v7-onboarding.png +0 -0
  25. package/docs/v7/cache.mdx +223 -0
  26. package/docs/v7/copilot/auto-healing.mdx +265 -0
  27. package/docs/v7/copilot/creating-tests.mdx +156 -0
  28. package/docs/v7/copilot/github.mdx +143 -0
  29. package/docs/v7/copilot/running-tests.mdx +149 -0
  30. package/docs/v7/copilot/setup.mdx +143 -0
  31. package/docs/v7/enterprise.mdx +3 -110
  32. package/docs/v7/errors.mdx +248 -0
  33. package/docs/v7/events.mdx +358 -0
  34. package/docs/v7/examples/ai.mdx +1 -1
  35. package/docs/v7/examples/assert.mdx +1 -1
  36. package/docs/v7/examples/captcha-api.mdx +1 -1
  37. package/docs/v7/examples/chrome-extension.mdx +1 -1
  38. package/docs/v7/examples/drag-and-drop.mdx +1 -1
  39. package/docs/v7/examples/element-not-found.mdx +1 -1
  40. package/docs/v7/examples/exec-output.mdx +85 -0
  41. package/docs/v7/examples/exec-pwsh.mdx +83 -0
  42. package/docs/v7/examples/focus-window.mdx +62 -0
  43. package/docs/v7/examples/hover-image.mdx +1 -1
  44. package/docs/v7/examples/hover-text.mdx +1 -1
  45. package/docs/v7/examples/installer.mdx +1 -1
  46. package/docs/v7/examples/launch-vscode-linux.mdx +1 -1
  47. package/docs/v7/examples/match-image.mdx +1 -1
  48. package/docs/v7/examples/press-keys.mdx +1 -1
  49. package/docs/v7/examples/scroll-keyboard.mdx +1 -1
  50. package/docs/v7/examples/scroll-until-image.mdx +1 -1
  51. package/docs/v7/examples/scroll-until-text.mdx +1 -1
  52. package/docs/v7/examples/scroll.mdx +1 -1
  53. package/docs/v7/examples/type.mdx +1 -1
  54. package/docs/v7/examples/windows-installer.mdx +1 -1
  55. package/docs/v7/find.mdx +14 -20
  56. package/docs/v7/{cloud.mdx → hosted.mdx} +43 -5
  57. package/docs/v7/mcp.mdx +9 -0
  58. package/docs/v7/provision.mdx +333 -0
  59. package/docs/v7/quickstart.mdx +30 -2
  60. package/docs/v7/redraw.mdx +216 -0
  61. package/docs/v7/running-tests.mdx +1 -1
  62. package/docs/v7/screenshots.mdx +186 -0
  63. package/docs/v7/self-hosted.mdx +127 -44
  64. package/examples/scroll-keyboard.test.mjs +1 -1
  65. package/interfaces/logger.js +0 -12
  66. package/interfaces/vitest-plugin.mjs +53 -43
  67. package/lib/core/Dashcam.js +30 -23
  68. package/lib/environments.json +18 -0
  69. package/lib/github-comment.mjs +58 -40
  70. package/lib/resolve-channel.js +4 -3
  71. package/lib/sentry.js +5 -0
  72. package/lib/vitest/hooks.mjs +8 -3
  73. package/{examples → manual}/drag-and-drop.test.mjs +1 -1
  74. package/mcp-server/dist/server.mjs +4 -0
  75. package/mcp-server/src/server.ts +5 -0
  76. package/package.json +3 -3
  77. package/sdk.d.ts +4 -0
  78. package/sdk.js +15 -7
  79. package/setup/aws/install-dev-runner.sh +79 -0
  80. package/setup/aws/spawn-runner.sh +165 -0
  81. package/vitest.config.mjs +22 -34
  82. package/vitest.runner.config.mjs +33 -0
  83. /package/{examples → manual}/flake-diffthreshold-001.test.mjs +0 -0
  84. /package/{examples → manual}/flake-diffthreshold-01.test.mjs +0 -0
  85. /package/{examples → manual}/flake-diffthreshold-05.test.mjs +0 -0
  86. /package/{examples → manual}/flake-noredraw-cache.test.mjs +0 -0
  87. /package/{examples → manual}/flake-noredraw-nocache.test.mjs +0 -0
  88. /package/{examples → manual}/flake-redraw-cache.test.mjs +0 -0
  89. /package/{examples → manual}/flake-redraw-nocache.test.mjs +0 -0
  90. /package/{examples → manual}/flake-rocket-match.test.mjs +0 -0
  91. /package/{examples → manual}/flake-shared.mjs +0 -0
  92. /package/{examples → manual}/no-provision.test.mjs +0 -0
  93. /package/{examples → manual}/scroll-until-text.test.mjs +0 -0
@@ -1,93 +1,17 @@
1
- const crypto = require("crypto");
2
1
  const Ably = require("ably");
2
+ const axios = require("axios");
3
3
  const { events } = require("../events");
4
4
  const logger = require("./logger");
5
5
  const { version } = require("../../package.json");
6
-
7
- function getSentryTraceHeaders(sessionId) {
8
- if (!sessionId) return {};
9
- const traceId = crypto.createHash("md5").update(sessionId).digest("hex");
10
- const spanId = crypto.randomBytes(8).toString("hex");
11
- return {
12
- "sentry-trace": traceId + "-" + spanId + "-1",
13
- baggage:
14
- "sentry-trace_id=" +
15
- traceId +
16
- ",sentry-sample_rate=1.0,sentry-sampled=true",
17
- };
18
- }
19
-
20
- function httpPost(apiRoot, path, body, timeout) {
21
- const http = require("http");
22
- const https = require("https");
23
- const url = new URL(apiRoot + path);
24
- const transport = url.protocol === "https:" ? https : http;
25
- const bodyStr = JSON.stringify(body);
26
-
27
- return new Promise(function (resolve, reject) {
28
- var timeoutId = timeout
29
- ? setTimeout(function () {
30
- req.destroy();
31
- reject(
32
- new Error("HTTP request timed out after " + timeout + "ms"),
33
- );
34
- }, timeout)
35
- : null;
36
-
37
- var req = transport.request(
38
- url,
39
- {
40
- method: "POST",
41
- headers: {
42
- "Content-Type": "application/json",
43
- "Content-Length": Buffer.byteLength(bodyStr),
44
- "Connection": "close",
45
- },
46
- },
47
- function (res) {
48
- var data = "";
49
- res.on("data", function (chunk) {
50
- data += chunk;
51
- });
52
- res.on("end", function () {
53
- if (timeoutId) clearTimeout(timeoutId);
54
- try {
55
- var parsed = JSON.parse(data);
56
- if (res.statusCode >= 400) {
57
- var err = new Error(
58
- parsed.errorMessage ||
59
- parsed.message ||
60
- "HTTP " + res.statusCode,
61
- );
62
- err.responseData = parsed;
63
- reject(err);
64
- } else {
65
- resolve(parsed);
66
- }
67
- } catch (e) {
68
- reject(new Error("Failed to parse API response: " + data));
69
- }
70
- });
71
- },
72
- );
73
- req.on("error", function (err) {
74
- if (timeoutId) clearTimeout(timeoutId);
75
- reject(err);
76
- });
77
- req.write(bodyStr);
78
- req.end();
79
- });
80
- }
6
+ const { withRetry, getSentryTraceHeaders } = require("./sdk");
7
+ const sentry = require("../../lib/sentry");
81
8
 
82
9
  const createSandbox = function (emitter, analytics, sessionInstance) {
83
10
  class Sandbox {
84
11
  constructor() {
85
12
  this._ably = null;
86
- this._cmdChannel = null;
87
- this._respChannel = null;
88
- this._ctrlChannel = null;
89
- this._filesChannel = null;
90
- this._channelNames = null;
13
+ this._sessionChannel = null;
14
+ this._channelName = null;
91
15
  this.ps = {};
92
16
  this._execBuffers = {}; // accumulate streamed exec.output chunks per requestId
93
17
  this.heartbeat = null;
@@ -105,6 +29,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
105
29
  this._lastConnectParams = null;
106
30
  this._teamId = null;
107
31
  this._sandboxId = null;
32
+ this._disconnectedAt = null; // tracks when Realtime connection dropped (for timeout extension on reconnect)
33
+
34
+ // Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
35
+ this._publishLastTime = 0;
36
+ this._publishMinIntervalMs = 25; // 40 msg/sec max, safely under Ably's 50 limit
37
+ this._publishCount = 0;
38
+ this._publishWindowStart = Date.now();
108
39
  }
109
40
 
110
41
  getTraceId() {
@@ -118,7 +49,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
118
49
  );
119
50
  }
120
51
 
121
- async _initAbly(ablyToken, channelNames) {
52
+ async _initAbly(ablyToken, channelName) {
122
53
  if (this._ably) {
123
54
  try {
124
55
  this._ably.close();
@@ -126,48 +57,69 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
126
57
  /* ignore */
127
58
  }
128
59
  }
129
- this._channelNames = channelNames;
60
+ this._channelName = channelName;
130
61
  var self = this;
131
62
 
132
63
  this._ably = new Ably.Realtime({
133
- authCallback: function (tokenParams, callback) {
134
- callback(null, ablyToken);
64
+ authCallback: async function (tokenParams, callback) {
65
+ // On initial connect Ably may supply the token directly; on renewal
66
+ // we must fetch a fresh one from the API (the original token will
67
+ // have expired, causing 40143 token.unrecognized if reused).
68
+ try {
69
+ const response = await axios({
70
+ method: "post",
71
+ url: self.apiRoot + "/api/v7/sandbox/ably-token",
72
+ data: { apiKey: self.apiKey, sandboxId: self._sandboxId },
73
+ headers: { "Content-Type": "application/json" },
74
+ timeout: 15000,
75
+ });
76
+ callback(null, response.data.token);
77
+ } catch (err) {
78
+ logger.warn("[ably] Token renewal failed, falling back to original token: " + (err.message || err));
79
+ callback(null, ablyToken);
80
+ }
135
81
  },
136
82
  clientId: "sdk-" + this._sandboxId,
83
+ echoMessages: false, // don't receive our own published messages
137
84
  disconnectedRetryTimeout: 5000, // retry reconnect every 5s (default 15s)
138
85
  suspendedRetryTimeout: 15000, // retry from suspended every 15s (default 30s)
139
86
  });
140
87
 
88
+ logger.debug(`[realtime] Connecting as sdk-${this._sandboxId}...`);
89
+
141
90
  await new Promise(function (resolve, reject) {
142
91
  self._ably.connection.on("connected", resolve);
143
92
  self._ably.connection.on("failed", function () {
144
- reject(new Error("Ably connection failed"));
93
+ reject(new Error("Realtime connection failed"));
145
94
  });
146
95
  setTimeout(function () {
147
- reject(new Error("Ably connection timeout"));
96
+ reject(new Error("Realtime connection timeout"));
148
97
  }, 30000);
149
98
  });
150
99
 
151
- this._cmdChannel = this._ably.channels.get(channelNames.commands);
152
- this._respChannel = this._ably.channels.get(channelNames.responses);
153
- this._ctrlChannel = this._ably.channels.get(channelNames.control);
154
- this._filesChannel = this._ably.channels.get(channelNames.files);
100
+ this._sessionChannel = this._ably.channels.get(channelName);
101
+
102
+ logger.debug(`[realtime] Channel initialized: ${channelName}`);
155
103
 
156
- // Enter presence on control channel so the API can count connected SDK clients
104
+ // Enter presence on the session channel so the API can count connected SDK clients
157
105
  try {
158
- await this._ctrlChannel.presence.enter({
106
+ await this._sessionChannel.presence.enter({
159
107
  sandboxId: this._sandboxId,
160
108
  connectedAt: Date.now(),
161
109
  });
110
+ logger.debug(`[realtime] Entered presence on session channel (sandbox=${this._sandboxId})`);
162
111
  } catch (e) {
163
112
  // Non-fatal — presence is used for concurrency counting, not critical path
164
- logger.warn("Failed to enter presence on control channel: " + (e.message || e));
113
+ logger.warn("Failed to enter presence on session channel: " + (e.message || e));
165
114
  }
166
115
 
167
- this._respChannel.subscribe("response", function (msg) {
116
+ // Save subscription references for historyBeforeSubscribe() during discontinuity recovery
117
+ this._onResponseMsg = function (msg) {
168
118
  var message = msg.data;
169
119
  if (!message) return;
170
120
 
121
+ logger.debug(`[realtime] Received response: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
122
+
171
123
  if (message.type === "sandbox.progress") {
172
124
  emitter.emit(events.sandbox.progress, {
173
125
  step: message.step,
@@ -218,31 +170,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
218
170
  }
219
171
 
220
172
  if (!message.requestId || !self.ps[message.requestId]) {
221
- var debugMode =
222
- process.env.VERBOSE || process.env.TD_DEBUG;
223
- if (debugMode) {
224
- console.warn(
225
- "No pending promise found for requestId:",
226
- message.requestId,
227
- );
228
- }
173
+ var pendingIds = Object.keys(self.ps);
174
+ var pendingSummary = pendingIds.length > 0
175
+ ? pendingIds.map(function (rid) {
176
+ var e = self.ps[rid];
177
+ return rid + '(' + (e && e.message ? e.message.type : '?') + ')';
178
+ }).join(', ')
179
+ : 'none';
180
+ logger.warn(
181
+ '[realtime] No pending promise for requestId=' + (message.requestId || 'null') +
182
+ ' | response type=' + (message.type || 'unknown') +
183
+ ' | error=' + (message.error ? (message.errorMessage || 'true') : 'false') +
184
+ ' | currently pending: [' + pendingSummary + ']'
185
+ );
229
186
  return;
230
187
  }
231
188
 
232
189
  if (message.error) {
233
- var pendingMessage =
234
- self.ps[message.requestId] &&
235
- self.ps[message.requestId].message;
190
+ var pendingEntry = self.ps[message.requestId];
191
+ var pendingMessage = pendingEntry && pendingEntry.message;
192
+ var pendingAge = pendingEntry && pendingEntry.startTime
193
+ ? ((Date.now() - pendingEntry.startTime) / 1000).toFixed(1) + 's'
194
+ : '?';
195
+ logger.warn(
196
+ '[realtime] Promise REJECTED: requestId=' + message.requestId +
197
+ ' | type=' + (pendingMessage ? pendingMessage.type : 'unknown') +
198
+ ' | age=' + pendingAge +
199
+ ' | error=' + (message.errorMessage || 'Sandbox error')
200
+ );
236
201
  if (!pendingMessage || pendingMessage.type !== "output") {
237
202
  emitter.emit(events.error.sandbox, message.errorMessage);
238
203
  }
239
204
  var error = new Error(message.errorMessage || "Sandbox error");
240
205
  error.responseData = message;
241
206
  delete self._execBuffers[message.requestId];
242
- self.ps[message.requestId].reject(error);
207
+ pendingEntry.reject(error);
243
208
  } else {
244
209
  emitter.emit(events.sandbox.received);
245
210
  if (self.ps[message.requestId]) {
211
+ var resolveEntry = self.ps[message.requestId];
212
+ var resolveAge = resolveEntry.startTime
213
+ ? ((Date.now() - resolveEntry.startTime) / 1000).toFixed(1) + 's'
214
+ : '?';
215
+ logger.debug(
216
+ '[realtime] Promise RESOLVED: requestId=' + message.requestId +
217
+ ' | type=' + (resolveEntry.message ? resolveEntry.message.type : 'unknown') +
218
+ ' | age=' + resolveAge
219
+ );
246
220
  // Unwrap the result from the Ably response envelope
247
221
  // The runner sends { requestId, type, result, success }
248
222
  // But SDK commands expect just the result object
@@ -261,77 +235,230 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
261
235
  }
262
236
  }
263
237
  delete self.ps[message.requestId];
264
- });
238
+ };
239
+ this._responseSubscription = await this._sessionChannel.subscribe("response", this._onResponseMsg);
265
240
 
266
- this._filesChannel.subscribe("response", function (msg) {
241
+ this._onFileMsg = function (msg) {
267
242
  var message = msg.data;
268
243
  if (!message) return;
244
+ logger.debug(`[realtime] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
269
245
  if (message.requestId && self.ps[message.requestId]) {
270
246
  emitter.emit(events.sandbox.received);
271
247
  self.ps[message.requestId].resolve(message);
272
248
  delete self.ps[message.requestId];
273
249
  }
274
250
  emitter.emit(events.sandbox.file, message);
275
- });
251
+ };
252
+ this._fileSubscription = await this._sessionChannel.subscribe("file", this._onFileMsg);
276
253
 
277
- this.heartbeat = setInterval(function () {}, 5000);
254
+ this.heartbeat = setInterval(function () { }, 5000);
278
255
  if (this.heartbeat.unref) this.heartbeat.unref();
279
256
 
257
+ // ─── Periodic stats logging ────────────────────────────────────────
258
+ this._statsInterval = setInterval(() => {
259
+ const connState = this._ably ? this._ably.connection.state : 'no-client';
260
+ const chState = this._sessionChannel ? this._sessionChannel.state : 'null';
261
+ const pendingIds = Object.keys(this.ps);
262
+ const pending = pendingIds.length;
263
+ logger.debug(`[realtime][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
264
+ if (pending > 0) {
265
+ const now = Date.now();
266
+ for (const rid of pendingIds) {
267
+ const entry = this.ps[rid];
268
+ if (!entry) continue;
269
+ const type = entry.message ? entry.message.type : 'unknown';
270
+ const ageSec = ((now - (entry.startTime || now)) / 1000).toFixed(1);
271
+ logger.debug(`[realtime][stats] pending: requestId=${rid} | type=${type} | age=${ageSec}s`);
272
+ }
273
+ }
274
+ }, 10000);
275
+ if (this._statsInterval.unref) this._statsInterval.unref();
276
+
280
277
  this._ably.connection.on("disconnected", function () {
281
- logger.log("Ably disconnected - will auto-reconnect");
278
+ logger.debug("[realtime] Connection: disconnected - will auto-reconnect");
279
+ self._disconnectedAt = Date.now();
282
280
  });
283
281
 
284
282
  this._ably.connection.on("connected", function () {
285
283
  // Log reconnection so the user knows the blip was recovered
286
- logger.log("Ably reconnected");
284
+ logger.debug("[realtime] Connection: reconnected");
285
+ // Extend any pending command timeouts by the disconnection duration so
286
+ // commands whose timer was counting down while the connection was down
287
+ // don't get incorrectly timed out.
288
+ if (self._disconnectedAt) {
289
+ var disconnectionDurationMs = Date.now() - self._disconnectedAt;
290
+ self._disconnectedAt = null;
291
+ var pendingIds = Object.keys(self.ps);
292
+ if (pendingIds.length > 0) {
293
+ logger.debug(
294
+ '[realtime] Extending ' + pendingIds.length + ' pending timeout(s) by ' +
295
+ disconnectionDurationMs + 'ms after disconnection'
296
+ );
297
+ for (var i = 0; i < pendingIds.length; i++) {
298
+ var entry = self.ps[pendingIds[i]];
299
+ if (entry && typeof entry.extendTimeout === 'function') {
300
+ entry.extendTimeout(disconnectionDurationMs);
301
+ }
302
+ }
303
+ }
304
+ }
287
305
  });
288
306
 
289
307
  this._ably.connection.on("suspended", function () {
290
- logger.warn("Ably suspended - connection lost for extended period, will keep retrying");
308
+ logger.warn("[realtime] Connection: suspended - connection lost for extended period, will keep retrying");
291
309
  });
292
310
 
293
311
  this._ably.connection.on("failed", function () {
312
+ logger.error("[realtime] Connection: failed");
294
313
  self.apiSocketConnected = false;
295
314
  self.instanceSocketConnected = false;
296
- emitter.emit(events.error.sandbox, "Ably connection failed");
315
+ emitter.emit(events.error.sandbox, "Realtime connection failed");
316
+ });
317
+
318
+ // ─── Channel discontinuity detection ──────────────────────────────
319
+ // Set up BEFORE subscribing so we catch any continuity loss during
320
+ // the initial attachment. Fires at the channel level, covering all
321
+ // message types (response, file, control).
322
+ this._sessionChannel.on(function (stateChange) {
323
+ var current = stateChange.current;
324
+ var previous = stateChange.previous;
325
+ var reason = stateChange.reason;
326
+ var reasonMsg = reason ? (reason.message || reason.code || String(reason)) : '';
327
+
328
+ if (current === 'attached' && stateChange.resumed === false && previous === 'attached') {
329
+ logger.warn('[realtime] Channel DISCONTINUITY detected (resumed=false)' + (reasonMsg ? ' — ' + reasonMsg : ''));
330
+ emitter.emit(events.sandbox.progress, {
331
+ step: 'discontinuity',
332
+ message: 'Recovering missed messages after connection interruption...',
333
+ });
334
+ self._recoverFromDiscontinuity();
335
+ }
297
336
  });
298
337
  }
299
338
 
300
339
  /**
301
- * Wrapper around httpPost that retries when the server responds with
302
- * CONCURRENCY_LIMIT_EXCEEDED (HTTP 429). Instead of failing the test
303
- * immediately, we wait for a slot to become available — polling every
304
- * 10 s until vitest's testTimeout kills the test.
340
+ * Recover missed messages after a channel discontinuity.
341
+ * Uses historyBeforeSubscribe() on each subscription, which guarantees
342
+ * no gap between historical and live messages. Each recovered message
343
+ * is dispatched through the same handler that processes live messages
344
+ * so that pending promises are resolved/rejected correctly.
345
+ */
346
+ async _recoverFromDiscontinuity() {
347
+ var subs = [
348
+ { name: 'response', sub: this._responseSubscription, handler: this._onResponseMsg },
349
+ { name: 'file', sub: this._fileSubscription, handler: this._onFileMsg },
350
+ ];
351
+ var totalRecovered = 0;
352
+ for (var i = 0; i < subs.length; i++) {
353
+ var entry = subs[i];
354
+ if (!entry.sub) continue;
355
+ try {
356
+ logger.debug('[realtime] Discontinuity recovery: fetching historyBeforeSubscribe for ' + entry.name + '...');
357
+ var page = await entry.sub.historyBeforeSubscribe({ limit: 100 });
358
+ var recovered = 0;
359
+ while (page) {
360
+ // Replay each missed message through the handler so pending
361
+ // promises get resolved instead of timing out.
362
+ for (var j = 0; j < page.items.length; j++) {
363
+ recovered++;
364
+ try {
365
+ if (entry.handler) {
366
+ logger.debug('[realtime] Replaying recovered ' + entry.name + ' message (requestId=' + (page.items[j].data && page.items[j].data.requestId || 'none') + ')');
367
+ entry.handler(page.items[j]);
368
+ }
369
+ } catch (replayErr) {
370
+ logger.error('[realtime] Error replaying recovered message: ' + (replayErr.message || replayErr));
371
+ }
372
+ }
373
+ page = page.hasNext() ? await page.next() : null;
374
+ }
375
+ totalRecovered += recovered;
376
+ logger.debug('[realtime] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
377
+ } catch (err) {
378
+ logger.error('[realtime] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
379
+ }
380
+ }
381
+ if (totalRecovered > 0) {
382
+ logger.warn('[realtime] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
383
+ } else {
384
+ logger.debug('[realtime] Discontinuity recovery: no missed messages found');
385
+ }
386
+ }
387
+
388
+ /**
389
+ * POST to the API with retry for transient network errors (via withRetry)
390
+ * and infinite polling for CONCURRENCY_LIMIT_EXCEEDED (until vitest's
391
+ * testTimeout kills the test).
305
392
  */
306
393
  async _httpPostWithConcurrencyRetry(path, body, timeout) {
307
- var retryInterval = 10000; // 10 seconds between retries
394
+ var concurrencyRetryInterval = 10000; // 10 seconds between concurrency retries
308
395
  var startTime = Date.now();
396
+ var sessionId = this.sessionInstance ? this.sessionInstance.get() : null;
397
+
398
+ var self = this;
399
+ var makeRequest = function () {
400
+ return axios({
401
+ method: "post",
402
+ url: self.apiRoot + path,
403
+ data: body,
404
+ headers: {
405
+ "Content-Type": "application/json",
406
+ "User-Agent": "TestDriverSDK/" + version + " (Node.js " + process.version + ")",
407
+ ...getSentryTraceHeaders(sessionId),
408
+ },
409
+ timeout: timeout || 120000,
410
+ });
411
+ };
309
412
 
310
413
  while (true) {
311
414
  try {
312
- return await httpPost(this.apiRoot, path, body, timeout);
415
+ var response = await withRetry(makeRequest, {
416
+ retryConfig: {
417
+ maxRetries: 3,
418
+ baseDelayMs: 2000,
419
+ retryableStatusCodes: [500, 502, 503, 504], // Don't retry 429 — handled below
420
+ },
421
+ onRetry: function (attempt, error, delayMs) {
422
+ var elapsed = Date.now() - startTime;
423
+ logger.warn(
424
+ "Transient network error: " + (error.message || error.code) +
425
+ " — POST " + path +
426
+ " — retry " + attempt + "/3" +
427
+ " in " + (delayMs / 1000).toFixed(1) + "s" +
428
+ " (" + Math.round(elapsed / 1000) + "s elapsed)...",
429
+ );
430
+ },
431
+ });
432
+ return response.data;
313
433
  } catch (err) {
314
- var isConcurrencyLimit =
315
- err.responseData &&
316
- err.responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED";
317
-
318
- if (!isConcurrencyLimit) {
319
- throw err;
320
- }
321
-
322
- var elapsed = Date.now() - startTime;
323
-
324
- logger.log(
325
- "Concurrency limit reached — waiting " +
326
- retryInterval / 1000 +
434
+ // Concurrency limit — poll forever until a slot opens
435
+ var responseData = err.response && err.response.data;
436
+ if (responseData && responseData.errorCode === "CONCURRENCY_LIMIT_EXCEEDED") {
437
+ var elapsed = Date.now() - startTime;
438
+ logger.log(
439
+ "Concurrency limit reached — waiting " +
440
+ concurrencyRetryInterval / 1000 +
327
441
  "s for a slot to become available (" +
328
442
  Math.round(elapsed / 1000) +
329
443
  "s elapsed)...",
330
- );
331
- await new Promise(function (resolve) {
332
- var t = setTimeout(resolve, retryInterval);
333
- if (t.unref) t.unref();
334
- });
444
+ );
445
+ await new Promise(function (resolve) {
446
+ var t = setTimeout(resolve, concurrencyRetryInterval);
447
+ if (t.unref) t.unref();
448
+ });
449
+ continue;
450
+ }
451
+
452
+ // Non-retryable HTTP error — preserve responseData for callers
453
+ if (responseData) {
454
+ var httpErr = new Error(
455
+ responseData.errorMessage || responseData.message || "HTTP " + err.response.status,
456
+ );
457
+ httpErr.responseData = responseData;
458
+ throw httpErr;
459
+ }
460
+
461
+ throw err;
335
462
  }
336
463
  }
337
464
  }
@@ -362,6 +489,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
362
489
  body.ci = message.ci;
363
490
  if (message.ami) body.ami = message.ami;
364
491
  if (message.instanceType) body.instanceType = message.instanceType;
492
+ if (message.e2bTemplateId) body.e2bTemplateId = message.e2bTemplateId;
365
493
  if (message.keepAlive !== undefined) body.keepAlive = message.keepAlive;
366
494
  }
367
495
 
@@ -390,14 +518,14 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
390
518
  this._teamId = reply.teamId;
391
519
 
392
520
  if (reply.ably && reply.ably.token) {
393
- await this._initAbly(reply.ably.token, reply.ably.channels);
521
+ await this._initAbly(reply.ably.token, reply.ably.channel);
394
522
  this.instanceSocketConnected = true;
395
523
 
396
524
  // Tell the runner to enable debug log forwarding if debug mode is on
397
525
  var debugMode =
398
526
  process.env.VERBOSE || process.env.TD_DEBUG;
399
- if (debugMode && this._ctrlChannel) {
400
- this._ctrlChannel.publish("control", {
527
+ if (debugMode && this._sessionChannel) {
528
+ this._sessionChannel.publish("control", {
401
529
  type: "debug",
402
530
  enabled: true,
403
531
  });
@@ -405,47 +533,60 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
405
533
  }
406
534
 
407
535
  if (message.type === "create") {
408
- // E2B (Linux) sandboxes: the API proxies commands and returns a url directly.
409
- // No runner agent involved skip runner.ready wait.
410
- if (reply.url) {
411
- logger.log(`E2B sandbox ready — url=${reply.url}`);
412
- return {
413
- success: true,
414
- sandbox: {
415
- sandboxId: reply.sandboxId,
416
- instanceId: reply.sandbox?.sandboxId || reply.sandboxId,
417
- os: body.os || 'linux',
418
- url: reply.url,
419
- },
420
- };
421
- }
422
-
536
+ // E2B (Linux) sandboxes return a url directly.
537
+ // We still need to wait for runner.ready since sandbox-agent.js runs inside E2B.
538
+ const isE2B = !!reply.url;
539
+
423
540
  const runnerIp = reply.runner && reply.runner.ip;
424
541
  const noVncPort = reply.runner && reply.runner.noVncPort;
425
542
  const runnerVncUrl = reply.runner && reply.runner.vncUrl;
426
543
 
427
- logger.log(`Runner claimed ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
544
+ // Log image version info (AMI for Windows, E2B template for Linux)
545
+ if (reply.imageVersion) {
546
+ if (isE2B) {
547
+ logger.log('E2B image version: v' + reply.imageVersion + (reply.e2bTemplateId ? ' (template: ' + reply.e2bTemplateId + ')' : ''));
548
+ } else {
549
+ logger.log('AMI image version: v' + reply.imageVersion + (reply.amiId ? ' (ami: ' + reply.amiId + ')' : ''));
550
+ }
551
+ }
552
+
553
+ if (!isE2B) {
554
+ logger.log(`Runner claimed — ip=${runnerIp || 'none'}, os=${reply.runner?.os || 'unknown'}, noVncPort=${noVncPort || 'not reported'}, vncUrl=${runnerVncUrl || 'not reported'}`);
555
+ }
428
556
 
429
- // For cloud Windows sandboxes (no runner in reply), wait for the
430
- // agent to signal readiness before sending commands. Without this
431
- // gate, commands published before the agent subscribes are lost.
557
+ // Wait for the runner agent to signal readiness before sending commands.
558
+ // Without this gate, commands published before the agent subscribes are lost.
559
+ // This applies to:
560
+ // - E2B Linux sandboxes (native runner agent via sandbox-agent.js)
561
+ // - Windows EC2 sandboxes without presence runners
562
+ // For presence-based Windows runners (reply.runner already set), the runner
563
+ // is already listening so we can skip the wait.
432
564
  var self = this;
433
- if (!reply.runner && this._ctrlChannel) {
565
+ const needsReadyWait = this._sessionChannel && (isE2B || !reply.runner);
566
+ if (needsReadyWait) {
434
567
  logger.log('Waiting for runner agent to signal readiness...');
435
- var readyTimeout = 120000; // 120s allows for EC2 boot + agent startup
568
+ // E2B (Linux) sandboxes need extra time: S3 upload + npm install can add 60-120s on top of sandbox boot
569
+ var readyTimeout = isE2B ? 300000 : 120000; // 5 min for E2B (S3+npm), 2 min for EC2
436
570
  await new Promise(function (resolve, reject) {
437
571
  var resolved = false;
438
572
  function finish(data) {
439
573
  if (resolved) return;
440
574
  resolved = true;
441
575
  clearTimeout(timer);
442
- self._ctrlChannel.unsubscribe('control', onCtrl);
576
+ self._sessionChannel.unsubscribe('control', onCtrl);
443
577
  // Update runner info if provided
444
578
  if (data && data.os) reply.runner = reply.runner || {};
445
579
  if (data && data.os && reply.runner) reply.runner.os = data.os;
446
580
  if (data && data.ip && reply.runner) reply.runner.ip = data.ip;
447
581
  if (data && data.runnerVersion && reply.runner) reply.runner.version = data.runnerVersion;
448
582
  logger.log('Runner agent ready (os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
583
+ // Show upgrade info: if the runner's npm version differs from the baked image version,
584
+ // the runner was upgraded during provisioning.
585
+ var runnerVer = data && data.runnerVersion;
586
+ var imageVer = reply.imageVersion;
587
+ if (runnerVer && imageVer && runnerVer !== imageVer) {
588
+ logger.log('Runner upgraded during provisioning: v' + imageVer + ' \u2192 v' + runnerVer);
589
+ }
449
590
  if (data && data.update) {
450
591
  var u = data.update;
451
592
  if (u.status === 'up-to-date') {
@@ -464,8 +605,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
464
605
  var timer = setTimeout(function () {
465
606
  if (!resolved) {
466
607
  resolved = true;
467
- self._ctrlChannel.unsubscribe('control', onCtrl);
468
- reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms'));
608
+ self._sessionChannel.unsubscribe('control', onCtrl);
609
+ var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms');
610
+ sentry.captureException(err, {
611
+ tags: { phase: 'runner_ready', connection_type: 'create' },
612
+ extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId },
613
+ });
614
+ reject(err);
469
615
  }
470
616
  }, readyTimeout);
471
617
  if (timer.unref) timer.unref();
@@ -478,12 +624,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
478
624
  finish(data);
479
625
  }
480
626
  };
481
- self._ctrlChannel.subscribe('control', onCtrl);
627
+ self._sessionChannel.subscribe('control', onCtrl);
482
628
 
483
629
  // Also check channel history in case runner.ready was published
484
630
  // before we subscribed (race condition on fast-booting agents).
485
631
  try {
486
- self._ctrlChannel.history({ limit: 50 }, function (err, page) {
632
+ self._sessionChannel.history({ limit: 50 }, function (err, page) {
487
633
  if (err) {
488
634
  logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
489
635
  return;
@@ -505,9 +651,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
505
651
  });
506
652
  }
507
653
  // Prefer the full vncUrl reported by the runner (infrastructure-agnostic).
654
+ // For E2B sandboxes, use the url from the API reply.
508
655
  // Fall back to constructing from ip + noVncPort for older runners.
509
656
  let url;
510
- if (runnerVncUrl) {
657
+ if (isE2B && reply.url) {
658
+ url = reply.url;
659
+ logger.log(`E2B sandbox ready — url=${url}`);
660
+ } else if (runnerVncUrl) {
511
661
  url = runnerVncUrl;
512
662
  logger.log(`Using runner-provided vncUrl: ${url}`);
513
663
  } else if (runnerIp && noVncPort) {
@@ -538,17 +688,22 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
538
688
  // provision the config to the instance via SSM (client-side).
539
689
  // This runs from the user's infrastructure where AWS permissions exist,
540
690
  // rather than from the API server.
541
- if (reply.agentConfig && message.instanceId) {
542
- logger.log('Provisioning agent config to instance ' + message.instanceId + ' via SSM...');
543
- await this._provisionAgentConfig(message.instanceId, reply.agentConfig);
691
+ // NOTE: For direct connections, the user MUST provide the AWS instanceId
692
+ // because the API only knows the sandboxId, not the actual EC2 instance ID.
693
+ var instanceId = message.instanceId;
694
+ if (reply.agentConfig && instanceId) {
695
+ logger.log('Provisioning agent config to instance ' + instanceId + ' via SSM...');
696
+ await this._provisionAgentConfig(instanceId, reply.agentConfig);
544
697
  logger.log('Agent config provisioned successfully.');
698
+ } else if (reply.agentConfig && !instanceId) {
699
+ logger.log('Warning: agentConfig returned but no instanceId provided - cannot provision via SSM');
545
700
  }
546
701
 
547
702
  // If the API returned agent credentials (reply.agent present),
548
703
  // wait for the runner agent to signal readiness before sending commands.
549
704
  // Without this gate, commands published before the agent subscribes are lost.
550
705
  var self = this;
551
- if (reply.agent && this._ctrlChannel) {
706
+ if (reply.agent && this._sessionChannel) {
552
707
  logger.log('Waiting for runner agent to signal readiness (direct connection)...');
553
708
  var readyTimeout = 120000; // 120s — allows for SSM provisioning + agent startup
554
709
  await new Promise(function (resolve, reject) {
@@ -557,7 +712,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
557
712
  if (resolved) return;
558
713
  resolved = true;
559
714
  clearTimeout(timer);
560
- self._ctrlChannel.unsubscribe('control', onCtrl);
715
+ self._sessionChannel.unsubscribe('control', onCtrl);
561
716
  logger.log('Runner agent ready (direct, os=' + ((data && data.os) || 'unknown') + ', runner v' + ((data && data.runnerVersion) || 'unknown') + ')');
562
717
  if (data && data.update) {
563
718
  var u = data.update;
@@ -577,8 +732,13 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
577
732
  var timer = setTimeout(function () {
578
733
  if (!resolved) {
579
734
  resolved = true;
580
- self._ctrlChannel.unsubscribe('control', onCtrl);
581
- reject(new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)'));
735
+ self._sessionChannel.unsubscribe('control', onCtrl);
736
+ var err = new Error('Runner agent did not signal readiness within ' + readyTimeout + 'ms (direct connection)');
737
+ sentry.captureException(err, {
738
+ tags: { phase: 'runner_ready', connection_type: 'direct' },
739
+ extra: { readyTimeout: readyTimeout, sandboxId: reply.sandboxId, instanceId: message.instanceId },
740
+ });
741
+ reject(err);
582
742
  }
583
743
  }, readyTimeout);
584
744
  if (timer.unref) timer.unref();
@@ -591,12 +751,12 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
591
751
  finish(data);
592
752
  }
593
753
  };
594
- self._ctrlChannel.subscribe('control', onCtrl);
754
+ self._sessionChannel.subscribe('control', onCtrl);
595
755
 
596
756
  // Also check channel history in case runner.ready was published
597
757
  // before we subscribed (race condition on fast-booting agents).
598
758
  try {
599
- self._ctrlChannel.history({ limit: 50 }, function (err, page) {
759
+ self._sessionChannel.history({ limit: 50 }, function (err, page) {
600
760
  if (err) {
601
761
  logger.warn('History lookup failed (non-fatal): ' + (err.message || err));
602
762
  return;
@@ -638,7 +798,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
638
798
  _sendAbly(message, timeout) {
639
799
  if (timeout === undefined) timeout = 300000;
640
800
 
641
- if (!this._cmdChannel || !this._ably) {
801
+ if (!this._sessionChannel || !this._ably) {
642
802
  return Promise.reject(
643
803
  new Error("Sandbox not connected (no Ably client)"),
644
804
  );
@@ -666,7 +826,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
666
826
  function onFailed() {
667
827
  clearTimeout(timer);
668
828
  self._ably.connection.off("connected", onConnected);
669
- reject(new Error("Ably connection failed while waiting to send"));
829
+ reject(new Error("Realtime connection failed while waiting to send"));
670
830
  }
671
831
  self._ably.connection.once("connected", onConnected);
672
832
  self._ably.connection.once("failed", onFailed);
@@ -724,21 +884,41 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
724
884
 
725
885
  var requestId = message.requestId;
726
886
 
727
- var timeoutId = setTimeout(function () {
887
+ // timeoutId and timeoutExpiresAt are declared as vars so they can be
888
+ // updated by extendTimeout() (closure mutation).
889
+ var timeoutId;
890
+ var timeoutExpiresAt;
891
+
892
+ var timeoutFn = function () {
728
893
  if (self.ps[requestId]) {
894
+ var pendingIds = Object.keys(self.ps);
895
+ var pendingSummary = pendingIds.map(function (rid) {
896
+ var e = self.ps[rid];
897
+ var age = e && e.startTime ? ((Date.now() - e.startTime) / 1000).toFixed(1) + 's' : '?';
898
+ return rid + '(' + (e && e.message ? e.message.type : '?') + ', ' + age + ')';
899
+ }).join(', ');
900
+ logger.error(
901
+ '[realtime] Promise TIMEOUT: requestId=' + requestId +
902
+ ' | type=' + message.type +
903
+ ' | timeout=' + timeout + 'ms' +
904
+ ' | all pending: [' + pendingSummary + ']'
905
+ );
729
906
  delete self.ps[requestId];
730
907
  delete self._execBuffers[requestId];
731
908
  rejectPromise(
732
909
  new Error(
733
910
  "Sandbox message '" +
734
- message.type +
735
- "' timed out after " +
736
- timeout +
737
- "ms",
911
+ message.type +
912
+ "' timed out after " +
913
+ timeout +
914
+ "ms",
738
915
  ),
739
916
  );
740
917
  }
741
- }, timeout);
918
+ };
919
+
920
+ timeoutId = setTimeout(timeoutFn, timeout);
921
+ timeoutExpiresAt = Date.now() + timeout;
742
922
  if (timeoutId.unref) timeoutId.unref();
743
923
 
744
924
  this.ps[requestId] = {
@@ -751,16 +931,35 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
751
931
  clearTimeout(timeoutId);
752
932
  rejectPromise(error);
753
933
  },
934
+ /**
935
+ * Extend the pending timeout by disconnectionDurationMs — called on Ably reconnect
936
+ * to compensate for time spent disconnected.
937
+ */
938
+ extendTimeout: function (disconnectionDurationMs) {
939
+ clearTimeout(timeoutId);
940
+ // Clamp remaining to 0 so a command whose timer expired during the
941
+ // outage still gets the full disconnection duration as its new budget.
942
+ var remaining = Math.max(0, timeoutExpiresAt - Date.now());
943
+ // Minimum 5s remaining after extension to allow the response to arrive.
944
+ var MIN_REMAINING_MS = 5000;
945
+ var newRemaining = Math.max(remaining + disconnectionDurationMs, MIN_REMAINING_MS);
946
+ timeoutExpiresAt = Date.now() + newRemaining;
947
+ timeoutId = setTimeout(timeoutFn, newRemaining);
948
+ if (timeoutId.unref) timeoutId.unref();
949
+ logger.log(
950
+ '[realtime] Extended timeout for requestId=' + requestId +
951
+ ' by ' + disconnectionDurationMs + 'ms (new remaining: ' + Math.round(newRemaining / 1000) + 's)'
952
+ );
953
+ },
754
954
  message: message,
755
955
  startTime: Date.now(),
756
956
  };
757
957
 
758
958
  if (message.type === "output") {
759
- p.catch(function () {});
959
+ p.catch(function () { });
760
960
  }
761
961
 
762
- this._cmdChannel
763
- .publish("command", message)
962
+ this._throttledPublish(this._sessionChannel, "command", message)
764
963
  .then(function () {
765
964
  emitter.emit(events.sandbox.sent, message);
766
965
  })
@@ -777,6 +976,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
777
976
  return p;
778
977
  }
779
978
 
979
+ /**
980
+ * Throttled publish to stay under Ably's 50 msg/sec per-connection limit.
981
+ * Also tracks and logs the current publish rate for debugging.
982
+ * @param {Object} channel - Ably channel to publish on
983
+ * @param {string} eventName - Event name for the publish
984
+ * @param {Object} message - Message payload
985
+ * @returns {Promise} - Resolves when publish completes
986
+ */
987
+ async _throttledPublish(channel, eventName, message) {
988
+ var self = this;
989
+ var now = Date.now();
990
+
991
+ // Rate limiting: wait if too soon since last publish
992
+ var elapsed = now - this._publishLastTime;
993
+ if (elapsed < this._publishMinIntervalMs) {
994
+ var waitMs = this._publishMinIntervalMs - elapsed;
995
+ await new Promise(function (resolve) {
996
+ var timer = setTimeout(resolve, waitMs);
997
+ if (timer.unref) timer.unref();
998
+ });
999
+ }
1000
+ this._publishLastTime = Date.now();
1001
+
1002
+ // Metrics: track messages per second
1003
+ this._publishCount++;
1004
+ var windowElapsed = Date.now() - this._publishWindowStart;
1005
+ if (windowElapsed >= 1000) {
1006
+ var rate = (this._publishCount / windowElapsed) * 1000;
1007
+ var rateStr = rate.toFixed(1);
1008
+
1009
+ // Log rate - warning if approaching limit, debug otherwise
1010
+ if (rate > 45) {
1011
+ logger.warn("Ably publish rate: " + rateStr + " msg/sec (approaching 50/sec limit)");
1012
+ } else if (process.env.VERBOSE || process.env.TD_DEBUG) {
1013
+ logger.log("Ably publish rate: " + rateStr + " msg/sec");
1014
+ }
1015
+
1016
+ // Reset window
1017
+ this._publishCount = 0;
1018
+ this._publishWindowStart = Date.now();
1019
+ }
1020
+
1021
+ return channel.publish(eventName, message).then(function () {
1022
+ logger.debug(`[realtime] Published: channel=${channel.name.split(':').pop()}, event=${eventName}, type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
1023
+ });
1024
+ }
1025
+
780
1026
  async auth(apiKey) {
781
1027
  this.apiKey = apiKey;
782
1028
  var sessionId = this.sessionInstance
@@ -803,7 +1049,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
803
1049
  logger.log("Trace Report (Share When Reporting Bugs):");
804
1050
  logger.log(
805
1051
  "https://testdriver.sentry.io/explore/traces/trace/" +
806
- reply.traceId,
1052
+ reply.traceId,
807
1053
  );
808
1054
  }
809
1055
 
@@ -846,7 +1092,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
846
1092
  this._sandboxId = reply.sandboxId;
847
1093
 
848
1094
  if (reply.ably && reply.ably.token) {
849
- await this._initAbly(reply.ably.token, reply.ably.channels);
1095
+ await this._initAbly(reply.ably.token, reply.ably.channel);
850
1096
  }
851
1097
 
852
1098
  this.setConnectionParams({
@@ -895,38 +1141,43 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
895
1141
  clearInterval(this.heartbeat);
896
1142
  this.heartbeat = null;
897
1143
  }
1144
+ if (this._statsInterval) {
1145
+ clearInterval(this._statsInterval);
1146
+ this._statsInterval = null;
1147
+ }
898
1148
 
899
1149
  // Send end-session control message to runner before disconnecting
900
- if (this._ctrlChannel && this._ably?.connection?.state === 'connected') {
1150
+ if (this._sessionChannel && this._ably?.connection?.state === 'connected') {
901
1151
  try {
902
- await this._ctrlChannel.publish('control', { type: 'end-session' });
1152
+ logger.log('[realtime] Publishing control: type=end-session');
1153
+ await this._sessionChannel.publish('control', { type: 'end-session' });
903
1154
  } catch (e) {
904
1155
  // Ignore - best effort
905
1156
  }
906
1157
  }
907
1158
 
908
- // Leave presence on control channel
909
- if (this._ctrlChannel) {
1159
+ // Leave presence on session channel
1160
+ if (this._sessionChannel) {
910
1161
  try {
911
- await this._ctrlChannel.presence.leave();
1162
+ logger.log('[realtime] Leaving presence on session channel');
1163
+ await this._sessionChannel.presence.leave();
912
1164
  } catch (e) {
913
1165
  // ignore - best effort, Ably will auto-leave on disconnect
914
1166
  }
915
1167
  }
916
1168
 
917
1169
  try {
918
- await Promise.allSettled([
919
- this._cmdChannel?.detach(),
920
- this._respChannel?.detach(),
921
- this._ctrlChannel?.detach(),
922
- this._filesChannel?.detach(),
923
- ].filter(Boolean));
1170
+ logger.log('[realtime] Detaching session channel');
1171
+ if (this._sessionChannel) {
1172
+ await this._sessionChannel.detach();
1173
+ }
924
1174
  } catch (e) {
925
1175
  /* ignore */
926
1176
  }
927
1177
 
928
1178
  if (this._ably) {
929
1179
  try {
1180
+ logger.log('[realtime] Closing Realtime connection');
930
1181
  this._ably.close();
931
1182
  } catch (e) {
932
1183
  /* ignore */
@@ -934,11 +1185,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
934
1185
  this._ably = null;
935
1186
  }
936
1187
 
937
- this._cmdChannel = null;
938
- this._respChannel = null;
939
- this._ctrlChannel = null;
940
- this._filesChannel = null;
941
- this._channelNames = null;
1188
+ this._sessionChannel = null;
1189
+ this._channelName = null;
942
1190
  this.apiSocketConnected = false;
943
1191
  this.instanceSocketConnected = false;
944
1192
  this.authenticated = false;
@@ -961,11 +1209,51 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
961
1209
  const region = process.env.AWS_REGION || 'us-east-2';
962
1210
 
963
1211
  // Write SSM parameters to a temp file to avoid shell quoting issues
1212
+ // Log key config details for debugging
1213
+ logger.log('Agent config being provisioned:');
1214
+ logger.log(' sandboxId: ' + agentConfig.sandboxId);
1215
+ logger.log(' apiRoot: ' + agentConfig.apiRoot);
1216
+ logger.log(' channel: ' + (agentConfig.ably?.channel || 'N/A'));
1217
+ logger.log(' token length: ' + (agentConfig.ably?.token ? JSON.stringify(agentConfig.ably.token).length : 0));
1218
+
964
1219
  const paramsJson = JSON.stringify({
965
1220
  commands: [
1221
+ // Debug: show existing state
1222
+ "Write-Host '=== Checking existing state ==='",
1223
+ "$task = Get-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
1224
+ "if ($task) { Write-Host \"Task exists, state: $($task.State)\" } else { Write-Host 'Task does NOT exist!' }",
1225
+ "if (Test-Path 'C:\\Windows\\Temp\\testdriver-agent.json') { Write-Host 'Old config:'; Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | Write-Host } else { Write-Host 'Config file does NOT exist yet' }",
1226
+ // Stop any running runner
1227
+ "Write-Host '=== Stopping runner ==='",
1228
+ "Stop-Process -Name node -Force -ErrorAction SilentlyContinue",
1229
+ "Stop-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction SilentlyContinue",
1230
+ // Write config
1231
+ "Write-Host '=== Writing config ==='",
966
1232
  "$config = '" + configJson.replace(/'/g, "''") + "'",
967
1233
  "[System.IO.File]::WriteAllText('C:\\Windows\\Temp\\testdriver-agent.json', $config)",
968
1234
  "Write-Host 'Config written for sandbox " + agentConfig.sandboxId + "'",
1235
+ // Show what was written (redact token)
1236
+ "Write-Host '=== New config (token redacted) ==='",
1237
+ "$cfg = Get-Content 'C:\\Windows\\Temp\\testdriver-agent.json' | ConvertFrom-Json",
1238
+ "Write-Host \"sandboxId: $($cfg.sandboxId)\"",
1239
+ "Write-Host \"apiRoot: $($cfg.apiRoot)\"",
1240
+ "Write-Host \"channel: $($cfg.ably.channel)\"",
1241
+ "Write-Host \"token type: $($cfg.ably.token.GetType().Name)\"",
1242
+ // Start the runner
1243
+ "Write-Host '=== Starting runner ==='",
1244
+ "Start-Sleep -Seconds 1",
1245
+ "Start-ScheduledTask -TaskName RunTestDriverAgent -ErrorAction Stop",
1246
+ "$task = Get-ScheduledTask -TaskName RunTestDriverAgent",
1247
+ "Write-Host \"Task state after start: $($task.State)\"",
1248
+ // Check if node process started
1249
+ "Start-Sleep -Seconds 3",
1250
+ "Write-Host '=== Checking runner process ==='",
1251
+ "$procs = Get-Process -Name node -ErrorAction SilentlyContinue",
1252
+ "if ($procs) { Write-Host \"Node processes: $($procs.Count)\"; $procs | ForEach-Object { Write-Host \" PID: $($_.Id), StartTime: $($_.StartTime)\" } } else { Write-Host 'No node process found!' }",
1253
+ // Check runner logs
1254
+ "Write-Host '=== Runner log (last 30 lines) ==='",
1255
+ "if (Test-Path 'C:\\testdriver\\logs\\sandbox-agent.log') { Get-Content 'C:\\testdriver\\logs\\sandbox-agent.log' -Tail 30 | Write-Host } else { Write-Host 'No log file found' }",
1256
+ "Write-Host '=== Done ==='",
969
1257
  ],
970
1258
  });
971
1259
  const tmpFile = join(tmpdir(), 'td-provision-' + Date.now() + '.json');
@@ -987,6 +1275,24 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
987
1275
  '--command-id "' + cmdId + '" --instance-id "' + instanceId + '"',
988
1276
  { encoding: 'utf-8', timeout: 60000 }
989
1277
  );
1278
+
1279
+ // Get the command output for debugging
1280
+ try {
1281
+ const invocationOutput = execSync(
1282
+ 'aws ssm get-command-invocation --region "' + region + '" ' +
1283
+ '--command-id "' + cmdId + '" --instance-id "' + instanceId + '" --output json',
1284
+ { encoding: 'utf-8', timeout: 30000 }
1285
+ );
1286
+ const invocation = JSON.parse(invocationOutput);
1287
+ if (invocation.StandardOutputContent) {
1288
+ logger.log('SSM output:\n' + invocation.StandardOutputContent);
1289
+ }
1290
+ if (invocation.StandardErrorContent) {
1291
+ logger.warn('SSM errors:\n' + invocation.StandardErrorContent);
1292
+ }
1293
+ } catch (e) {
1294
+ logger.warn('Could not retrieve SSM command output: ' + e.message);
1295
+ }
990
1296
  } finally {
991
1297
  try { unlinkSync(tmpFile); } catch (e) { /* ignore */ }
992
1298
  }