@claw-link/gateway-host 0.3.6 → 0.3.7

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.7",
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": {
@@ -190,7 +190,7 @@ async function addAgentCmd() {
190
190
  rl.close();
191
191
  if (agent) {
192
192
  saveAgent(cfg, agent);
193
- const restarted = serviceControl('restart');
193
+ const { restarted } = await gracefulServiceRestartCLI();
194
194
  console.log(`\n ✓ Added agent ${agent.agent_id} (${agent.runtime}).` +
195
195
  (restarted ? ' Service restarted.' : ' Run `clhost restart` (or start) to apply.') + '\n');
196
196
  }
@@ -212,7 +212,7 @@ async function removeAgentCmd() {
212
212
  cfg.agents = cfg.agents.filter((a) => !(a.agent_id && (a.agent_id === target || a.agent_id.startsWith(target))));
213
213
  if (cfg.agents.length === before) { console.log(` No agent matched '${target}'.`); return; }
214
214
  config.save(cfg);
215
- const restarted = serviceControl('restart');
215
+ const { restarted } = await gracefulServiceRestartCLI();
216
216
  console.log(` ✓ Removed agent '${target}'.` + (restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.'));
217
217
  }
218
218
 
@@ -269,7 +269,8 @@ async function retokenCmd() {
269
269
  agent.runtime = resp.runtime;
270
270
  }
271
271
  config.save(cfg);
272
- const restarted = serviceControl('restart');
272
+ rl.close(); // release stdin before the (possibly long) graceful drain so Ctrl-C is handled by our cleanup
273
+ const { restarted } = await gracefulServiceRestartCLI();
273
274
  console.log(`\n ✓ Updated token for ${agent.agent_id} (…${token.slice(-4)}).` +
274
275
  (restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.') + '\n');
275
276
  } finally { rl.close(); }
@@ -302,7 +303,15 @@ function serviceControl(action) {
302
303
  return false;
303
304
  }
304
305
 
305
- function serviceControlCmd(action) {
306
+ async function serviceControlCmd(action) {
307
+ // `restart` is graceful — drain in-flight jobs first (override with --force / --timeout <sec>).
308
+ // `stop` stays prompt: its SIGTERM already lets the worker finish in-flight work (2nd signal forces).
309
+ if (action === 'restart') {
310
+ const { force, timeoutMs } = parseDrainArgs();
311
+ const { restarted } = await gracefulServiceRestartCLI({ force, timeoutMs });
312
+ console.log(restarted ? ' ✓ Service restarted.' : ' ⚠ Could not restart the service (is it installed? run `clhost install`).');
313
+ return;
314
+ }
306
315
  const ok = serviceControl(action);
307
316
  console.log(ok ? ` ✓ Service ${action}ed.` : ` ⚠ Could not ${action} the service (is it installed? run \`clhost install\`).`);
308
317
  }
@@ -538,17 +547,28 @@ async function drainToIdle({ timeoutMs = 15 * 60 * 1000, force = false, onWait }
538
547
  if (force) return { hadWorker: true, drained: false, forced: true };
539
548
 
540
549
  control.requestDrain();
541
- const startedAt = Date.now();
542
- let waited = false;
543
- while (Date.now() - startedAt < timeoutMs) {
544
- const s = control.liveStatus();
545
- if (!s) return { hadWorker: true, drained: true, workerGone: true }; // worker exited → safe
546
- // Safe to restart only once the worker has ACKED the drain (won't claim more) AND is idle.
547
- if (s.drain && !s.busy) return { hadWorker: true, drained: true, waited };
548
- if (s.busy && typeof onWait === 'function') { onWait(s); waited = true; }
549
- await sleep(1500);
550
+ // If the operator Ctrl-C's (or the CLI is killed) mid-wait, clear the flag so the still-running
551
+ // worker resumes claiming instead of sitting drained forever. Without this, an aborted update/
552
+ // restart would silently wedge the worker.
553
+ const onInt = () => { try { control.clearDrain(); } catch { /* ignore */ } process.exit(130); };
554
+ process.on('SIGINT', onInt);
555
+ process.on('SIGTERM', onInt);
556
+ try {
557
+ const startedAt = Date.now();
558
+ let waited = false;
559
+ while (Date.now() - startedAt < timeoutMs) {
560
+ const s = control.liveStatus();
561
+ if (!s) return { hadWorker: true, drained: true, workerGone: true }; // worker exited → safe
562
+ // Safe to restart only once the worker has ACKED the drain (won't claim more) AND is idle.
563
+ if (s.drain && !s.busy) return { hadWorker: true, drained: true, waited };
564
+ if (s.busy && typeof onWait === 'function') { onWait(s); waited = true; }
565
+ await sleep(1500);
566
+ }
567
+ return { hadWorker: true, drained: false, timedOut: true };
568
+ } finally {
569
+ process.off('SIGINT', onInt);
570
+ process.off('SIGTERM', onInt);
550
571
  }
551
- return { hadWorker: true, drained: false, timedOut: true };
552
572
  }
553
573
 
554
574
  // Drain the running worker to idle (unless forced), then stage the new code + restart the service.
@@ -561,10 +581,31 @@ async function gracefulRestart(opts = {}) {
561
581
  return { ...drain, restarted };
562
582
  }
563
583
 
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() {
584
+ // Config-change restart (add/remove/retoken agent, `clhost restart`): drain in-flight jobs first,
585
+ // then a plain restart. Unlike gracefulRestart it does NOT re-stage code — the version is unchanged.
586
+ async function gracefulServiceRestart(opts = {}) {
587
+ const drain = await drainToIdle(opts);
588
+ const restarted = serviceControl('restart');
589
+ if (!restarted) control.clearDrain();
590
+ return { ...drain, restarted };
591
+ }
592
+
593
+ // Console wrapper around gracefulServiceRestart: prints a one-time "waiting" notice + a live progress
594
+ // line while a job finishes, so config commands never silently hang. Returns the restart result.
595
+ async function gracefulServiceRestartCLI(opts = {}) {
596
+ const live = control.liveStatus();
597
+ const willWait = live && live.busy && !opts.force;
598
+ if (willWait) console.log(' ⏳ An agent is mid-job — restarting once it finishes (Ctrl-C to cancel; it stays on the current version)…');
599
+ const r = await gracefulServiceRestart({
600
+ ...opts,
601
+ onWait: (s) => process.stdout.write(`\r … still working: ${s.activeJobs} job(s) in flight `),
602
+ });
603
+ if (willWait) process.stdout.write('\n');
604
+ return r;
605
+ }
606
+
607
+ // Shared `--force` / `--timeout <seconds>` parser for update() and the restart command.
608
+ function parseDrainArgs() {
568
609
  const args = process.argv.slice(3);
569
610
  const force = args.includes('--force') || args.includes('-f');
570
611
  const ti = args.findIndex((a) => a === '--timeout' || a === '-t');
@@ -574,6 +615,14 @@ async function update() {
574
615
  if (Number.isFinite(secs) && secs > 0) timeoutMs = secs * 1000;
575
616
  else console.log(` ⚠ Ignoring invalid --timeout '${args[ti + 1]}' (expected seconds) — using the default.`);
576
617
  }
618
+ return { force, timeoutMs };
619
+ }
620
+
621
+ // `update` — refresh the background service to the version of THIS CLI. GRACEFUL: it waits for any
622
+ // in-flight job to finish before restarting (override with `--force`, cap the wait with
623
+ // `--timeout <seconds>`). To update the CLI itself: npm i -g @claw-link/gateway-host@latest.
624
+ async function update() {
625
+ const { force, timeoutMs } = parseDrainArgs();
577
626
 
578
627
  console.log(` Refreshing the background service to v${VERSION}…`);
579
628
  const live = control.liveStatus();
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);