@testdriverai/mcp 7.8.0-canary.12 → 7.8.0-canary.13
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.
- package/agent/lib/sandbox.js +136 -25
- package/lib/vitest/hooks.mjs +3 -3
- package/package.json +1 -1
package/agent/lib/sandbox.js
CHANGED
|
@@ -29,6 +29,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
29
29
|
this._lastConnectParams = null;
|
|
30
30
|
this._teamId = null;
|
|
31
31
|
this._sandboxId = null;
|
|
32
|
+
this._disconnectedAt = null; // tracks when Ably connection dropped (for timeout extension on reconnect)
|
|
32
33
|
|
|
33
34
|
// Rate limiting state for Ably publishes (Ably limits to 50 msg/sec per connection)
|
|
34
35
|
this._publishLastTime = 0;
|
|
@@ -98,7 +99,7 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
// Save subscription references for historyBeforeSubscribe() during discontinuity recovery
|
|
101
|
-
this.
|
|
102
|
+
this._onResponseMsg = function (msg) {
|
|
102
103
|
var message = msg.data;
|
|
103
104
|
if (!message) return;
|
|
104
105
|
|
|
@@ -154,31 +155,53 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
154
155
|
}
|
|
155
156
|
|
|
156
157
|
if (!message.requestId || !self.ps[message.requestId]) {
|
|
157
|
-
var
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
158
|
+
var pendingIds = Object.keys(self.ps);
|
|
159
|
+
var pendingSummary = pendingIds.length > 0
|
|
160
|
+
? pendingIds.map(function (rid) {
|
|
161
|
+
var e = self.ps[rid];
|
|
162
|
+
return rid + '(' + (e && e.message ? e.message.type : '?') + ')';
|
|
163
|
+
}).join(', ')
|
|
164
|
+
: 'none';
|
|
165
|
+
logger.warn(
|
|
166
|
+
'[ably] No pending promise for requestId=' + (message.requestId || 'null') +
|
|
167
|
+
' | response type=' + (message.type || 'unknown') +
|
|
168
|
+
' | error=' + (message.error ? (message.errorMessage || 'true') : 'false') +
|
|
169
|
+
' | currently pending: [' + pendingSummary + ']'
|
|
170
|
+
);
|
|
165
171
|
return;
|
|
166
172
|
}
|
|
167
173
|
|
|
168
174
|
if (message.error) {
|
|
169
|
-
var
|
|
170
|
-
|
|
171
|
-
|
|
175
|
+
var pendingEntry = self.ps[message.requestId];
|
|
176
|
+
var pendingMessage = pendingEntry && pendingEntry.message;
|
|
177
|
+
var pendingAge = pendingEntry && pendingEntry.startTime
|
|
178
|
+
? ((Date.now() - pendingEntry.startTime) / 1000).toFixed(1) + 's'
|
|
179
|
+
: '?';
|
|
180
|
+
logger.warn(
|
|
181
|
+
'[ably] Promise REJECTED: requestId=' + message.requestId +
|
|
182
|
+
' | type=' + (pendingMessage ? pendingMessage.type : 'unknown') +
|
|
183
|
+
' | age=' + pendingAge +
|
|
184
|
+
' | error=' + (message.errorMessage || 'Sandbox error')
|
|
185
|
+
);
|
|
172
186
|
if (!pendingMessage || pendingMessage.type !== "output") {
|
|
173
187
|
emitter.emit(events.error.sandbox, message.errorMessage);
|
|
174
188
|
}
|
|
175
189
|
var error = new Error(message.errorMessage || "Sandbox error");
|
|
176
190
|
error.responseData = message;
|
|
177
191
|
delete self._execBuffers[message.requestId];
|
|
178
|
-
|
|
192
|
+
pendingEntry.reject(error);
|
|
179
193
|
} else {
|
|
180
194
|
emitter.emit(events.sandbox.received);
|
|
181
195
|
if (self.ps[message.requestId]) {
|
|
196
|
+
var resolveEntry = self.ps[message.requestId];
|
|
197
|
+
var resolveAge = resolveEntry.startTime
|
|
198
|
+
? ((Date.now() - resolveEntry.startTime) / 1000).toFixed(1) + 's'
|
|
199
|
+
: '?';
|
|
200
|
+
logger.log(
|
|
201
|
+
'[ably] Promise RESOLVED: requestId=' + message.requestId +
|
|
202
|
+
' | type=' + (resolveEntry.message ? resolveEntry.message.type : 'unknown') +
|
|
203
|
+
' | age=' + resolveAge
|
|
204
|
+
);
|
|
182
205
|
// Unwrap the result from the Ably response envelope
|
|
183
206
|
// The runner sends { requestId, type, result, success }
|
|
184
207
|
// But SDK commands expect just the result object
|
|
@@ -197,9 +220,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
197
220
|
}
|
|
198
221
|
}
|
|
199
222
|
delete self.ps[message.requestId];
|
|
200
|
-
}
|
|
223
|
+
};
|
|
224
|
+
this._responseSubscription = await this._sessionChannel.subscribe("response", this._onResponseMsg);
|
|
201
225
|
|
|
202
|
-
this.
|
|
226
|
+
this._onFileMsg = function (msg) {
|
|
203
227
|
var message = msg.data;
|
|
204
228
|
if (!message) return;
|
|
205
229
|
logger.log(`[ably] Received file: type=${message.type || 'unknown'} (requestId=${message.requestId || 'none'})`);
|
|
@@ -209,7 +233,8 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
209
233
|
delete self.ps[message.requestId];
|
|
210
234
|
}
|
|
211
235
|
emitter.emit(events.sandbox.file, message);
|
|
212
|
-
}
|
|
236
|
+
};
|
|
237
|
+
this._fileSubscription = await this._sessionChannel.subscribe("file", this._onFileMsg);
|
|
213
238
|
|
|
214
239
|
this.heartbeat = setInterval(function () { }, 5000);
|
|
215
240
|
if (this.heartbeat.unref) this.heartbeat.unref();
|
|
@@ -218,18 +243,50 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
218
243
|
this._statsInterval = setInterval(() => {
|
|
219
244
|
const connState = this._ably ? this._ably.connection.state : 'no-client';
|
|
220
245
|
const chState = this._sessionChannel ? this._sessionChannel.state : 'null';
|
|
221
|
-
const
|
|
246
|
+
const pendingIds = Object.keys(this.ps);
|
|
247
|
+
const pending = pendingIds.length;
|
|
222
248
|
logger.log(`[ably][stats] connection=${connState} | sandbox=${this._sandboxId} | pending=${pending} | channel=${chState}`);
|
|
249
|
+
if (pending > 0) {
|
|
250
|
+
const now = Date.now();
|
|
251
|
+
for (const rid of pendingIds) {
|
|
252
|
+
const entry = this.ps[rid];
|
|
253
|
+
if (!entry) continue;
|
|
254
|
+
const type = entry.message ? entry.message.type : 'unknown';
|
|
255
|
+
const ageSec = ((now - (entry.startTime || now)) / 1000).toFixed(1);
|
|
256
|
+
logger.log(`[ably][stats] pending: requestId=${rid} | type=${type} | age=${ageSec}s`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
223
259
|
}, 10000);
|
|
224
260
|
if (this._statsInterval.unref) this._statsInterval.unref();
|
|
225
261
|
|
|
226
262
|
this._ably.connection.on("disconnected", function () {
|
|
227
263
|
logger.log("[ably] Connection: disconnected - will auto-reconnect");
|
|
264
|
+
self._disconnectedAt = Date.now();
|
|
228
265
|
});
|
|
229
266
|
|
|
230
267
|
this._ably.connection.on("connected", function () {
|
|
231
268
|
// Log reconnection so the user knows the blip was recovered
|
|
232
269
|
logger.log("[ably] Connection: reconnected");
|
|
270
|
+
// Extend any pending command timeouts by the disconnection duration so
|
|
271
|
+
// commands whose timer was counting down while the connection was down
|
|
272
|
+
// don't get incorrectly timed out.
|
|
273
|
+
if (self._disconnectedAt) {
|
|
274
|
+
var disconnectionDurationMs = Date.now() - self._disconnectedAt;
|
|
275
|
+
self._disconnectedAt = null;
|
|
276
|
+
var pendingIds = Object.keys(self.ps);
|
|
277
|
+
if (pendingIds.length > 0) {
|
|
278
|
+
logger.log(
|
|
279
|
+
'[ably] Extending ' + pendingIds.length + ' pending timeout(s) by ' +
|
|
280
|
+
disconnectionDurationMs + 'ms after disconnection'
|
|
281
|
+
);
|
|
282
|
+
for (var i = 0; i < pendingIds.length; i++) {
|
|
283
|
+
var entry = self.ps[pendingIds[i]];
|
|
284
|
+
if (entry && typeof entry.extendTimeout === 'function') {
|
|
285
|
+
entry.extendTimeout(disconnectionDurationMs);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
233
290
|
});
|
|
234
291
|
|
|
235
292
|
this._ably.connection.on("suspended", function () {
|
|
@@ -267,12 +324,14 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
267
324
|
/**
|
|
268
325
|
* Recover missed messages after a channel discontinuity.
|
|
269
326
|
* Uses historyBeforeSubscribe() on each subscription, which guarantees
|
|
270
|
-
* no gap between historical and live messages.
|
|
327
|
+
* no gap between historical and live messages. Each recovered message
|
|
328
|
+
* is dispatched through the same handler that processes live messages
|
|
329
|
+
* so that pending promises are resolved/rejected correctly.
|
|
271
330
|
*/
|
|
272
331
|
async _recoverFromDiscontinuity() {
|
|
273
332
|
var subs = [
|
|
274
|
-
{ name: 'response', sub: this._responseSubscription },
|
|
275
|
-
{ name: 'file', sub: this._fileSubscription },
|
|
333
|
+
{ name: 'response', sub: this._responseSubscription, handler: this._onResponseMsg },
|
|
334
|
+
{ name: 'file', sub: this._fileSubscription, handler: this._onFileMsg },
|
|
276
335
|
];
|
|
277
336
|
var totalRecovered = 0;
|
|
278
337
|
for (var i = 0; i < subs.length; i++) {
|
|
@@ -283,17 +342,29 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
283
342
|
var page = await entry.sub.historyBeforeSubscribe({ limit: 100 });
|
|
284
343
|
var recovered = 0;
|
|
285
344
|
while (page) {
|
|
286
|
-
|
|
345
|
+
// Replay each missed message through the handler so pending
|
|
346
|
+
// promises get resolved instead of timing out.
|
|
347
|
+
for (var j = 0; j < page.items.length; j++) {
|
|
348
|
+
recovered++;
|
|
349
|
+
try {
|
|
350
|
+
if (entry.handler) {
|
|
351
|
+
logger.log('[ably] Replaying recovered ' + entry.name + ' message (requestId=' + (page.items[j].data && page.items[j].data.requestId || 'none') + ')');
|
|
352
|
+
entry.handler(page.items[j]);
|
|
353
|
+
}
|
|
354
|
+
} catch (replayErr) {
|
|
355
|
+
logger.error('[ably] Error replaying recovered message: ' + (replayErr.message || replayErr));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
287
358
|
page = page.hasNext() ? await page.next() : null;
|
|
288
359
|
}
|
|
289
360
|
totalRecovered += recovered;
|
|
290
|
-
logger.log('[ably] Discontinuity recovery:
|
|
361
|
+
logger.log('[ably] Discontinuity recovery: replayed ' + recovered + ' ' + entry.name + ' message(s) from gap');
|
|
291
362
|
} catch (err) {
|
|
292
363
|
logger.error('[ably] Discontinuity recovery failed for ' + entry.name + ': ' + (err.message || err));
|
|
293
364
|
}
|
|
294
365
|
}
|
|
295
366
|
if (totalRecovered > 0) {
|
|
296
|
-
logger.warn('[ably] Recovered ' + totalRecovered + ' message(s) that were missed during connection interruption');
|
|
367
|
+
logger.warn('[ably] Recovered and replayed ' + totalRecovered + ' message(s) that were missed during connection interruption');
|
|
297
368
|
} else {
|
|
298
369
|
logger.log('[ably] Discontinuity recovery: no missed messages found');
|
|
299
370
|
}
|
|
@@ -780,8 +851,25 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
780
851
|
|
|
781
852
|
var requestId = message.requestId;
|
|
782
853
|
|
|
783
|
-
|
|
854
|
+
// timeoutId and timeoutExpiresAt are declared as vars so they can be
|
|
855
|
+
// updated by extendTimeout() (closure mutation).
|
|
856
|
+
var timeoutId;
|
|
857
|
+
var timeoutExpiresAt;
|
|
858
|
+
|
|
859
|
+
var timeoutFn = function () {
|
|
784
860
|
if (self.ps[requestId]) {
|
|
861
|
+
var pendingIds = Object.keys(self.ps);
|
|
862
|
+
var pendingSummary = pendingIds.map(function (rid) {
|
|
863
|
+
var e = self.ps[rid];
|
|
864
|
+
var age = e && e.startTime ? ((Date.now() - e.startTime) / 1000).toFixed(1) + 's' : '?';
|
|
865
|
+
return rid + '(' + (e && e.message ? e.message.type : '?') + ', ' + age + ')';
|
|
866
|
+
}).join(', ');
|
|
867
|
+
logger.error(
|
|
868
|
+
'[ably] Promise TIMEOUT: requestId=' + requestId +
|
|
869
|
+
' | type=' + message.type +
|
|
870
|
+
' | timeout=' + timeout + 'ms' +
|
|
871
|
+
' | all pending: [' + pendingSummary + ']'
|
|
872
|
+
);
|
|
785
873
|
delete self.ps[requestId];
|
|
786
874
|
delete self._execBuffers[requestId];
|
|
787
875
|
rejectPromise(
|
|
@@ -794,7 +882,10 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
794
882
|
),
|
|
795
883
|
);
|
|
796
884
|
}
|
|
797
|
-
}
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
timeoutId = setTimeout(timeoutFn, timeout);
|
|
888
|
+
timeoutExpiresAt = Date.now() + timeout;
|
|
798
889
|
if (timeoutId.unref) timeoutId.unref();
|
|
799
890
|
|
|
800
891
|
this.ps[requestId] = {
|
|
@@ -807,6 +898,26 @@ const createSandbox = function (emitter, analytics, sessionInstance) {
|
|
|
807
898
|
clearTimeout(timeoutId);
|
|
808
899
|
rejectPromise(error);
|
|
809
900
|
},
|
|
901
|
+
/**
|
|
902
|
+
* Extend the pending timeout by disconnectionDurationMs — called on Ably reconnect
|
|
903
|
+
* to compensate for time spent disconnected.
|
|
904
|
+
*/
|
|
905
|
+
extendTimeout: function (disconnectionDurationMs) {
|
|
906
|
+
clearTimeout(timeoutId);
|
|
907
|
+
// Clamp remaining to 0 so a command whose timer expired during the
|
|
908
|
+
// outage still gets the full disconnection duration as its new budget.
|
|
909
|
+
var remaining = Math.max(0, timeoutExpiresAt - Date.now());
|
|
910
|
+
// Minimum 5s remaining after extension to allow the response to arrive.
|
|
911
|
+
var MIN_REMAINING_MS = 5000;
|
|
912
|
+
var newRemaining = Math.max(remaining + disconnectionDurationMs, MIN_REMAINING_MS);
|
|
913
|
+
timeoutExpiresAt = Date.now() + newRemaining;
|
|
914
|
+
timeoutId = setTimeout(timeoutFn, newRemaining);
|
|
915
|
+
if (timeoutId.unref) timeoutId.unref();
|
|
916
|
+
logger.log(
|
|
917
|
+
'[ably] Extended timeout for requestId=' + requestId +
|
|
918
|
+
' by ' + disconnectionDurationMs + 'ms (new remaining: ' + Math.round(newRemaining / 1000) + 's)'
|
|
919
|
+
);
|
|
920
|
+
},
|
|
810
921
|
message: message,
|
|
811
922
|
startTime: Date.now(),
|
|
812
923
|
};
|
package/lib/vitest/hooks.mjs
CHANGED
|
@@ -42,14 +42,14 @@ function checkVitestVersion() {
|
|
|
42
42
|
if (major < MINIMUM_VITEST_VERSION) {
|
|
43
43
|
throw new Error(
|
|
44
44
|
`TestDriver requires Vitest >= ${MINIMUM_VITEST_VERSION}.0.0, but found ${version}. ` +
|
|
45
|
-
|
|
45
|
+
`Please upgrade Vitest: npm install vitest@latest`,
|
|
46
46
|
);
|
|
47
47
|
}
|
|
48
48
|
} catch (err) {
|
|
49
49
|
if (err.code === "MODULE_NOT_FOUND") {
|
|
50
50
|
throw new Error(
|
|
51
51
|
"TestDriver requires Vitest to be installed. " +
|
|
52
|
-
|
|
52
|
+
"Please install it: npm install vitest@latest",
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
throw err;
|
|
@@ -646,7 +646,7 @@ export function TestDriver(context, options = {}) {
|
|
|
646
646
|
|
|
647
647
|
// Wait for connection to finish if it was initiated
|
|
648
648
|
if (currentInstance.__connectionPromise) {
|
|
649
|
-
await currentInstance.__connectionPromise.catch(() => {}); // Ignore connection errors during cleanup
|
|
649
|
+
await currentInstance.__connectionPromise.catch(() => { }); // Ignore connection errors during cleanup
|
|
650
650
|
}
|
|
651
651
|
|
|
652
652
|
// Disconnect with timeout
|