@reefclaw/connect 0.1.32 → 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/plugin/config/brackets-config.d.ts +2 -1
- package/assets/plugin/config/brackets-config.js +25 -3
- package/assets/plugin/index.js +19 -2
- package/assets/plugin/ingest/readiness-reporter.d.ts +23 -2
- package/assets/plugin/ingest/readiness-reporter.js +56 -1
- package/assets/shared/readiness.d.ts +1 -1
- package/assets/shared/readiness.js +11 -0
- package/package.json +1 -1
|
@@ -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.
|
|
@@ -2925,6 +2934,14 @@ const paperTradingPlugin = {
|
|
|
2925
2934
|
venue,
|
|
2926
2935
|
publicApi: hlPublicApi ?? binanceApi,
|
|
2927
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
|
+
}),
|
|
2928
2945
|
});
|
|
2929
2946
|
maybeStartConnectorSupervisor();
|
|
2930
2947
|
},
|
|
@@ -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(', ');
|
|
@@ -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
|