@reefclaw/connect 0.1.26 → 0.1.27
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.
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { type ReconnectConfig as BaseReconnectConfig } from '../utils/reconnect.js';
|
|
2
2
|
import type { GatewayConfig } from './gateway-config.js';
|
|
3
3
|
interface ReconnectConfig extends BaseReconnectConfig {
|
|
4
|
-
|
|
4
|
+
/** Fast-backoff attempts before falling back to the slow retry loop */
|
|
5
|
+
maxFastAttempts: number;
|
|
6
|
+
/** Slow retry interval once the fast attempts are exhausted (ms) */
|
|
7
|
+
slowRetryMs: number;
|
|
5
8
|
}
|
|
6
9
|
/** hello-ok response payload from gateway */
|
|
7
10
|
export interface HelloOkPayload {
|
|
@@ -56,6 +59,9 @@ export declare class GatewayWsClient {
|
|
|
56
59
|
private ws;
|
|
57
60
|
private state;
|
|
58
61
|
private attempt;
|
|
62
|
+
/** True once the fast burst is spent and we are in the slow retry loop
|
|
63
|
+
* (kept so the "switching to slow retry" warning logs once, not per attempt). */
|
|
64
|
+
private slowRetryActive;
|
|
59
65
|
private reconnectTimer;
|
|
60
66
|
private staleWarnTimer;
|
|
61
67
|
private staleReconnectTimer;
|
|
@@ -30,11 +30,23 @@ const MIN_PROTOCOL_VERSION = 3;
|
|
|
30
30
|
const MAX_PROTOCOL_VERSION = 4;
|
|
31
31
|
const CLIENT_ID = 'openclaw-tui';
|
|
32
32
|
const CLIENT_VERSION = '0.1.0';
|
|
33
|
+
// Mirrors the relay connector's proven shape (see connector.ts DEFAULT_RECONNECT):
|
|
34
|
+
// a bounded fast-backoff burst, then a slow retry that NEVER gives up.
|
|
35
|
+
//
|
|
36
|
+
// This client used to have `maxAttempts: 20` and go terminal ('failed') when it
|
|
37
|
+
// ran out — roughly 9 minutes of budget (2+4+8+16s, then 16 x 30s). On
|
|
38
|
+
// 2026-07-31 an OpenClaw upgrade left the gateway crash-looping for 56 minutes;
|
|
39
|
+
// the bridge burned its 20 attempts in the first 9, gave up permanently, and
|
|
40
|
+
// stayed dark even AFTER the gateway recovered. The dashboard — the operator's
|
|
41
|
+
// window and one of the emergency-control paths — required a manual bridge
|
|
42
|
+
// restart to come back. Any gateway downtime over ~9 min (upgrade, reboot,
|
|
43
|
+
// crash-loop) reproduced it, on every deployment.
|
|
33
44
|
const DEFAULT_RECONNECT = {
|
|
34
45
|
baseDelayMs: 2_000,
|
|
35
46
|
maxDelayMs: 30_000,
|
|
36
|
-
|
|
47
|
+
maxFastAttempts: 20,
|
|
37
48
|
jitterFactor: 0.3,
|
|
49
|
+
slowRetryMs: 60_000,
|
|
38
50
|
};
|
|
39
51
|
// ---- Fatal close codes ----
|
|
40
52
|
/** Auth errors — do not reconnect */
|
|
@@ -45,6 +57,9 @@ export class GatewayWsClient {
|
|
|
45
57
|
ws = null;
|
|
46
58
|
state = 'disconnected';
|
|
47
59
|
attempt = 0;
|
|
60
|
+
/** True once the fast burst is spent and we are in the slow retry loop
|
|
61
|
+
* (kept so the "switching to slow retry" warning logs once, not per attempt). */
|
|
62
|
+
slowRetryActive = false;
|
|
48
63
|
reconnectTimer = null;
|
|
49
64
|
staleWarnTimer = null;
|
|
50
65
|
staleReconnectTimer = null;
|
|
@@ -351,6 +366,7 @@ export class GatewayWsClient {
|
|
|
351
366
|
logger.info(TAG, `Connected to gateway (protocol=${payload.protocol})`);
|
|
352
367
|
this.lastHelloOk = payload;
|
|
353
368
|
this.attempt = 0; // Reset reconnect counter
|
|
369
|
+
this.slowRetryActive = false; // Back on the fast budget for the next drop
|
|
354
370
|
// Parse policy
|
|
355
371
|
if (payload.policy?.tickIntervalMs) {
|
|
356
372
|
this.tickIntervalMs = payload.policy.tickIntervalMs;
|
|
@@ -403,14 +419,21 @@ export class GatewayWsClient {
|
|
|
403
419
|
// ---- Reconnection ----
|
|
404
420
|
scheduleReconnect() {
|
|
405
421
|
this.attempt++;
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
422
|
+
const { maxFastAttempts, slowRetryMs } = this.reconnectConfig;
|
|
423
|
+
// Exhausting the fast burst is NOT terminal — a gateway that is down for an
|
|
424
|
+
// hour (upgrade, reboot, crash-loop) must still be picked up when it
|
|
425
|
+
// returns. Only a fatal close code (4001 auth) ends the client, because a
|
|
426
|
+
// rejected token will never start working and must not be retried forever.
|
|
427
|
+
const exhausted = this.attempt > maxFastAttempts;
|
|
428
|
+
if (exhausted && !this.slowRetryActive) {
|
|
429
|
+
this.slowRetryActive = true;
|
|
430
|
+
logger.warn(TAG, `Fast reconnect budget (${maxFastAttempts} attempts) exhausted — switching to slow retry every ` +
|
|
431
|
+
`${Math.round(slowRetryMs / 1000)}s. Still trying; will reconnect on its own when the gateway returns.`);
|
|
411
432
|
}
|
|
412
|
-
const delay = computeDelay(this.attempt - 1, this.reconnectConfig);
|
|
413
|
-
logger.info(TAG,
|
|
433
|
+
const delay = exhausted ? slowRetryMs : computeDelay(this.attempt - 1, this.reconnectConfig);
|
|
434
|
+
logger.info(TAG, exhausted
|
|
435
|
+
? `Reconnecting in ${Math.round(delay)}ms (slow retry, attempt ${this.attempt})`
|
|
436
|
+
: `Reconnecting in ${Math.round(delay)}ms (attempt ${this.attempt}/${maxFastAttempts})`);
|
|
414
437
|
this.setState('reconnecting');
|
|
415
438
|
this.emit('reconnecting', { attempt: this.attempt, delayMs: Math.round(delay) });
|
|
416
439
|
this.reconnectTimer = setTimeout(() => {
|
|
@@ -124,6 +124,25 @@ export async function onCreateOrderFilled(ctx, inputs, order) {
|
|
|
124
124
|
// scale-ins with WS-exact data; capturing here would double-post).
|
|
125
125
|
const existing = ctx.stateStore.get(inputs.symbol);
|
|
126
126
|
if (existing) {
|
|
127
|
+
// ★ SAME-ORDER GUARD — the twin of the dedup in onWsFillObserved (which
|
|
128
|
+
// returns when `stateEntry.openedFromExchangeTradeId === fill.exchangeOrderId`).
|
|
129
|
+
// That one covers create_order-then-WS; this covers WS-then-create_order,
|
|
130
|
+
// which is the ORDINARY ordering for a market order: the WS fill routinely
|
|
131
|
+
// beats the REST response (measured ~270-415ms on prod).
|
|
132
|
+
//
|
|
133
|
+
// Without it the stale probe below decides this case, and it CANNOT: its
|
|
134
|
+
// test is "does the exchange position equal this fill?", which is equally
|
|
135
|
+
// true of a genuinely stale mapping AND of the mapping the WS path just
|
|
136
|
+
// wrote for THIS VERY FILL. It chose 'stale', dropped the fresh mapping and
|
|
137
|
+
// journaled a second position — 38 duplicate pairs in 9 days (~11/day), each
|
|
138
|
+
// orphaning the WS row at 0 reviews while the exchange held ONE position.
|
|
139
|
+
// The twins were undedupable downstream because they key on different ids
|
|
140
|
+
// (WS = trade id, here = order id).
|
|
141
|
+
const orderId = typeof order.id === 'string' ? order.id : undefined;
|
|
142
|
+
if (orderId && existing.openedFromExchangeTradeId === orderId) {
|
|
143
|
+
logger.info(TAG, `onCreateOrderFilled ${inputs.symbol}: WS fill path already captured this order (${orderId}) — skipping duplicate capture`);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
127
146
|
const probe = await probeStaleStateEntry(ctx, inputs.symbol, filledQty);
|
|
128
147
|
if (probe === 'stale') {
|
|
129
148
|
logger.warn(TAG, `onCreateOrderFilled ${inputs.symbol}: state-store entry (openedAt=${existing.openedAt}) is STALE — ` +
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "reefclaw-paper-trading",
|
|
3
3
|
"name": "ReefClaw Trading",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.20",
|
|
5
5
|
"description": "Supervised trading plugin for the ReefClaw dashboard. It runs on YOUR machine and starts in PAPER mode with no API keys. It cannot trade real funds until you supply exchange credentials and step PAPER→MICRO_LIVE→LIVE yourself from the dashboard — the agent cannot make that change (the tool is refused without operator provenance). Exchange keys stay local, are used only to sign requests to the exchange, and are never transmitted to ReefClaw (asserted by a test in this package). Trading telemetry — positions, fills, decision journal — is sent to ReefClaw to render the dashboard. Every live position carries exchange-native protective stops. Remote updates to the agent's trading instructions are applied only after an Ed25519 signature is verified against a public key pinned in this build.",
|
|
6
6
|
"author": "ReefClaw",
|
|
7
7
|
"activation": {
|
package/package.json
CHANGED
|
@@ -1,32 +1,32 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@reefclaw/connect",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"bin": {
|
|
7
|
-
"reefclaw-connect": "dist/cli.js"
|
|
8
|
-
},
|
|
9
|
-
"files": [
|
|
10
|
-
"dist",
|
|
11
|
-
"assets"
|
|
12
|
-
],
|
|
13
|
-
"engines": {
|
|
14
|
-
"node": ">=20"
|
|
15
|
-
},
|
|
16
|
-
"scripts": {
|
|
17
|
-
"bundle-assets": "node scripts/bundle-assets.mjs",
|
|
18
|
-
"build": "tsc && node scripts/bundle-assets.mjs",
|
|
19
|
-
"test": "vitest",
|
|
20
|
-
"test:run": "vitest run"
|
|
21
|
-
},
|
|
22
|
-
"dependencies": {
|
|
23
|
-
"json5": "2.2.3"
|
|
24
|
-
},
|
|
25
|
-
"devDependencies": {
|
|
26
|
-
"@types/node": "^20",
|
|
27
|
-
"typescript": "^5",
|
|
28
|
-
"vitest": "^4.0.18"
|
|
29
|
-
},
|
|
30
|
-
"license": "MIT",
|
|
31
|
-
"homepage": "https://reefclaw.com"
|
|
32
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "@reefclaw/connect",
|
|
3
|
+
"version": "0.1.27",
|
|
4
|
+
"description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"reefclaw-connect": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"assets"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"bundle-assets": "node scripts/bundle-assets.mjs",
|
|
18
|
+
"build": "tsc && node scripts/bundle-assets.mjs",
|
|
19
|
+
"test": "vitest",
|
|
20
|
+
"test:run": "vitest run"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"json5": "2.2.3"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@types/node": "^20",
|
|
27
|
+
"typescript": "^5",
|
|
28
|
+
"vitest": "^4.0.18"
|
|
29
|
+
},
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"homepage": "https://reefclaw.com"
|
|
32
|
+
}
|
package/dist/daemon.js
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
1
|
-
// Keep the bridge running across reboots. Linux/systemd-user is implemented
|
|
2
|
-
// fully; macOS and Windows fall back to printing the manual run command (a
|
|
3
|
-
// launchd/Task-Scheduler unit is a follow-up). The bridge reads its config from
|
|
4
|
-
// ~/.openclaw/openclaw.json, so until the user pastes their connect message the
|
|
5
|
-
// service will start, find no token, and restart — harmless; it connects within
|
|
6
|
-
// seconds of the agent writing the config.
|
|
7
|
-
import { writeFileSync, mkdirSync } from 'node:fs';
|
|
8
|
-
import { join } from 'node:path';
|
|
9
|
-
import { homedir, userInfo } from 'node:os';
|
|
10
|
-
import { BRIDGE_DIR } from './paths.js';
|
|
11
|
-
import { run, which } from './exec.js';
|
|
12
|
-
import { step, ok, info, warn } from './ui.js';
|
|
13
|
-
const SERVICE_NAME = 'reefclaw-bridge';
|
|
14
|
-
const NODE = process.execPath; // absolute path to the node running the installer
|
|
15
|
-
/**
|
|
16
|
-
* Build the systemd user-unit text. Pure + exported so the path-quoting is
|
|
17
|
-
* unit-testable. Both `node` (process.execPath) and `bridgeDir` (under the
|
|
18
|
-
* user's home) can contain spaces. Quoting rules differ per directive:
|
|
19
|
-
* - ExecStart= is parsed with shell-like word splitting, so an unquoted
|
|
20
|
-
* `ExecStart=/home/a b/node …` reads the binary as `/home/a` — QUOTE both
|
|
21
|
-
* the binary and the script path.
|
|
22
|
-
* - WorkingDirectory= takes the raw value after `=` as a single path (no word
|
|
23
|
-
* splitting) — spaces are safe UNQUOTED, and quotes are treated as literal
|
|
24
|
-
* characters, failing the unit with "path is not absolute" (verified live
|
|
25
|
-
* on systemd 255 / Ubuntu 24.04). Do NOT quote it.
|
|
26
|
-
*/
|
|
27
|
-
export function buildSystemdUnit(node, bridgeDir) {
|
|
28
|
-
const indexJs = join(bridgeDir, 'index.js');
|
|
29
|
-
return `[Unit]
|
|
30
|
-
Description=ReefClaw connector - bridges OpenClaw to the ReefClaw dashboard
|
|
31
|
-
After=network-online.target
|
|
32
|
-
Wants=network-online.target
|
|
33
|
-
|
|
34
|
-
[Service]
|
|
35
|
-
Type=simple
|
|
36
|
-
WorkingDirectory=${bridgeDir}
|
|
37
|
-
ExecStart="${node}" "${indexJs}" --provider gateway --log-level info
|
|
38
|
-
Restart=always
|
|
39
|
-
RestartSec=5s
|
|
40
|
-
|
|
41
|
-
[Install]
|
|
42
|
-
WantedBy=default.target
|
|
43
|
-
`;
|
|
44
|
-
}
|
|
45
|
-
function manualHint() {
|
|
46
|
-
warn('Could not set up an auto-start service on this OS yet.');
|
|
47
|
-
info('Keep the connector running with this command (leave it open / use your own service manager):');
|
|
48
|
-
info(` "${NODE}" "${join(BRIDGE_DIR, 'index.js')}" --provider gateway`);
|
|
49
|
-
}
|
|
50
|
-
function installSystemd() {
|
|
51
|
-
if (!which('systemctl'))
|
|
52
|
-
return false;
|
|
53
|
-
const unitDir = join(homedir(), '.config', 'systemd', 'user');
|
|
54
|
-
mkdirSync(unitDir, { recursive: true });
|
|
55
|
-
const unit = buildSystemdUnit(NODE, BRIDGE_DIR);
|
|
56
|
-
writeFileSync(join(unitDir, `${SERVICE_NAME}.service`), unit, 'utf-8');
|
|
57
|
-
run('systemctl', ['--user', 'daemon-reload']);
|
|
58
|
-
const enabled = run('systemctl', ['--user', 'enable', '--now', `${SERVICE_NAME}.service`]);
|
|
59
|
-
if (!enabled.ok) {
|
|
60
|
-
warn('systemd --user enable/start did not succeed:');
|
|
61
|
-
if (enabled.stderr.trim())
|
|
62
|
-
info(enabled.stderr.trim().split('\n').slice(-2).join('\n'));
|
|
63
|
-
info(`Try: systemctl --user enable --now ${SERVICE_NAME}.service`);
|
|
64
|
-
return false;
|
|
65
|
-
}
|
|
66
|
-
// `enable --now` can exit 0 while the unit failed to load (e.g. a bad unit
|
|
67
|
-
// file setting) — verify the unit actually came up before claiming ✓.
|
|
68
|
-
// 'active' = running; 'activating' = the expected pre-token restart loop
|
|
69
|
-
// (the bridge exits until the user pastes their connect message, and
|
|
70
|
-
// Restart=always re-launches it). Anything else (inactive/failed) means the
|
|
71
|
-
// unit never loaded.
|
|
72
|
-
const active = run('systemctl', ['--user', 'is-active', `${SERVICE_NAME}.service`]);
|
|
73
|
-
const state = active.stdout.trim();
|
|
74
|
-
if (state !== 'active' && state !== 'activating') {
|
|
75
|
-
warn(`the service did not come up (state: ${state || 'unknown'}).`);
|
|
76
|
-
info(`Inspect: systemctl --user status ${SERVICE_NAME}.service`);
|
|
77
|
-
return false;
|
|
78
|
-
}
|
|
79
|
-
// Linger lets the user service run without an active login session (servers).
|
|
80
|
-
// Best-effort: needs privileges; non-fatal if it fails.
|
|
81
|
-
const linger = run('loginctl', ['enable-linger', userInfo().username]);
|
|
82
|
-
if (linger.ok) {
|
|
83
|
-
info('enabled linger (service survives logout / reboot)');
|
|
84
|
-
}
|
|
85
|
-
else {
|
|
86
|
-
info('note: run `sudo loginctl enable-linger $USER` so the connector survives logout.');
|
|
87
|
-
}
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
export function installDaemon() {
|
|
91
|
-
step('Starting the connector as a background service');
|
|
92
|
-
if (process.platform === 'linux') {
|
|
93
|
-
if (installSystemd()) {
|
|
94
|
-
ok(`connector running as a systemd user service (${SERVICE_NAME})`);
|
|
95
|
-
info(`logs: journalctl --user -u ${SERVICE_NAME} -f`);
|
|
96
|
-
return true;
|
|
97
|
-
}
|
|
98
|
-
manualHint();
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
101
|
-
// macOS / Windows: manual for now (launchd / Task Scheduler unit is a follow-up).
|
|
102
|
-
manualHint();
|
|
103
|
-
return false;
|
|
104
|
-
}
|