@claw-link/gateway-host 0.3.14 → 0.3.16
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 -9
- package/src/worker.js +127 -42
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.16",
|
|
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) {
|
|
@@ -38,6 +52,9 @@ class Bridge {
|
|
|
38
52
|
register(runtime, version) { return this._post('register', { runtime, version }); }
|
|
39
53
|
heartbeat() { return this._post('heartbeat', {}); }
|
|
40
54
|
claim() { return this._post('claim', { instance_id: this.instanceId }); }
|
|
55
|
+
// Multiplexed poll: ONE invocation authenticates all the host's agent tokens, refreshes their
|
|
56
|
+
// liveness (replacing per-agent heartbeats), and claims up to capacities[i] jobs per agent.
|
|
57
|
+
claimAll(tokens, capacities) { return this._post('claim_all', { tokens, capacities, instance_id: this.instanceId }); }
|
|
41
58
|
stream(jobId, seq, type, content) { return this._post('stream', { job_id: jobId, seq, type, content }); }
|
|
42
59
|
complete(jobId, finalContent, metadata) { return this._post('complete', { job_id: jobId, final_content: finalContent, metadata: metadata || {} }); }
|
|
43
60
|
fail(jobId, error) { return this._post('fail', { job_id: jobId, error: String(error).slice(0, 1000) }); }
|
package/src/worker.js
CHANGED
|
@@ -127,11 +127,13 @@ async function processJob(bridge, agentCfg, job, p2p) {
|
|
|
127
127
|
}
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
|
|
130
|
+
// Register one agent and prepare its execution slot (its own Bridge for stream/complete/fail +
|
|
131
|
+
// concurrency/rate counters). Returns null on a bad token / machine mismatch so the rest of the
|
|
132
|
+
// host keeps working.
|
|
133
|
+
async function prepareAgent(agentCfg, cfg) {
|
|
131
134
|
const bridge = new Bridge(cfg.bridge_url, agentCfg, cfg.instance_id);
|
|
132
135
|
logger.addSecret(agentCfg.host_token);
|
|
133
136
|
if (agentCfg.local_gateway_token) logger.addSecret(agentCfg.local_gateway_token);
|
|
134
|
-
|
|
135
137
|
try {
|
|
136
138
|
// Token-only register: the server resolves agent_id + runtime (dashboard is
|
|
137
139
|
// the source of truth) and binds this machine on first connect.
|
|
@@ -139,76 +141,132 @@ async function runAgentLoop(agentCfg, cfg, p2p) {
|
|
|
139
141
|
agentCfg.agent_id = resp.agent_id || agentCfg.agent_id;
|
|
140
142
|
if (resp.runtime) agentCfg.runtime = resp.runtime;
|
|
141
143
|
bridge.agentId = agentCfg.agent_id;
|
|
142
|
-
|
|
143
|
-
// Resolve binary + work_dir now that the runtime is known.
|
|
144
144
|
if (!agentCfg.binary && agentCfg.runtime !== 'openclaw') {
|
|
145
145
|
const found = detectRuntimes()[agentCfg.runtime];
|
|
146
146
|
if (found) agentCfg.binary = found;
|
|
147
147
|
else logger.warn(`no '${agentCfg.runtime}' binary found on PATH — set "binary" in config for ${agentCfg.agent_id}`);
|
|
148
148
|
}
|
|
149
149
|
if (!agentCfg.work_dir) agentCfg.work_dir = config.defaultWorkDir(agentCfg.runtime, agentCfg.agent_id);
|
|
150
|
-
|
|
151
150
|
logger.info(`registered ${agentCfg.agent_id} as '${agentCfg.runtime}'`);
|
|
151
|
+
return { bridge, agent: agentCfg, inFlight: 0, starts: [] };
|
|
152
152
|
} catch (e) {
|
|
153
|
-
logger.error(`register failed — check the Host Token & machine binding (rotate
|
|
154
|
-
return;
|
|
153
|
+
logger.error(`register failed for ${String(agentCfg.agent_id || agentCfg.host_token || '?').slice(0, 8)}… — check the Host Token & machine binding (rotate in the dashboard to re-bind). ${e.message}`);
|
|
154
|
+
return null;
|
|
155
155
|
}
|
|
156
|
+
}
|
|
156
157
|
|
|
157
|
-
|
|
158
|
+
// Start a claimed job with the watchdog + slot/liveness bookkeeping (shared by both poll modes).
|
|
159
|
+
function dispatchJob(slot, job, p2p, jobMaxMs) {
|
|
160
|
+
slot.inFlight++; slot.starts.push(Date.now()); activeJobIds.add(job.id);
|
|
161
|
+
const watchdog = new Promise((resolve) => setTimeout(() => resolve('__watchdog__'), jobMaxMs).unref());
|
|
162
|
+
Promise.race([processJob(slot.bridge, slot.agent, job, p2p), watchdog])
|
|
163
|
+
.then(async (r) => {
|
|
164
|
+
if (r === '__watchdog__') {
|
|
165
|
+
logger.error(`job ${job.id} exceeded ${jobMaxMs}ms — reclaiming slot`);
|
|
166
|
+
try { await slot.bridge.fail(job.id, `host watchdog: job exceeded ${jobMaxMs}ms`); } catch { /* ignore */ }
|
|
167
|
+
}
|
|
168
|
+
})
|
|
169
|
+
.finally(() => { slot.inFlight--; activeJobIds.delete(job.id); });
|
|
170
|
+
}
|
|
158
171
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
172
|
+
// ── Multiplexed poll: ONE edge-function invocation per tick for the WHOLE host ─────────────────
|
|
173
|
+
// claim_all authenticates every agent token, refreshes every agent's liveness (no separate
|
|
174
|
+
// heartbeats), and returns jobs for all free slots. With adaptive idle backoff this cuts
|
|
175
|
+
// invocations by ~2 orders of magnitude vs the old per-agent claim(1.5s) + heartbeat(10s) loops
|
|
176
|
+
// (7 agents: ~460k/day → <10k/day idle) — the fix for the quota exhaustion that killed the
|
|
177
|
+
// original Supabase project. Returns 'legacy' if the server doesn't know claim_all yet.
|
|
178
|
+
async function multiplexLoop(slots, cfg, p2p) {
|
|
179
|
+
const base = cfg.poll_interval_ms || 1500;
|
|
180
|
+
// Idle ceiling: default 60s, configurable up to 5 min (`idle_poll_max_ms` in config). Higher =
|
|
181
|
+
// fewer invocations; the trade-off is worst-case pickup delay for the FIRST job after a long
|
|
182
|
+
// idle (cadence snaps back to `base` the moment any job arrives).
|
|
183
|
+
const idleMax = Math.min(Math.max(Number(cfg.idle_poll_max_ms) || 60_000, base), 300_000);
|
|
164
184
|
const jobMaxMs = Number(cfg.job_max_ms) > 0 ? Number(cfg.job_max_ms) : JOB_MAX_MS;
|
|
185
|
+
const primary = slots[0].bridge;
|
|
186
|
+
let interval = base;
|
|
187
|
+
let claimFails = 0;
|
|
165
188
|
|
|
166
189
|
while (!stopping) {
|
|
190
|
+
const draining = stopping || drainFlag;
|
|
167
191
|
const now = Date.now();
|
|
168
|
-
while (starts.length && now - starts[0] > 60000) starts.shift();
|
|
192
|
+
for (const s of slots) { while (s.starts.length && now - s.starts[0] > 60000) s.starts.shift(); }
|
|
193
|
+
const tokens = slots.map((s) => s.agent.host_token);
|
|
194
|
+
// Capacity 0 still refreshes that agent's liveness — draining/busy agents stay "live".
|
|
195
|
+
const capacities = slots.map((s) => draining ? 0
|
|
196
|
+
: Math.max(0, Math.min(s.agent.concurrency - s.inFlight, s.agent.rate_limit_per_min - s.starts.length)));
|
|
197
|
+
|
|
198
|
+
let got = 0;
|
|
199
|
+
let errored = false;
|
|
200
|
+
claimsInFlight++; // busy for the whole round-trip — preserves the graceful-drain guarantee
|
|
201
|
+
try {
|
|
202
|
+
const r = await primary.claimAll(tokens, capacities);
|
|
203
|
+
claimFails = 0;
|
|
204
|
+
for (const job of r.jobs || []) {
|
|
205
|
+
const slot = slots.find((s) => s.agent.agent_id === job.agent_id);
|
|
206
|
+
if (!slot) continue;
|
|
207
|
+
got++;
|
|
208
|
+
dispatchJob(slot, job, p2p, jobMaxMs);
|
|
209
|
+
}
|
|
210
|
+
if (Array.isArray(r.denied) && r.denied.length) {
|
|
211
|
+
logger.warn(`claim_all: ${r.denied.length} token(s) rejected (rotated or bound to another machine)`);
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {
|
|
214
|
+
errored = true;
|
|
215
|
+
claimFails++;
|
|
216
|
+
if (/unknown action/i.test(String(e.message))) return 'legacy'; // older server — fall back
|
|
217
|
+
logger.warn(`claim_all failed [x${claimFails}]: ${e.message}`);
|
|
218
|
+
} finally {
|
|
219
|
+
claimsInFlight--;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Adaptive cadence: fast while work flows (a job arrived or any is running), decaying to
|
|
223
|
+
// idleMax when quiet; exponential backoff with full jitter on repeated errors.
|
|
224
|
+
const active = got > 0 || activeJobIds.size > 0;
|
|
225
|
+
if (errored && claimFails > 1) {
|
|
226
|
+
const ceil = Math.min(base * 2 ** (claimFails - 1), CLAIM_BACKOFF_CAP_MS);
|
|
227
|
+
interval = base + Math.floor(Math.random() * Math.max(0, ceil - base));
|
|
228
|
+
} else {
|
|
229
|
+
interval = active ? base : Math.min(Math.max(interval, base) * 1.6, idleMax);
|
|
230
|
+
}
|
|
231
|
+
await sleep(interval);
|
|
232
|
+
}
|
|
233
|
+
return 'stopped';
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Legacy per-agent loop — only used against a server that predates claim_all. Costs one claim
|
|
237
|
+
// per agent per tick + a heartbeat timer, exactly like the original implementation.
|
|
238
|
+
async function runAgentLoopLegacy(slot, cfg, p2p) {
|
|
239
|
+
const { bridge, agent: agentCfg } = slot;
|
|
240
|
+
const hb = setInterval(() => { bridge.heartbeat().catch(() => {}); }, cfg.heartbeat_ms || 10000);
|
|
241
|
+
let claimFails = 0;
|
|
242
|
+
const jobMaxMs = Number(cfg.job_max_ms) > 0 ? Number(cfg.job_max_ms) : JOB_MAX_MS;
|
|
169
243
|
|
|
170
|
-
|
|
244
|
+
while (!stopping) {
|
|
245
|
+
const now = Date.now();
|
|
246
|
+
while (slot.starts.length && now - slot.starts[0] > 60000) slot.starts.shift();
|
|
171
247
|
const draining = stopping || drainFlag;
|
|
172
|
-
const canRun = !draining && inFlight < agentCfg.concurrency && starts.length < agentCfg.rate_limit_per_min;
|
|
248
|
+
const canRun = !draining && slot.inFlight < agentCfg.concurrency && slot.starts.length < agentCfg.rate_limit_per_min;
|
|
173
249
|
let claimErrored = false;
|
|
174
250
|
if (canRun) {
|
|
175
251
|
let job = null;
|
|
176
|
-
claimsInFlight++;
|
|
252
|
+
claimsInFlight++;
|
|
177
253
|
try {
|
|
178
|
-
try { const r = await bridge.claim(); job = r && r.job; claimFails = 0; }
|
|
254
|
+
try { const r = await bridge.claim(); job = r && r.job; claimFails = 0; }
|
|
179
255
|
catch (e) { claimErrored = true; claimFails++; logger.warn(`claim failed (${agentCfg.agent_id}) [x${claimFails}]: ${e.message}`); }
|
|
180
|
-
if (job)
|
|
181
|
-
inFlight++; starts.push(Date.now()); activeJobIds.add(job.id);
|
|
182
|
-
// Watchdog: guarantees the slot is reclaimed and the job is failed back even
|
|
183
|
-
// if the adapter never settles (no per-adapter timeout by default).
|
|
184
|
-
const watchdog = new Promise((resolve) => setTimeout(() => resolve('__watchdog__'), jobMaxMs).unref());
|
|
185
|
-
Promise.race([processJob(bridge, agentCfg, job, p2p), watchdog])
|
|
186
|
-
.then(async (r) => {
|
|
187
|
-
if (r === '__watchdog__') {
|
|
188
|
-
logger.error(`job ${job.id} exceeded ${jobMaxMs}ms — reclaiming slot`);
|
|
189
|
-
try { await bridge.fail(job.id, `host watchdog: job exceeded ${jobMaxMs}ms`); } catch { /* ignore */ }
|
|
190
|
-
}
|
|
191
|
-
})
|
|
192
|
-
.finally(() => { inFlight--; activeJobIds.delete(job.id); });
|
|
193
|
-
}
|
|
256
|
+
if (job) dispatchJob(slot, job, p2p, jobMaxMs);
|
|
194
257
|
} finally {
|
|
195
|
-
// Decrement AFTER activeJobIds.add above (no await between them), so a claimed job hands off
|
|
196
|
-
// from claimsInFlight to activeJobIds without the busy signal ever dipping to false.
|
|
197
258
|
claimsInFlight--;
|
|
198
259
|
}
|
|
199
|
-
if (job) continue;
|
|
260
|
+
if (job) continue;
|
|
200
261
|
}
|
|
201
|
-
// Poll interval — but after repeated claim errors (a Supabase blip), back off exponentially with
|
|
202
|
-
// full jitter (capped) so N agents don't hammer the bridge in lockstep. Resets on the next success.
|
|
203
262
|
let waitMs = cfg.poll_interval_ms || 1500;
|
|
204
263
|
if (claimErrored && claimFails > 1) {
|
|
205
|
-
const
|
|
206
|
-
const ceil = Math.min(
|
|
207
|
-
waitMs =
|
|
264
|
+
const b = cfg.poll_interval_ms || 1500;
|
|
265
|
+
const ceil = Math.min(b * 2 ** (claimFails - 1), CLAIM_BACKOFF_CAP_MS);
|
|
266
|
+
waitMs = b + Math.floor(Math.random() * Math.max(0, ceil - b));
|
|
208
267
|
}
|
|
209
268
|
await sleep(waitMs);
|
|
210
269
|
}
|
|
211
|
-
|
|
212
270
|
clearInterval(hb);
|
|
213
271
|
}
|
|
214
272
|
|
|
@@ -276,7 +334,34 @@ async function startWorker() {
|
|
|
276
334
|
process.on('SIGINT', () => onStop('SIGINT'));
|
|
277
335
|
process.on('SIGTERM', () => onStop('SIGTERM'));
|
|
278
336
|
|
|
279
|
-
|
|
337
|
+
// Register every agent up-front, then poll with ONE multiplexed claim_all per tick for the
|
|
338
|
+
// whole host (falls back to legacy per-agent loops against an older server).
|
|
339
|
+
let slots = [];
|
|
340
|
+
for (const a of agents) {
|
|
341
|
+
const s = await prepareAgent(a, raw);
|
|
342
|
+
if (s) slots.push(s);
|
|
343
|
+
}
|
|
344
|
+
// No agent registered (rotated/dead tokens, or the backend was migrated): DON'T exit — launchd
|
|
345
|
+
// would just crash-loop us and the status file would flap, confusing `clhost update`. Stay
|
|
346
|
+
// alive (status keeps publishing "live + idle") and retry registration every 60s; `clhost link`
|
|
347
|
+
// restarts the service anyway the moment new tokens arrive.
|
|
348
|
+
while (!slots.length && !stopping) {
|
|
349
|
+
logger.error('No agent could register — tokens rotated or invalid. Retrying in 60s (fix with: clhost link <code> · clhost retoken <id> · clhost doctor).');
|
|
350
|
+
const until = Date.now() + 60_000;
|
|
351
|
+
while (Date.now() < until && !stopping) await sleep(1000);
|
|
352
|
+
if (stopping) return;
|
|
353
|
+
slots = [];
|
|
354
|
+
for (const a of agents) {
|
|
355
|
+
const s = await prepareAgent(a, raw);
|
|
356
|
+
if (s) slots.push(s);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (stopping) return;
|
|
360
|
+
const mode = await multiplexLoop(slots, raw, p2p);
|
|
361
|
+
if (mode === 'legacy') {
|
|
362
|
+
logger.warn('server has no claim_all yet — using per-agent polling (update the ClawLink backend to cut invocations)');
|
|
363
|
+
await Promise.all(slots.map((s) => runAgentLoopLegacy(s, raw, p2p)));
|
|
364
|
+
}
|
|
280
365
|
}
|
|
281
366
|
|
|
282
367
|
module.exports = { startWorker };
|