@reefclaw/openclaw-plugin 0.1.23 → 0.1.25
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/bridge/bridge.js +72 -5
- package/bridge/connector.d.ts +3 -1
- package/bridge/connector.js +51 -4
- package/bridge/gateway/heartbeat-cron.js +31 -7
- package/bridge/gateway/poller.d.ts +5 -0
- package/bridge/gateway/poller.js +9 -0
- package/bridge/index.js +21 -0
- package/bridge/provider.d.ts +15 -0
- package/bridge/providers/connector-update.d.ts +89 -0
- package/bridge/providers/connector-update.js +212 -0
- package/bridge/providers/emergency-commands.d.ts +36 -0
- package/bridge/providers/emergency-commands.js +91 -0
- package/bridge/providers/gateway.d.ts +26 -1
- package/bridge/providers/gateway.js +159 -8
- package/bridge/providers/mock.js +1 -0
- package/bridge/shock-wake.d.ts +80 -0
- package/bridge/shock-wake.js +291 -0
- package/bridge/types.d.ts +5 -1
- package/bridge/types.js +5 -0
- package/bridge/utils/instance-id.d.ts +3 -0
- package/bridge/utils/instance-id.js +48 -0
- package/ccxt/binance-private.js +2 -1
- package/ccxt/binance-public.js +6 -1
- package/config/agent-config-client.d.ts +7 -2
- package/config/agent-config-client.js +17 -0
- package/config/agent-config-poller.js +5 -1
- package/config/brackets-config.d.ts +2 -1
- package/config/brackets-config.js +25 -3
- package/config/gate-store.d.ts +12 -0
- package/config/gate-store.js +26 -2
- package/config/loss-streak-config.d.ts +2 -0
- package/config/loss-streak-config.js +33 -0
- package/config/plugin-config-io.d.ts +19 -0
- package/config/plugin-config-io.js +24 -2
- package/config/reentry-cooldown-config.d.ts +7 -0
- package/config/reentry-cooldown-config.js +59 -0
- package/http/keepalive-fetch.d.ts +5 -0
- package/http/keepalive-fetch.js +50 -0
- package/index.js +77 -8
- package/ingest/position-auto-capture.js +49 -4
- package/ingest/position-decisions-client.d.ts +6 -0
- package/ingest/position-decisions-client.js +27 -9
- package/ingest/readiness-reporter.d.ts +23 -2
- package/ingest/readiness-reporter.js +56 -1
- package/live/approval-lifecycle.d.ts +10 -0
- package/live/approval-lifecycle.js +16 -2
- package/live/microstructure-assembler.js +11 -2
- package/live/proposal-decision-listener.d.ts +21 -0
- package/live/proposal-decision-listener.js +39 -0
- package/live/proposal-manager.d.ts +12 -0
- package/live/proposal-manager.js +47 -0
- package/live/stop-watcher.d.ts +16 -1
- package/live/stop-watcher.js +48 -8
- package/onboarding/runtime.js +4 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +38 -38
- package/persistence/state-manager.d.ts +7 -0
- package/persistence/state-manager.js +28 -1
- package/portfolio/directional-scoreboard.d.ts +17 -0
- package/portfolio/directional-scoreboard.js +71 -0
- package/portfolio/reentry-tracker.d.ts +38 -1
- package/portfolio/reentry-tracker.js +49 -0
- package/signals/change-of-character.d.ts +38 -0
- package/signals/change-of-character.js +93 -0
- package/simulator/exchange-simulator.d.ts +27 -1
- package/simulator/exchange-simulator.js +98 -38
- package/simulator/types.d.ts +11 -0
- package/skills/reefclaw/SKILL.md +2 -2
- package/strategy/evaluator.d.ts +4 -0
- package/tools/audit-bracket-protection.js +11 -7
- package/tools/close-position.js +10 -1
- package/tools/create-order.js +121 -9
- package/tools/get-funding-context.js +6 -1
- package/tools/get-liquidation-levels.js +5 -1
- package/tools/get-liquidation-pulse.js +7 -1
- package/tools/get-market-intel.js +2 -1
- package/tools/get-relevant-learnings.js +20 -1
- package/tools/get-resting-liquidity.js +6 -1
- package/tools/get-wave9-status.js +17 -0
- package/tools/hl-provision-agent-wallet.js +29 -11
- package/tools/intel-api.d.ts +9 -0
- package/tools/intel-api.js +32 -1
- package/tools/record-position-reviews.js +2 -2
- package/tools/reentry-cooldown.d.ts +33 -0
- package/tools/reentry-cooldown.js +74 -0
- package/tools/scan-pairs.d.ts +7 -0
- package/tools/scan-pairs.js +67 -11
- package/tools/set-exchange-credentials.js +19 -0
- package/tools/set-trading-mode.d.ts +6 -0
- package/tools/set-trading-mode.js +48 -1
- package/types.d.ts +7 -0
- package/venues/hyperliquid/hl-agent-wallet.d.ts +26 -0
- package/venues/hyperliquid/hl-agent-wallet.js +32 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
- package/venues/hyperliquid/hl-live-adapter.js +101 -13
package/config/gate-store.js
CHANGED
|
@@ -30,11 +30,15 @@ class GateStore {
|
|
|
30
30
|
apply(gates) {
|
|
31
31
|
const next = gates ?? {};
|
|
32
32
|
const changed = next.exitGate !== this.gates.exitGate ||
|
|
33
|
-
next.positionReviewMode !== this.gates.positionReviewMode
|
|
33
|
+
next.positionReviewMode !== this.gates.positionReviewMode ||
|
|
34
|
+
next.approvalMode !== this.gates.approvalMode ||
|
|
35
|
+
next.reentryCooldown !== this.gates.reentryCooldown;
|
|
34
36
|
this.gates = { ...next };
|
|
35
37
|
if (changed) {
|
|
36
38
|
logger.info(TAG, `applied central gates: exitGate=${next.exitGate ?? UNSET} ` +
|
|
37
|
-
`positionReviewMode=${next.positionReviewMode ?? UNSET}`
|
|
39
|
+
`positionReviewMode=${next.positionReviewMode ?? UNSET} ` +
|
|
40
|
+
`approvalMode=${next.approvalMode ?? UNSET} ` +
|
|
41
|
+
`reentryCooldown=${next.reentryCooldown ?? UNSET}`);
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
/** The central exitGate mode, or null when central has no value (or the
|
|
@@ -51,6 +55,26 @@ class GateStore {
|
|
|
51
55
|
return null;
|
|
52
56
|
return this.gates.positionReviewMode ?? null;
|
|
53
57
|
}
|
|
58
|
+
/** The central approval.mode, or null when central has no value (or the
|
|
59
|
+
* kill-switch is on) — null tells the reader to fall back to the file.
|
|
60
|
+
*
|
|
61
|
+
* ★ Central can only ever say 'off' or 'per_trade'. It cannot enable shadow
|
|
62
|
+
* telemetry (that's the local APPROVAL_SHADOW_MODE env flag) and it cannot
|
|
63
|
+
* weaken the hardcoded safety floor — per_trade only ADDS a gate, and 'off'
|
|
64
|
+
* is the pre-existing autonomous behaviour, so neither value can leave a
|
|
65
|
+
* position unprotected. */
|
|
66
|
+
getApprovalMode() {
|
|
67
|
+
if (!centralGatesEnabled())
|
|
68
|
+
return null;
|
|
69
|
+
return this.gates.approvalMode ?? null;
|
|
70
|
+
}
|
|
71
|
+
/** The central reentryCooldown mode, or null when central has no value (or
|
|
72
|
+
* the kill-switch is on) — null tells the reader to fall back to the file. */
|
|
73
|
+
getReentryCooldown() {
|
|
74
|
+
if (!centralGatesEnabled())
|
|
75
|
+
return null;
|
|
76
|
+
return this.gates.reentryCooldown ?? null;
|
|
77
|
+
}
|
|
54
78
|
/** Test-only. */
|
|
55
79
|
__reset() {
|
|
56
80
|
this.gates = {};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// WS1 (docs/MARKET_ADAPTIVITY_PLAN.md §3) — mode resolver for the LIVE
|
|
2
|
+
// loss-streak sizing brake feed.
|
|
3
|
+
//
|
|
4
|
+
// preTradeRiskCheck has always had a graduated loss-streak brake (0.5× /
|
|
5
|
+
// 0.25× position size at the operator-tunable lossStreakHalfSize /
|
|
6
|
+
// lossStreakQuarterSize thresholds — never a hard block), but live fed it a
|
|
7
|
+
// hardcoded consecutiveLosses=0 ("would come from intelligence DB — use 0
|
|
8
|
+
// for now"). The real feed now comes from the ReentryTracker exit records
|
|
9
|
+
// (wasLoss + book tag, persisted).
|
|
10
|
+
//
|
|
11
|
+
// Env RC_LOSS_STREAK_SIZING:
|
|
12
|
+
// 'enforce' (DEFAULT since 2026-09-05) — feed the real streak to the risk
|
|
13
|
+
// check; the graduated size reduction applies on
|
|
14
|
+
// live exactly as it always has on paper. Promoted
|
|
15
|
+
// from 'log' after the pre-registered soak: 3 days
|
|
16
|
+
// of clean streak logs (1→3 tracked + reset
|
|
17
|
+
// correctly) and an 18/18 loss-sign agreement audit
|
|
18
|
+
// between the agent's r_multiple_at_close and DB
|
|
19
|
+
// realized_r.
|
|
20
|
+
// 'log' — compute + log on entries; sizing UNAFFECTED
|
|
21
|
+
// (riskCheck still sees 0). The rollout soak mode.
|
|
22
|
+
// 'off' — no compute, no log; byte-identical to the
|
|
23
|
+
// pre-WS1 path (kill-switch).
|
|
24
|
+
//
|
|
25
|
+
// Deliberately env-based (not the central gate channel): it is a
|
|
26
|
+
// live/paper-parity bug fix on a risk-reduction mechanism, not a new policy
|
|
27
|
+
// ladder — and its only enforce-direction effect is SMALLER size.
|
|
28
|
+
export function resolveLossStreakSizingMode() {
|
|
29
|
+
const raw = (process.env.RC_LOSS_STREAK_SIZING ?? '').toLowerCase();
|
|
30
|
+
if (raw === 'off' || raw === 'log')
|
|
31
|
+
return raw;
|
|
32
|
+
return 'enforce';
|
|
33
|
+
}
|
|
@@ -197,6 +197,25 @@ export interface PluginConfigFile {
|
|
|
197
197
|
stopWatcher?: {
|
|
198
198
|
intervalMs?: number;
|
|
199
199
|
};
|
|
200
|
+
/** Re-entry cooldown gate — blocks (mode-laddered) a NEW create_order entry
|
|
201
|
+
* on a symbol whose last close within `minutes` was a LOSS. See
|
|
202
|
+
* plugin/src/tools/reentry-cooldown.ts for the evidence + semantics.
|
|
203
|
+
*
|
|
204
|
+
* mode='off' (default) — gate never runs; behaviour identical to today.
|
|
205
|
+
* mode='shadow' — gate runs; verdict logged + tagged into
|
|
206
|
+
* position_entries.metadata.reentry_cooldown;
|
|
207
|
+
* the order always fires.
|
|
208
|
+
* mode='observe' — as shadow, but a triggered verdict logs at WARN.
|
|
209
|
+
* mode='enforce' — triggered verdict hard-rejects create_order.
|
|
210
|
+
*
|
|
211
|
+
* `mode` here is the LOCAL fallback — once the central gate
|
|
212
|
+
* (agent_config.gates.reentryCooldown) is set, central rules (kill-switch
|
|
213
|
+
* RC_CENTRAL_GATES=off). `minutes` is local-only (default 60, clamped
|
|
214
|
+
* 5–1440). */
|
|
215
|
+
reentryCooldown?: {
|
|
216
|
+
mode?: 'off' | 'shadow' | 'observe' | 'enforce';
|
|
217
|
+
minutes?: number;
|
|
218
|
+
};
|
|
200
219
|
[extra: string]: unknown;
|
|
201
220
|
}
|
|
202
221
|
export declare function defaultConfigPath(): string;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// operator-only tool gated on dashboard provenance (verifyOperatorProvenance,
|
|
11
11
|
// audit F12) — the agent cannot reach these writes conversationally. The file
|
|
12
12
|
// is the plugin's OWN config store; nothing here touches OpenClaw's config.
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from 'node:fs';
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { logger } from '../logger.js';
|
|
@@ -82,17 +82,39 @@ export function loadMicroLiveConfig(path) {
|
|
|
82
82
|
catch { /* best-effort — adapter default applies */ }
|
|
83
83
|
return undefined;
|
|
84
84
|
}
|
|
85
|
+
// mtime(ns)+size memo: several hot paths re-read this file per tool call
|
|
86
|
+
// (approval mode, bracket mode/requirements, exit gate, review mode, the
|
|
87
|
+
// microstructure flag × N symbols on the review path). A write always bumps
|
|
88
|
+
// mtime, so the documented per-call hot-reload semantics are preserved
|
|
89
|
+
// exactly — an unchanged file just costs one stat instead of read+parse.
|
|
90
|
+
// structuredClone on both sides keeps today's fresh-object-per-call contract.
|
|
91
|
+
const readMemo = new Map();
|
|
85
92
|
/** Read the config file. Returns `{}` if the file doesn't exist.
|
|
86
93
|
* Throws if the file exists but is unreadable or not valid JSON — callers
|
|
87
94
|
* should treat that as an abort signal, not silently overwrite. */
|
|
88
95
|
export function readPluginConfig(path = defaultConfigPath()) {
|
|
89
|
-
if (!existsSync(path))
|
|
96
|
+
if (!existsSync(path)) {
|
|
97
|
+
readMemo.delete(path);
|
|
90
98
|
return {};
|
|
99
|
+
}
|
|
100
|
+
let stat;
|
|
101
|
+
try {
|
|
102
|
+
const s = statSync(path, { bigint: true });
|
|
103
|
+
stat = { mtimeNs: s.mtimeNs, size: s.size };
|
|
104
|
+
const hit = readMemo.get(path);
|
|
105
|
+
if (hit && hit.mtimeNs === stat.mtimeNs && hit.size === stat.size) {
|
|
106
|
+
return structuredClone(hit.parsed);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
catch { /* stat raced a delete — fall through to the plain read */ }
|
|
91
110
|
const raw = readFileSync(path, 'utf-8');
|
|
92
111
|
const parsed = JSON.parse(raw);
|
|
93
112
|
if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
94
113
|
throw new Error(`plugin-config.json root is not an object`);
|
|
95
114
|
}
|
|
115
|
+
if (stat) {
|
|
116
|
+
readMemo.set(path, { ...stat, parsed: structuredClone(parsed) });
|
|
117
|
+
}
|
|
96
118
|
return parsed;
|
|
97
119
|
}
|
|
98
120
|
/** Apply a patch on top of the existing file and write atomically.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type PluginConfigFile } from './plugin-config-io.js';
|
|
2
|
+
import type { ReentryCooldownMode } from '../tools/reentry-cooldown.js';
|
|
3
|
+
export declare const DEFAULT_REENTRY_COOLDOWN_MINUTES = 60;
|
|
4
|
+
export declare function getReentryCooldownMode(config?: PluginConfigFile): ReentryCooldownMode;
|
|
5
|
+
export declare function loadReentryCooldownMode(): ReentryCooldownMode;
|
|
6
|
+
export declare function getReentryCooldownMinutes(config?: PluginConfigFile): number;
|
|
7
|
+
export declare function loadReentryCooldownMinutes(): number;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Feature-flag readers for the re-entry cooldown gate (create_order).
|
|
2
|
+
//
|
|
3
|
+
// Mode default 'off' so the gate ships dead-code; the cooldown window default
|
|
4
|
+
// (60 min) matches the 2026-09-02 measurement window that motivated the gate.
|
|
5
|
+
// Mode resolution follows the exitGate pattern (config-service slice 2):
|
|
6
|
+
//
|
|
7
|
+
// central (agent_config.gates.reentryCooldown via gate-store)
|
|
8
|
+
// → plugin-config.json reentryCooldown.mode
|
|
9
|
+
// → 'off'
|
|
10
|
+
//
|
|
11
|
+
// create_order reads the mode PER CALL, so a dashboard/API flip via
|
|
12
|
+
// scripts/enable-reentry-cooldown.py hot-applies within one config poll —
|
|
13
|
+
// no restart. Kill-switch RC_CENTRAL_GATES=off hands control back to the
|
|
14
|
+
// local file. The minutes knob is LOCAL-only (mechanism tunable, not a
|
|
15
|
+
// ladder) — central carries only the mode.
|
|
16
|
+
import { readPluginConfig } from './plugin-config-io.js';
|
|
17
|
+
import { gateStore } from './gate-store.js';
|
|
18
|
+
const VALID_MODES = new Set([
|
|
19
|
+
'off',
|
|
20
|
+
'shadow',
|
|
21
|
+
'observe',
|
|
22
|
+
'enforce',
|
|
23
|
+
]);
|
|
24
|
+
export const DEFAULT_REENTRY_COOLDOWN_MINUTES = 60;
|
|
25
|
+
const MIN_COOLDOWN_MINUTES = 5;
|
|
26
|
+
const MAX_COOLDOWN_MINUTES = 1440;
|
|
27
|
+
export function getReentryCooldownMode(config) {
|
|
28
|
+
const raw = config?.reentryCooldown?.mode;
|
|
29
|
+
if (typeof raw === 'string' && VALID_MODES.has(raw)) {
|
|
30
|
+
return raw;
|
|
31
|
+
}
|
|
32
|
+
return 'off';
|
|
33
|
+
}
|
|
34
|
+
export function loadReentryCooldownMode() {
|
|
35
|
+
const central = gateStore.getReentryCooldown();
|
|
36
|
+
if (central)
|
|
37
|
+
return central;
|
|
38
|
+
try {
|
|
39
|
+
return getReentryCooldownMode(readPluginConfig());
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return 'off';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function getReentryCooldownMinutes(config) {
|
|
46
|
+
const raw = config?.reentryCooldown?.minutes;
|
|
47
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) {
|
|
48
|
+
return Math.max(MIN_COOLDOWN_MINUTES, Math.min(MAX_COOLDOWN_MINUTES, raw));
|
|
49
|
+
}
|
|
50
|
+
return DEFAULT_REENTRY_COOLDOWN_MINUTES;
|
|
51
|
+
}
|
|
52
|
+
export function loadReentryCooldownMinutes() {
|
|
53
|
+
try {
|
|
54
|
+
return getReentryCooldownMinutes(readPluginConfig());
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return DEFAULT_REENTRY_COOLDOWN_MINUTES;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type FetchLike = (url: string, init?: RequestInit) => Promise<Response>;
|
|
2
|
+
/** Drop-in fetch with connection keep-alive; falls back to global fetch when
|
|
3
|
+
* undici is unavailable, and defers to globalThis.fetch whenever it has been
|
|
4
|
+
* replaced (mocks/instrumentation). */
|
|
5
|
+
export declare function keepAliveFetch(url: string, init?: RequestInit): Promise<Response>;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Keep-alive HTTP for the plugin's intel/webapp clients.
|
|
2
|
+
//
|
|
3
|
+
// Node's built-in fetch closes idle sockets after undici's 4s default, and the
|
|
4
|
+
// gap between two agent tool calls is LLM think-time (seconds to tens of
|
|
5
|
+
// seconds) — so every intel/webapp call was paying a fresh TCP+TLS handshake
|
|
6
|
+
// (~2 RTTs) before any server work started. A dedicated undici Agent with a
|
|
7
|
+
// 60s idle timeout holds the connection across those gaps.
|
|
8
|
+
//
|
|
9
|
+
// undici loads via createRequire (same rule as CCXT — see
|
|
10
|
+
// docs/CLAUDE/plugin-integration.md) and the whole module FAILS OPEN to the
|
|
11
|
+
// global fetch: dist-only overlay deploys land on boxes whose node_modules
|
|
12
|
+
// predate this dependency, and a missing package must degrade to today's
|
|
13
|
+
// behaviour, never crash the connector.
|
|
14
|
+
import { createRequire } from 'node:module';
|
|
15
|
+
// Captured at module load. When someone REPLACES globalThis.fetch later
|
|
16
|
+
// (vitest fetch mocks, tracing wrappers), keepAliveFetch honors the
|
|
17
|
+
// replacement instead of undici — otherwise every fetch-stubbing test (and
|
|
18
|
+
// any legitimate instrumentation) would be silently bypassed onto the real
|
|
19
|
+
// network.
|
|
20
|
+
const nativeFetch = globalThis.fetch;
|
|
21
|
+
let cached;
|
|
22
|
+
function build() {
|
|
23
|
+
try {
|
|
24
|
+
const req = createRequire(import.meta.url);
|
|
25
|
+
const undici = req('undici');
|
|
26
|
+
const dispatcher = new undici.Agent({
|
|
27
|
+
keepAliveTimeout: 60_000,
|
|
28
|
+
keepAliveMaxTimeout: 300_000,
|
|
29
|
+
connections: 16,
|
|
30
|
+
});
|
|
31
|
+
// undici's own fetch + Agent are used together: passing an npm-undici
|
|
32
|
+
// dispatcher to Node's built-in fetch can fail an instanceof check against
|
|
33
|
+
// the internal undici copy.
|
|
34
|
+
return (url, init) => undici.fetch(url, { ...init, dispatcher });
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return (url, init) => fetch(url, init);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Drop-in fetch with connection keep-alive; falls back to global fetch when
|
|
41
|
+
* undici is unavailable, and defers to globalThis.fetch whenever it has been
|
|
42
|
+
* replaced (mocks/instrumentation). */
|
|
43
|
+
export function keepAliveFetch(url, init) {
|
|
44
|
+
if (globalThis.fetch !== nativeFetch) {
|
|
45
|
+
return globalThis.fetch(url, init);
|
|
46
|
+
}
|
|
47
|
+
if (!cached)
|
|
48
|
+
cached = build();
|
|
49
|
+
return cached(url, init);
|
|
50
|
+
}
|
package/index.js
CHANGED
|
@@ -989,7 +989,10 @@ const paperTradingPlugin = {
|
|
|
989
989
|
// gateway process must reload it before serving HTTP API calls.
|
|
990
990
|
const reloadState = () => {
|
|
991
991
|
try {
|
|
992
|
-
|
|
992
|
+
// Skips the full read+parse (and replaceState) when the on-disk file
|
|
993
|
+
// hasn't changed since the last load — this runs on every paper-mode
|
|
994
|
+
// tool call and the file grows with trade history.
|
|
995
|
+
const fresh = stateManager.loadSyncIfChanged();
|
|
993
996
|
if (fresh) {
|
|
994
997
|
simulator.replaceState(fresh);
|
|
995
998
|
}
|
|
@@ -1293,11 +1296,32 @@ const paperTradingPlugin = {
|
|
|
1293
1296
|
// rows while collecting real ones). See docs/APPROVAL_MODE_DESIGN.md §12.
|
|
1294
1297
|
const approvalShadowEnabled = (process.env.APPROVAL_SHADOW_MODE ?? '').trim() === '1';
|
|
1295
1298
|
const resolveApprovalMode = () => {
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
+
// Precedence (config-service slice 4, mirrors loadExitGateMode /
|
|
1300
|
+
// loadPositionReviewMode):
|
|
1301
|
+
//
|
|
1302
|
+
// central (agent_config.gates.approvalMode) → plugin-config.json →
|
|
1303
|
+
// APPROVAL_SHADOW_MODE env → 'off'
|
|
1304
|
+
//
|
|
1305
|
+
// Central is enum-validated plugin-side (agent-config-client
|
|
1306
|
+
// validateGates) and can only ever say 'off' | 'per_trade' — it cannot
|
|
1307
|
+
// enable shadow telemetry and cannot weaken the safety floor. Kill-switch
|
|
1308
|
+
// RC_CENTRAL_GATES=off makes the store report null and the file rules
|
|
1309
|
+
// again. Read per call, so a dashboard flip applies within one poll
|
|
1310
|
+
// (~60 s) with no restart.
|
|
1311
|
+
const central = gateStore.getApprovalMode();
|
|
1312
|
+
if (central === 'per_trade')
|
|
1313
|
+
return 'per_trade';
|
|
1314
|
+
if (central !== 'off') {
|
|
1315
|
+
// No central value — fall back to the local file.
|
|
1316
|
+
try {
|
|
1317
|
+
if (readPluginConfig().approval?.mode === 'per_trade')
|
|
1318
|
+
return 'per_trade';
|
|
1319
|
+
}
|
|
1320
|
+
catch { /* config unreadable — env-only fallback below */ }
|
|
1299
1321
|
}
|
|
1300
|
-
|
|
1322
|
+
// Central 'off' still permits local shadow telemetry: shadow does not
|
|
1323
|
+
// change trading behaviour (the order fires either way), it only writes
|
|
1324
|
+
// an extra row, and the env flag is the operator's own local choice.
|
|
1301
1325
|
return approvalShadowEnabled ? 'shadow' : 'off';
|
|
1302
1326
|
};
|
|
1303
1327
|
// Proposal manager + listener credentials: built whenever ingest
|
|
@@ -1327,8 +1351,18 @@ const paperTradingPlugin = {
|
|
|
1327
1351
|
logger.info(TAG, `Approval wiring active (mode=${bootApprovalMode}) → ${ingestBaseUrl} (userId=${reefclawUserId.slice(0, 8)}…)`);
|
|
1328
1352
|
}
|
|
1329
1353
|
}
|
|
1354
|
+
else if (bootApprovalMode === 'per_trade') {
|
|
1355
|
+
// FAIL-CLOSED state: create_order will REFUSE every new entry until the
|
|
1356
|
+
// credentials are restored or approval.mode is set back to off. Loud at
|
|
1357
|
+
// boot AND at each refusal (create-order.ts) — a boot-only signal is how
|
|
1358
|
+
// this used to go unnoticed while orders fired without the gate.
|
|
1359
|
+
logger.error(TAG, `approval mode=per_trade but ingest token / REEFCLAW_USER_ID missing — proposals CANNOT ` +
|
|
1360
|
+
`reach the operator, so create_order will REFUSE every new entry (fail-closed). ` +
|
|
1361
|
+
`Restore the plugin connectionToken/WEBAPP_INGEST_TOKEN + REEFCLAW_USER_ID, or set ` +
|
|
1362
|
+
`approval.mode=off. Exits, stops, brackets and operator controls are unaffected.`);
|
|
1363
|
+
}
|
|
1330
1364
|
else if (bootApprovalMode !== 'off') {
|
|
1331
|
-
logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing —
|
|
1365
|
+
logger.warn(TAG, `approval mode=${bootApprovalMode} but ingest token / REEFCLAW_USER_ID missing — shadow telemetry disabled (orders fire directly, as in off mode)`);
|
|
1332
1366
|
}
|
|
1333
1367
|
}
|
|
1334
1368
|
// ---- Create exchange adapter based on trading mode ----
|
|
@@ -1351,12 +1385,21 @@ const paperTradingPlugin = {
|
|
|
1351
1385
|
// Micro-live cap — same loader every runtime reconnect uses
|
|
1352
1386
|
// (buildAdapter), so boot and reconnect can never disagree (F8).
|
|
1353
1387
|
const microLiveConfig = loadMicroLiveConfig();
|
|
1354
|
-
// Bracket-orders feature flag read from plugin-config at construction
|
|
1355
|
-
//
|
|
1388
|
+
// Bracket-orders feature flag read from plugin-config at construction
|
|
1389
|
+
// time. Since 2026-08-25 (E2E audit #3) the default on a live-Binance
|
|
1390
|
+
// box is 'enforce' — the old 'off' default meant a fresh npx install
|
|
1391
|
+
// flipped to live traded genuinely naked (no exchange stops AND the
|
|
1392
|
+
// mandatory-stop gate skipped, since it is wired behind
|
|
1393
|
+
// bracketsEnabled). An 'off' here can only be an explicit override.
|
|
1356
1394
|
const bracketMode = loadBracketMode();
|
|
1357
1395
|
if (bracketMode !== 'off') {
|
|
1358
1396
|
logger.info(TAG, `Bracket orders enabled in mode=${bracketMode}`);
|
|
1359
1397
|
}
|
|
1398
|
+
else {
|
|
1399
|
+
logger.warn(TAG, `LIVE Binance with brackets.mode='off' (explicit config override) — NO exchange-side ` +
|
|
1400
|
+
`stops; positions rely on the software watcher alone and the mandatory-stop ` +
|
|
1401
|
+
`pre-trade gate is OFF. The dashboard readiness banner will show this red.`);
|
|
1402
|
+
}
|
|
1360
1403
|
// User-data WebSocket stream flag — same mode-ladder pattern as brackets.
|
|
1361
1404
|
// Default 'off' keeps REST polling authoritative. Phase 1 ships dead-code;
|
|
1362
1405
|
// the flag flip to 'shadow' / 'observe' / 'enforce' is operator-driven.
|
|
@@ -1384,6 +1427,11 @@ const paperTradingPlugin = {
|
|
|
1384
1427
|
// F26: same wiring object as the Binance arm — the SIGTERM
|
|
1385
1428
|
// drain covers both venues because it drains this client.
|
|
1386
1429
|
tradeIngest,
|
|
1430
|
+
// Journal close capture (close-bypass fix, HL arm): without
|
|
1431
|
+
// this, every bracket SL/TP fill leaked as status='open'
|
|
1432
|
+
// until the reconciler healed it reason-less (50% of wisekid
|
|
1433
|
+
// 30d closes were reconciler_observed_flat).
|
|
1434
|
+
autoCapture,
|
|
1387
1435
|
},
|
|
1388
1436
|
}
|
|
1389
1437
|
: {
|
|
@@ -1887,6 +1935,14 @@ const paperTradingPlugin = {
|
|
|
1887
1935
|
pollIntervalMs: approvalCfg?.pollIntervalMs ?? 3_000,
|
|
1888
1936
|
});
|
|
1889
1937
|
},
|
|
1938
|
+
// The approval path just went away (mode flipped off, or live→PAPER).
|
|
1939
|
+
// Anything still pending is now un-fireable but still shows an Approve
|
|
1940
|
+
// button — cancel it rather than leave the operator a dead control.
|
|
1941
|
+
onApprovalPathDisabled: async () => {
|
|
1942
|
+
if (!proposalManagerCtx)
|
|
1943
|
+
return;
|
|
1944
|
+
await proposalManagerCtx.manager.cancelAll(proposalManagerCtx.userId, 'mode_disabled');
|
|
1945
|
+
},
|
|
1890
1946
|
});
|
|
1891
1947
|
runtime.setOnAdapterSwapped((a) => { void approvalLifecycle.onAdapterSwapped(a); });
|
|
1892
1948
|
// Boot application — the same path every later swap takes.
|
|
@@ -2581,6 +2637,11 @@ const paperTradingPlugin = {
|
|
|
2581
2637
|
decisionsClient: positionDecisionsClient,
|
|
2582
2638
|
userId: positionDecisionsUserId,
|
|
2583
2639
|
reentryTracker,
|
|
2640
|
+
// WS2 directional scoreboard (docs/MARKET_ADAPTIVITY_PLAN.md §3):
|
|
2641
|
+
// tracked positions from the state store (no exchange round-trip)
|
|
2642
|
+
// + book resolved per call so a paper↔live flip follows.
|
|
2643
|
+
openPositions: () => positionStateStore.getAll().map((e) => ({ side: e.side })),
|
|
2644
|
+
book: () => (runtime.adapter.isLive ? 'live' : 'paper'),
|
|
2584
2645
|
})),
|
|
2585
2646
|
},
|
|
2586
2647
|
{
|
|
@@ -2878,6 +2939,14 @@ const paperTradingPlugin = {
|
|
|
2878
2939
|
venue,
|
|
2879
2940
|
publicApi: hlPublicApi ?? binanceApi,
|
|
2880
2941
|
toolCount: toolNames.length,
|
|
2942
|
+
// live_stop_protection (E2E audit #3): what would stop a losing live
|
|
2943
|
+
// position. Deferred closure over the runtime so paper↔live flips and
|
|
2944
|
+
// adapter swaps surface on the next 5-min report without a restart.
|
|
2945
|
+
resolveStopProtection: () => ({
|
|
2946
|
+
tradingMode: runtime.mode,
|
|
2947
|
+
venueEnforced: runtime.adapter.bracketsAlwaysEnforced === true,
|
|
2948
|
+
bracketMode: loadBracketMode(),
|
|
2949
|
+
}),
|
|
2881
2950
|
});
|
|
2882
2951
|
maybeStartConnectorSupervisor();
|
|
2883
2952
|
},
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// that hooks into the WS-ingest pipeline (see POSITION_DECISION_JOURNAL_PLAN
|
|
20
20
|
// §5.1 for the longer-term design).
|
|
21
21
|
import { logger } from '../logger.js';
|
|
22
|
-
import {
|
|
22
|
+
import { isBracketClientId, parseBracketClientId } from '../live/bracket-id.js';
|
|
23
23
|
import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
|
|
24
24
|
import { fillPriceFromOrder } from '../live/fill-price.js';
|
|
25
25
|
import { getSkillVersionCached, withSkillVersion } from './skill-version-reader.js';
|
|
@@ -306,12 +306,27 @@ export async function onClosePositionFilled(ctx, inputs, order) {
|
|
|
306
306
|
};
|
|
307
307
|
ctx.decisionsClient.postClose(ctx.userId, close);
|
|
308
308
|
// Re-entry indication (issue #204) — record the exit so scan_pairs can flag
|
|
309
|
-
// same-bar re-entries on this (symbol, setup)
|
|
309
|
+
// same-bar re-entries on this (symbol, setup), and so the reentryCooldown
|
|
310
|
+
// gate can see recent losses. Live agent-closes have no engine trade record
|
|
311
|
+
// here (exchange-exact PnL arrives later on the WS fill, racing this path),
|
|
312
|
+
// so the loss sign falls back to the agent's own r_multiple_at_close — the
|
|
313
|
+
// same validator-checked field the exit gate trusts. lossSource lets the
|
|
314
|
+
// shadow soak audit that sign against the DB before enforce.
|
|
315
|
+
const rRaw = inputs.closeAssessment?.['r_multiple_at_close'];
|
|
316
|
+
const rAtClose = typeof rRaw === 'number' && Number.isFinite(rRaw) ? rRaw : undefined;
|
|
310
317
|
ctx.reentryTracker?.recordExit({
|
|
311
318
|
symbol: inputs.symbol,
|
|
312
319
|
setupType: stateEntry.setupType ?? paperTrade?.setupType,
|
|
313
320
|
side: stateEntry.side,
|
|
314
|
-
wasLoss: paperTrade
|
|
321
|
+
wasLoss: paperTrade
|
|
322
|
+
? paperTrade.netRealizedPnl < 0
|
|
323
|
+
: rAtClose != null
|
|
324
|
+
? rAtClose < 0
|
|
325
|
+
: undefined,
|
|
326
|
+
lossSource: paperTrade ? 'paper_engine' : rAtClose != null ? 'assessment_r' : undefined,
|
|
327
|
+
mode: ctx.resolveMode?.(),
|
|
328
|
+
realizedR: rAtClose,
|
|
329
|
+
realizedPnl: paperTrade?.netRealizedPnl,
|
|
315
330
|
closedAtMs: closeAtMs,
|
|
316
331
|
});
|
|
317
332
|
// Drop local state — symbol can re-enter as a new position.
|
|
@@ -388,6 +403,7 @@ export async function onAutoFlattenClose(ctx, inputs, lookup = {
|
|
|
388
403
|
symbol: inputs.symbol,
|
|
389
404
|
setupType: flattenState?.setupType,
|
|
390
405
|
side: flattenState?.side ?? 'long',
|
|
406
|
+
mode: ctx.resolveMode?.(),
|
|
391
407
|
closedAtMs: inputs.observedAtMs ?? Date.now(),
|
|
392
408
|
});
|
|
393
409
|
ctx.stateStore.remove(inputs.symbol);
|
|
@@ -421,6 +437,9 @@ export async function onStopWatcherClose(ctx, inputs) {
|
|
|
421
437
|
setupType: stateEntry?.setupType ?? paperTrade?.setupType,
|
|
422
438
|
side: stateEntry?.side ?? 'long',
|
|
423
439
|
wasLoss: paperTrade ? paperTrade.netRealizedPnl < 0 : undefined,
|
|
440
|
+
lossSource: paperTrade ? 'paper_engine' : undefined,
|
|
441
|
+
mode: ctx.resolveMode?.(),
|
|
442
|
+
realizedPnl: paperTrade?.netRealizedPnl,
|
|
424
443
|
closedAtMs: closeAtMs,
|
|
425
444
|
});
|
|
426
445
|
const dropState = () => { ctx.stateStore?.remove(inputs.symbol); };
|
|
@@ -627,7 +646,13 @@ async function handleReduceOnlyExit(ctx, fill) {
|
|
|
627
646
|
// onClosePositionFilled) or a manual/external close — both are handled by
|
|
628
647
|
// their own paths (close_position's rich reason+assessment, or the reconciler
|
|
629
648
|
// backstop). Closing here would clobber the agent's close reasoning, so defer.
|
|
630
|
-
|
|
649
|
+
// Recognition is VENUE-DISPATCHED (bracket-id rule): Binance `bkt…`/`rc-…`
|
|
650
|
+
// cids, Hyperliquid `0xbc7…` cloids — the Binance-only check silently
|
|
651
|
+
// classed every HL bracket fill as external and deferred it forever.
|
|
652
|
+
const cidVenue = ctx.venue ?? 'binance';
|
|
653
|
+
const isBracket = fill.clientOrderId
|
|
654
|
+
? isBracketClientId(cidVenue, fill.clientOrderId)
|
|
655
|
+
: false;
|
|
631
656
|
if (!isBracket) {
|
|
632
657
|
logger.info(TAG, `${fill.symbol} flat via non-bracket reduce-only fill (cid=${fill.clientOrderId ?? 'none'}) — ` +
|
|
633
658
|
`deferring close to close_position / reconciler backstop (no clobber)`);
|
|
@@ -643,6 +668,12 @@ async function handleReduceOnlyExit(ctx, fill) {
|
|
|
643
668
|
'Auto-journaled from the WS fill — no close_position call (close-bypass path).',
|
|
644
669
|
observedFrom: 'ws_reduce_only_fill',
|
|
645
670
|
clientOrderId: fill.clientOrderId,
|
|
671
|
+
// Which protective leg fired ('stop' | 'target'), parsed from the cid.
|
|
672
|
+
// Kept in the assessment (not a new close reason) so the close_reason
|
|
673
|
+
// vocabulary stays stable for the miner's plan-adherence classifier.
|
|
674
|
+
leg: fill.clientOrderId
|
|
675
|
+
? parseBracketClientId(cidVenue, fill.clientOrderId)?.role
|
|
676
|
+
: undefined,
|
|
646
677
|
},
|
|
647
678
|
scorecardVerdict: 'NO_GO',
|
|
648
679
|
confluenceScore: 0,
|
|
@@ -668,6 +699,9 @@ async function handleReduceOnlyExit(ctx, fill) {
|
|
|
668
699
|
setupType: stateEntry.setupType,
|
|
669
700
|
side: stateEntry.side,
|
|
670
701
|
wasLoss: realizedPnl < 0,
|
|
702
|
+
lossSource: 'ws_fill',
|
|
703
|
+
mode: ctx.resolveMode?.(),
|
|
704
|
+
realizedPnl,
|
|
671
705
|
closedAtMs: fill.exchangeTimeMs ?? Date.now(),
|
|
672
706
|
});
|
|
673
707
|
ctx.stateStore.remove(fill.symbol);
|
|
@@ -733,5 +767,16 @@ export function buildEntryPlanMetadata(md) {
|
|
|
733
767
|
j.note = rr.note;
|
|
734
768
|
out.realization_rule = j;
|
|
735
769
|
}
|
|
770
|
+
// Cooldown-gate measurement tag (snake_case per the canonical JSONB key
|
|
771
|
+
// rule) — present only when the gate triggered and the entry fired anyway.
|
|
772
|
+
const rc = md.reentryCooldown;
|
|
773
|
+
if (rc) {
|
|
774
|
+
out.reentry_cooldown = {
|
|
775
|
+
mode: rc.mode,
|
|
776
|
+
minutes_since_loss: rc.minutesSinceLoss,
|
|
777
|
+
cooldown_minutes: rc.cooldownMinutes,
|
|
778
|
+
would_block: rc.wouldBlock,
|
|
779
|
+
};
|
|
780
|
+
}
|
|
736
781
|
return Object.keys(out).length > 0 ? out : undefined;
|
|
737
782
|
}
|
|
@@ -322,6 +322,12 @@ export declare class PositionDecisionsClient {
|
|
|
322
322
|
private fireAndForget;
|
|
323
323
|
private run;
|
|
324
324
|
private runReturning;
|
|
325
|
+
/** Decision-path GET budget. Every read caller degrades to empty/null on
|
|
326
|
+
* failure, so burning the write-grade budget (4 × 10s + backoff ≈ 42s
|
|
327
|
+
* worst case) of the agent's turn to reach an optional result is pure
|
|
328
|
+
* heartbeat latency. Background reads (reconcile sweep) override per call. */
|
|
329
|
+
private static readonly READ_MAX_ATTEMPTS;
|
|
330
|
+
private static readonly READ_DEADLINE_MS;
|
|
325
331
|
private runGetReturning;
|
|
326
332
|
private sleepBackoff;
|
|
327
333
|
}
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// - postEntry/Review/Close are fire-and-forget (don't block the WS hot path).
|
|
13
13
|
//
|
|
14
14
|
// All four routes accept the same auth: Bearer + X-User-Id headers.
|
|
15
|
+
import { keepAliveFetch } from '../http/keepalive-fetch.js';
|
|
15
16
|
import { logger, formatError } from '../logger.js';
|
|
16
17
|
const TAG = 'position-decisions-client';
|
|
17
18
|
export class PositionDecisionsClient {
|
|
@@ -25,7 +26,7 @@ export class PositionDecisionsClient {
|
|
|
25
26
|
this.opts = {
|
|
26
27
|
baseUrl: options.baseUrl.replace(/\/+$/, ''),
|
|
27
28
|
ingestToken: options.ingestToken,
|
|
28
|
-
fetchImpl: options.fetchImpl ??
|
|
29
|
+
fetchImpl: options.fetchImpl ?? keepAliveFetch,
|
|
29
30
|
requestTimeoutMs: options.requestTimeoutMs ?? 10_000,
|
|
30
31
|
maxAttempts: options.maxAttempts ?? 4,
|
|
31
32
|
baseBackoffMs: options.baseBackoffMs ?? 250,
|
|
@@ -65,7 +66,12 @@ export class PositionDecisionsClient {
|
|
|
65
66
|
qs.set('mode', mode);
|
|
66
67
|
if (exchange)
|
|
67
68
|
qs.set('exchange', exchange);
|
|
68
|
-
return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}
|
|
69
|
+
return this.runGetReturning(userId, `/api/internal/positions?${qs.toString()}`, {
|
|
70
|
+
// Reconcile-sweep read, NOT on the agent decision path — keep the
|
|
71
|
+
// write-grade retry budget: a null here skips a whole reconcile pass.
|
|
72
|
+
maxAttempts: this.opts.maxAttempts,
|
|
73
|
+
deadlineMs: Number.POSITIVE_INFINITY,
|
|
74
|
+
});
|
|
69
75
|
}
|
|
70
76
|
/** Read endpoint for the Phase 1 self-reflection feature. Awaited.
|
|
71
77
|
* Returns null on terminal/retry-exhausted failure (caller logs + degrades). */
|
|
@@ -246,12 +252,24 @@ export class PositionDecisionsClient {
|
|
|
246
252
|
logger.error(TAG, `POST ${url} dropped after ${this.opts.maxAttempts} attempts. userId=${userId}.`);
|
|
247
253
|
return null;
|
|
248
254
|
}
|
|
249
|
-
|
|
255
|
+
/** Decision-path GET budget. Every read caller degrades to empty/null on
|
|
256
|
+
* failure, so burning the write-grade budget (4 × 10s + backoff ≈ 42s
|
|
257
|
+
* worst case) of the agent's turn to reach an optional result is pure
|
|
258
|
+
* heartbeat latency. Background reads (reconcile sweep) override per call. */
|
|
259
|
+
static READ_MAX_ATTEMPTS = 2;
|
|
260
|
+
static READ_DEADLINE_MS = 8_000;
|
|
261
|
+
async runGetReturning(userId, path, budget) {
|
|
250
262
|
const url = `${this.opts.baseUrl}${path}`;
|
|
251
|
-
|
|
263
|
+
const maxAttempts = budget?.maxAttempts ??
|
|
264
|
+
Math.min(this.opts.maxAttempts, PositionDecisionsClient.READ_MAX_ATTEMPTS);
|
|
265
|
+
const deadlineAt = Date.now() + (budget?.deadlineMs ?? PositionDecisionsClient.READ_DEADLINE_MS);
|
|
266
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
267
|
+
const remainingMs = deadlineAt - Date.now();
|
|
268
|
+
if (remainingMs <= 0)
|
|
269
|
+
break;
|
|
252
270
|
try {
|
|
253
271
|
const ac = new AbortController();
|
|
254
|
-
const tid = setTimeout(() => ac.abort(), this.opts.requestTimeoutMs);
|
|
272
|
+
const tid = setTimeout(() => ac.abort(), Math.min(this.opts.requestTimeoutMs, remainingMs));
|
|
255
273
|
let res;
|
|
256
274
|
try {
|
|
257
275
|
res = await this.opts.fetchImpl(url, {
|
|
@@ -285,16 +303,16 @@ export class PositionDecisionsClient {
|
|
|
285
303
|
logger.warn(TAG, `GET ${url} → ${res.status} (terminal, dropped). userId=${userId} body=${errBody.slice(0, 200)}`);
|
|
286
304
|
return null;
|
|
287
305
|
}
|
|
288
|
-
logger.warn(TAG, `GET ${url} → ${res.status} (attempt ${attempt}/${
|
|
306
|
+
logger.warn(TAG, `GET ${url} → ${res.status} (attempt ${attempt}/${maxAttempts}).`);
|
|
289
307
|
}
|
|
290
308
|
catch (err) {
|
|
291
|
-
logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${
|
|
309
|
+
logger.warn(TAG, `GET ${url} threw (attempt ${attempt}/${maxAttempts}): ${formatError(err)}`);
|
|
292
310
|
}
|
|
293
|
-
if (attempt <
|
|
311
|
+
if (attempt < maxAttempts && Date.now() < deadlineAt) {
|
|
294
312
|
await this.sleepBackoff(attempt);
|
|
295
313
|
}
|
|
296
314
|
}
|
|
297
|
-
logger.error(TAG, `GET ${url} failed after ${
|
|
315
|
+
logger.error(TAG, `GET ${url} failed after ${maxAttempts} attempt(s)/deadline. userId=${userId}.`);
|
|
298
316
|
return null;
|
|
299
317
|
}
|
|
300
318
|
async sleepBackoff(attempt) {
|