agent-gear 2.1.0 → 2.2.0
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/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/socketio-client.d.ts +6 -0
- package/dist/socketio-client.d.ts.map +1 -1
- package/dist/socketio-client.js +59 -6
- package/dist/socketio-client.js.map +1 -1
- package/dist/workload-executor.d.ts +80 -0
- package/dist/workload-executor.d.ts.map +1 -0
- package/dist/workload-executor.js +292 -0
- package/dist/workload-executor.js.map +1 -0
- package/dist/workload-executor.test.d.ts +2 -0
- package/dist/workload-executor.test.d.ts.map +1 -0
- package/dist/workload-executor.test.js +214 -0
- package/dist/workload-executor.test.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +7 -1
- package/src/socketio-client.ts +57 -6
- package/src/workload-executor.test.ts +296 -0
- package/src/workload-executor.ts +357 -0
- package/tests/socketio-client.test.ts +2 -2
package/src/socketio-client.ts
CHANGED
|
@@ -18,8 +18,13 @@ import { gatewayManager } from './gateway.js';
|
|
|
18
18
|
import { processRegistry, isProcessAlive } from './acp/process-registry.js';
|
|
19
19
|
import { McpRelay } from './mcp/relay.js';
|
|
20
20
|
import { LocalMcpServer } from './mcp/local-server.js';
|
|
21
|
+
import { WorkloadExecutor, type WorkloadRunPayload } from './workload-executor.js';
|
|
21
22
|
|
|
22
|
-
const CONTROLLER_VERSION = '2.
|
|
23
|
+
const CONTROLLER_VERSION = '2.2.0';
|
|
24
|
+
// 进程内全局单实例:工作负载批次严格串行(新批次打断旧批次,见 workload-executor)。
|
|
25
|
+
const workloadExecutor = new WorkloadExecutor();
|
|
26
|
+
// shutdown 到达时若仍有 post 批次在跑,最多等待该时长后退出(契约 §2.8)。
|
|
27
|
+
const SHUTDOWN_WORKLOAD_WAIT_MS = 5000;
|
|
23
28
|
const acpBridges = new Map<string, AcpSessionBridge>();
|
|
24
29
|
const permissionRequestBridge = new Map<string, AcpSessionBridge>();
|
|
25
30
|
const elicitationRequestBridge = new Map<string, AcpSessionBridge>();
|
|
@@ -261,6 +266,26 @@ export function stripTokenFromEndpoint(endpoint: string): string {
|
|
|
261
266
|
}
|
|
262
267
|
}
|
|
263
268
|
|
|
269
|
+
/**
|
|
270
|
+
* 展开 Socket.IO 连接错误的底层细节。engine.io 的 TransportError 会携带
|
|
271
|
+
* `type`/`description`/`context`(例如服务端返回非 101 状态、TLS 失败、协议错误),
|
|
272
|
+
* 而 `message` 常常只有 "websocket error",不足以定位问题。
|
|
273
|
+
*/
|
|
274
|
+
export function describeConnectError(err: Error): string {
|
|
275
|
+
const detail = err as Error & { type?: string; description?: string; context?: unknown };
|
|
276
|
+
const parts: string[] = [];
|
|
277
|
+
if (detail.type) parts.push(`type=${detail.type}`);
|
|
278
|
+
if (detail.description) parts.push(`description=${detail.description}`);
|
|
279
|
+
if (detail.context !== undefined) {
|
|
280
|
+
try {
|
|
281
|
+
parts.push(`context=${JSON.stringify(detail.context)}`);
|
|
282
|
+
} catch {
|
|
283
|
+
parts.push('context=<unserializable>');
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return parts.length > 0 ? ` (${parts.join(' ')})` : '';
|
|
287
|
+
}
|
|
288
|
+
|
|
264
289
|
export function connectToServer(
|
|
265
290
|
endpoint: string,
|
|
266
291
|
key: string,
|
|
@@ -310,6 +335,8 @@ export function connectToServer(
|
|
|
310
335
|
homeDir: homedir(),
|
|
311
336
|
platform: platform(),
|
|
312
337
|
});
|
|
338
|
+
// 重连重放:将未确认的工作负载执行结果重新上报;服务端按终态幂等忽略(契约 §2.9)。
|
|
339
|
+
workloadExecutor.replayPendingResults((result) => socket.emit('workload:result', result));
|
|
313
340
|
});
|
|
314
341
|
|
|
315
342
|
socket.on('registered', (data: RegisteredPayload) => {
|
|
@@ -351,10 +378,32 @@ export function connectToServer(
|
|
|
351
378
|
|
|
352
379
|
socket.on('shutdown', (data: ShutdownPayload) => {
|
|
353
380
|
console.log(`Shutdown requested: ${data.reason}`);
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
381
|
+
const finishShutdown = (): void => {
|
|
382
|
+
heartbeat.stop();
|
|
383
|
+
clearConfigKey();
|
|
384
|
+
socket.close();
|
|
385
|
+
process.exit(0);
|
|
386
|
+
};
|
|
387
|
+
// post 批次仍在执行时,等待其结束(≤5s)后再退出,避免清理脚本被中途打断。
|
|
388
|
+
// 无批次在跑时保持同步退出,行为与现状完全一致。
|
|
389
|
+
if (workloadExecutor.isRunning()) {
|
|
390
|
+
workloadExecutor.waitForIdle(SHUTDOWN_WORKLOAD_WAIT_MS).then(finishShutdown);
|
|
391
|
+
} else {
|
|
392
|
+
finishShutdown();
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// Workload events
|
|
397
|
+
socket.on('workload:run', (data: WorkloadRunPayload) => {
|
|
398
|
+
if (!data || !Array.isArray(data.items)) {
|
|
399
|
+
console.warn(`[Workload] workload:run ignored: invalid payload ${JSON.stringify(data)}`);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const budget = typeof data.budgetMs === 'number' ? ` budgetMs=${data.budgetMs}` : '';
|
|
403
|
+
console.log(`[Workload] workload:run received: phase=${data.phase} items=${data.items.length}${budget}`);
|
|
404
|
+
workloadExecutor.runBatch(data, (result) => socket.emit('workload:result', result)).catch((error: Error) => {
|
|
405
|
+
console.error(`[Workload] batch execution failed: ${error.message}`);
|
|
406
|
+
});
|
|
358
407
|
});
|
|
359
408
|
|
|
360
409
|
// ACP events
|
|
@@ -1144,7 +1193,9 @@ export function connectToServer(
|
|
|
1144
1193
|
});
|
|
1145
1194
|
|
|
1146
1195
|
socket.on('connect_error', (err) => {
|
|
1147
|
-
|
|
1196
|
+
// 传输层失败时 err.message 往往只有笼统的 "websocket error",真实原因在 description/context
|
|
1197
|
+
// (如 HTTP 404/502、TLS 失败、协议错误)里——一并打印,避免"看不出为何连不上"。
|
|
1198
|
+
console.error(`Connection error: ${err.message}${describeConnectError(err)}`);
|
|
1148
1199
|
});
|
|
1149
1200
|
|
|
1150
1201
|
return socket;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, mkdir, rm, access } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import {
|
|
6
|
+
WorkloadExecutor,
|
|
7
|
+
MAX_OUTPUT_BYTES,
|
|
8
|
+
MAX_PENDING_RESULTS,
|
|
9
|
+
EMPTY_SCRIPT_MESSAGE,
|
|
10
|
+
type WorkloadResult,
|
|
11
|
+
} from './workload-executor.js';
|
|
12
|
+
|
|
13
|
+
const TMP_BASE = '/tmp/opencode';
|
|
14
|
+
|
|
15
|
+
function collect() {
|
|
16
|
+
const results: WorkloadResult[] = [];
|
|
17
|
+
return { results, sink: (result: WorkloadResult) => results.push(result) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function makeTmpDir(): Promise<string> {
|
|
21
|
+
await mkdir(TMP_BASE, { recursive: true });
|
|
22
|
+
return mkdtemp(join(TMP_BASE, 'we-'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
26
|
+
try {
|
|
27
|
+
await access(path);
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('WorkloadExecutor', () => {
|
|
35
|
+
const dirs: string[] = [];
|
|
36
|
+
|
|
37
|
+
afterEach(async () => {
|
|
38
|
+
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('T-WE1 runs items serially, merges stdout+stderr and reports success in item order', async () => {
|
|
42
|
+
const executor = new WorkloadExecutor();
|
|
43
|
+
const { results, sink } = collect();
|
|
44
|
+
|
|
45
|
+
await executor.runBatch(
|
|
46
|
+
{
|
|
47
|
+
phase: 'init',
|
|
48
|
+
items: [
|
|
49
|
+
{ logId: 'a', workloadName: 'A', script: 'echo out-a; echo err-a >&2', timeoutMs: 5000 },
|
|
50
|
+
{ logId: 'b', workloadName: 'B', script: 'echo out-b', timeoutMs: 5000 },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
sink,
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
expect(results.map((r) => r.logId)).toEqual(['a', 'b']);
|
|
57
|
+
expect(results[0]).toMatchObject({ status: 'success', exitCode: 0 });
|
|
58
|
+
expect(results[0].log).toContain('out-a');
|
|
59
|
+
expect(results[0].log).toContain('err-a');
|
|
60
|
+
expect(results[1]).toMatchObject({ status: 'success', exitCode: 0 });
|
|
61
|
+
expect(results[1].log).toContain('out-b');
|
|
62
|
+
expect(results[0].startedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
63
|
+
expect(results[0].finishedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('T-WE2 reports failed with exitCode on non-zero exit and continues the batch', async () => {
|
|
67
|
+
const executor = new WorkloadExecutor();
|
|
68
|
+
const { results, sink } = collect();
|
|
69
|
+
|
|
70
|
+
await executor.runBatch(
|
|
71
|
+
{
|
|
72
|
+
phase: 'init',
|
|
73
|
+
items: [
|
|
74
|
+
{ logId: 'fail', workloadName: 'F', script: 'echo boom >&2; exit 3', timeoutMs: 5000 },
|
|
75
|
+
{ logId: 'ok', workloadName: 'O', script: 'echo ok', timeoutMs: 5000 },
|
|
76
|
+
],
|
|
77
|
+
},
|
|
78
|
+
sink,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
expect(results[0]).toMatchObject({ logId: 'fail', status: 'failed', exitCode: 3 });
|
|
82
|
+
expect(results[0].log).toContain('boom');
|
|
83
|
+
expect(results[1]).toMatchObject({ logId: 'ok', status: 'success' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('T-WE3 kills the whole process group on per-item timeout and continues', async () => {
|
|
87
|
+
const dir = await makeTmpDir();
|
|
88
|
+
dirs.push(dir);
|
|
89
|
+
const marker = join(dir, 'leaked.txt');
|
|
90
|
+
const executor = new WorkloadExecutor();
|
|
91
|
+
const { results, sink } = collect();
|
|
92
|
+
|
|
93
|
+
const started = Date.now();
|
|
94
|
+
await executor.runBatch(
|
|
95
|
+
{
|
|
96
|
+
phase: 'init',
|
|
97
|
+
items: [
|
|
98
|
+
{ logId: 'slow', workloadName: 'S', script: `( sleep 1.5; echo leaked > ${marker} ) & sleep 30`, timeoutMs: 300 },
|
|
99
|
+
{ logId: 'next', workloadName: 'N', script: 'echo next', timeoutMs: 5000 },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
sink,
|
|
103
|
+
);
|
|
104
|
+
const elapsed = Date.now() - started;
|
|
105
|
+
|
|
106
|
+
expect(elapsed).toBeLessThan(5000);
|
|
107
|
+
expect(results[0]).toMatchObject({ logId: 'slow', status: 'timeout', exitCode: null });
|
|
108
|
+
expect(results[1]).toMatchObject({ logId: 'next', status: 'success' });
|
|
109
|
+
|
|
110
|
+
// the backgrounded child would write the marker at 1.5s if the group was not killed
|
|
111
|
+
await delay(2000);
|
|
112
|
+
expect(await fileExists(marker)).toBe(false);
|
|
113
|
+
}, 15000);
|
|
114
|
+
|
|
115
|
+
it('T-WE4 truncates oversized output keeping the tail <= 256 KiB', async () => {
|
|
116
|
+
const executor = new WorkloadExecutor();
|
|
117
|
+
const { results, sink } = collect();
|
|
118
|
+
|
|
119
|
+
await executor.runBatch(
|
|
120
|
+
{
|
|
121
|
+
phase: 'init',
|
|
122
|
+
items: [{ logId: 'big', workloadName: 'Big', script: "yes A | head -c 300000; printf 'ENDMARK'", timeoutMs: 10000 }],
|
|
123
|
+
},
|
|
124
|
+
sink,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(results).toHaveLength(1);
|
|
128
|
+
expect(results[0].status).toBe('success');
|
|
129
|
+
expect(Buffer.byteLength(results[0].log, 'utf8')).toBeLessThanOrEqual(MAX_OUTPUT_BYTES);
|
|
130
|
+
expect(results[0].log.endsWith('ENDMARK')).toBe(true);
|
|
131
|
+
}, 15000);
|
|
132
|
+
|
|
133
|
+
it('T-WE4b: skips a leading split UTF-8 continuation byte when truncating the tail', async () => {
|
|
134
|
+
const executor = new WorkloadExecutor();
|
|
135
|
+
const { results, sink } = collect();
|
|
136
|
+
|
|
137
|
+
// 总长 262145 字节(3 字节「中」+ 262142 个 A),尾部保留 262144 字节 => 起点落在
|
|
138
|
+
// 「中」的第 2 个字节(续字节)上;截断后必须跳过续字节到字符边界,首字符不得是乱码。
|
|
139
|
+
await executor.runBatch(
|
|
140
|
+
{
|
|
141
|
+
phase: 'init',
|
|
142
|
+
items: [
|
|
143
|
+
{
|
|
144
|
+
logId: 'utf8',
|
|
145
|
+
workloadName: 'Utf8',
|
|
146
|
+
script: "printf '中'; head -c 262142 /dev/zero | tr '\\0' 'A'",
|
|
147
|
+
timeoutMs: 10000,
|
|
148
|
+
},
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
sink,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
expect(results).toHaveLength(1);
|
|
155
|
+
const log = results[0].log;
|
|
156
|
+
expect(log.includes('\uFFFD')).toBe(false);
|
|
157
|
+
expect(log).toBe('A'.repeat(262142));
|
|
158
|
+
expect(Buffer.byteLength(log, 'utf8')).toBeLessThanOrEqual(MAX_OUTPUT_BYTES);
|
|
159
|
+
}, 15000);
|
|
160
|
+
|
|
161
|
+
it('T-WE5 shares the post budget and reports timeout without starting later items', async () => {
|
|
162
|
+
const executor = new WorkloadExecutor();
|
|
163
|
+
const { results, sink } = collect();
|
|
164
|
+
|
|
165
|
+
const started = Date.now();
|
|
166
|
+
await executor.runBatch(
|
|
167
|
+
{
|
|
168
|
+
phase: 'post',
|
|
169
|
+
budgetMs: 300,
|
|
170
|
+
items: [
|
|
171
|
+
{ logId: 'p1', workloadName: 'P1', script: 'sleep 2', timeoutMs: 10000 },
|
|
172
|
+
{ logId: 'p2', workloadName: 'P2', script: 'echo should-not-run', timeoutMs: 10000 },
|
|
173
|
+
],
|
|
174
|
+
},
|
|
175
|
+
sink,
|
|
176
|
+
);
|
|
177
|
+
const elapsed = Date.now() - started;
|
|
178
|
+
|
|
179
|
+
expect(elapsed).toBeLessThan(5000);
|
|
180
|
+
expect(results.map((r) => r.logId)).toEqual(['p1', 'p2']);
|
|
181
|
+
expect(results[0]).toMatchObject({ status: 'timeout', exitCode: null });
|
|
182
|
+
expect(results[1]).toMatchObject({ status: 'timeout', exitCode: null });
|
|
183
|
+
expect(results[1].log).not.toContain('should-not-run');
|
|
184
|
+
}, 15000);
|
|
185
|
+
|
|
186
|
+
it('T-WE6 buffers unconfirmed results and replays then clears on reconnect', async () => {
|
|
187
|
+
const executor = new WorkloadExecutor();
|
|
188
|
+
|
|
189
|
+
await executor.runBatch(
|
|
190
|
+
{
|
|
191
|
+
phase: 'init',
|
|
192
|
+
items: [
|
|
193
|
+
{ logId: 'r1', workloadName: 'R1', script: 'echo one', timeoutMs: 5000 },
|
|
194
|
+
{ logId: 'r2', workloadName: 'R2', script: 'echo two', timeoutMs: 5000 },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
() => {},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
expect(executor.getPendingResults().map((r) => r.logId)).toEqual(['r1', 'r2']);
|
|
201
|
+
|
|
202
|
+
const replayed: WorkloadResult[] = [];
|
|
203
|
+
executor.replayPendingResults((result) => replayed.push(result));
|
|
204
|
+
|
|
205
|
+
expect(replayed.map((r) => r.logId)).toEqual(['r1', 'r2']);
|
|
206
|
+
expect(executor.getPendingResults()).toHaveLength(0);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('T-WE6b caps the replay buffer at 50 results', async () => {
|
|
210
|
+
const executor = new WorkloadExecutor();
|
|
211
|
+
const items = Array.from({ length: MAX_PENDING_RESULTS + 5 }, (_, i) => ({
|
|
212
|
+
logId: `x${i}`,
|
|
213
|
+
workloadName: `X${i}`,
|
|
214
|
+
script: ':',
|
|
215
|
+
timeoutMs: 5000,
|
|
216
|
+
}));
|
|
217
|
+
|
|
218
|
+
await executor.runBatch({ phase: 'init', items }, () => {});
|
|
219
|
+
|
|
220
|
+
const pending = executor.getPendingResults();
|
|
221
|
+
expect(pending).toHaveLength(MAX_PENDING_RESULTS);
|
|
222
|
+
expect(pending[pending.length - 1].logId).toBe(`x${MAX_PENDING_RESULTS + 4}`);
|
|
223
|
+
}, 30000);
|
|
224
|
+
|
|
225
|
+
it('T-WE7 terminates the running init batch when a new post batch arrives', async () => {
|
|
226
|
+
const dir = await makeTmpDir();
|
|
227
|
+
dirs.push(dir);
|
|
228
|
+
const startedFile = join(dir, 'init-started');
|
|
229
|
+
const executor = new WorkloadExecutor();
|
|
230
|
+
|
|
231
|
+
const initResults: WorkloadResult[] = [];
|
|
232
|
+
const initBatch = executor.runBatch(
|
|
233
|
+
{
|
|
234
|
+
phase: 'init',
|
|
235
|
+
items: [{ logId: 'init-1', workloadName: 'Init', script: `touch ${startedFile}; sleep 30`, timeoutMs: 60000 }],
|
|
236
|
+
},
|
|
237
|
+
(result) => initResults.push(result),
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// wait until the init child process has actually started
|
|
241
|
+
const deadline = Date.now() + 5000;
|
|
242
|
+
let startedFlag = false;
|
|
243
|
+
while (Date.now() < deadline) {
|
|
244
|
+
if (await fileExists(startedFile)) {
|
|
245
|
+
startedFlag = true;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
await delay(25);
|
|
249
|
+
}
|
|
250
|
+
expect(startedFlag).toBe(true);
|
|
251
|
+
|
|
252
|
+
const postResults: WorkloadResult[] = [];
|
|
253
|
+
await executor.runBatch(
|
|
254
|
+
{ phase: 'post', items: [{ logId: 'post-1', workloadName: 'Post', script: 'echo post-ok', timeoutMs: 5000 }] },
|
|
255
|
+
(result) => postResults.push(result),
|
|
256
|
+
);
|
|
257
|
+
await initBatch;
|
|
258
|
+
|
|
259
|
+
expect(postResults.map((r) => r.logId)).toEqual(['post-1']);
|
|
260
|
+
expect(postResults[0].status).toBe('success');
|
|
261
|
+
// the interrupted init item is not reported; the server marks it TIMEOUT
|
|
262
|
+
expect(initResults).toHaveLength(0);
|
|
263
|
+
}, 20000);
|
|
264
|
+
|
|
265
|
+
it('T-WE8 reports failed for an empty script and does nothing for empty items', async () => {
|
|
266
|
+
const executor = new WorkloadExecutor();
|
|
267
|
+
const { results, sink } = collect();
|
|
268
|
+
|
|
269
|
+
await executor.runBatch(
|
|
270
|
+
{ phase: 'init', items: [{ logId: 'empty', workloadName: 'Empty', script: ' ', timeoutMs: 5000 }] },
|
|
271
|
+
sink,
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
expect(results).toHaveLength(1);
|
|
275
|
+
expect(results[0]).toMatchObject({ logId: 'empty', status: 'failed', exitCode: null, log: EMPTY_SCRIPT_MESSAGE });
|
|
276
|
+
|
|
277
|
+
const empty = collect();
|
|
278
|
+
await executor.runBatch({ phase: 'init', items: [] }, empty.sink);
|
|
279
|
+
expect(empty.results).toHaveLength(0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('T-WE9 exposes running state and waitForIdle resolves after the batch completes', async () => {
|
|
283
|
+
const executor = new WorkloadExecutor();
|
|
284
|
+
expect(executor.isRunning()).toBe(false);
|
|
285
|
+
|
|
286
|
+
const batch = executor.runBatch(
|
|
287
|
+
{ phase: 'post', items: [{ logId: 'w', workloadName: 'W', script: 'sleep 0.4', timeoutMs: 5000 }] },
|
|
288
|
+
() => {},
|
|
289
|
+
);
|
|
290
|
+
expect(executor.isRunning()).toBe(true);
|
|
291
|
+
|
|
292
|
+
await executor.waitForIdle(2000);
|
|
293
|
+
expect(executor.isRunning()).toBe(false);
|
|
294
|
+
await batch;
|
|
295
|
+
}, 10000);
|
|
296
|
+
});
|