@shipers-dev/multi 0.9.6 → 0.10.1
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 +88 -20
- package/package.json +1 -1
- package/src/index.ts +95 -19
package/dist/index.js
CHANGED
|
@@ -5703,7 +5703,7 @@ import { join as join3, dirname as dirname2 } from "path";
|
|
|
5703
5703
|
// package.json
|
|
5704
5704
|
var package_default = {
|
|
5705
5705
|
name: "@shipers-dev/multi",
|
|
5706
|
-
version: "0.
|
|
5706
|
+
version: "0.10.1",
|
|
5707
5707
|
type: "module",
|
|
5708
5708
|
bin: {
|
|
5709
5709
|
"multi-agent": "./dist/index.js"
|
|
@@ -6052,6 +6052,9 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6052
6052
|
if (t.issue_id) {
|
|
6053
6053
|
postStream(apiUrl, t.issue_id, "queued", { queue_position: pos });
|
|
6054
6054
|
}
|
|
6055
|
+
if (t.dispatch_id) {
|
|
6056
|
+
ackDispatch(apiUrl, t.dispatch_id, config.dispatchSecret);
|
|
6057
|
+
}
|
|
6055
6058
|
queueMicrotask(() => schedule());
|
|
6056
6059
|
return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
|
|
6057
6060
|
} catch (e) {
|
|
@@ -6153,7 +6156,16 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6153
6156
|
process.exit(1);
|
|
6154
6157
|
}
|
|
6155
6158
|
log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
|
|
6156
|
-
|
|
6159
|
+
const heartbeat = async () => {
|
|
6160
|
+
const res = await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel?.url });
|
|
6161
|
+
return res.success && res.data?.pending_dispatches || 0;
|
|
6162
|
+
};
|
|
6163
|
+
{
|
|
6164
|
+
const pending = await heartbeat();
|
|
6165
|
+
if (pending > 0)
|
|
6166
|
+
drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
|
|
6167
|
+
}
|
|
6168
|
+
drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
|
|
6157
6169
|
let alive = true;
|
|
6158
6170
|
const shutdown = async (reason) => {
|
|
6159
6171
|
if (!alive)
|
|
@@ -6182,28 +6194,38 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6182
6194
|
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
6183
6195
|
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
6184
6196
|
schedule();
|
|
6197
|
+
let restarting = false;
|
|
6185
6198
|
const restartTunnel = async (reason) => {
|
|
6186
|
-
if (!alive)
|
|
6199
|
+
if (!alive || restarting)
|
|
6187
6200
|
return;
|
|
6188
|
-
|
|
6201
|
+
restarting = true;
|
|
6189
6202
|
try {
|
|
6190
|
-
tunnel
|
|
6191
|
-
|
|
6192
|
-
|
|
6193
|
-
|
|
6194
|
-
|
|
6195
|
-
|
|
6196
|
-
|
|
6197
|
-
|
|
6198
|
-
|
|
6199
|
-
|
|
6200
|
-
log(
|
|
6203
|
+
log(`\uD83D\uDD01 Restarting tunnel (${reason})`);
|
|
6204
|
+
const old = tunnel;
|
|
6205
|
+
tunnel = null;
|
|
6206
|
+
try {
|
|
6207
|
+
old?.child.kill();
|
|
6208
|
+
} catch {}
|
|
6209
|
+
for (let attempt = 1;alive; attempt++) {
|
|
6210
|
+
const next = await startTunnel(port);
|
|
6211
|
+
if (next) {
|
|
6212
|
+
tunnel = next;
|
|
6213
|
+
log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
|
|
6214
|
+
try {
|
|
6215
|
+
const pending = await heartbeat();
|
|
6216
|
+
if (pending > 0)
|
|
6217
|
+
drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
|
|
6218
|
+
} catch (e) {
|
|
6219
|
+
log(`heartbeat error after tunnel restart: ${String(e)}`);
|
|
6220
|
+
}
|
|
6221
|
+
return;
|
|
6201
6222
|
}
|
|
6202
|
-
|
|
6223
|
+
const wait = Math.min(30000, 2000 * attempt);
|
|
6224
|
+
log(`tunnel restart failed, retry in ${wait}ms`);
|
|
6225
|
+
await sleep(wait);
|
|
6203
6226
|
}
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
await sleep(wait);
|
|
6227
|
+
} finally {
|
|
6228
|
+
restarting = false;
|
|
6207
6229
|
}
|
|
6208
6230
|
};
|
|
6209
6231
|
(async () => {
|
|
@@ -6246,7 +6268,9 @@ async function cmdConnect(apiUrl, config) {
|
|
|
6246
6268
|
}
|
|
6247
6269
|
}
|
|
6248
6270
|
try {
|
|
6249
|
-
|
|
6271
|
+
const pending = await heartbeat();
|
|
6272
|
+
if (pending > 0)
|
|
6273
|
+
drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
|
|
6250
6274
|
} catch (e) {
|
|
6251
6275
|
log(`heartbeat error: ${String(e)}`);
|
|
6252
6276
|
}
|
|
@@ -7060,6 +7084,50 @@ async function resolveAcpAdapter(agentType, detectedPath) {
|
|
|
7060
7084
|
return [c];
|
|
7061
7085
|
return null;
|
|
7062
7086
|
}
|
|
7087
|
+
async function ackDispatch(apiUrl, dispatchId, secret) {
|
|
7088
|
+
try {
|
|
7089
|
+
await fetch(`${apiUrl}/api/issues/dispatches/${dispatchId}/ack`, {
|
|
7090
|
+
method: "POST",
|
|
7091
|
+
headers: { authorization: `Bearer ${secret}` }
|
|
7092
|
+
});
|
|
7093
|
+
} catch (e) {
|
|
7094
|
+
log(`ack dispatch ${dispatchId} failed: ${String(e)}`);
|
|
7095
|
+
}
|
|
7096
|
+
}
|
|
7097
|
+
async function drainOfflineDispatches(apiUrl, deviceId, secret, db, onEnqueued) {
|
|
7098
|
+
let list;
|
|
7099
|
+
try {
|
|
7100
|
+
list = await apiClient.get(`${apiUrl}/api/devices/${deviceId}/dispatches/pending`);
|
|
7101
|
+
} catch (e) {
|
|
7102
|
+
log(`drain list failed: ${String(e)}`);
|
|
7103
|
+
return;
|
|
7104
|
+
}
|
|
7105
|
+
const rows = list?.data?.results || list?.data || list?.results || list || [];
|
|
7106
|
+
if (!Array.isArray(rows) || rows.length === 0)
|
|
7107
|
+
return;
|
|
7108
|
+
log(`\uD83E\uDEA3 draining ${rows.length} offline dispatch(es)`);
|
|
7109
|
+
for (const r of rows) {
|
|
7110
|
+
try {
|
|
7111
|
+
const res = await fetch(`${apiUrl}/api/issues/dispatches/${r.id}/claim`, {
|
|
7112
|
+
method: "POST",
|
|
7113
|
+
headers: { authorization: `Bearer ${secret}` }
|
|
7114
|
+
});
|
|
7115
|
+
if (!res.ok) {
|
|
7116
|
+
log(`claim ${r.id} skipped: ${res.status}`);
|
|
7117
|
+
continue;
|
|
7118
|
+
}
|
|
7119
|
+
const { task } = await res.json();
|
|
7120
|
+
const taskId = task.issue_id ? `${task.issue_id}-${Date.now()}` : crypto.randomUUID();
|
|
7121
|
+
db.run("INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)", [taskId, JSON.stringify(task), task.agent_id ?? null, task.issue_id ?? null]);
|
|
7122
|
+
if (task.issue_id)
|
|
7123
|
+
postStream(apiUrl, task.issue_id, "queued", { drained: true });
|
|
7124
|
+
ackDispatch(apiUrl, r.id, secret);
|
|
7125
|
+
} catch (e) {
|
|
7126
|
+
log(`drain ${r.id} failed: ${String(e)}`);
|
|
7127
|
+
}
|
|
7128
|
+
}
|
|
7129
|
+
onEnqueued();
|
|
7130
|
+
}
|
|
7063
7131
|
async function postStream(apiUrl, issueId, event_type, payload) {
|
|
7064
7132
|
try {
|
|
7065
7133
|
ensureDirs();
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -355,6 +355,11 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
355
355
|
if (t.issue_id) {
|
|
356
356
|
void postStream(apiUrl, t.issue_id, 'queued', { queue_position: pos });
|
|
357
357
|
}
|
|
358
|
+
// Ack dispatch back to worker so UI flips from queued → acked. Fire-and-forget:
|
|
359
|
+
// missing/failed ack is fine, stall-sweep covers it.
|
|
360
|
+
if (t.dispatch_id) {
|
|
361
|
+
void ackDispatch(apiUrl, t.dispatch_id, config.dispatchSecret!);
|
|
362
|
+
}
|
|
358
363
|
queueMicrotask(() => schedule());
|
|
359
364
|
return Response.json({ accepted: true, task_id: taskId }, { status: 202 });
|
|
360
365
|
} catch (e) {
|
|
@@ -441,7 +446,17 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
441
446
|
}
|
|
442
447
|
log(`☁️ Tunnel up: ${tunnel.url}`);
|
|
443
448
|
|
|
444
|
-
|
|
449
|
+
const heartbeat = async (): Promise<number> => {
|
|
450
|
+
const res = await apiClient.post<{ pending_dispatches?: number }>(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel?.url });
|
|
451
|
+
return (res.success && res.data?.pending_dispatches) || 0;
|
|
452
|
+
};
|
|
453
|
+
{
|
|
454
|
+
const pending = await heartbeat();
|
|
455
|
+
if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
456
|
+
}
|
|
457
|
+
// Always attempt one drain on fresh startup, covering the case where worker
|
|
458
|
+
// couldn't reach the previous daemon instance right before restart.
|
|
459
|
+
void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
445
460
|
|
|
446
461
|
let alive = true;
|
|
447
462
|
|
|
@@ -466,26 +481,36 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
466
481
|
schedule();
|
|
467
482
|
|
|
468
483
|
// Tunnel self-heal: relaunch cloudflared if child exits or DNS stops resolving.
|
|
484
|
+
// `restarting` guards against two entries racing (probe-failure + exit-watcher
|
|
485
|
+
// both firing when we kill the child as part of our own restart).
|
|
486
|
+
let restarting = false;
|
|
469
487
|
const restartTunnel = async (reason: string) => {
|
|
470
|
-
if (!alive) return;
|
|
471
|
-
|
|
472
|
-
try {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
488
|
+
if (!alive || restarting) return;
|
|
489
|
+
restarting = true;
|
|
490
|
+
try {
|
|
491
|
+
log(`🔁 Restarting tunnel (${reason})`);
|
|
492
|
+
const old = tunnel;
|
|
493
|
+
tunnel = null; // null first so the exit-watcher's `tunnel === t` check skips our own kill
|
|
494
|
+
try { old?.child.kill(); } catch {}
|
|
495
|
+
for (let attempt = 1; alive; attempt++) {
|
|
496
|
+
const next = await startTunnel(port);
|
|
497
|
+
if (next) {
|
|
498
|
+
tunnel = next;
|
|
499
|
+
log(`☁️ Tunnel up: ${tunnel.url}`);
|
|
500
|
+
try {
|
|
501
|
+
const pending = await heartbeat();
|
|
502
|
+
if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
503
|
+
} catch (e) {
|
|
504
|
+
log(`heartbeat error after tunnel restart: ${String(e)}`);
|
|
505
|
+
}
|
|
506
|
+
return;
|
|
483
507
|
}
|
|
484
|
-
|
|
508
|
+
const wait = Math.min(30000, 2000 * attempt);
|
|
509
|
+
log(`tunnel restart failed, retry in ${wait}ms`);
|
|
510
|
+
await sleep(wait);
|
|
485
511
|
}
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
await sleep(wait);
|
|
512
|
+
} finally {
|
|
513
|
+
restarting = false;
|
|
489
514
|
}
|
|
490
515
|
};
|
|
491
516
|
|
|
@@ -496,6 +521,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
496
521
|
if (!t) { await sleep(1000); continue; }
|
|
497
522
|
const code = await t.child.exited;
|
|
498
523
|
if (!alive) return;
|
|
524
|
+
// If `tunnel` was nulled by restartTunnel, we killed it ourselves — skip.
|
|
499
525
|
if (tunnel === t) await restartTunnel(`cloudflared exited code=${code}`);
|
|
500
526
|
}
|
|
501
527
|
})();
|
|
@@ -524,7 +550,8 @@ async function cmdConnect(apiUrl: string, config: Config) {
|
|
|
524
550
|
}
|
|
525
551
|
}
|
|
526
552
|
try {
|
|
527
|
-
|
|
553
|
+
const pending = await heartbeat();
|
|
554
|
+
if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
|
|
528
555
|
} catch (e) {
|
|
529
556
|
log(`heartbeat error: ${String(e)}`);
|
|
530
557
|
}
|
|
@@ -1247,6 +1274,55 @@ async function resolveAcpAdapter(agentType: string, detectedPath?: string): Prom
|
|
|
1247
1274
|
return null;
|
|
1248
1275
|
}
|
|
1249
1276
|
|
|
1277
|
+
// Ack a dispatch so the worker flips its status from dispatched → acked.
|
|
1278
|
+
// Uses dispatch_secret (Bearer) so the daemon can call an unauthenticated-by-user endpoint.
|
|
1279
|
+
async function ackDispatch(apiUrl: string, dispatchId: string, secret: string) {
|
|
1280
|
+
try {
|
|
1281
|
+
await fetch(`${apiUrl}/api/issues/dispatches/${dispatchId}/ack`, {
|
|
1282
|
+
method: 'POST',
|
|
1283
|
+
headers: { 'authorization': `Bearer ${secret}` },
|
|
1284
|
+
});
|
|
1285
|
+
} catch (e) {
|
|
1286
|
+
log(`ack dispatch ${dispatchId} failed: ${String(e)}`);
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
// On reconnect, pull dispatches the worker couldn't deliver and enqueue them.
|
|
1291
|
+
// Each call claims the row (offline → dispatched) atomically so parallel daemons
|
|
1292
|
+
// don't double-run the same task.
|
|
1293
|
+
async function drainOfflineDispatches(apiUrl: string, deviceId: string, secret: string, db: Database, onEnqueued: () => void) {
|
|
1294
|
+
let list: any;
|
|
1295
|
+
try {
|
|
1296
|
+
list = await apiClient.get<any>(`${apiUrl}/api/devices/${deviceId}/dispatches/pending`);
|
|
1297
|
+
} catch (e) {
|
|
1298
|
+
log(`drain list failed: ${String(e)}`);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
const rows = (list?.data?.results || list?.data || list?.results || list || []) as any[];
|
|
1302
|
+
if (!Array.isArray(rows) || rows.length === 0) return;
|
|
1303
|
+
log(`🪣 draining ${rows.length} offline dispatch(es)`);
|
|
1304
|
+
for (const r of rows) {
|
|
1305
|
+
try {
|
|
1306
|
+
const res = await fetch(`${apiUrl}/api/issues/dispatches/${r.id}/claim`, {
|
|
1307
|
+
method: 'POST',
|
|
1308
|
+
headers: { 'authorization': `Bearer ${secret}` },
|
|
1309
|
+
});
|
|
1310
|
+
if (!res.ok) { log(`claim ${r.id} skipped: ${res.status}`); continue; }
|
|
1311
|
+
const { task } = await res.json() as { task: any };
|
|
1312
|
+
const taskId = task.issue_id ? `${task.issue_id}-${Date.now()}` : crypto.randomUUID();
|
|
1313
|
+
db.run(
|
|
1314
|
+
"INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)",
|
|
1315
|
+
[taskId, JSON.stringify(task), task.agent_id ?? null, task.issue_id ?? null],
|
|
1316
|
+
);
|
|
1317
|
+
if (task.issue_id) void postStream(apiUrl, task.issue_id, 'queued', { drained: true });
|
|
1318
|
+
void ackDispatch(apiUrl, r.id, secret);
|
|
1319
|
+
} catch (e) {
|
|
1320
|
+
log(`drain ${r.id} failed: ${String(e)}`);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
onEnqueued();
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1250
1326
|
async function postStream(apiUrl: string, issueId: string, event_type: string, payload: any) {
|
|
1251
1327
|
// Local ndjson sink for tail -f debugging.
|
|
1252
1328
|
try {
|