@claw-link/gateway-host 0.3.5 → 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 +4 -3
- package/package.json +1 -1
- package/scripts/install.js +141 -15
- package/src/control.js +66 -0
- package/src/worker.js +89 -22
package/bin/cli.js
CHANGED
|
@@ -104,9 +104,10 @@ 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
|
|
109
|
-
' update Refresh 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
|
+
' update Refresh the service to this CLI’s version — waits for in-flight jobs to finish',
|
|
110
|
+
' (clhost update [--force] [--timeout <seconds>])',
|
|
110
111
|
' setup install + add-agent in one flow',
|
|
111
112
|
' rotate How to rotate a Host Token / move to a new machine',
|
|
112
113
|
' uninstall Stop + remove the background service',
|
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
|
@@ -12,6 +12,9 @@ const config = require('../src/config');
|
|
|
12
12
|
const { detectRuntimes } = require('../src/detect');
|
|
13
13
|
const { Bridge } = require('../src/bridge');
|
|
14
14
|
const scaffold = require('../src/scaffold');
|
|
15
|
+
const control = require('../src/control');
|
|
16
|
+
|
|
17
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
15
18
|
|
|
16
19
|
const VERSION = (() => { try { return require('../package.json').version; } catch { return '0.0.0'; } })();
|
|
17
20
|
const RUNTIMES = ['openclaw', 'hermes', 'claude', 'codex', 'cursor'];
|
|
@@ -129,9 +132,15 @@ async function run() {
|
|
|
129
132
|
const existing = (cfg.agents || []).length;
|
|
130
133
|
if (existing > 0) {
|
|
131
134
|
console.log(`\n ClawLink Host Gateway v${VERSION} — already set up (${existing} agent${existing > 1 ? 's' : ''}).`);
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
+
// Graceful: let any in-flight job finish before the service restarts (same as `clhost update`).
|
|
136
|
+
const live = control.liveStatus();
|
|
137
|
+
if (live && live.busy) console.log(' ⏳ An agent is mid-job — finishing it before refreshing the service…');
|
|
138
|
+
const r = await gracefulRestart({
|
|
139
|
+
onWait: (s) => process.stdout.write(`\r … still working: ${s.activeJobs} job(s) in flight `),
|
|
140
|
+
});
|
|
141
|
+
if (live && live.busy) process.stdout.write('\n');
|
|
142
|
+
console.log(r.restarted
|
|
143
|
+
? (r.hadWorker && !r.timedOut ? ' ✓ Finished in-flight work, then refreshed to the latest version.' : ' ✓ Service refreshed to the latest version.')
|
|
135
144
|
: ' ⚠ Could not refresh the service automatically — run: clhost restart');
|
|
136
145
|
reportCli();
|
|
137
146
|
console.log('\n Commands: clhost status · clhost agents · clhost add-agent · clhost transport-test\n');
|
|
@@ -181,7 +190,7 @@ async function addAgentCmd() {
|
|
|
181
190
|
rl.close();
|
|
182
191
|
if (agent) {
|
|
183
192
|
saveAgent(cfg, agent);
|
|
184
|
-
const restarted =
|
|
193
|
+
const { restarted } = await gracefulServiceRestartCLI();
|
|
185
194
|
console.log(`\n ✓ Added agent ${agent.agent_id} (${agent.runtime}).` +
|
|
186
195
|
(restarted ? ' Service restarted.' : ' Run `clhost restart` (or start) to apply.') + '\n');
|
|
187
196
|
}
|
|
@@ -203,7 +212,7 @@ async function removeAgentCmd() {
|
|
|
203
212
|
cfg.agents = cfg.agents.filter((a) => !(a.agent_id && (a.agent_id === target || a.agent_id.startsWith(target))));
|
|
204
213
|
if (cfg.agents.length === before) { console.log(` No agent matched '${target}'.`); return; }
|
|
205
214
|
config.save(cfg);
|
|
206
|
-
const restarted =
|
|
215
|
+
const { restarted } = await gracefulServiceRestartCLI();
|
|
207
216
|
console.log(` ✓ Removed agent '${target}'.` + (restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.'));
|
|
208
217
|
}
|
|
209
218
|
|
|
@@ -260,7 +269,8 @@ async function retokenCmd() {
|
|
|
260
269
|
agent.runtime = resp.runtime;
|
|
261
270
|
}
|
|
262
271
|
config.save(cfg);
|
|
263
|
-
|
|
272
|
+
rl.close(); // release stdin before the (possibly long) graceful drain so Ctrl-C is handled by our cleanup
|
|
273
|
+
const { restarted } = await gracefulServiceRestartCLI();
|
|
264
274
|
console.log(`\n ✓ Updated token for ${agent.agent_id} (…${token.slice(-4)}).` +
|
|
265
275
|
(restarted ? ' Service restarted.' : ' Run `clhost restart` to apply.') + '\n');
|
|
266
276
|
} finally { rl.close(); }
|
|
@@ -293,7 +303,15 @@ function serviceControl(action) {
|
|
|
293
303
|
return false;
|
|
294
304
|
}
|
|
295
305
|
|
|
296
|
-
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
|
+
}
|
|
297
315
|
const ok = serviceControl(action);
|
|
298
316
|
console.log(ok ? ` ✓ Service ${action}ed.` : ` ⚠ Could not ${action} the service (is it installed? run \`clhost install\`).`);
|
|
299
317
|
}
|
|
@@ -371,7 +389,11 @@ WantedBy=default.target
|
|
|
371
389
|
`;
|
|
372
390
|
fs.writeFileSync(path.join(unitDir, 'clawlink-host.service'), unit, { mode: 0o644 });
|
|
373
391
|
execSafe('systemctl --user daemon-reload');
|
|
374
|
-
|
|
392
|
+
execSafe('systemctl --user enable clawlink-host'); // start at login (idempotent)
|
|
393
|
+
// `restart` STARTS a stopped unit AND restarts a running one — so this both first-installs and
|
|
394
|
+
// UPDATES. (`enable --now` is a no-op on an already-running unit, which would leave the old
|
|
395
|
+
// code running and, mid-drain, wedge the worker with the drain flag never cleared.)
|
|
396
|
+
return execSafe('systemctl --user restart clawlink-host') !== null;
|
|
375
397
|
}
|
|
376
398
|
if (platform === 'win32') {
|
|
377
399
|
return installServiceWindows();
|
|
@@ -446,6 +468,12 @@ function status() {
|
|
|
446
468
|
if (!cfg) { console.log('Not configured. Run: clhost setup'); return; }
|
|
447
469
|
console.log(` Bridge: ${cfg.bridge_url}`);
|
|
448
470
|
console.log(` Service: ${serviceRunning() ? '● running' : '○ stopped'}`);
|
|
471
|
+
// Live worker heartbeat (from the worker's status file) — shows whether an update would wait.
|
|
472
|
+
const live = control.liveStatus();
|
|
473
|
+
if (live) {
|
|
474
|
+
console.log(` Worker: ● live (v${live.version || '?'})` +
|
|
475
|
+
(live.busy ? ` — busy: ${live.activeJobs} job(s) in flight (\`clhost update\` will wait)` : ' — idle'));
|
|
476
|
+
}
|
|
449
477
|
console.log('');
|
|
450
478
|
printAgents(cfg);
|
|
451
479
|
}
|
|
@@ -507,14 +535,112 @@ function logs() {
|
|
|
507
535
|
console.log(`\n ── last ${n} lines of ${logPath} ──`);
|
|
508
536
|
}
|
|
509
537
|
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
|
|
538
|
+
// Ask the running worker to finish its in-flight job(s) and stop claiming new ones, then wait until
|
|
539
|
+
// it reports idle — so the coming restart never kills work in progress. Returns immediately when
|
|
540
|
+
// there's no live worker to drain (first install, or a worker that predates drain support and thus
|
|
541
|
+
// never writes a status file — in that case the restart is the old, non-graceful hard swap).
|
|
542
|
+
async function drainToIdle({ timeoutMs = 15 * 60 * 1000, force = false, onWait } = {}) {
|
|
543
|
+
// Never let a bad/NaN timeout collapse the wait loop into an instant (silent) hard restart.
|
|
544
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) timeoutMs = 15 * 60 * 1000;
|
|
545
|
+
const live = control.liveStatus();
|
|
546
|
+
if (!live) return { hadWorker: false, drained: true }; // nothing running to drain
|
|
547
|
+
if (force) return { hadWorker: true, drained: false, forced: true };
|
|
548
|
+
|
|
549
|
+
control.requestDrain();
|
|
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);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// Drain the running worker to idle (unless forced), then stage the new code + restart the service.
|
|
575
|
+
// The fresh worker clears the drain flag on boot; if the restart fails we clear it here so the old
|
|
576
|
+
// worker resumes claiming instead of sitting permanently drained.
|
|
577
|
+
async function gracefulRestart(opts = {}) {
|
|
578
|
+
const drain = await drainToIdle(opts);
|
|
579
|
+
const restarted = installService(); // syncAppDir (stage new code) + bootstrap/restart the service
|
|
580
|
+
if (!restarted) control.clearDrain();
|
|
581
|
+
return { ...drain, restarted };
|
|
582
|
+
}
|
|
583
|
+
|
|
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() {
|
|
609
|
+
const args = process.argv.slice(3);
|
|
610
|
+
const force = args.includes('--force') || args.includes('-f');
|
|
611
|
+
const ti = args.findIndex((a) => a === '--timeout' || a === '-t');
|
|
612
|
+
let timeoutMs;
|
|
613
|
+
if (ti >= 0 && args[ti + 1]) {
|
|
614
|
+
const secs = Number(args[ti + 1]);
|
|
615
|
+
if (Number.isFinite(secs) && secs > 0) timeoutMs = secs * 1000;
|
|
616
|
+
else console.log(` ⚠ Ignoring invalid --timeout '${args[ti + 1]}' (expected seconds) — using the default.`);
|
|
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();
|
|
626
|
+
|
|
513
627
|
console.log(` Refreshing the background service to v${VERSION}…`);
|
|
514
|
-
const
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
628
|
+
const live = control.liveStatus();
|
|
629
|
+
if (live && live.busy && !force) {
|
|
630
|
+
console.log(' ⏳ An agent is mid-job — waiting for it to finish before restarting.');
|
|
631
|
+
console.log(' (Ctrl-C to keep the current version · `clhost update --force` to restart now.)');
|
|
632
|
+
}
|
|
633
|
+
const r = await gracefulRestart({
|
|
634
|
+
force, timeoutMs,
|
|
635
|
+
onWait: (s) => process.stdout.write(`\r … still working: ${s.activeJobs} job(s) in flight${s.currentJobId ? ` (${String(s.currentJobId).slice(0, 8)}…)` : ''} `),
|
|
636
|
+
});
|
|
637
|
+
if (live && live.busy && !force) process.stdout.write('\n');
|
|
638
|
+
|
|
639
|
+
if (!r.restarted) { console.log(' ⚠ Could not refresh automatically — run: clhost restart'); return; }
|
|
640
|
+
if (r.forced) console.log(` ✓ Restarted now (--force). Service runs v${VERSION} from ${appDir()}.`);
|
|
641
|
+
else if (r.timedOut) console.log(` ⚠ A job was still running after the wait — restarted anyway (it may have been interrupted). Service runs v${VERSION}.`);
|
|
642
|
+
else if (r.hadWorker) console.log(` ✓ Finished in-flight work, then refreshed. Service runs v${VERSION} from ${appDir()}.`);
|
|
643
|
+
else console.log(` ✓ Service now runs v${VERSION} from ${appDir()}.`);
|
|
518
644
|
console.log(' (To upgrade the `clhost` CLI you type: npm i -g @claw-link/gateway-host@latest)\n');
|
|
519
645
|
}
|
|
520
646
|
|
package/src/control.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Local, file-based control channel between a running worker and the CLI (`clhost update`/
|
|
3
|
+
// `restart`). Everything lives under ~/.clawlink-host so it works with zero network/DB access:
|
|
4
|
+
// • worker-status.json — the worker's heartbeat: pid, version, in-flight job count, drain ack.
|
|
5
|
+
// • drain — a flag file; while present the worker STOPS claiming new jobs but keeps
|
|
6
|
+
// finishing in-flight ones. The CLI uses it for graceful, no-job-killed
|
|
7
|
+
// restarts: request drain → wait for the worker to report idle → restart.
|
|
8
|
+
// The worker clears a stale `drain` flag on boot, so a crashed update can never wedge a fresh worker.
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const config = require('./config');
|
|
13
|
+
|
|
14
|
+
const STATUS_PATH = path.join(config.HOME_DIR, 'worker-status.json');
|
|
15
|
+
const DRAIN_PATH = path.join(config.HOME_DIR, 'drain');
|
|
16
|
+
|
|
17
|
+
// A worker is considered "live" only if its status was written within this window — so the CLI
|
|
18
|
+
// treats a crashed/stopped worker (stale file) as "nothing to drain" and proceeds immediately.
|
|
19
|
+
const STATUS_FRESH_MS = 30 * 1000;
|
|
20
|
+
|
|
21
|
+
function writeStatus(s) {
|
|
22
|
+
try {
|
|
23
|
+
config.ensureHomeDir();
|
|
24
|
+
fs.writeFileSync(STATUS_PATH, JSON.stringify({ ...s, ts: Date.now() }), { mode: 0o600 });
|
|
25
|
+
} catch { /* best effort — status is advisory */ }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readStatus() {
|
|
29
|
+
try { return JSON.parse(fs.readFileSync(STATUS_PATH, 'utf8')); } catch { return null; }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// The last status IF a worker is actively writing it (fresh), else null.
|
|
33
|
+
function liveStatus() {
|
|
34
|
+
const s = readStatus();
|
|
35
|
+
if (!s || typeof s.ts !== 'number') return null;
|
|
36
|
+
return Date.now() - s.ts < STATUS_FRESH_MS ? s : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requestDrain() {
|
|
40
|
+
try { config.ensureHomeDir(); fs.writeFileSync(DRAIN_PATH, String(Date.now()), { mode: 0o600 }); return true; }
|
|
41
|
+
catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function clearDrain() {
|
|
45
|
+
try { fs.rmSync(DRAIN_PATH, { force: true }); } catch { /* ignore */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function drainRequested() {
|
|
49
|
+
try { return fs.existsSync(DRAIN_PATH); } catch { return false; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// True only if a drain flag exists AND was written at/after `sinceMs`. The worker passes its boot
|
|
53
|
+
// time here so a STALE flag left over from before it booted (e.g. a crashed update, or a clearDrain()
|
|
54
|
+
// that failed) is IGNORED — the worker can never wedge itself refusing to claim. A present-but-
|
|
55
|
+
// unparseable flag is honored (fail toward draining) since that's the job-safe direction.
|
|
56
|
+
function drainRequestedSince(sinceMs) {
|
|
57
|
+
let raw;
|
|
58
|
+
try { raw = fs.readFileSync(DRAIN_PATH, 'utf8'); } catch { return false; } // no flag → not draining
|
|
59
|
+
const ts = Number(raw);
|
|
60
|
+
return Number.isFinite(ts) ? ts >= sinceMs : true;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = {
|
|
64
|
+
STATUS_PATH, DRAIN_PATH, STATUS_FRESH_MS,
|
|
65
|
+
writeStatus, readStatus, liveStatus, requestDrain, clearDrain, drainRequested, drainRequestedSince,
|
|
66
|
+
};
|
package/src/worker.js
CHANGED
|
@@ -12,6 +12,7 @@ const { Bridge, withRetry } = require('./bridge');
|
|
|
12
12
|
const { getAdapter } = require('./adapters');
|
|
13
13
|
const { detectRuntimes } = require('./detect');
|
|
14
14
|
const { P2P } = require('./transport/p2p');
|
|
15
|
+
const control = require('./control');
|
|
15
16
|
|
|
16
17
|
const VERSION = (() => { try { return require('../package.json').version; } catch { return '0.0.0'; } })();
|
|
17
18
|
|
|
@@ -22,9 +23,25 @@ const JOB_MAX_MS = Number(process.env.CLAWHOST_JOB_MAX_MS) || 15 * 60 * 1000;
|
|
|
22
23
|
// Offer outputs larger than this over P2P (must match the engine's TRANSPORT_MIN_BYTES).
|
|
23
24
|
// Lowered to 1 KiB for validation so a normal reply triggers P2P; raise for production.
|
|
24
25
|
const TRANSPORT_MIN_BYTES = Number(process.env.CLAWHOST_TRANSPORT_MIN) || 1024;
|
|
26
|
+
// On a stop signal, how long to let in-flight jobs finish before exiting anyway (a 2nd signal
|
|
27
|
+
// forces immediate exit). The graceful-update path pre-drains to idle, so a restart's signal
|
|
28
|
+
// finds the worker idle and exits at once; this only bounds a direct stop/restart/Ctrl-C.
|
|
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;
|
|
25
32
|
|
|
26
33
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
27
34
|
let stopping = false;
|
|
35
|
+
// In-flight job ids across ALL agent loops — the single source of truth for "is this host busy?".
|
|
36
|
+
// Drives the status file (so `clhost update` can wait for idle) and the graceful-shutdown drain.
|
|
37
|
+
const activeJobIds = new Set();
|
|
38
|
+
// Claim RPCs currently outstanding (across all loops). Counted as "busy" for their whole round-trip
|
|
39
|
+
// so a drain that lands MID-claim can't see a false-idle window and let the CLI restart on top of a
|
|
40
|
+
// job that's about to start running. Kept in lockstep with activeJobIds (no await between add+decr).
|
|
41
|
+
let claimsInFlight = 0;
|
|
42
|
+
// When a drain is in effect (SIGTERM, or the CLI's drain flag) the loops stop claiming NEW jobs
|
|
43
|
+
// but let in-flight ones finish. Cache the flag check so many agents don't stat the file every poll.
|
|
44
|
+
let drainFlag = false;
|
|
28
45
|
|
|
29
46
|
// Replace ⟦clawlink:transport:CID⟧ markers in an inbound job's content with the actual upstream
|
|
30
47
|
// bytes — fetched P2P from the producing host, falling back to the inline DB copy. Mutates job.content.
|
|
@@ -141,33 +158,52 @@ async function runAgentLoop(agentCfg, cfg, p2p) {
|
|
|
141
158
|
|
|
142
159
|
let inFlight = 0;
|
|
143
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)
|
|
144
162
|
|
|
145
163
|
while (!stopping) {
|
|
146
164
|
const now = Date.now();
|
|
147
165
|
while (starts.length && now - starts[0] > 60000) starts.shift();
|
|
148
166
|
|
|
149
|
-
|
|
167
|
+
// Draining (graceful update/shutdown): keep finishing in-flight jobs, but don't claim new ones.
|
|
168
|
+
const draining = stopping || drainFlag;
|
|
169
|
+
const canRun = !draining && inFlight < agentCfg.concurrency && starts.length < agentCfg.rate_limit_per_min;
|
|
170
|
+
let claimErrored = false;
|
|
150
171
|
if (canRun) {
|
|
151
172
|
let job = null;
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
173
|
+
claimsInFlight++; // "busy" for the whole claim round-trip — closes the mid-claim drain race
|
|
174
|
+
try {
|
|
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}`); }
|
|
177
|
+
if (job) {
|
|
178
|
+
inFlight++; starts.push(Date.now()); activeJobIds.add(job.id);
|
|
179
|
+
// Watchdog: guarantees the slot is reclaimed and the job is failed back even
|
|
180
|
+
// if the adapter never settles (no per-adapter timeout by default).
|
|
181
|
+
const watchdog = new Promise((resolve) => setTimeout(() => resolve('__watchdog__'), JOB_MAX_MS).unref());
|
|
182
|
+
Promise.race([processJob(bridge, agentCfg, job, p2p), watchdog])
|
|
183
|
+
.then(async (r) => {
|
|
184
|
+
if (r === '__watchdog__') {
|
|
185
|
+
logger.error(`job ${job.id} exceeded ${JOB_MAX_MS}ms — reclaiming slot`);
|
|
186
|
+
try { await bridge.fail(job.id, `host watchdog: job exceeded ${JOB_MAX_MS}ms`); } catch { /* ignore */ }
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
.finally(() => { inFlight--; activeJobIds.delete(job.id); });
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
// Decrement AFTER activeJobIds.add above (no await between them), so a claimed job hands off
|
|
193
|
+
// from claimsInFlight to activeJobIds without the busy signal ever dipping to false.
|
|
194
|
+
claimsInFlight--;
|
|
168
195
|
}
|
|
196
|
+
if (job) continue; // filled a slot — try to fill remaining concurrency immediately
|
|
197
|
+
}
|
|
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));
|
|
169
205
|
}
|
|
170
|
-
await sleep(
|
|
206
|
+
await sleep(waitMs);
|
|
171
207
|
}
|
|
172
208
|
|
|
173
209
|
clearInterval(hb);
|
|
@@ -193,12 +229,43 @@ async function startWorker() {
|
|
|
193
229
|
? `H2H transport: native P2P active (${p2pAddr})`
|
|
194
230
|
: 'H2H transport: central relay (no native core for this platform — install acceleration later)');
|
|
195
231
|
|
|
232
|
+
// Advertise liveness + in-flight count so `clhost update`/`restart` can wait for this host to go
|
|
233
|
+
// idle before restarting, and pick up the CLI's drain flag (stop claiming, finish current work).
|
|
234
|
+
// Clear any stale flag first; and only ever honor a flag written AFTER this boot, so a leftover
|
|
235
|
+
// flag from a crashed update (or a clearDrain that failed) can never wedge a fresh worker.
|
|
236
|
+
control.clearDrain(); // drop any stale flag left by a previous life FIRST…
|
|
237
|
+
const bootAt = Date.now(); // …then anchor "now": only a drain requested after this point is honored.
|
|
238
|
+
const pushStatus = () => {
|
|
239
|
+
drainFlag = control.drainRequestedSince(bootAt);
|
|
240
|
+
const busy = activeJobIds.size > 0 || claimsInFlight > 0;
|
|
241
|
+
control.writeStatus({
|
|
242
|
+
pid: process.pid, version: VERSION, agents: agents.length,
|
|
243
|
+
activeJobs: activeJobIds.size, busy, drain: drainFlag,
|
|
244
|
+
currentJobId: activeJobIds.size ? [...activeJobIds][0] : null,
|
|
245
|
+
});
|
|
246
|
+
};
|
|
247
|
+
pushStatus();
|
|
248
|
+
const statusTimer = setInterval(pushStatus, 2000);
|
|
249
|
+
statusTimer.unref();
|
|
250
|
+
|
|
251
|
+
// Graceful shutdown: stop claiming, let in-flight jobs finish (keeping P2P alive for them), then
|
|
252
|
+
// exit. A second signal forces an immediate exit; SHUTDOWN_DRAIN_MS bounds the wait.
|
|
253
|
+
let stopSignals = 0;
|
|
196
254
|
const onStop = (sig) => {
|
|
197
|
-
|
|
255
|
+
stopSignals += 1;
|
|
256
|
+
if (stopSignals >= 2) { logger.info(`${sig} again — exiting now (in-flight work abandoned).`); process.exit(0); }
|
|
198
257
|
stopping = true;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
258
|
+
const n = activeJobIds.size;
|
|
259
|
+
logger.info(n
|
|
260
|
+
? `${sig} received — finishing ${n} in-flight job(s) before exit (send again to force)…`
|
|
261
|
+
: `${sig} received — exiting.`);
|
|
262
|
+
const startedAt = Date.now();
|
|
263
|
+
const finish = () => { try { p2p.close(); } catch { /* ignore */ } process.exit(0); };
|
|
264
|
+
const tick = setInterval(() => {
|
|
265
|
+
if (activeJobIds.size === 0) { clearInterval(tick); logger.info('drained — exiting.'); finish(); }
|
|
266
|
+
else if (Date.now() - startedAt > SHUTDOWN_DRAIN_MS) { clearInterval(tick); logger.info('drain timed out — exiting.'); finish(); }
|
|
267
|
+
}, 500);
|
|
268
|
+
tick.unref();
|
|
202
269
|
};
|
|
203
270
|
process.on('SIGINT', () => onStop('SIGINT'));
|
|
204
271
|
process.on('SIGTERM', () => onStop('SIGTERM'));
|