@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 +2 -2
- package/package.json +1 -1
- package/scripts/install.js +67 -18
- package/src/worker.js +15 -3
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
|
|
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.
|
|
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": {
|
package/scripts/install.js
CHANGED
|
@@ -190,7 +190,7 @@ async function addAgentCmd() {
|
|
|
190
190
|
rl.close();
|
|
191
191
|
if (agent) {
|
|
192
192
|
saveAgent(cfg, agent);
|
|
193
|
-
const restarted =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
//
|
|
565
|
-
//
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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);
|