@shipers-dev/multi 0.9.6 → 0.10.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 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.0",
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)
@@ -6195,7 +6207,9 @@ async function cmdConnect(apiUrl, config) {
6195
6207
  tunnel = next;
6196
6208
  log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
6197
6209
  try {
6198
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel.url });
6210
+ const pending = await heartbeat();
6211
+ if (pending > 0)
6212
+ drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
6199
6213
  } catch (e) {
6200
6214
  log(`heartbeat error after tunnel restart: ${String(e)}`);
6201
6215
  }
@@ -6246,7 +6260,9 @@ async function cmdConnect(apiUrl, config) {
6246
6260
  }
6247
6261
  }
6248
6262
  try {
6249
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel?.url });
6263
+ const pending = await heartbeat();
6264
+ if (pending > 0)
6265
+ drainOfflineDispatches(apiUrl, config.deviceId, config.dispatchSecret, db, () => schedule());
6250
6266
  } catch (e) {
6251
6267
  log(`heartbeat error: ${String(e)}`);
6252
6268
  }
@@ -7060,6 +7076,50 @@ async function resolveAcpAdapter(agentType, detectedPath) {
7060
7076
  return [c];
7061
7077
  return null;
7062
7078
  }
7079
+ async function ackDispatch(apiUrl, dispatchId, secret) {
7080
+ try {
7081
+ await fetch(`${apiUrl}/api/issues/dispatches/${dispatchId}/ack`, {
7082
+ method: "POST",
7083
+ headers: { authorization: `Bearer ${secret}` }
7084
+ });
7085
+ } catch (e) {
7086
+ log(`ack dispatch ${dispatchId} failed: ${String(e)}`);
7087
+ }
7088
+ }
7089
+ async function drainOfflineDispatches(apiUrl, deviceId, secret, db, onEnqueued) {
7090
+ let list;
7091
+ try {
7092
+ list = await apiClient.get(`${apiUrl}/api/devices/${deviceId}/dispatches/pending`);
7093
+ } catch (e) {
7094
+ log(`drain list failed: ${String(e)}`);
7095
+ return;
7096
+ }
7097
+ const rows = list?.data?.results || list?.data || list?.results || list || [];
7098
+ if (!Array.isArray(rows) || rows.length === 0)
7099
+ return;
7100
+ log(`\uD83E\uDEA3 draining ${rows.length} offline dispatch(es)`);
7101
+ for (const r of rows) {
7102
+ try {
7103
+ const res = await fetch(`${apiUrl}/api/issues/dispatches/${r.id}/claim`, {
7104
+ method: "POST",
7105
+ headers: { authorization: `Bearer ${secret}` }
7106
+ });
7107
+ if (!res.ok) {
7108
+ log(`claim ${r.id} skipped: ${res.status}`);
7109
+ continue;
7110
+ }
7111
+ const { task } = await res.json();
7112
+ const taskId = task.issue_id ? `${task.issue_id}-${Date.now()}` : crypto.randomUUID();
7113
+ 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]);
7114
+ if (task.issue_id)
7115
+ postStream(apiUrl, task.issue_id, "queued", { drained: true });
7116
+ ackDispatch(apiUrl, r.id, secret);
7117
+ } catch (e) {
7118
+ log(`drain ${r.id} failed: ${String(e)}`);
7119
+ }
7120
+ }
7121
+ onEnqueued();
7122
+ }
7063
7123
  async function postStream(apiUrl, issueId, event_type, payload) {
7064
7124
  try {
7065
7125
  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.0",
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
 
@@ -477,7 +492,8 @@ async function cmdConnect(apiUrl: string, config: Config) {
477
492
  tunnel = next;
478
493
  log(`☁️ Tunnel up: ${tunnel.url}`);
479
494
  try {
480
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel.url });
495
+ const pending = await heartbeat();
496
+ if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
481
497
  } catch (e) {
482
498
  log(`heartbeat error after tunnel restart: ${String(e)}`);
483
499
  }
@@ -524,7 +540,8 @@ async function cmdConnect(apiUrl: string, config: Config) {
524
540
  }
525
541
  }
526
542
  try {
527
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel?.url });
543
+ const pending = await heartbeat();
544
+ if (pending > 0) void drainOfflineDispatches(apiUrl, config.deviceId!, config.dispatchSecret!, db, () => schedule());
528
545
  } catch (e) {
529
546
  log(`heartbeat error: ${String(e)}`);
530
547
  }
@@ -1247,6 +1264,55 @@ async function resolveAcpAdapter(agentType: string, detectedPath?: string): Prom
1247
1264
  return null;
1248
1265
  }
1249
1266
 
1267
+ // Ack a dispatch so the worker flips its status from dispatched → acked.
1268
+ // Uses dispatch_secret (Bearer) so the daemon can call an unauthenticated-by-user endpoint.
1269
+ async function ackDispatch(apiUrl: string, dispatchId: string, secret: string) {
1270
+ try {
1271
+ await fetch(`${apiUrl}/api/issues/dispatches/${dispatchId}/ack`, {
1272
+ method: 'POST',
1273
+ headers: { 'authorization': `Bearer ${secret}` },
1274
+ });
1275
+ } catch (e) {
1276
+ log(`ack dispatch ${dispatchId} failed: ${String(e)}`);
1277
+ }
1278
+ }
1279
+
1280
+ // On reconnect, pull dispatches the worker couldn't deliver and enqueue them.
1281
+ // Each call claims the row (offline → dispatched) atomically so parallel daemons
1282
+ // don't double-run the same task.
1283
+ async function drainOfflineDispatches(apiUrl: string, deviceId: string, secret: string, db: Database, onEnqueued: () => void) {
1284
+ let list: any;
1285
+ try {
1286
+ list = await apiClient.get<any>(`${apiUrl}/api/devices/${deviceId}/dispatches/pending`);
1287
+ } catch (e) {
1288
+ log(`drain list failed: ${String(e)}`);
1289
+ return;
1290
+ }
1291
+ const rows = (list?.data?.results || list?.data || list?.results || list || []) as any[];
1292
+ if (!Array.isArray(rows) || rows.length === 0) return;
1293
+ log(`🪣 draining ${rows.length} offline dispatch(es)`);
1294
+ for (const r of rows) {
1295
+ try {
1296
+ const res = await fetch(`${apiUrl}/api/issues/dispatches/${r.id}/claim`, {
1297
+ method: 'POST',
1298
+ headers: { 'authorization': `Bearer ${secret}` },
1299
+ });
1300
+ if (!res.ok) { log(`claim ${r.id} skipped: ${res.status}`); continue; }
1301
+ const { task } = await res.json() as { task: any };
1302
+ const taskId = task.issue_id ? `${task.issue_id}-${Date.now()}` : crypto.randomUUID();
1303
+ db.run(
1304
+ "INSERT INTO tasks (id, status, payload, agent_id, issue_id) VALUES (?, 'queued', ?, ?, ?)",
1305
+ [taskId, JSON.stringify(task), task.agent_id ?? null, task.issue_id ?? null],
1306
+ );
1307
+ if (task.issue_id) void postStream(apiUrl, task.issue_id, 'queued', { drained: true });
1308
+ void ackDispatch(apiUrl, r.id, secret);
1309
+ } catch (e) {
1310
+ log(`drain ${r.id} failed: ${String(e)}`);
1311
+ }
1312
+ }
1313
+ onEnqueued();
1314
+ }
1315
+
1250
1316
  async function postStream(apiUrl: string, issueId: string, event_type: string, payload: any) {
1251
1317
  // Local ndjson sink for tail -f debugging.
1252
1318
  try {