@claw-link/gateway-host 0.3.15 → 0.3.18
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/package.json +1 -1
- package/scripts/install.js +14 -0
- package/src/bridge.js +26 -10
- package/src/worker.js +20 -3
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.18",
|
|
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
|
@@ -724,11 +724,25 @@ async function drainToIdle({ timeoutMs = 15 * 60 * 1000, force = false, onWait }
|
|
|
724
724
|
// where an interrupt could leave the worker drained. Don't install a handler here.
|
|
725
725
|
const startedAt = Date.now();
|
|
726
726
|
let waited = false;
|
|
727
|
+
let claimOnlySince = 0; // busy with ZERO active jobs = just a poll round-trip, not real work
|
|
727
728
|
while (Date.now() - startedAt < timeoutMs) {
|
|
728
729
|
const s = control.liveStatus();
|
|
729
730
|
if (!s) return { hadWorker: true, drained: true, workerGone: true }; // worker exited → safe
|
|
730
731
|
// Safe to restart only once the worker has ACKED the drain (won't claim more) AND is idle.
|
|
731
732
|
if (s.drain && !s.busy) return { hadWorker: true, drained: true, waited };
|
|
733
|
+
// Wedge escape: "busy" caused ONLY by an in-flight claim (no running job) that never clears —
|
|
734
|
+
// e.g. an older worker whose HTTP call to a dead backend hangs without a timeout. A claim
|
|
735
|
+
// round-trip is seconds; 90s of claim-only busy means it's stuck, and a restart can't lose
|
|
736
|
+
// work because no job is actually running.
|
|
737
|
+
if (s.drain && s.busy && Number(s.activeJobs) === 0) {
|
|
738
|
+
if (!claimOnlySince) claimOnlySince = Date.now();
|
|
739
|
+
if (Date.now() - claimOnlySince > 90_000) {
|
|
740
|
+
console.log('\n ⚠ Worker reports busy with no running job for 90s (stuck network call) — proceeding with the restart.');
|
|
741
|
+
return { hadWorker: true, drained: true, waited, wedged: true };
|
|
742
|
+
}
|
|
743
|
+
} else {
|
|
744
|
+
claimOnlySince = 0;
|
|
745
|
+
}
|
|
732
746
|
if (s.busy && typeof onWait === 'function') { onWait(s); waited = true; }
|
|
733
747
|
await sleep(1500);
|
|
734
748
|
}
|
package/src/bridge.js
CHANGED
|
@@ -15,15 +15,29 @@ class Bridge {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
async _post(action, extra) {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
18
|
+
// Hard request timeout: a dead/paused backend must FAIL a call, never hang it — a hung claim
|
|
19
|
+
// pins claimsInFlight, which reports the worker "busy" forever and wedges graceful update's
|
|
20
|
+
// drain-wait. 45s covers slow uplinks posting large complete() payloads.
|
|
21
|
+
const ac = new AbortController();
|
|
22
|
+
const kill = setTimeout(() => ac.abort(), Number(process.env.CLAWHOST_HTTP_TIMEOUT_MS) || 45_000);
|
|
23
|
+
let res;
|
|
24
|
+
try {
|
|
25
|
+
res = await fetch(this.url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
28
|
+
'Content-Type': 'application/json',
|
|
29
|
+
Authorization: `Bearer ${this.token}`,
|
|
30
|
+
},
|
|
31
|
+
// agent_id is only a hint (the token resolves the agent); machine_id binds the host.
|
|
32
|
+
body: JSON.stringify({ action, agent_id: this.agentId || undefined, machine_id: this.machineId, ...extra }),
|
|
33
|
+
signal: ac.signal,
|
|
34
|
+
});
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (ac.signal.aborted) { const err = new Error(`host-bridge ${action} timed out`); err.status = 0; throw err; }
|
|
37
|
+
throw e;
|
|
38
|
+
} finally {
|
|
39
|
+
clearTimeout(kill);
|
|
40
|
+
}
|
|
27
41
|
let data = null;
|
|
28
42
|
try { data = await res.json(); } catch { /* non-json */ }
|
|
29
43
|
if (!res.ok) {
|
|
@@ -40,7 +54,9 @@ class Bridge {
|
|
|
40
54
|
claim() { return this._post('claim', { instance_id: this.instanceId }); }
|
|
41
55
|
// Multiplexed poll: ONE invocation authenticates all the host's agent tokens, refreshes their
|
|
42
56
|
// liveness (replacing per-agent heartbeats), and claims up to capacities[i] jobs per agent.
|
|
43
|
-
|
|
57
|
+
// `active` = in-flight job ids; the server bumps their keepalive so the reaper knows the
|
|
58
|
+
// difference between a long-running job and a host that died mid-job.
|
|
59
|
+
claimAll(tokens, capacities, active) { return this._post('claim_all', { tokens, capacities, active, instance_id: this.instanceId }); }
|
|
44
60
|
stream(jobId, seq, type, content) { return this._post('stream', { job_id: jobId, seq, type, content }); }
|
|
45
61
|
complete(jobId, finalContent, metadata) { return this._post('complete', { job_id: jobId, final_content: finalContent, metadata: metadata || {} }); }
|
|
46
62
|
fail(jobId, error) { return this._post('fail', { job_id: jobId, error: String(error).slice(0, 1000) }); }
|
package/src/worker.js
CHANGED
|
@@ -199,7 +199,9 @@ async function multiplexLoop(slots, cfg, p2p) {
|
|
|
199
199
|
let errored = false;
|
|
200
200
|
claimsInFlight++; // busy for the whole round-trip — preserves the graceful-drain guarantee
|
|
201
201
|
try {
|
|
202
|
-
|
|
202
|
+
// `active` = in-flight job ids: the server bumps their keepalive (touched_at) so the
|
|
203
|
+
// reaper never expires a job this host is still running (long builds are legitimate).
|
|
204
|
+
const r = await primary.claimAll(tokens, capacities, [...activeJobIds]);
|
|
203
205
|
claimFails = 0;
|
|
204
206
|
for (const job of r.jobs || []) {
|
|
205
207
|
const slot = slots.find((s) => s.agent.agent_id === job.agent_id);
|
|
@@ -336,12 +338,27 @@ async function startWorker() {
|
|
|
336
338
|
|
|
337
339
|
// Register every agent up-front, then poll with ONE multiplexed claim_all per tick for the
|
|
338
340
|
// whole host (falls back to legacy per-agent loops against an older server).
|
|
339
|
-
|
|
341
|
+
let slots = [];
|
|
340
342
|
for (const a of agents) {
|
|
341
343
|
const s = await prepareAgent(a, raw);
|
|
342
344
|
if (s) slots.push(s);
|
|
343
345
|
}
|
|
344
|
-
|
|
346
|
+
// No agent registered (rotated/dead tokens, or the backend was migrated): DON'T exit — launchd
|
|
347
|
+
// would just crash-loop us and the status file would flap, confusing `clhost update`. Stay
|
|
348
|
+
// alive (status keeps publishing "live + idle") and retry registration every 60s; `clhost link`
|
|
349
|
+
// restarts the service anyway the moment new tokens arrive.
|
|
350
|
+
while (!slots.length && !stopping) {
|
|
351
|
+
logger.error('No agent could register — tokens rotated or invalid. Retrying in 60s (fix with: clhost link <code> · clhost retoken <id> · clhost doctor).');
|
|
352
|
+
const until = Date.now() + 60_000;
|
|
353
|
+
while (Date.now() < until && !stopping) await sleep(1000);
|
|
354
|
+
if (stopping) return;
|
|
355
|
+
slots = [];
|
|
356
|
+
for (const a of agents) {
|
|
357
|
+
const s = await prepareAgent(a, raw);
|
|
358
|
+
if (s) slots.push(s);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (stopping) return;
|
|
345
362
|
const mode = await multiplexLoop(slots, raw, p2p);
|
|
346
363
|
if (mode === 'legacy') {
|
|
347
364
|
logger.warn('server has no claim_all yet — using per-agent polling (update the ClawLink backend to cut invocations)');
|