@reefclaw/connect 0.1.31 → 0.1.33
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/assets/bridge/connector.d.ts +3 -1
- package/assets/bridge/connector.js +37 -2
- package/assets/bridge/gateway/heartbeat-cron.js +2 -1
- package/assets/bridge/index.js +4 -0
- package/assets/bridge/types.d.ts +4 -0
- package/assets/bridge/utils/instance-id.d.ts +3 -0
- package/assets/bridge/utils/instance-id.js +48 -0
- package/assets/plugin/config/brackets-config.d.ts +2 -1
- package/assets/plugin/config/brackets-config.js +25 -3
- package/assets/plugin/index.js +24 -2
- package/assets/plugin/ingest/position-auto-capture.js +14 -2
- package/assets/plugin/ingest/readiness-reporter.d.ts +23 -2
- package/assets/plugin/ingest/readiness-reporter.js +56 -1
- package/assets/plugin/onboarding/runtime.js +4 -0
- package/assets/plugin/simulator/exchange-simulator.d.ts +5 -1
- package/assets/plugin/simulator/exchange-simulator.js +24 -6
- package/assets/plugin/tools/close-position.js +10 -1
- package/assets/plugin/venues/hyperliquid/hl-live-adapter.d.ts +27 -2
- package/assets/plugin/venues/hyperliquid/hl-live-adapter.js +101 -13
- package/assets/shared/readiness.d.ts +1 -1
- package/assets/shared/readiness.js +11 -0
- package/dist/openclaw.js +1 -1
- package/package.json +2 -1
|
@@ -22,7 +22,9 @@ export declare class Connector {
|
|
|
22
22
|
private lastMessageAt;
|
|
23
23
|
private destroyed;
|
|
24
24
|
constructor(config: ConnectorConfig, callbacks: ConnectorCallbacks);
|
|
25
|
-
/** Build the relay WebSocket URL (token is sent via Authorization header, not
|
|
25
|
+
/** Build the relay WebSocket URL (token is sent via Authorization header, not
|
|
26
|
+
* URL; the instance id is non-secret and rides the query string so the
|
|
27
|
+
* relay's skill-slot admission can recognise a same-box restart). */
|
|
26
28
|
private buildUrl;
|
|
27
29
|
/** Start connecting to the relay */
|
|
28
30
|
connect(): void;
|
|
@@ -37,10 +37,15 @@ export class Connector {
|
|
|
37
37
|
this.reconnectConfig = { ...DEFAULT_RECONNECT, ...config.reconnect };
|
|
38
38
|
this.heartbeatConfig = { ...DEFAULT_HEARTBEAT };
|
|
39
39
|
}
|
|
40
|
-
/** Build the relay WebSocket URL (token is sent via Authorization header, not
|
|
40
|
+
/** Build the relay WebSocket URL (token is sent via Authorization header, not
|
|
41
|
+
* URL; the instance id is non-secret and rides the query string so the
|
|
42
|
+
* relay's skill-slot admission can recognise a same-box restart). */
|
|
41
43
|
buildUrl() {
|
|
42
44
|
const base = this.config.relayUrl.replace(/\/$/, '');
|
|
43
|
-
|
|
45
|
+
const path = `${base}/parties/reefclaw/${this.config.userId}`;
|
|
46
|
+
return this.config.instanceId
|
|
47
|
+
? `${path}?instance=${encodeURIComponent(this.config.instanceId)}`
|
|
48
|
+
: path;
|
|
44
49
|
}
|
|
45
50
|
/** Start connecting to the relay */
|
|
46
51
|
connect() {
|
|
@@ -96,6 +101,24 @@ export class Connector {
|
|
|
96
101
|
}, 15 * 60_000);
|
|
97
102
|
return;
|
|
98
103
|
}
|
|
104
|
+
// 4011 = another agent holds this account's skill slot and is alive
|
|
105
|
+
// (relay skill-slot admission, 2026-08-25 — the fix for the 4010
|
|
106
|
+
// ping-pong two bridges used to fight). Retrying fast is pointless and
|
|
107
|
+
// noisy: the seated agent keeps the slot until it stops. Slow-probe
|
|
108
|
+
// every 5 min so a deliberate box switch (stop the old one) is picked
|
|
109
|
+
// up without a manual restart here.
|
|
110
|
+
if (code === 4011) {
|
|
111
|
+
logger.error(TAG, `Relay refused this connection (4011): another agent is already connected for this ` +
|
|
112
|
+
`account. ReefClaw runs ONE agent per account — stop the other agent (or revoke its ` +
|
|
113
|
+
`token in the dashboard) to move this box in. Probing again in 5 min.`);
|
|
114
|
+
this.setState('failed');
|
|
115
|
+
this.attempt = 0;
|
|
116
|
+
this.reconnectTimer = setTimeout(() => {
|
|
117
|
+
this.reconnectTimer = null;
|
|
118
|
+
this.connect();
|
|
119
|
+
}, 5 * 60_000);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
99
122
|
// 4002 = stale skill connection on relay. Wait for it to time out, then retry.
|
|
100
123
|
if (code === 4002) {
|
|
101
124
|
logger.warn(TAG, 'Stale skill connection on relay — waiting 10s before retry');
|
|
@@ -275,6 +298,18 @@ export class Connector {
|
|
|
275
298
|
// Send a native WebSocket ping (protocol-level, handled by PartyKit automatically).
|
|
276
299
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
277
300
|
this.ws.ping();
|
|
301
|
+
// App-level keepalive: native pings are absorbed by the PartyKit
|
|
302
|
+
// runtime and never reach the relay DO, so an idle-but-healthy agent
|
|
303
|
+
// would look dead to the skill-slot admission's liveness window and
|
|
304
|
+
// could be evicted by a second box's probe. One tiny frame per
|
|
305
|
+
// heartbeat tick keeps the seat provably occupied; the relay swallows
|
|
306
|
+
// it (never forwarded to browsers, never audited).
|
|
307
|
+
this.ws.send(JSON.stringify({
|
|
308
|
+
type: 'event',
|
|
309
|
+
event: 'skill_keepalive',
|
|
310
|
+
channel: 'agent_state',
|
|
311
|
+
payload: { ts: Date.now() },
|
|
312
|
+
}));
|
|
278
313
|
}
|
|
279
314
|
}, this.heartbeatConfig.intervalMs);
|
|
280
315
|
logger.debug(TAG, `Heartbeat started: interval=${this.heartbeatConfig.intervalMs}ms timeout=${this.heartbeatConfig.timeoutMs}ms`);
|
|
@@ -49,7 +49,8 @@ export const HEARTBEAT_MESSAGE = 'Heartbeat. Execute EVERY checkbox in the HEART
|
|
|
49
49
|
'do NOT re-read SKILL.md from disk unless preparing a NEW entry). ' +
|
|
50
50
|
'NON-SKIPPABLE every beat: (1) query_trades({hours:1}) stop-watcher reconcile; ' +
|
|
51
51
|
'(2) get_wave9_status() once, unconditionally; ' +
|
|
52
|
-
'(3) if ANY position is open
|
|
52
|
+
'(3) if ANY position is open and the Position Decision Journal is enabled (record_position_reviews reports off-mode when it is not): ' +
|
|
53
|
+
'get_my_recent_reviews() + get_relevant_learnings({applies_at: heartbeat}) in one batch, ' +
|
|
53
54
|
'then record_position_reviews with ONE review per open position, ' +
|
|
54
55
|
'plus get_resting_liquidity + get_liquidation_levels + get_liquidation_pulse for ALL positions in one parallel batch; ' +
|
|
55
56
|
'(4) the Market Assessment reads. ' +
|
package/assets/bridge/index.js
CHANGED
|
@@ -17,6 +17,7 @@ import { GatewayProvider } from './providers/gateway.js';
|
|
|
17
17
|
import { resolveConfig } from './config.js';
|
|
18
18
|
import { resolveGatewayConfig, validateGatewayConfig } from './gateway/gateway-config.js';
|
|
19
19
|
import { runSetup } from './setup.js';
|
|
20
|
+
import { resolveRelayInstanceId } from './utils/instance-id.js';
|
|
20
21
|
const TAG = 'main';
|
|
21
22
|
// ---- Load .env file (skill/.env only) ----
|
|
22
23
|
function loadEnvFile() {
|
|
@@ -223,6 +224,9 @@ async function main() {
|
|
|
223
224
|
relayUrl,
|
|
224
225
|
userId,
|
|
225
226
|
token,
|
|
227
|
+
// Stable per-install id → the relay's skill-slot admission recognises a
|
|
228
|
+
// same-box restart (instant takeover) vs a second box (rejected 4011).
|
|
229
|
+
instanceId: resolveRelayInstanceId(),
|
|
226
230
|
};
|
|
227
231
|
const bridge = new Bridge(provider, connectorConfig);
|
|
228
232
|
// Handle graceful shutdown
|
package/assets/bridge/types.d.ts
CHANGED
|
@@ -602,6 +602,10 @@ export interface ConnectorConfig {
|
|
|
602
602
|
relayUrl: string;
|
|
603
603
|
userId: string;
|
|
604
604
|
token: string;
|
|
605
|
+
/** Stable per-install id (non-secret), sent as `?instance=` so the relay's
|
|
606
|
+
* skill-slot admission can tell "same box restarting" (instant takeover)
|
|
607
|
+
* from "second box" (rejected 4011). See utils/instance-id.ts. */
|
|
608
|
+
instanceId?: string;
|
|
605
609
|
reconnect?: {
|
|
606
610
|
baseDelayMs?: number;
|
|
607
611
|
maxDelayMs?: number;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Stable per-install relay instance id (2026-08-25, skill-slot admission).
|
|
2
|
+
//
|
|
3
|
+
// The relay seats ONE skill connection per account and refuses newcomers —
|
|
4
|
+
// EXCEPT a newcomer proving it is the same box restarting, which takes over
|
|
5
|
+
// instantly (the graceful-restart property). "Same box" = this id, sent as a
|
|
6
|
+
// non-secret `?instance=` query param on the relay URL. It must therefore
|
|
7
|
+
// survive process restarts: persisted once per install at
|
|
8
|
+
// `~/.reefclaw/relay-instance-id` and reused forever.
|
|
9
|
+
//
|
|
10
|
+
// Fail-open: when the filesystem refuses (read-only home, exotic container),
|
|
11
|
+
// fall back to a per-process id. Takeover-after-crash then degrades to the
|
|
12
|
+
// relay's liveness timeout instead of being instant — worse, never wrong.
|
|
13
|
+
import { randomUUID } from 'node:crypto';
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { logger } from '../logger.js';
|
|
18
|
+
const TAG = 'instance-id';
|
|
19
|
+
const ID_SHAPE = /^[A-Za-z0-9-]{8,64}$/;
|
|
20
|
+
/** Read-or-create the stable instance id. `baseDir` overrides the storage
|
|
21
|
+
* directory (tests; defaults to ~/.reefclaw). Never throws. */
|
|
22
|
+
export function resolveRelayInstanceId(baseDir) {
|
|
23
|
+
// Operator escape hatch (e.g. two deliberate installs sharing one home dir).
|
|
24
|
+
const fromEnv = process.env.RC_RELAY_INSTANCE_ID?.trim();
|
|
25
|
+
if (fromEnv && ID_SHAPE.test(fromEnv))
|
|
26
|
+
return fromEnv;
|
|
27
|
+
const dir = baseDir ?? join(homedir(), '.reefclaw');
|
|
28
|
+
const file = join(dir, 'relay-instance-id');
|
|
29
|
+
try {
|
|
30
|
+
if (existsSync(file)) {
|
|
31
|
+
const existing = readFileSync(file, 'utf8').trim();
|
|
32
|
+
if (ID_SHAPE.test(existing))
|
|
33
|
+
return existing;
|
|
34
|
+
// Garbled file: fall through and rewrite — a fresh id only costs one
|
|
35
|
+
// liveness-timeout takeover, a garbled param corrupts the admission key.
|
|
36
|
+
}
|
|
37
|
+
const fresh = randomUUID();
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
writeFileSync(file, fresh + '\n', 'utf8');
|
|
40
|
+
return fresh;
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
const perProcess = randomUUID();
|
|
44
|
+
logger.warn(TAG, `Could not persist relay instance id (${err.message}) — using per-process id; ` +
|
|
45
|
+
`crash takeover degrades to the relay liveness timeout`);
|
|
46
|
+
return perProcess;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type PluginConfigFile } from './plugin-config-io.js';
|
|
2
2
|
export type BracketMode = 'off' | 'observe' | 'enforce';
|
|
3
|
-
/** Read bracket mode from a config object. Invalid values fall back to
|
|
3
|
+
/** Read bracket mode from a config object. Invalid values fall back to the
|
|
4
|
+
* context default ('enforce' on a live-Binance box, else 'off'). */
|
|
4
5
|
export declare function getBracketMode(config?: PluginConfigFile, remoteOverride?: BracketMode): BracketMode;
|
|
5
6
|
/** Convenience: load from disk and return the effective mode. */
|
|
6
7
|
export declare function loadBracketMode(): BracketMode;
|
|
@@ -5,10 +5,32 @@
|
|
|
5
5
|
// in memory on the plugin after push; not persisted to plugin-config.json.
|
|
6
6
|
// Wired in Phase 2; for Phase 1 this is always undefined.
|
|
7
7
|
// 2. Local plugin-config.json `brackets.mode` field.
|
|
8
|
-
// 3. Default:
|
|
8
|
+
// 3. Default: CONTEXT-AWARE since 2026-08-25 (E2E audit #3, the last
|
|
9
|
+
// real-money item on the fresh-box list): a box whose persisted
|
|
10
|
+
// tradingMode is MICRO_LIVE/LIVE on the BINANCE venue defaults to
|
|
11
|
+
// 'enforce' — the historical 'off' default meant a fresh npx install
|
|
12
|
+
// flipped to live traded with NO exchange-side stops AND no mandatory-
|
|
13
|
+
// stop pre-trade gate (the gate is wired behind bracketsEnabled), i.e.
|
|
14
|
+
// genuinely naked, with no in-product way to notice. Everything else
|
|
15
|
+
// (paper, shadow, hyperliquid — whose adapter enforces brackets
|
|
16
|
+
// unconditionally and ignores this Binance-only knob) keeps 'off'.
|
|
17
|
+
// An EXPLICIT 'off' in config is still honored (operator break-glass,
|
|
18
|
+
// behind the Settings type-to-confirm guard) — with the new default it
|
|
19
|
+
// can only ever be a deliberate choice.
|
|
9
20
|
import { readPluginConfig } from './plugin-config-io.js';
|
|
10
21
|
const VALID = new Set(['off', 'observe', 'enforce']);
|
|
11
|
-
/**
|
|
22
|
+
/** Does this config describe a live-Binance box — the one context where an
|
|
23
|
+
* unset bracket mode must NOT mean "no protection"? Reads the PERSISTED
|
|
24
|
+
* trading mode (set_trading_mode persists BEFORE the adapter swap, so
|
|
25
|
+
* construction-time and per-call reads agree). */
|
|
26
|
+
function isLiveBinanceContext(config) {
|
|
27
|
+
const mode = config?.tradingMode;
|
|
28
|
+
if (mode !== 'MICRO_LIVE' && mode !== 'LIVE')
|
|
29
|
+
return false;
|
|
30
|
+
return config?.exchange?.venue !== 'hyperliquid';
|
|
31
|
+
}
|
|
32
|
+
/** Read bracket mode from a config object. Invalid values fall back to the
|
|
33
|
+
* context default ('enforce' on a live-Binance box, else 'off'). */
|
|
12
34
|
export function getBracketMode(config, remoteOverride) {
|
|
13
35
|
if (remoteOverride && VALID.has(remoteOverride))
|
|
14
36
|
return remoteOverride;
|
|
@@ -16,7 +38,7 @@ export function getBracketMode(config, remoteOverride) {
|
|
|
16
38
|
if (typeof raw === 'string' && VALID.has(raw)) {
|
|
17
39
|
return raw;
|
|
18
40
|
}
|
|
19
|
-
return 'off';
|
|
41
|
+
return isLiveBinanceContext(config) ? 'enforce' : 'off';
|
|
20
42
|
}
|
|
21
43
|
/** Convenience: load from disk and return the effective mode. */
|
|
22
44
|
export function loadBracketMode() {
|
package/assets/plugin/index.js
CHANGED
|
@@ -1385,12 +1385,21 @@ const paperTradingPlugin = {
|
|
|
1385
1385
|
// Micro-live cap — same loader every runtime reconnect uses
|
|
1386
1386
|
// (buildAdapter), so boot and reconnect can never disagree (F8).
|
|
1387
1387
|
const microLiveConfig = loadMicroLiveConfig();
|
|
1388
|
-
// Bracket-orders feature flag read from plugin-config at construction
|
|
1389
|
-
//
|
|
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.
|
|
1390
1394
|
const bracketMode = loadBracketMode();
|
|
1391
1395
|
if (bracketMode !== 'off') {
|
|
1392
1396
|
logger.info(TAG, `Bracket orders enabled in mode=${bracketMode}`);
|
|
1393
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
|
+
}
|
|
1394
1403
|
// User-data WebSocket stream flag — same mode-ladder pattern as brackets.
|
|
1395
1404
|
// Default 'off' keeps REST polling authoritative. Phase 1 ships dead-code;
|
|
1396
1405
|
// the flag flip to 'shadow' / 'observe' / 'enforce' is operator-driven.
|
|
@@ -1418,6 +1427,11 @@ const paperTradingPlugin = {
|
|
|
1418
1427
|
// F26: same wiring object as the Binance arm — the SIGTERM
|
|
1419
1428
|
// drain covers both venues because it drains this client.
|
|
1420
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,
|
|
1421
1435
|
},
|
|
1422
1436
|
}
|
|
1423
1437
|
: {
|
|
@@ -2920,6 +2934,14 @@ const paperTradingPlugin = {
|
|
|
2920
2934
|
venue,
|
|
2921
2935
|
publicApi: hlPublicApi ?? binanceApi,
|
|
2922
2936
|
toolCount: toolNames.length,
|
|
2937
|
+
// live_stop_protection (E2E audit #3): what would stop a losing live
|
|
2938
|
+
// position. Deferred closure over the runtime so paper↔live flips and
|
|
2939
|
+
// adapter swaps surface on the next 5-min report without a restart.
|
|
2940
|
+
resolveStopProtection: () => ({
|
|
2941
|
+
tradingMode: runtime.mode,
|
|
2942
|
+
venueEnforced: runtime.adapter.bracketsAlwaysEnforced === true,
|
|
2943
|
+
bracketMode: loadBracketMode(),
|
|
2944
|
+
}),
|
|
2923
2945
|
});
|
|
2924
2946
|
maybeStartConnectorSupervisor();
|
|
2925
2947
|
},
|
|
@@ -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';
|
|
@@ -627,7 +627,13 @@ async function handleReduceOnlyExit(ctx, fill) {
|
|
|
627
627
|
// onClosePositionFilled) or a manual/external close — both are handled by
|
|
628
628
|
// their own paths (close_position's rich reason+assessment, or the reconciler
|
|
629
629
|
// backstop). Closing here would clobber the agent's close reasoning, so defer.
|
|
630
|
-
|
|
630
|
+
// Recognition is VENUE-DISPATCHED (bracket-id rule): Binance `bkt…`/`rc-…`
|
|
631
|
+
// cids, Hyperliquid `0xbc7…` cloids — the Binance-only check silently
|
|
632
|
+
// classed every HL bracket fill as external and deferred it forever.
|
|
633
|
+
const cidVenue = ctx.venue ?? 'binance';
|
|
634
|
+
const isBracket = fill.clientOrderId
|
|
635
|
+
? isBracketClientId(cidVenue, fill.clientOrderId)
|
|
636
|
+
: false;
|
|
631
637
|
if (!isBracket) {
|
|
632
638
|
logger.info(TAG, `${fill.symbol} flat via non-bracket reduce-only fill (cid=${fill.clientOrderId ?? 'none'}) — ` +
|
|
633
639
|
`deferring close to close_position / reconciler backstop (no clobber)`);
|
|
@@ -643,6 +649,12 @@ async function handleReduceOnlyExit(ctx, fill) {
|
|
|
643
649
|
'Auto-journaled from the WS fill — no close_position call (close-bypass path).',
|
|
644
650
|
observedFrom: 'ws_reduce_only_fill',
|
|
645
651
|
clientOrderId: fill.clientOrderId,
|
|
652
|
+
// Which protective leg fired ('stop' | 'target'), parsed from the cid.
|
|
653
|
+
// Kept in the assessment (not a new close reason) so the close_reason
|
|
654
|
+
// vocabulary stays stable for the miner's plan-adherence classifier.
|
|
655
|
+
leg: fill.clientOrderId
|
|
656
|
+
? parseBracketClientId(cidVenue, fill.clientOrderId)?.role
|
|
657
|
+
: undefined,
|
|
646
658
|
},
|
|
647
659
|
scorecardVerdict: 'NO_GO',
|
|
648
660
|
confluenceScore: 0,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ReadinessReport, type VenueId, type VenueReachabilityResult } from '@reefclaw/shared';
|
|
1
|
+
import { type ReadinessCheck, type ReadinessReport, type VenueId, type VenueReachabilityResult } from '@reefclaw/shared';
|
|
2
2
|
/** Who froze the loop. 'unknown' when the kernel counter is unreadable (not
|
|
3
3
|
* Linux / no CONFIG_SCHEDSTATS / first cycle) — attribution is evidence, and
|
|
4
4
|
* absent evidence stays absent rather than defaulting to a blame. */
|
|
@@ -11,6 +11,23 @@ export declare function attributeStall(stallMs: number, runqueueWaitMs: number |
|
|
|
11
11
|
export interface VenueReachabilityProbe {
|
|
12
12
|
probeReachability(): Promise<VenueReachabilityResult>;
|
|
13
13
|
}
|
|
14
|
+
/** Snapshot for the `live_stop_protection` check (E2E audit #3, 2026-08-25):
|
|
15
|
+
* what — if anything — would stop a losing live position. Resolved per cycle
|
|
16
|
+
* via a deferred closure over the runtime so it follows paper↔live flips and
|
|
17
|
+
* adapter swaps without a restart. */
|
|
18
|
+
export interface StopProtectionSnapshot {
|
|
19
|
+
/** 'PAPER' | 'SHADOW' | 'MICRO_LIVE' | 'LIVE' (runtime.mode). */
|
|
20
|
+
tradingMode: string;
|
|
21
|
+
/** Adapter declares venue-enforced brackets (Hyperliquid live). */
|
|
22
|
+
venueEnforced: boolean;
|
|
23
|
+
/** The effective Binance bracket mode ('off' | 'observe' | 'enforce'). */
|
|
24
|
+
bracketMode: string;
|
|
25
|
+
}
|
|
26
|
+
/** Map a snapshot to the readiness check. Pure — the whole point of the row is
|
|
27
|
+
* that `fail` means REAL MONEY WITH NO STOP, so the mapping is unit-tested
|
|
28
|
+
* branch by branch. Null snapshot → null (older wiring: omit the row rather
|
|
29
|
+
* than fabricate a verdict). */
|
|
30
|
+
export declare function stopProtectionCheck(snap: StopProtectionSnapshot | null, checkedAt: number): ReadinessCheck | null;
|
|
14
31
|
/** Cross-cycle memory for the debounced rungs. Held by the interval loop and
|
|
15
32
|
* passed in explicitly so `collectReadiness` stays a pure function of its
|
|
16
33
|
* inputs — a module-global counter would leak between unit tests. */
|
|
@@ -31,6 +48,10 @@ export interface ReadinessReporterOptions {
|
|
|
31
48
|
publicApi: VenueReachabilityProbe;
|
|
32
49
|
/** Number of trading tools registered (a health signal). */
|
|
33
50
|
toolCount: number;
|
|
51
|
+
/** Resolve the stop-protection snapshot at CALL time (deferred closure over
|
|
52
|
+
* the runtime — follows paper↔live flips and adapter swaps). Absent/null →
|
|
53
|
+
* the `live_stop_protection` row is omitted, never fabricated. */
|
|
54
|
+
resolveStopProtection?: () => StopProtectionSnapshot | null;
|
|
34
55
|
fetchImpl?: typeof fetch;
|
|
35
56
|
intervalMs?: number;
|
|
36
57
|
requestTimeoutMs?: number;
|
|
@@ -46,7 +67,7 @@ export interface ReadinessReporterOptions {
|
|
|
46
67
|
* warn/fail drift is reported 'unknown' (not amber/red) so the readiness
|
|
47
68
|
* banner doesn't cry-wolf for ~5 min after every restart; a genuinely
|
|
48
69
|
* skewed clock still surfaces on cycle 2. */
|
|
49
|
-
export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'venue' | 'publicApi' | 'toolCount'>, bootWarmup?: boolean, deps?: {
|
|
70
|
+
export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'venue' | 'publicApi' | 'toolCount' | 'resolveStopProtection'>, bootWarmup?: boolean, deps?: {
|
|
50
71
|
/** Debounce memory. Omitted → a fresh state, so a lone unreachable reads
|
|
51
72
|
* `unknown`; only a caller that persists state across cycles can ever
|
|
52
73
|
* reach the warn rung. */
|
|
@@ -43,6 +43,45 @@ export function attributeStall(stallMs, runqueueWaitMs) {
|
|
|
43
43
|
const hostThreshold = Math.max(HOST_WAIT_FLOOR_MS, stallMs * HOST_WAIT_SHARE_OF_STALL);
|
|
44
44
|
return runqueueWaitMs >= hostThreshold ? 'host' : 'self';
|
|
45
45
|
}
|
|
46
|
+
/** Map a snapshot to the readiness check. Pure — the whole point of the row is
|
|
47
|
+
* that `fail` means REAL MONEY WITH NO STOP, so the mapping is unit-tested
|
|
48
|
+
* branch by branch. Null snapshot → null (older wiring: omit the row rather
|
|
49
|
+
* than fabricate a verdict). */
|
|
50
|
+
export function stopProtectionCheck(snap, checkedAt) {
|
|
51
|
+
if (!snap)
|
|
52
|
+
return null;
|
|
53
|
+
const live = snap.tradingMode === 'LIVE' || snap.tradingMode === 'MICRO_LIVE';
|
|
54
|
+
if (!live) {
|
|
55
|
+
return makeReadinessCheck('live_stop_protection', 'pass', {
|
|
56
|
+
detail: 'paper — software stop-watcher',
|
|
57
|
+
checkedAt,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
if (snap.venueEnforced) {
|
|
61
|
+
return makeReadinessCheck('live_stop_protection', 'pass', {
|
|
62
|
+
detail: 'venue-enforced exchange brackets',
|
|
63
|
+
checkedAt,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
if (snap.bracketMode === 'enforce') {
|
|
67
|
+
return makeReadinessCheck('live_stop_protection', 'pass', {
|
|
68
|
+
detail: 'exchange-native brackets (enforce)',
|
|
69
|
+
checkedAt,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (snap.bracketMode === 'observe') {
|
|
73
|
+
return makeReadinessCheck('live_stop_protection', 'pass', {
|
|
74
|
+
detail: 'exchange brackets (observe) + software watcher',
|
|
75
|
+
checkedAt,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
// LIVE with brackets off — with the live-enforce default this can only be an
|
|
79
|
+
// explicit operator override, and it must burn red on every surface.
|
|
80
|
+
return makeReadinessCheck('live_stop_protection', 'fail', {
|
|
81
|
+
detail: `brackets.mode='${snap.bracketMode}' on a ${snap.tradingMode} box`,
|
|
82
|
+
checkedAt,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
46
85
|
export function createReadinessCycleState() {
|
|
47
86
|
return { consecutiveReachFailures: 0 };
|
|
48
87
|
}
|
|
@@ -84,6 +123,17 @@ export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
|
|
|
84
123
|
detail: `${opts.toolCount} tools`,
|
|
85
124
|
checkedAt: now,
|
|
86
125
|
}));
|
|
126
|
+
// live_stop_protection — a local config/adapter read, no network. Resolved
|
|
127
|
+
// per cycle so a paper→live flip surfaces on the next report without a
|
|
128
|
+
// restart. Resolver failure → omit the row (absent evidence stays absent).
|
|
129
|
+
try {
|
|
130
|
+
const stopCheck = stopProtectionCheck(opts.resolveStopProtection?.() ?? null, now);
|
|
131
|
+
if (stopCheck)
|
|
132
|
+
checks.push(stopCheck);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
logger.warn(TAG, `stop-protection snapshot failed (row omitted): ${formatError(err)}`);
|
|
136
|
+
}
|
|
87
137
|
// ★ Probe FIRST, sample the loop delay AFTER. The freeze that makes a probe
|
|
88
138
|
// abort happens *during* the probe, so sampling first would file the evidence
|
|
89
139
|
// in the NEXT cycle's window — reachability would read 'stalled' this cycle
|
|
@@ -296,7 +346,12 @@ export function startReadinessReporter(opts) {
|
|
|
296
346
|
const state = createReadinessCycleState();
|
|
297
347
|
const cycle = async (bootWarmup) => {
|
|
298
348
|
try {
|
|
299
|
-
const report = await collectReadiness({
|
|
349
|
+
const report = await collectReadiness({
|
|
350
|
+
venue: opts.venue,
|
|
351
|
+
publicApi: opts.publicApi,
|
|
352
|
+
toolCount: opts.toolCount,
|
|
353
|
+
resolveStopProtection: opts.resolveStopProtection,
|
|
354
|
+
}, bootWarmup, { state });
|
|
300
355
|
await postReadiness(opts.apiBaseUrl, opts.token, report, fetchImpl, timeoutMs);
|
|
301
356
|
if (report.overall === 'fail') {
|
|
302
357
|
const failing = report.checks.filter((c) => c.status === 'fail').map((c) => c.id).join(', ');
|
|
@@ -63,6 +63,10 @@ export function buildAdapter(input) {
|
|
|
63
63
|
marketSlippagePct: readPluginConfig().hl?.marketSlippagePct,
|
|
64
64
|
microLive,
|
|
65
65
|
tradeIngest,
|
|
66
|
+
// Journal close capture (close-bypass fix) — same wiring the boot
|
|
67
|
+
// path passes; omitting it here would shed the capture on every
|
|
68
|
+
// reconnect-built adapter (the F9 class).
|
|
69
|
+
autoCapture: input.wiring?.autoCapture,
|
|
66
70
|
},
|
|
67
71
|
});
|
|
68
72
|
}
|
|
@@ -76,7 +76,11 @@ export declare class ExchangeSimulator extends EventEmitter {
|
|
|
76
76
|
* market branch prices off THIS instead of the last tick, and skips the
|
|
77
77
|
* stale-quote guard — the caller supplied the price, so quote age is
|
|
78
78
|
* irrelevant, and a protective exit must never be blocked (issue #202). */
|
|
79
|
-
referencePrice?: number
|
|
79
|
+
referencePrice?: number,
|
|
80
|
+
/** Set ONLY by closePosition for mechanical/operator-initiated exits —
|
|
81
|
+
* exempts them from the startup lockout. Never reachable from the
|
|
82
|
+
* agent-facing create_order path. */
|
|
83
|
+
protectiveExit?: boolean): CcxtOrder;
|
|
80
84
|
cancelOrder(orderId: string): CcxtOrder;
|
|
81
85
|
cancelAllOrders(symbol?: string): CcxtOrder[];
|
|
82
86
|
/**
|
|
@@ -290,17 +290,29 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
290
290
|
* market branch prices off THIS instead of the last tick, and skips the
|
|
291
291
|
* stale-quote guard — the caller supplied the price, so quote age is
|
|
292
292
|
* irrelevant, and a protective exit must never be blocked (issue #202). */
|
|
293
|
-
referencePrice
|
|
293
|
+
referencePrice,
|
|
294
|
+
/** Set ONLY by closePosition for mechanical/operator-initiated exits —
|
|
295
|
+
* exempts them from the startup lockout. Never reachable from the
|
|
296
|
+
* agent-facing create_order path. */
|
|
297
|
+
protectiveExit) {
|
|
294
298
|
// ---- Startup trade lockout ----
|
|
295
299
|
// Block trades during the first 15s after gateway restart IF there were
|
|
296
300
|
// existing positions at startup. This prevents stale agent sessions from
|
|
297
301
|
// selling positions before the session is cleared and the agent re-reads SKILL.md.
|
|
298
302
|
// Only activates when positions exist (nothing to protect if starting empty).
|
|
303
|
+
// Protective exits pass through: a stop breached 3s after a restart must
|
|
304
|
+
// close NOW — blocking the watcher here left positions unprotected for
|
|
305
|
+
// the whole window (open item since 2026-07-27).
|
|
299
306
|
const elapsed = Date.now() - this.startupTime;
|
|
300
307
|
if (this.hadPositionsAtStartup && elapsed < ExchangeSimulator.STARTUP_LOCKOUT_MS) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
308
|
+
if (protectiveExit) {
|
|
309
|
+
logger.info(TAG, `Startup lockout bypassed for protective exit: ${side} ${amount} ${symbol}`);
|
|
310
|
+
}
|
|
311
|
+
else {
|
|
312
|
+
const remaining = Math.ceil((ExchangeSimulator.STARTUP_LOCKOUT_MS - elapsed) / 1000);
|
|
313
|
+
logger.warn(TAG, `STARTUP LOCKOUT: Blocked ${side} ${amount} ${symbol} — ${remaining}s remaining. This prevents stale session trades during restart.`);
|
|
314
|
+
throw new Error(`Trade blocked: startup lockout (${remaining}s remaining). The gateway just restarted — wait for the agent to re-read its instructions and check positions before trading.`);
|
|
315
|
+
}
|
|
304
316
|
}
|
|
305
317
|
if (amount <= 0) {
|
|
306
318
|
throw new Error('Order amount must be positive');
|
|
@@ -410,9 +422,15 @@ export class ExchangeSimulator extends EventEmitter {
|
|
|
410
422
|
if (closeReason) {
|
|
411
423
|
position.metadata = { ...(position.metadata ?? {}), closeReason };
|
|
412
424
|
}
|
|
413
|
-
// Create opposing market order to close the position
|
|
425
|
+
// Create opposing market order to close the position.
|
|
426
|
+
// Mechanical / operator-initiated exits (stop_watcher, exchange_target,
|
|
427
|
+
// emergency, operator, bracket_attach_failed, …) must never wait out the
|
|
428
|
+
// startup lockout — a stop breached seconds after a restart has to close
|
|
429
|
+
// immediately. Only discretionary closes ('agent' or reason-less) keep
|
|
430
|
+
// the stale-session guard.
|
|
431
|
+
const protectiveExit = closeReason !== undefined && closeReason !== 'agent';
|
|
414
432
|
const closeSide = position.side === 'long' ? 'sell' : 'buy';
|
|
415
|
-
return this.createOrder(symbol, closeSide, 'market', position.quantity, undefined, undefined, referencePrice);
|
|
433
|
+
return this.createOrder(symbol, closeSide, 'market', position.quantity, undefined, undefined, referencePrice, protectiveExit);
|
|
416
434
|
}
|
|
417
435
|
/** Paper-only: move an open position's MUTABLE protective levels (stopPrice /
|
|
418
436
|
* targetPrice) in place and persist, WITHOUT the close+reopen round-trip
|
|
@@ -317,9 +317,18 @@ export async function closePositionTool(args, deps) {
|
|
|
317
317
|
'Use get_wave9_status for a reversal authorization or operator_command for an emergency close.');
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
|
+
// Thread the validated reason to the adapter as its CloseReason:
|
|
321
|
+
// 'operator_command' maps to 'operator' so an operator-driven close is
|
|
322
|
+
// never caught by the paper startup lockout (whose protective-exit
|
|
323
|
+
// carve-out keys on the reason — before this, the tool dropped the
|
|
324
|
+
// reason entirely and EVERY generic close arrived as discretionary).
|
|
325
|
+
// All other reasons are agent-discretionary by design and stay subject
|
|
326
|
+
// to the lockout; the rich reason still reaches the journal via
|
|
327
|
+
// close_reason, this only fixes the adapter-level class + metadata.
|
|
328
|
+
const adapterCloseReason = args.reason === 'operator_command' ? 'operator' : 'agent';
|
|
320
329
|
return wave9ExitClaimed
|
|
321
330
|
? deps.adapter.closePosition(args.symbol, 'wave9_signal_reversal')
|
|
322
|
-
: deps.adapter.closePosition(args.symbol);
|
|
331
|
+
: deps.adapter.closePosition(args.symbol, adapterCloseReason);
|
|
323
332
|
};
|
|
324
333
|
const closeWave9 = async () => {
|
|
325
334
|
if (!wave9Lease)
|
|
@@ -6,6 +6,7 @@ import { type HlCredentials } from './hl-private.js';
|
|
|
6
6
|
import type { BracketId } from '../../live/bracket-types.js';
|
|
7
7
|
import { BracketLedger } from '../../live/bracket-ledger.js';
|
|
8
8
|
import { HlBracketCoordinator } from './hl-bracket-coordinator.js';
|
|
9
|
+
import { type AutoCaptureContext } from '../../ingest/position-auto-capture.js';
|
|
9
10
|
import type { TradeIngestWiring } from '../../live/live-adapter.js';
|
|
10
11
|
export interface HlLiveAdapterOptions {
|
|
11
12
|
credentials: HlCredentials;
|
|
@@ -23,6 +24,16 @@ export interface HlLiveAdapterOptions {
|
|
|
23
24
|
* via userFillsByTime. Same object boot passes the Binance adapter —
|
|
24
25
|
* `exchange` MUST be fillExchangeId('hyperliquid'). */
|
|
25
26
|
tradeIngest?: TradeIngestWiring;
|
|
27
|
+
/** Position-decision journal wiring. When present, a close-direction user-
|
|
28
|
+
* stream fill (dir "Close …" or a liquidation fill) is routed through
|
|
29
|
+
* `onWsFillObserved` so an exchange-native bracket SL/TP fill journals an
|
|
30
|
+
* EXACT close (reason `bracket_fill`) instead of leaking as status='open'
|
|
31
|
+
* until the reconciler heals it as `reconciler_observed_flat` — the HL
|
|
32
|
+
* analog of the wiring Binance's ws-ingest has carried since PR #205.
|
|
33
|
+
* Entry/scale-in fills are deliberately NOT routed (the sync create_order
|
|
34
|
+
* path captures them; the WS dedup key is unverified on HL — see
|
|
35
|
+
* onUserFill). */
|
|
36
|
+
autoCapture?: AutoCaptureContext;
|
|
26
37
|
/** Test seams. Production omits both. */
|
|
27
38
|
bracketLedger?: BracketLedger;
|
|
28
39
|
disableUserStream?: boolean;
|
|
@@ -54,6 +65,10 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
|
|
|
54
65
|
/** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
|
|
55
66
|
* low-water mark the reconnect gap backfill widens from. */
|
|
56
67
|
private lastFillIngestMs;
|
|
68
|
+
/** oid → cloid backfill for fills that omit `cloid` (the journal close path
|
|
69
|
+
* recognizes bracket legs by client id). Populated from `orderUpdates`,
|
|
70
|
+
* which always carries both. Bounded, insertion-order eviction. */
|
|
71
|
+
private readonly oidToCloid;
|
|
57
72
|
/** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
|
|
58
73
|
* income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
|
|
59
74
|
* each balance fetch. Without this the skill self-computes a bogus anchor and
|
|
@@ -187,10 +202,20 @@ export declare class HyperliquidLiveAdapter extends EventEmitter implements IExc
|
|
|
187
202
|
* `startPosition` is the position BEFORE this fill — the WS-authoritative
|
|
188
203
|
* way to know the after-fill total without an extra REST read. */
|
|
189
204
|
private onUserFill;
|
|
205
|
+
/** Close-direction fill → position-decision journal (close-bypass fix, HL
|
|
206
|
+
* arm). Fire-and-forget: a journal POST blip must never touch the WS hot
|
|
207
|
+
* path. Reduce-only is derived from `dir` (HL fills carry no reduceOnly
|
|
208
|
+
* flag): "Close Long"/"Close Short", plus the liquidation marker. Flip
|
|
209
|
+
* dirs ("Long > Short") are NOT closes of a tracked side we understand —
|
|
210
|
+
* they stay with the reconciler backstop. */
|
|
211
|
+
private captureCloseFill;
|
|
190
212
|
/** `orderUpdates` is authoritative for leg lifecycle (the ALGO_UPDATE
|
|
191
|
-
* analog). A trigger = the exchange closed the position — surface the
|
|
213
|
+
* analog). A trigger = the exchange closed the position — surface the
|
|
192
214
|
* `drift_detected` close shape the Binance reconciler emits so the journal
|
|
193
|
-
* close-bypass cleanup fires
|
|
215
|
+
* close-bypass cleanup fires fast — but AFTER a short grace, so the close
|
|
216
|
+
* FILL (the exact-close journal path, `captureCloseFill`) wins the
|
|
217
|
+
* undocumented orderUpdates/userFills frame ordering. The cleanup is
|
|
218
|
+
* idempotent: state already dropped by the fill path ⇒ no-op. */
|
|
194
219
|
private onUserOrderUpdate;
|
|
195
220
|
/** T-5 REST truth-check — serialized so a slow pass can't stack. */
|
|
196
221
|
private runTruthCheck;
|
|
@@ -38,7 +38,8 @@ import { generateBracketId } from '../../live/bracket-id.js';
|
|
|
38
38
|
import { validateStopDirection, validateTargetDirection } from '../../live/bracket-params.js';
|
|
39
39
|
import { HlBracketCoordinator, isRejectedOrder, isTerminalBracketState, } from './hl-bracket-coordinator.js';
|
|
40
40
|
import { HyperliquidUserStream } from './hl-user-stream.js';
|
|
41
|
-
import { hlFillToFillEvent } from './hl-fill-ingest.js';
|
|
41
|
+
import { hlFillToFillEvent, hlCoinToCanonical } from './hl-fill-ingest.js';
|
|
42
|
+
import { onWsFillObserved } from '../../ingest/position-auto-capture.js';
|
|
42
43
|
import { formatError } from '../../logger.js';
|
|
43
44
|
const TAG = 'hl-live-adapter';
|
|
44
45
|
/** Venue-distinct ledger storage — a venue switch on the same box must never
|
|
@@ -55,6 +56,21 @@ const DEFAULT_MARKET_SLIPPAGE = 0.005;
|
|
|
55
56
|
/** Sticky cooldown after a failed open-orders fetch (the Binance lesson: the
|
|
56
57
|
* SKILL.md audit→attach loop re-calls every ~3s and would pin the budget). */
|
|
57
58
|
const OPEN_ORDERS_COOLDOWN_MS = 45_000;
|
|
59
|
+
/** Grace before a bracket-trigger `drift_detected` emit. A trigger's close FILL
|
|
60
|
+
* arrives on `userFills` and journals the exact close (real price, real PnL,
|
|
61
|
+
* reason `bracket_fill`); the drift path's cleanup can only post a generic
|
|
62
|
+
* `reconciler_observed_flat`. HL's WS frame ordering between `orderUpdates`
|
|
63
|
+
* and `userFills` is undocumented, so without this grace the generic close
|
|
64
|
+
* routinely won the race and 50% of HL live closes carried no close reason
|
|
65
|
+
* (wisekid, 30d to 2026-08-17: 150/299). The cleanup is idempotent — when the
|
|
66
|
+
* fill already journaled + dropped state, the delayed drift is a no-op; when
|
|
67
|
+
* the fill never arrives (T-5 gap), the drift still heals, 10s late instead
|
|
68
|
+
* of instant (previously ≤5 min via the periodic sweep). */
|
|
69
|
+
const TRIGGER_DRIFT_GRACE_MS = 10_000;
|
|
70
|
+
/** Bound on the oid→cloid map (fills MAY omit `cloid` — facts ledger §3.4 —
|
|
71
|
+
* while `orderUpdates` always carries both, so the map backfills the fill's
|
|
72
|
+
* client id for bracket recognition). Insertion-ordered eviction. */
|
|
73
|
+
const OID_CLOID_MAP_MAX = 512;
|
|
58
74
|
export class HyperliquidLiveAdapter extends EventEmitter {
|
|
59
75
|
opts;
|
|
60
76
|
api;
|
|
@@ -82,6 +98,10 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
82
98
|
/** exchangeTime of the newest fill ingested (WS or backfill) — the overlap
|
|
83
99
|
* low-water mark the reconnect gap backfill widens from. */
|
|
84
100
|
lastFillIngestMs = 0;
|
|
101
|
+
/** oid → cloid backfill for fills that omit `cloid` (the journal close path
|
|
102
|
+
* recognizes bracket legs by client id). Populated from `orderUpdates`,
|
|
103
|
+
* which always carries both. Bounded, insertion-order eviction. */
|
|
104
|
+
oidToCloid = new Map();
|
|
85
105
|
/** UTC-midnight Day-P&L anchor (KPI-must-equal-the-HL-app, §5.8). HL has no
|
|
86
106
|
* income endpoint, so the anchor is rebuilt from userFillsByTime + userFunding
|
|
87
107
|
* each balance fetch. Without this the skill self-computes a bogus anchor and
|
|
@@ -821,6 +841,19 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
821
841
|
// healed it, in a repeating flap. Exchange truth is the resync's job.
|
|
822
842
|
if (meta?.isSnapshot)
|
|
823
843
|
return;
|
|
844
|
+
// Journal close capture: a close-direction fill (bracket SL/TP trigger,
|
|
845
|
+
// liquidation, external reduce) bypasses close_position, and before this
|
|
846
|
+
// wiring the journal only learned about it from the reconciler — 50% of
|
|
847
|
+
// wisekid's 30d closes were `reconciler_observed_flat` heals with the
|
|
848
|
+
// close reason lost. Route it through the SAME generic path Binance's
|
|
849
|
+
// ws-ingest uses; `handleReduceOnlyExit` posts the exact close when the
|
|
850
|
+
// position goes flat and defers to close_position/backstop otherwise.
|
|
851
|
+
// ONLY close-direction fills are routed: entry/scale-in fills stay with
|
|
852
|
+
// the synchronous create_order capture, because the WS dedup key
|
|
853
|
+
// (`openedFromExchangeTradeId === exchangeOrderId`) is unverified against
|
|
854
|
+
// ccxt's HL order.id shape and a dedup miss would re-mint the 38-duplicate-
|
|
855
|
+
// pairs class (issue #199 twin bug).
|
|
856
|
+
this.captureCloseFill(fill);
|
|
824
857
|
try {
|
|
825
858
|
// Matches the primary entry cid OR any additional cid recorded for a
|
|
826
859
|
// scale-in / second resting entry (F5) — matching on `entryCid` alone
|
|
@@ -848,27 +881,82 @@ export class HyperliquidLiveAdapter extends EventEmitter {
|
|
|
848
881
|
logger.error(TAG, `onUserFill handler error: ${formatError(err)}`);
|
|
849
882
|
}
|
|
850
883
|
}
|
|
884
|
+
/** Close-direction fill → position-decision journal (close-bypass fix, HL
|
|
885
|
+
* arm). Fire-and-forget: a journal POST blip must never touch the WS hot
|
|
886
|
+
* path. Reduce-only is derived from `dir` (HL fills carry no reduceOnly
|
|
887
|
+
* flag): "Close Long"/"Close Short", plus the liquidation marker. Flip
|
|
888
|
+
* dirs ("Long > Short") are NOT closes of a tracked side we understand —
|
|
889
|
+
* they stay with the reconciler backstop. */
|
|
890
|
+
captureCloseFill(fill) {
|
|
891
|
+
const capture = this.opts.autoCapture;
|
|
892
|
+
if (!capture)
|
|
893
|
+
return;
|
|
894
|
+
const dir = (fill.dir ?? '').toLowerCase();
|
|
895
|
+
const isClose = dir.startsWith('close') || fill.liquidation !== undefined;
|
|
896
|
+
if (!isClose)
|
|
897
|
+
return;
|
|
898
|
+
const price = Number(fill.px);
|
|
899
|
+
const size = Number(fill.sz);
|
|
900
|
+
if (!Number.isFinite(price) || price <= 0 || !Number.isFinite(size) || size <= 0)
|
|
901
|
+
return;
|
|
902
|
+
const realizedPnl = Number(fill.closedPnl);
|
|
903
|
+
const cloid = typeof fill.cloid === 'string' && fill.cloid.length > 0
|
|
904
|
+
? fill.cloid
|
|
905
|
+
: this.oidToCloid.get(fill.oid);
|
|
906
|
+
onWsFillObserved(capture, {
|
|
907
|
+
symbol: hlCoinToCanonical(fill.coin),
|
|
908
|
+
side: fill.side === 'B' ? 'buy' : 'sell',
|
|
909
|
+
exchangeOrderId: String(fill.oid),
|
|
910
|
+
exchangeTradeId: String(fill.tid),
|
|
911
|
+
fillPrice: price,
|
|
912
|
+
fillSize: size,
|
|
913
|
+
reduceOnly: true,
|
|
914
|
+
realizedPnl: Number.isFinite(realizedPnl) ? realizedPnl : undefined,
|
|
915
|
+
clientOrderId: cloid,
|
|
916
|
+
exchangeTimeMs: Number.isFinite(fill.time) ? fill.time : undefined,
|
|
917
|
+
}).catch((err) => {
|
|
918
|
+
logger.warn(TAG, `journal close capture failed for ${fill.coin} oid=${fill.oid}: ${msg(err)}`);
|
|
919
|
+
});
|
|
920
|
+
}
|
|
851
921
|
/** `orderUpdates` is authoritative for leg lifecycle (the ALGO_UPDATE
|
|
852
|
-
* analog). A trigger = the exchange closed the position — surface the
|
|
922
|
+
* analog). A trigger = the exchange closed the position — surface the
|
|
853
923
|
* `drift_detected` close shape the Binance reconciler emits so the journal
|
|
854
|
-
* close-bypass cleanup fires
|
|
924
|
+
* close-bypass cleanup fires fast — but AFTER a short grace, so the close
|
|
925
|
+
* FILL (the exact-close journal path, `captureCloseFill`) wins the
|
|
926
|
+
* undocumented orderUpdates/userFills frame ordering. The cleanup is
|
|
927
|
+
* idempotent: state already dropped by the fill path ⇒ no-op. */
|
|
855
928
|
onUserOrderUpdate(update) {
|
|
856
929
|
try {
|
|
930
|
+
// oid→cloid backfill for fills that omit their client id (see
|
|
931
|
+
// captureCloseFill). orderUpdates always carries both.
|
|
932
|
+
const { oid, cloid } = update.order;
|
|
933
|
+
if (typeof oid === 'number' && typeof cloid === 'string' && cloid.length > 0) {
|
|
934
|
+
this.oidToCloid.set(oid, cloid);
|
|
935
|
+
if (this.oidToCloid.size > OID_CLOID_MAP_MAX) {
|
|
936
|
+
const oldest = this.oidToCloid.keys().next().value;
|
|
937
|
+
if (oldest !== undefined)
|
|
938
|
+
this.oidToCloid.delete(oldest);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
857
941
|
const transition = this.getHlBracketCoordinator().handleOrderUpdate(update);
|
|
858
942
|
if (transition === 'triggered_sl' || transition === 'triggered_tp' || transition === 'forced_close') {
|
|
859
943
|
const row = this.getHlBracketCoordinator().getLedger().getAll().find((r) => update.order.cloid && (r.slCid === update.order.cloid || r.tpCid === update.order.cloid));
|
|
860
944
|
const symbol = row?.symbol;
|
|
861
945
|
if (symbol) {
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
946
|
+
const qty = row?.qty ?? 0;
|
|
947
|
+
const timer = setTimeout(() => {
|
|
948
|
+
this.emit('drift_detected', {
|
|
949
|
+
timestamp: new Date().toISOString(),
|
|
950
|
+
drifts: [
|
|
951
|
+
{
|
|
952
|
+
type: 'closed',
|
|
953
|
+
symbol,
|
|
954
|
+
localContracts: qty,
|
|
955
|
+
},
|
|
956
|
+
],
|
|
957
|
+
});
|
|
958
|
+
}, TRIGGER_DRIFT_GRACE_MS);
|
|
959
|
+
timer.unref?.();
|
|
872
960
|
}
|
|
873
961
|
}
|
|
874
962
|
}
|
|
@@ -2,7 +2,7 @@ export type ReadinessStatus = 'pass' | 'warn' | 'fail' | 'unknown';
|
|
|
2
2
|
/** Which lifecycle phase a check belongs to. `connect` runs with NO Binance API
|
|
3
3
|
* keys (covers paper trading too); `golive` needs keys (Phase 2). */
|
|
4
4
|
export type ReadinessPhase = 'connect' | 'golive';
|
|
5
|
-
export type ReadinessCheckId = 'binance_reachable' | 'hyperliquid_reachable' | 'clock_in_sync' | 'plugin_loaded' | 'tools_registered' | 'host_responsive';
|
|
5
|
+
export type ReadinessCheckId = 'binance_reachable' | 'hyperliquid_reachable' | 'clock_in_sync' | 'plugin_loaded' | 'tools_registered' | 'host_responsive' | 'live_stop_protection';
|
|
6
6
|
/** What a venue reachability probe concluded.
|
|
7
7
|
*
|
|
8
8
|
* ★ `stalled` is the one that is NOT about the venue: it means the probe's own
|
|
@@ -68,6 +68,17 @@ export const READINESS_CHECK_COPY = {
|
|
|
68
68
|
phase: 'connect',
|
|
69
69
|
fixHint: 'The agent process froze for seconds at a time, so order handling, stop attachment and bracket resync can be delayed by that much. Two things cause this and we could not tell them apart on this host: the host being short of CPU/memory (other containers, agents or a leaking process competing for the box), or something inside the agent process blocking it — note the AI agent itself runs in this same process. Check host load first; if it is idle, the freeze came from inside.',
|
|
70
70
|
},
|
|
71
|
+
// The safety-floor visibility row (2026-08-25, E2E audit #3): a live box
|
|
72
|
+
// whose stop protection is not armed used to be indistinguishable from a
|
|
73
|
+
// healthy one on every dashboard surface. `fail` here means REAL MONEY WITH
|
|
74
|
+
// NO STOP — the one readiness state that is never acceptable. Emitted with a
|
|
75
|
+
// detail naming the active protection (exchange brackets / venue-enforced /
|
|
76
|
+
// paper watcher) so a passing row is informative, not just green.
|
|
77
|
+
live_stop_protection: {
|
|
78
|
+
label: 'Live stop protection',
|
|
79
|
+
phase: 'connect',
|
|
80
|
+
fixHint: 'This agent is LIVE but exchange-side stop protection is OFF — positions have no stop-loss the exchange would honor if the agent host dies. This only happens when brackets.mode was explicitly set to "off" in the agent\'s plugin-config.json (live boxes default to enforce). Remove that override (or set brackets.mode to "enforce") and restart the agent.',
|
|
81
|
+
},
|
|
71
82
|
};
|
|
72
83
|
/** Assertive copy for a stall we COULD attribute, passed explicitly as the
|
|
73
84
|
* `fixHint` override — the same "only name a cause you measured" rule that
|
package/dist/openclaw.js
CHANGED
|
@@ -74,7 +74,7 @@ export function mergeReefClawConfig(input, connect = {}) {
|
|
|
74
74
|
// --- tool profile widening ---
|
|
75
75
|
// `openclaw setup` defaults tools.profile to "coding" (2026.6+), whose fixed
|
|
76
76
|
// core allowlist filters out ALL plugin tools — the agent would see none of
|
|
77
|
-
// the
|
|
77
|
+
// the 66 trading tools. tools.alsoAllow explicitly widens the profile;
|
|
78
78
|
// group:plugins covers every plugin-provided tool. Union, never replace.
|
|
79
79
|
const existingAlsoAllow = Array.isArray(tools.alsoAllow) ? tools.alsoAllow.map(String) : [];
|
|
80
80
|
if (!existingAlsoAllow.includes('group:plugins')) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reefclaw/connect",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.33",
|
|
4
4
|
"description": "One-command installer that connects your OpenClaw agent to ReefClaw (paper trading, no exchange keys).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"bundle-assets": "node scripts/bundle-assets.mjs",
|
|
18
|
+
"prepack": "npm run build",
|
|
18
19
|
"build": "tsc && node scripts/bundle-assets.mjs",
|
|
19
20
|
"test": "vitest",
|
|
20
21
|
"test:run": "vitest run"
|