@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.
Files changed (3) hide show
  1. package/dist/index.js +88 -20
  2. package/package.json +1 -1
  3. 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.9.6",
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
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel.url });
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
- log(`\uD83D\uDD01 Restarting tunnel (${reason})`);
6201
+ restarting = true;
6189
6202
  try {
6190
- tunnel?.child.kill();
6191
- } catch {}
6192
- for (let attempt = 1;alive; attempt++) {
6193
- const next = await startTunnel(port);
6194
- if (next) {
6195
- tunnel = next;
6196
- log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
6197
- try {
6198
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel.url });
6199
- } catch (e) {
6200
- log(`heartbeat error after tunnel restart: ${String(e)}`);
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
- return;
6223
+ const wait = Math.min(30000, 2000 * attempt);
6224
+ log(`tunnel restart failed, retry in ${wait}ms`);
6225
+ await sleep(wait);
6203
6226
  }
6204
- const wait = Math.min(30000, 2000 * attempt);
6205
- log(`tunnel restart failed, retry in ${wait}ms`);
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
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel?.url });
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.9.6",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
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
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel.url });
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
- log(`🔁 Restarting tunnel (${reason})`);
472
- try { tunnel?.child.kill(); } catch {}
473
- // Small backoff so we don't spam cloudflared edge under prolonged outage.
474
- for (let attempt = 1; alive; attempt++) {
475
- const next = await startTunnel(port);
476
- if (next) {
477
- tunnel = next;
478
- log(`☁️ Tunnel up: ${tunnel.url}`);
479
- try {
480
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel.url });
481
- } catch (e) {
482
- log(`heartbeat error after tunnel restart: ${String(e)}`);
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
- return;
508
+ const wait = Math.min(30000, 2000 * attempt);
509
+ log(`tunnel restart failed, retry in ${wait}ms`);
510
+ await sleep(wait);
485
511
  }
486
- const wait = Math.min(30000, 2000 * attempt);
487
- log(`tunnel restart failed, retry in ${wait}ms`);
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
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel?.url });
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 {