@claw-link/gateway-host 0.3.14 → 0.3.15

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@claw-link/gateway-host",
3
- "version": "0.3.14",
3
+ "version": "0.3.15",
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/src/bridge.js CHANGED
@@ -38,6 +38,9 @@ class Bridge {
38
38
  register(runtime, version) { return this._post('register', { runtime, version }); }
39
39
  heartbeat() { return this._post('heartbeat', {}); }
40
40
  claim() { return this._post('claim', { instance_id: this.instanceId }); }
41
+ // Multiplexed poll: ONE invocation authenticates all the host's agent tokens, refreshes their
42
+ // liveness (replacing per-agent heartbeats), and claims up to capacities[i] jobs per agent.
43
+ claimAll(tokens, capacities) { return this._post('claim_all', { tokens, capacities, instance_id: this.instanceId }); }
41
44
  stream(jobId, seq, type, content) { return this._post('stream', { job_id: jobId, seq, type, content }); }
42
45
  complete(jobId, finalContent, metadata) { return this._post('complete', { job_id: jobId, final_content: finalContent, metadata: metadata || {} }); }
43
46
  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
- async function runAgentLoop(agentCfg, cfg, p2p) {
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 the token in the dashboard to re-bind a new machine). ${e.message}`);
154
- return; // bad token / machine mismatch: stop this agent's loop (don't spin)
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
- const hb = setInterval(() => { bridge.heartbeat().catch(() => {}); }, cfg.heartbeat_ms || 10000);
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
- let inFlight = 0;
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)
162
- // Hard job ceiling: config `job_max_ms` overrides the env/default long dev/build turns
163
- // (harness agents running mvn/npm/docker) can legitimately exceed the 15-min default.
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
- // Draining (graceful update/shutdown): keep finishing in-flight jobs, but don't claim new ones.
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++; // "busy" for the whole claim round-trip — closes the mid-claim drain race
252
+ claimsInFlight++;
177
253
  try {
178
- try { const r = await bridge.claim(); job = r && r.job; claimFails = 0; } // success (job or empty) resets backoff
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; // filled a slot — try to fill remaining concurrency immediately
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 base = cfg.poll_interval_ms || 1500;
206
- const ceil = Math.min(base * 2 ** (claimFails - 1), CLAIM_BACKOFF_CAP_MS);
207
- waitMs = base + Math.floor(Math.random() * Math.max(0, ceil - base));
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,19 @@ async function startWorker() {
276
334
  process.on('SIGINT', () => onStop('SIGINT'));
277
335
  process.on('SIGTERM', () => onStop('SIGTERM'));
278
336
 
279
- await Promise.all(agents.map((a) => runAgentLoop(a, raw, p2p)));
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
+ const slots = [];
340
+ for (const a of agents) {
341
+ const s = await prepareAgent(a, raw);
342
+ if (s) slots.push(s);
343
+ }
344
+ if (!slots.length) { logger.error('No agent could register — check Host Tokens (clhost doctor).'); return; }
345
+ const mode = await multiplexLoop(slots, raw, p2p);
346
+ if (mode === 'legacy') {
347
+ logger.warn('server has no claim_all yet — using per-agent polling (update the ClawLink backend to cut invocations)');
348
+ await Promise.all(slots.map((s) => runAgentLoopLegacy(s, raw, p2p)));
349
+ }
280
350
  }
281
351
 
282
352
  module.exports = { startWorker };