@claw-link/gateway-host 0.3.6 → 0.3.8

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/bin/cli.js CHANGED
@@ -104,8 +104,8 @@ async function main() {
104
104
  ' transport-test Verify content-addressed Capsule relay (host ⇄ central sync + integrity)',
105
105
  ' p2p-test Verify the native QUIC P2P transport end-to-end on this machine (loopback)',
106
106
  ' start Start the background service',
107
- ' stop Stop the background service',
108
- ' restart Restart the background service (apply config changes)',
107
+ ' stop Stop the background service (finishes the in-flight job first)',
108
+ ' restart Restart the service — waits for in-flight jobs ([--force] [--timeout <s>])',
109
109
  ' update Refresh the service to this CLI’s version — waits for in-flight jobs to finish',
110
110
  ' (clhost update [--force] [--timeout <seconds>])',
111
111
  ' setup install + add-agent in one flow',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claw-link/gateway-host",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "ClawLink Host Gateway — a secure, outbound-only worker that bridges a local agent CLI (OpenClaw, Hermes, Claude, Codex, Cursor) to your ClawLink agents. No inbound ports; authenticated per-agent by a Host Token.",
5
5
  "homepage": "https://claw-link.co",
6
6
  "bin": {
@@ -139,9 +139,9 @@ async function run() {
139
139
  onWait: (s) => process.stdout.write(`\r … still working: ${s.activeJobs} job(s) in flight `),
140
140
  });
141
141
  if (live && live.busy) process.stdout.write('\n');
142
- console.log(r.restarted
143
- ? (r.hadWorker && !r.timedOut ? 'Finished in-flight work, then refreshed to the latest version.' : ' ✓ Service refreshed to the latest version.')
144
- : 'Could not refresh the service automaticallyrun: clhost restart');
142
+ if (!r.restarted) console.log(' ⚠ Could not refresh the service automatically — run: clhost restart');
143
+ else if (r.confirmed) console.log(`Refreshed now running v${r.version} (pid ${r.pid}).`);
144
+ else console.log(`Restart triggered but the new worker hasn't come up on v${VERSION} yet check \`clhost status\` in a few seconds.`);
145
145
  reportCli();
146
146
  console.log('\n Commands: clhost status · clhost agents · clhost add-agent · clhost transport-test\n');
147
147
  return;
@@ -190,9 +190,10 @@ async function addAgentCmd() {
190
190
  rl.close();
191
191
  if (agent) {
192
192
  saveAgent(cfg, agent);
193
- const restarted = serviceControl('restart');
194
- console.log(`\n ✓ Added agent ${agent.agent_id} (${agent.runtime}).` +
195
- (restarted ? ' Service restarted.' : ' Run `clhost restart` (or start) to apply.') + '\n');
193
+ const r = await gracefulServiceRestartCLI();
194
+ const note = !r.restarted ? ' Run `clhost restart` (or start) to apply.'
195
+ : r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
196
+ console.log(`\n ✓ Added agent ${agent.agent_id} (${agent.runtime}).` + note + '\n');
196
197
  }
197
198
  }
198
199
 
@@ -212,8 +213,10 @@ async function removeAgentCmd() {
212
213
  cfg.agents = cfg.agents.filter((a) => !(a.agent_id && (a.agent_id === target || a.agent_id.startsWith(target))));
213
214
  if (cfg.agents.length === before) { console.log(` No agent matched '${target}'.`); return; }
214
215
  config.save(cfg);
215
- const restarted = serviceControl('restart');
216
- console.log(` ✓ Removed agent '${target}'.` + (restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.'));
216
+ const r = await gracefulServiceRestartCLI();
217
+ const note = !r.restarted ? ' Run `clhost restart` to apply.'
218
+ : r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
219
+ console.log(` ✓ Removed agent '${target}'.` + note);
217
220
  }
218
221
 
219
222
  function printAgents(cfg) {
@@ -269,9 +272,11 @@ async function retokenCmd() {
269
272
  agent.runtime = resp.runtime;
270
273
  }
271
274
  config.save(cfg);
272
- const restarted = serviceControl('restart');
273
- console.log(`\n ✓ Updated token for ${agent.agent_id} (…${token.slice(-4)}).` +
274
- (restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.') + '\n');
275
+ rl.close(); // release stdin before the (possibly long) graceful drain so Ctrl-C is handled by our cleanup
276
+ const r = await gracefulServiceRestartCLI();
277
+ const note = !r.restarted ? ' Run `clhost restart` to apply.'
278
+ : r.confirmed ? ' Service restarted.' : ' Restarting — check `clhost status`.';
279
+ console.log(`\n ✓ Updated token for ${agent.agent_id} (…${token.slice(-4)}).` + note + '\n');
275
280
  } finally { rl.close(); }
276
281
  }
277
282
 
@@ -296,13 +301,33 @@ function serviceControl(action) {
296
301
  if (platform === 'win32') {
297
302
  if (action === 'stop') return execSafe('schtasks /End /TN ClawLinkHost') !== null;
298
303
  if (action === 'start') return execSafe('schtasks /Run /TN ClawLinkHost') !== null;
299
- if (action === 'restart') { execSafe('schtasks /End /TN ClawLinkHost'); return execSafe('schtasks /Run /TN ClawLinkHost') !== null; }
304
+ if (action === 'restart') {
305
+ execSafe('schtasks /End /TN ClawLinkHost');
306
+ // /End is async, and the task is MultipleInstancesPolicy=IgnoreNew — so an immediate /Run can be
307
+ // SILENTLY DROPPED while the old instance is still stopping (the Windows analogue of the macOS
308
+ // bootout→bootstrap race). Give it a brief, LANGUAGE-NEUTRAL pause to stop before /Run: `ping -n
309
+ // 4 127.0.0.1` ≈ 3s (parsing schtasks' status would depend on the localized word "Running"; and
310
+ // `timeout` needs an interactive console). If /Run still doesn't take, restartAndConfirm's
311
+ // confirm-then-reinstall fallback recovers.
312
+ execSafe('ping -n 4 127.0.0.1');
313
+ return execSafe('schtasks /Run /TN ClawLinkHost') !== null;
314
+ }
300
315
  }
301
316
  } catch { /* fall through */ }
302
317
  return false;
303
318
  }
304
319
 
305
- function serviceControlCmd(action) {
320
+ async function serviceControlCmd(action) {
321
+ // `restart` is graceful — drain in-flight jobs first (override with --force / --timeout <sec>).
322
+ // `stop` stays prompt: its SIGTERM already lets the worker finish in-flight work (2nd signal forces).
323
+ if (action === 'restart') {
324
+ const { force, timeoutMs } = parseDrainArgs();
325
+ const r = await gracefulServiceRestartCLI({ force, timeoutMs });
326
+ if (!r.restarted) console.log(' ⚠ Could not restart the service (is it installed? run `clhost install`).');
327
+ else if (r.confirmed) console.log(` ✓ Service restarted — running v${r.version} (pid ${r.pid}).`);
328
+ else console.log(' ⚠ Restart triggered but the worker hasn’t reported yet — check `clhost status`.');
329
+ return;
330
+ }
306
331
  const ok = serviceControl(action);
307
332
  console.log(ok ? ` ✓ Service ${action}ed.` : ` ⚠ Could not ${action} the service (is it installed? run \`clhost install\`).`);
308
333
  }
@@ -538,6 +563,9 @@ async function drainToIdle({ timeoutMs = 15 * 60 * 1000, force = false, onWait }
538
563
  if (force) return { hadWorker: true, drained: false, forced: true };
539
564
 
540
565
  control.requestDrain();
566
+ // NOTE: the Ctrl-C cleanup that clears the drain flag on abort lives in withDrainGuard(), which
567
+ // wraps this call AND the restart — so there's no window (between drain finishing and the restart)
568
+ // where an interrupt could leave the worker drained. Don't install a handler here.
541
569
  const startedAt = Date.now();
542
570
  let waited = false;
543
571
  while (Date.now() - startedAt < timeoutMs) {
@@ -551,20 +579,92 @@ async function drainToIdle({ timeoutMs = 15 * 60 * 1000, force = false, onWait }
551
579
  return { hadWorker: true, drained: false, timedOut: true };
552
580
  }
553
581
 
554
- // Drain the running worker to idle (unless forced), then stage the new code + restart the service.
555
- // The fresh worker clears the drain flag on boot; if the restart fails we clear it here so the old
556
- // worker resumes claiming instead of sitting permanently drained.
582
+ // Run an async drain+restart sequence under a Ctrl-C guard: if the operator interrupts at ANY point
583
+ // (during the drain wait OR the restart), clear the drain flag FIRST so the still-running worker
584
+ // resumes claiming never left wedged — then exit. Spanning the whole sequence closes the gap that
585
+ // a per-drainToIdle handler would leave open between draining and restarting.
586
+ async function withDrainGuard(fn) {
587
+ const onInt = () => { try { control.clearDrain(); } catch { /* ignore */ } process.exit(130); };
588
+ process.on('SIGINT', onInt);
589
+ process.on('SIGTERM', onInt);
590
+ try { return await fn(); }
591
+ finally { process.off('SIGINT', onInt); process.off('SIGTERM', onInt); }
592
+ }
593
+
594
+ // After triggering a restart, wait until the NEW worker actually publishes status (a different pid
595
+ // than before and — if given — the expected version), so a caller never reports success while the old
596
+ // process is still winding down or a launchd unload/reload is mid-flight. Best-effort: confirmed=false
597
+ // on timeout. This is what makes `clhost update`'s "runs vX" honest instead of premature.
598
+ async function waitForWorkerUp(prevPid, wantVersion, timeoutMs = 25000) {
599
+ const startedAt = Date.now();
600
+ while (Date.now() - startedAt < timeoutMs) {
601
+ await sleep(1000);
602
+ const s = control.liveStatus();
603
+ if (s && s.pid !== prevPid && (!wantVersion || String(s.version) === String(wantVersion))) {
604
+ return { confirmed: true, version: s.version, pid: s.pid };
605
+ }
606
+ }
607
+ const s = control.liveStatus();
608
+ return { confirmed: false, version: s && s.version, pid: s && s.pid };
609
+ }
610
+
611
+ // Restart the service and CONFIRM the new worker came up. Prefer an IN-PLACE restart
612
+ // (kickstart -k / systemctl restart / schtasks) — it never unloads the job (no "Service: stopped"
613
+ // window like bootout→bootstrap) and relaunches from the stable app dir. Only if that fails to bring
614
+ // a worker up (service not loaded yet, or a stale Node path in the plist after an nvm/Node upgrade)
615
+ // fall back to a full (re)install, which rewrites the plist. Clears the drain flag unless a fresh
616
+ // worker is confirmed (which clears it on boot) so nothing is ever left drained.
617
+ async function restartAndConfirm({ stageCode = false, wantVersion, onRestart } = {}) {
618
+ if (stageCode) syncAppDir(); // copy the new code into the stable app dir the service runs from
619
+ const prevPid = (control.liveStatus() || {}).pid;
620
+ let notified = false;
621
+ const notify = () => { if (!notified && typeof onRestart === 'function') { notified = true; onRestart(); } };
622
+ let restarted = serviceControl('restart');
623
+ if (restarted) notify();
624
+ let up = restarted ? await waitForWorkerUp(prevPid, wantVersion) : { confirmed: false };
625
+ if (!up.confirmed) {
626
+ const reinstalled = installService(); // rewrites plist/unit (handles a moved Node) + reload
627
+ if (reinstalled) { restarted = true; notify(); up = await waitForWorkerUp(prevPid, wantVersion); }
628
+ }
629
+ if (!up.confirmed) control.clearDrain();
630
+ return { restarted, ...up };
631
+ }
632
+
633
+ // `update` path — drain, then stage new code + restart, confirming the new version is actually live.
557
634
  async function gracefulRestart(opts = {}) {
558
- const drain = await drainToIdle(opts);
559
- const restarted = installService(); // syncAppDir (stage new code) + bootstrap/restart the service
560
- if (!restarted) control.clearDrain();
561
- return { ...drain, restarted };
635
+ return withDrainGuard(async () => {
636
+ const drain = await drainToIdle(opts);
637
+ const r = await restartAndConfirm({ stageCode: true, wantVersion: VERSION, onRestart: opts.onRestart });
638
+ return { ...drain, ...r };
639
+ });
562
640
  }
563
641
 
564
- // `update` refresh the background service to the version of THIS CLI. GRACEFUL: it waits for any
565
- // in-flight job to finish before restarting (override with `--force`, cap the wait with
566
- // `--timeout <seconds>`). To update the CLI itself: npm i -g @claw-link/gateway-host@latest.
567
- async function update() {
642
+ // Config-change restart (add/remove/retoken agent, `clhost restart`): drain first, then restart in
643
+ // place (no code re-stage same version), confirming the worker actually bounced (new pid).
644
+ async function gracefulServiceRestart(opts = {}) {
645
+ return withDrainGuard(async () => {
646
+ const drain = await drainToIdle(opts);
647
+ const r = await restartAndConfirm({ stageCode: false, onRestart: opts.onRestart });
648
+ return { ...drain, ...r };
649
+ });
650
+ }
651
+
652
+ // Console wrapper around gracefulServiceRestart: prints a one-time "waiting" notice + a live progress
653
+ // line while a job finishes, so config commands never silently hang. Returns the restart result.
654
+ async function gracefulServiceRestartCLI(opts = {}) {
655
+ const live = control.liveStatus();
656
+ const willWait = live && live.busy && !opts.force;
657
+ if (willWait) console.log(' ⏳ An agent is mid-job — restarting once it finishes (Ctrl-C to cancel; it stays on the current version)…');
658
+ const r = await gracefulServiceRestart({
659
+ ...opts,
660
+ onWait: (s) => process.stdout.write(`\r … still working: ${s.activeJobs} job(s) in flight `),
661
+ });
662
+ if (willWait) process.stdout.write('\n');
663
+ return r;
664
+ }
665
+
666
+ // Shared `--force` / `--timeout <seconds>` parser for update() and the restart command.
667
+ function parseDrainArgs() {
568
668
  const args = process.argv.slice(3);
569
669
  const force = args.includes('--force') || args.includes('-f');
570
670
  const ti = args.findIndex((a) => a === '--timeout' || a === '-t');
@@ -574,6 +674,14 @@ async function update() {
574
674
  if (Number.isFinite(secs) && secs > 0) timeoutMs = secs * 1000;
575
675
  else console.log(` ⚠ Ignoring invalid --timeout '${args[ti + 1]}' (expected seconds) — using the default.`);
576
676
  }
677
+ return { force, timeoutMs };
678
+ }
679
+
680
+ // `update` — refresh the background service to the version of THIS CLI. GRACEFUL: it waits for any
681
+ // in-flight job to finish before restarting (override with `--force`, cap the wait with
682
+ // `--timeout <seconds>`). To update the CLI itself: npm i -g @claw-link/gateway-host@latest.
683
+ async function update() {
684
+ const { force, timeoutMs } = parseDrainArgs();
577
685
 
578
686
  console.log(` Refreshing the background service to v${VERSION}…`);
579
687
  const live = control.liveStatus();
@@ -588,10 +696,15 @@ async function update() {
588
696
  if (live && live.busy && !force) process.stdout.write('\n');
589
697
 
590
698
  if (!r.restarted) { console.log(' ⚠ Could not refresh automatically — run: clhost restart'); return; }
591
- if (r.forced) console.log(` ✓ Restarted now (--force). Service runs v${VERSION} from ${appDir()}.`);
592
- else if (r.timedOut) console.log(` ⚠ A job was still running after the wait restarted anyway (it may have been interrupted). Service runs v${VERSION}.`);
593
- else if (r.hadWorker) console.log(` ✓ Finished in-flight work, then refreshed. Service runs v${VERSION} from ${appDir()}.`);
594
- else console.log(` ✓ Service now runs v${VERSION} from ${appDir()}.`);
699
+ if (r.confirmed) {
700
+ // The success line now reflects what the new worker ACTUALLY reports (pid + version), not a guess.
701
+ const drained = r.hadWorker && !r.forced && !r.timedOut;
702
+ console.log(` ✓ ${drained ? 'Finished in-flight work, then restarted' : 'Restarted'} — now running v${r.version} (pid ${r.pid}).`);
703
+ if (r.timedOut) console.log(' ⚠ (A job was still running after the wait — it may have been interrupted.)');
704
+ } else {
705
+ console.log(` ⚠ Restart triggered but the new worker hasn't come up on v${VERSION} yet` +
706
+ (r.version ? ` (still seeing v${r.version})` : '') + ` — check \`clhost status\` in a few seconds (\`clhost logs\` if it doesn't appear).`);
707
+ }
595
708
  console.log(' (To upgrade the `clhost` CLI you type: npm i -g @claw-link/gateway-host@latest)\n');
596
709
  }
597
710
 
package/src/worker.js CHANGED
@@ -27,6 +27,8 @@ const TRANSPORT_MIN_BYTES = Number(process.env.CLAWHOST_TRANSPORT_MIN) || 1024;
27
27
  // forces immediate exit). The graceful-update path pre-drains to idle, so a restart's signal
28
28
  // finds the worker idle and exits at once; this only bounds a direct stop/restart/Ctrl-C.
29
29
  const SHUTDOWN_DRAIN_MS = Number(process.env.CLAWHOST_SHUTDOWN_DRAIN_MS) || JOB_MAX_MS;
30
+ // Ceiling for the exponential backoff after consecutive claim() errors (transient Supabase 5xx).
31
+ const CLAIM_BACKOFF_CAP_MS = Number(process.env.CLAWHOST_CLAIM_BACKOFF_CAP_MS) || 30 * 1000;
30
32
 
31
33
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
32
34
  let stopping = false;
@@ -156,6 +158,7 @@ async function runAgentLoop(agentCfg, cfg, p2p) {
156
158
 
157
159
  let inFlight = 0;
158
160
  const starts = []; // job start timestamps for the rolling rate-limit window
161
+ let claimFails = 0; // consecutive claim() errors → exponential backoff w/ jitter (transient 5xx)
159
162
 
160
163
  while (!stopping) {
161
164
  const now = Date.now();
@@ -164,12 +167,13 @@ async function runAgentLoop(agentCfg, cfg, p2p) {
164
167
  // Draining (graceful update/shutdown): keep finishing in-flight jobs, but don't claim new ones.
165
168
  const draining = stopping || drainFlag;
166
169
  const canRun = !draining && inFlight < agentCfg.concurrency && starts.length < agentCfg.rate_limit_per_min;
170
+ let claimErrored = false;
167
171
  if (canRun) {
168
172
  let job = null;
169
173
  claimsInFlight++; // "busy" for the whole claim round-trip — closes the mid-claim drain race
170
174
  try {
171
- try { const r = await bridge.claim(); job = r && r.job; }
172
- catch (e) { logger.warn(`claim failed (${agentCfg.agent_id}): ${e.message}`); }
175
+ try { const r = await bridge.claim(); job = r && r.job; claimFails = 0; } // success (job or empty) resets backoff
176
+ catch (e) { claimErrored = true; claimFails++; logger.warn(`claim failed (${agentCfg.agent_id}) [x${claimFails}]: ${e.message}`); }
173
177
  if (job) {
174
178
  inFlight++; starts.push(Date.now()); activeJobIds.add(job.id);
175
179
  // Watchdog: guarantees the slot is reclaimed and the job is failed back even
@@ -191,7 +195,15 @@ async function runAgentLoop(agentCfg, cfg, p2p) {
191
195
  }
192
196
  if (job) continue; // filled a slot — try to fill remaining concurrency immediately
193
197
  }
194
- await sleep(cfg.poll_interval_ms || 1500);
198
+ // Poll interval — but after repeated claim errors (a Supabase blip), back off exponentially with
199
+ // full jitter (capped) so N agents don't hammer the bridge in lockstep. Resets on the next success.
200
+ let waitMs = cfg.poll_interval_ms || 1500;
201
+ if (claimErrored && claimFails > 1) {
202
+ const base = cfg.poll_interval_ms || 1500;
203
+ const ceil = Math.min(base * 2 ** (claimFails - 1), CLAIM_BACKOFF_CAP_MS);
204
+ waitMs = base + Math.floor(Math.random() * Math.max(0, ceil - base));
205
+ }
206
+ await sleep(waitMs);
195
207
  }
196
208
 
197
209
  clearInterval(hb);
@@ -209,18 +221,14 @@ async function startWorker() {
209
221
 
210
222
  logger.info(`ClawLink Host Gateway v${VERSION} — ${agents.length} agent(s): ${agents.map((a) => `${(a.agent_id || 'pending').slice(0, 8)}…(${a.runtime || 'resolving'})`).join(', ')}`);
211
223
 
212
- // H2H data plane: bring up the native QUIC transport once per host (shared by all agents). If no
213
- // prebuilt native core exists for this platform, every transfer routes through the central relay.
214
224
  const p2p = new P2P(logger);
215
- const p2pAddr = await p2p.start();
216
- logger.info(p2pAddr
217
- ? `H2H transport: native P2P active (${p2pAddr})`
218
- : 'H2H transport: central relay (no native core for this platform — install acceleration later)');
219
225
 
220
226
  // Advertise liveness + in-flight count so `clhost update`/`restart` can wait for this host to go
221
227
  // idle before restarting, and pick up the CLI's drain flag (stop claiming, finish current work).
222
- // Clear any stale flag first; and only ever honor a flag written AFTER this boot, so a leftover
223
- // flag from a crashed update (or a clearDrain that failed) can never wedge a fresh worker.
228
+ // Do this BEFORE the (possibly slow) P2P init, so a restart is CONFIRMED fast waitForWorkerUp sees
229
+ // the new pid/version within ~1s even while iroh is still binding. Clear any stale flag first; and
230
+ // only ever honor a flag written AFTER this boot, so a leftover flag from a crashed update (or a
231
+ // clearDrain that failed) can never wedge a fresh worker. (pushStatus never touches p2p.)
224
232
  control.clearDrain(); // drop any stale flag left by a previous life FIRST…
225
233
  const bootAt = Date.now(); // …then anchor "now": only a drain requested after this point is honored.
226
234
  const pushStatus = () => {
@@ -236,6 +244,13 @@ async function startWorker() {
236
244
  const statusTimer = setInterval(pushStatus, 2000);
237
245
  statusTimer.unref();
238
246
 
247
+ // H2H data plane: bring up the native QUIC transport once per host (shared by all agents). If no
248
+ // prebuilt native core exists for this platform, every transfer routes through the central relay.
249
+ const p2pAddr = await p2p.start();
250
+ logger.info(p2pAddr
251
+ ? `H2H transport: native P2P active (${p2pAddr})`
252
+ : 'H2H transport: central relay (no native core for this platform — install acceleration later)');
253
+
239
254
  // Graceful shutdown: stop claiming, let in-flight jobs finish (keeping P2P alive for them), then
240
255
  // exit. A second signal forces an immediate exit; SHUTDOWN_DRAIN_MS bounds the wait.
241
256
  let stopSignals = 0;