@reefclaw/openclaw-plugin 0.1.12 → 0.1.14
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/gateway/event-parser.d.ts +19 -0
- package/bridge/gateway/event-parser.js +52 -0
- package/bridge/gateway/gateway-config.d.ts +16 -5
- package/bridge/gateway/gateway-config.js +68 -12
- package/bridge/gateway/heartbeat-cron.d.ts +1 -0
- package/bridge/gateway/heartbeat-cron.js +22 -1
- package/bridge/gateway/poller.js +18 -8
- package/bridge/providers/emergency-commands.d.ts +9 -1
- package/bridge/providers/emergency-commands.js +38 -1
- package/bridge/providers/gateway.d.ts +72 -1
- package/bridge/providers/gateway.js +241 -29
- package/bridge/providers/onboarding-commands.d.ts +8 -0
- package/bridge/providers/onboarding-commands.js +4 -4
- package/bridge/types.d.ts +30 -0
- package/bridge/utils/identity-name.d.ts +24 -0
- package/bridge/utils/identity-name.js +54 -0
- package/ccxt/binance-public.d.ts +21 -7
- package/ccxt/binance-public.js +70 -6
- package/config/operator-provenance.d.ts +6 -0
- package/config/operator-provenance.js +50 -0
- package/config/plugin-config-io.d.ts +15 -1
- package/config/plugin-config-io.js +24 -0
- package/index.js +216 -173
- package/ingest/event-loop-monitor.d.ts +11 -0
- package/ingest/event-loop-monitor.js +113 -0
- package/ingest/position-auto-capture.d.ts +5 -0
- package/ingest/position-auto-capture.js +14 -5
- package/ingest/readiness-reporter.d.ts +17 -6
- package/ingest/readiness-reporter.js +88 -9
- package/ingest/skill-version-reader.d.ts +16 -0
- package/ingest/skill-version-reader.js +64 -0
- package/live/approval-lifecycle.d.ts +30 -0
- package/live/approval-lifecycle.js +80 -0
- package/live/bracket-types.d.ts +9 -0
- package/live/live-adapter.d.ts +0 -1
- package/live/proposal-decision-listener.d.ts +20 -0
- package/live/proposal-decision-listener.js +211 -48
- package/onboarding/runtime.d.ts +34 -1
- package/onboarding/runtime.js +56 -5
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -2
- package/simulator/exchange-simulator.d.ts +45 -2
- package/simulator/exchange-simulator.js +96 -4
- package/simulator/types.d.ts +17 -0
- package/tools/attach-brackets.js +50 -1
- package/tools/create-order.d.ts +11 -0
- package/tools/create-order.js +23 -2
- package/tools/get-risk-summary.d.ts +4 -0
- package/tools/get-risk-summary.js +62 -23
- package/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
- package/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
- package/venues/hyperliquid/hl-brackets.d.ts +10 -0
- package/venues/hyperliquid/hl-brackets.js +45 -13
- package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
- package/venues/hyperliquid/hl-fill-ingest.js +69 -0
- package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
- package/venues/hyperliquid/hl-live-adapter.js +155 -12
- package/venues/hyperliquid/hl-order.d.ts +35 -0
- package/venues/hyperliquid/hl-order.js +123 -0
- package/venues/hyperliquid/hl-position.d.ts +36 -0
- package/venues/hyperliquid/hl-position.js +127 -0
- package/venues/hyperliquid/hl-private.d.ts +20 -3
- package/venues/hyperliquid/hl-private.js +37 -6
- package/venues/hyperliquid/hl-public.d.ts +12 -5
- package/venues/hyperliquid/hl-public.js +24 -3
- package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
- package/venues/hyperliquid/hl-user-stream.js +4 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Event-loop delay monitor — the signal that tells "this process was frozen"
|
|
2
|
+
// apart from "the venue was unreachable" (issue #265).
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: the readiness probe wraps its request in a 10s abort timer.
|
|
5
|
+
// When the HOST starves the process (CPU contention, a neighbouring container
|
|
6
|
+
// leaking into swap), the loop stops running — the request never gets a fair
|
|
7
|
+
// chance and the abort fires late. The old code fell into one catch and blamed
|
|
8
|
+
// the venue, producing a false "cannot reach the API — check DNS/firewall"
|
|
9
|
+
// banner. Live evidence on the HL rig: a 10s timer that fired 86s late, with
|
|
10
|
+
// OpenClaw's own diagnostic recording loop delays up to 556s at 2-17% of one
|
|
11
|
+
// core (loop runnable, never scheduled).
|
|
12
|
+
//
|
|
13
|
+
// perf_hooks.monitorEventLoopDelay samples libuv timer lag into an
|
|
14
|
+
// IntervalHistogram. Its internal timer does NOT hold the process open, and
|
|
15
|
+
// every entry point is wrapped so a failure here can never break readiness
|
|
16
|
+
// reporting — an unavailable monitor reports `null` (⇒ 'unknown'), never a
|
|
17
|
+
// fabricated zero.
|
|
18
|
+
// ★ Static import, NOT require(): this package is ESM ("type":"module" +
|
|
19
|
+
// module:ES2022), so tsc emits a bare `require` verbatim and Node throws
|
|
20
|
+
// ReferenceError on it. The throw landed in the catch below, which set
|
|
21
|
+
// initFailed and disabled the monitor FOREVER — the check would have reported
|
|
22
|
+
// 'unknown' on every cycle in production while every test passed (vitest's
|
|
23
|
+
// CJS interop defines `require`). node:perf_hooks is a builtin with no
|
|
24
|
+
// CJS-shape problem, so there is nothing here to defer: the ccxt-style
|
|
25
|
+
// createRequire dance exists for that dependency's module shape, not for
|
|
26
|
+
// builtins. See docs/CLAUDE/plugin-integration.md.
|
|
27
|
+
import { monitorEventLoopDelay } from 'node:perf_hooks';
|
|
28
|
+
import { logger } from '../logger.js';
|
|
29
|
+
const TAG = 'event-loop-monitor';
|
|
30
|
+
/** Sampling resolution. 20ms is fine-grained enough to catch a real stall
|
|
31
|
+
* without meaningful overhead — we only ever read the MAX. */
|
|
32
|
+
const RESOLUTION_MS = 20;
|
|
33
|
+
/** How long after enabling before a reading means anything.
|
|
34
|
+
*
|
|
35
|
+
* ★ The histogram is BLIND to a block that happens in the same tick as
|
|
36
|
+
* `enable()`: Node baselines it at its first internal timer fire, not at the
|
|
37
|
+
* enable call, so a stall that predates that fire is never attributed.
|
|
38
|
+
* Measured (node 22): identical 300ms block reads 30ms when it runs in the
|
|
39
|
+
* enable tick vs 300ms once the loop has turned. The floor it reports instead
|
|
40
|
+
* (~30ms on Windows, the timer-coalescing noise) looks exactly like a healthy
|
|
41
|
+
* host — a `pass` from a measurement that CANNOT fail, which is the same
|
|
42
|
+
* false-green this check exists to kill. So we withhold judgement until the
|
|
43
|
+
* histogram's own timer has fired: this delay is > 2× RESOLUTION_MS, and a
|
|
44
|
+
* starved loop only pushes it later, never earlier. */
|
|
45
|
+
const ARM_DELAY_MS = 50;
|
|
46
|
+
let histogram = null;
|
|
47
|
+
let initFailed = false;
|
|
48
|
+
/** False until the histogram can actually attribute a stall (see ARM_DELAY_MS).
|
|
49
|
+
* While false, sampling reports `null` ⇒ 'unknown' rather than the floor. */
|
|
50
|
+
let armed = false;
|
|
51
|
+
/** Lazily create + enable the histogram. Returns null if the runtime refuses
|
|
52
|
+
* one (never throws — readiness must survive a missing monitor). */
|
|
53
|
+
function getHistogram() {
|
|
54
|
+
if (histogram || initFailed)
|
|
55
|
+
return histogram;
|
|
56
|
+
try {
|
|
57
|
+
const h = monitorEventLoopDelay({ resolution: RESOLUTION_MS });
|
|
58
|
+
h.enable();
|
|
59
|
+
histogram = h;
|
|
60
|
+
// unref'd: arming must never be a reason the process stays alive.
|
|
61
|
+
const t = setTimeout(() => {
|
|
62
|
+
armed = true;
|
|
63
|
+
}, ARM_DELAY_MS);
|
|
64
|
+
t.unref?.();
|
|
65
|
+
return histogram;
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
initFailed = true;
|
|
69
|
+
logger.warn(TAG, `event-loop monitor unavailable (host-responsiveness check will report unknown): ${err instanceof Error ? err.message : String(err)}`);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Start sampling. Safe to call repeatedly (idempotent). */
|
|
74
|
+
export function startEventLoopMonitor() {
|
|
75
|
+
getHistogram();
|
|
76
|
+
}
|
|
77
|
+
/** Worst event-loop delay (ms) observed since the previous call, then reset —
|
|
78
|
+
* so consecutive calls partition the timeline into non-overlapping windows.
|
|
79
|
+
* Returns null when the monitor is unavailable or has no sample yet.
|
|
80
|
+
*
|
|
81
|
+
* The histogram stores nanoseconds; `max` is Infinity-safe but can read as a
|
|
82
|
+
* sentinel before the first sample lands, so anything non-finite → null. */
|
|
83
|
+
export function sampleEventLoopDelayMs() {
|
|
84
|
+
const h = getHistogram();
|
|
85
|
+
if (!h)
|
|
86
|
+
return null;
|
|
87
|
+
// Not yet able to measure — report 'unknown' rather than the idle floor, which
|
|
88
|
+
// would read as a healthy host on a boot cycle that never observed anything.
|
|
89
|
+
if (!armed)
|
|
90
|
+
return null;
|
|
91
|
+
try {
|
|
92
|
+
const maxNs = Number(h.max);
|
|
93
|
+
h.reset();
|
|
94
|
+
if (!Number.isFinite(maxNs) || maxNs < 0)
|
|
95
|
+
return null;
|
|
96
|
+
return maxNs / 1e6;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Test-only — drop the singleton so a fresh histogram is created. */
|
|
103
|
+
export function __resetEventLoopMonitorForTests() {
|
|
104
|
+
try {
|
|
105
|
+
histogram?.disable();
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* ignore */
|
|
109
|
+
}
|
|
110
|
+
histogram = null;
|
|
111
|
+
initFailed = false;
|
|
112
|
+
armed = false;
|
|
113
|
+
}
|
|
@@ -105,6 +105,11 @@ export interface StopWatcherCloseInputs {
|
|
|
105
105
|
symbol: string;
|
|
106
106
|
stopPrice: number;
|
|
107
107
|
markPrice: number;
|
|
108
|
+
/** Which protective leg fired. Defaults to the stop (the historical caller).
|
|
109
|
+
* 'exchange_target' is the paper take-profit leg — the same journal path,
|
|
110
|
+
* because a target close bypasses close_position exactly like a stop does
|
|
111
|
+
* and would otherwise leave a phantom-open row (issue #199). */
|
|
112
|
+
leg?: 'stop_watcher' | 'exchange_target';
|
|
108
113
|
/** The executed close order from adapter.closePosition (may be absent if the
|
|
109
114
|
* close resolved through a path that didn't surface it). */
|
|
110
115
|
order?: CcxtOrder;
|
|
@@ -22,6 +22,7 @@ import { logger } from '../logger.js';
|
|
|
22
22
|
import { isBracketCid } from '../live/bracket-id.js';
|
|
23
23
|
import { normalizeBracketSymbol } from '../live/bracket-ledger.js';
|
|
24
24
|
import { fillPriceFromOrder } from '../live/fill-price.js';
|
|
25
|
+
import { getSkillVersionCached, withSkillVersion } from './skill-version-reader.js';
|
|
25
26
|
const TAG = 'position-auto-capture';
|
|
26
27
|
/** Flatness tolerance for remaining-contracts tracking. Reduce-only fills sum
|
|
27
28
|
* exactly to the position size on Binance, so any residual below this is noise
|
|
@@ -428,14 +429,19 @@ export async function onStopWatcherClose(ctx, inputs) {
|
|
|
428
429
|
dropState();
|
|
429
430
|
return;
|
|
430
431
|
}
|
|
432
|
+
const leg = inputs.leg ?? 'stop_watcher';
|
|
431
433
|
const close = {
|
|
432
434
|
positionId: stateEntry.webappPositionId,
|
|
433
435
|
closeAt: closeAtMs,
|
|
434
|
-
closeReason:
|
|
436
|
+
closeReason: leg,
|
|
435
437
|
closeAssessment: {
|
|
436
|
-
note:
|
|
437
|
-
'
|
|
438
|
-
|
|
438
|
+
note: leg === 'exchange_target'
|
|
439
|
+
? 'Position auto-closed by the paper take-profit leg: price reached the pinned targetPrice. ' +
|
|
440
|
+
'The live analog is an exchange-native TAKE_PROFIT_MARKET fill. Journaled from the close ' +
|
|
441
|
+
'fill — no close_position call.'
|
|
442
|
+
: 'Position auto-closed by the stop-watcher: mark crossed the pinned stopPrice. ' +
|
|
443
|
+
'Journaled from the watcher close fill — no close_position call (issue #199).',
|
|
444
|
+
observedFrom: leg,
|
|
439
445
|
stop_price: inputs.stopPrice,
|
|
440
446
|
mark_price: inputs.markPrice,
|
|
441
447
|
...(paperTrade
|
|
@@ -669,7 +675,10 @@ function buildEntryPayload(args) {
|
|
|
669
675
|
fillPrice: args.fillPrice,
|
|
670
676
|
fillSize: args.fillSize,
|
|
671
677
|
exchangeTradeId: args.exchangeTradeId,
|
|
672
|
-
|
|
678
|
+
// skill_version stamp: makes every SKILL.md release measurable against
|
|
679
|
+
// outcomes (capture ratio / scratch rate per doctrine version). Fail-soft —
|
|
680
|
+
// unreadable/absent SKILL.md just omits the key.
|
|
681
|
+
metadata: withSkillVersion(buildEntryPlanMetadata(md), getSkillVersionCached()),
|
|
673
682
|
};
|
|
674
683
|
}
|
|
675
684
|
/** Pinned-plan subset persisted to position_entries.metadata (free-form JSONB).
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
import { type ReadinessReport, type VenueId } from '@reefclaw/shared';
|
|
1
|
+
import { type ReadinessReport, type VenueId, type VenueReachabilityResult } from '@reefclaw/shared';
|
|
2
2
|
/** Venue-agnostic reachability probe — BinancePublicApi.probeReachability and
|
|
3
3
|
* HyperliquidPublicApi.probeReachability both return exactly this shape. */
|
|
4
4
|
export interface VenueReachabilityProbe {
|
|
5
|
-
probeReachability(): Promise<
|
|
6
|
-
outcome: 'reachable' | 'geo_blocked' | 'unreachable' | 'unknown';
|
|
7
|
-
driftMs: number | null;
|
|
8
|
-
}>;
|
|
5
|
+
probeReachability(): Promise<VenueReachabilityResult>;
|
|
9
6
|
}
|
|
7
|
+
/** Cross-cycle memory for the debounced rungs. Held by the interval loop and
|
|
8
|
+
* passed in explicitly so `collectReadiness` stays a pure function of its
|
|
9
|
+
* inputs — a module-global counter would leak between unit tests. */
|
|
10
|
+
export interface ReadinessCycleState {
|
|
11
|
+
consecutiveReachFailures: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function createReadinessCycleState(): ReadinessCycleState;
|
|
10
14
|
export interface ReadinessReporterOptions {
|
|
11
15
|
apiBaseUrl: string;
|
|
12
16
|
token: string;
|
|
@@ -35,7 +39,14 @@ export interface ReadinessReporterOptions {
|
|
|
35
39
|
* warn/fail drift is reported 'unknown' (not amber/red) so the readiness
|
|
36
40
|
* banner doesn't cry-wolf for ~5 min after every restart; a genuinely
|
|
37
41
|
* skewed clock still surfaces on cycle 2. */
|
|
38
|
-
export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'venue' | 'publicApi' | 'toolCount'>, bootWarmup?: boolean
|
|
42
|
+
export declare function collectReadiness(opts: Pick<ReadinessReporterOptions, 'venue' | 'publicApi' | 'toolCount'>, bootWarmup?: boolean, deps?: {
|
|
43
|
+
/** Debounce memory. Omitted → a fresh state, so a lone unreachable reads
|
|
44
|
+
* `unknown`; only a caller that persists state across cycles can ever
|
|
45
|
+
* reach the warn rung. */
|
|
46
|
+
state?: ReadinessCycleState;
|
|
47
|
+
/** Injected for tests; production reads the real event-loop histogram. */
|
|
48
|
+
sampleHostStallMs?: () => number | null;
|
|
49
|
+
}): Promise<ReadinessReport>;
|
|
39
50
|
/** Test-only — reset the singleton guard between unit tests. */
|
|
40
51
|
export declare function __resetReadinessReporterForTests(): void;
|
|
41
52
|
/** Fire the readiness report once at boot then on an unref'd interval. */
|
|
@@ -8,13 +8,26 @@
|
|
|
8
8
|
// module-level singleton guard mirrors `pluginInitialised` in index.ts — OpenClaw
|
|
9
9
|
// calls register() multiple times per process and we must never spawn a second
|
|
10
10
|
// timer. The interval is unref()'d so it never holds the process open.
|
|
11
|
-
import { makeReadinessCheck, deriveOverallReadiness, } from '@reefclaw/shared';
|
|
11
|
+
import { makeReadinessCheck, deriveOverallReadiness, GEO_BLOCK_FIX_HINT, } from '@reefclaw/shared';
|
|
12
12
|
import { logger, formatError } from '../logger.js';
|
|
13
|
+
import { startEventLoopMonitor, sampleEventLoopDelayMs } from './event-loop-monitor.js';
|
|
13
14
|
const TAG = 'readiness';
|
|
14
15
|
const DEFAULT_INTERVAL_MS = 300_000; // 5 min — geo/clock state changes rarely.
|
|
15
16
|
const MIN_INTERVAL_MS = 60_000;
|
|
16
17
|
/** Best-effort display fact; kept in sync with the register() banner in index.ts. */
|
|
17
18
|
const PLUGIN_VERSION = '3.8.0';
|
|
19
|
+
/** Consecutive non-pass reachability probes required before the banner goes
|
|
20
|
+
* amber. One 5-min sample is not enough evidence to send an operator hunting a
|
|
21
|
+
* network fault — the intel-health AMBER rung already debounces the same way
|
|
22
|
+
* (docs/CLAUDE/intel-health.md), readiness had no equivalent (issue #265). */
|
|
23
|
+
const REACH_WARN_AFTER_FAILURES = 2;
|
|
24
|
+
/** Worst event-loop delay in a cycle that still counts as a responsive host.
|
|
25
|
+
* Well above ordinary GC/boot jitter (tens to low hundreds of ms) and far
|
|
26
|
+
* below the freezes worth alarming on — the live incident produced 89s-556s. */
|
|
27
|
+
const HOST_STALL_WARN_MS = 5_000;
|
|
28
|
+
export function createReadinessCycleState() {
|
|
29
|
+
return { consecutiveReachFailures: 0 };
|
|
30
|
+
}
|
|
18
31
|
function resolveIntervalMs(explicit) {
|
|
19
32
|
if (explicit && explicit > 0)
|
|
20
33
|
return Math.max(MIN_INTERVAL_MS, explicit);
|
|
@@ -34,9 +47,11 @@ function resolveIntervalMs(explicit) {
|
|
|
34
47
|
* warn/fail drift is reported 'unknown' (not amber/red) so the readiness
|
|
35
48
|
* banner doesn't cry-wolf for ~5 min after every restart; a genuinely
|
|
36
49
|
* skewed clock still surfaces on cycle 2. */
|
|
37
|
-
export async function collectReadiness(opts, bootWarmup = false) {
|
|
50
|
+
export async function collectReadiness(opts, bootWarmup = false, deps = {}) {
|
|
38
51
|
const now = Date.now();
|
|
39
52
|
const checks = [];
|
|
53
|
+
const state = deps.state ?? createReadinessCycleState();
|
|
54
|
+
const sampleStall = deps.sampleHostStallMs ?? sampleEventLoopDelayMs;
|
|
40
55
|
// The venue decides which reachability check this report carries; the copy
|
|
41
56
|
// for both ids lives in shared/src/readiness.ts. Clock drift comes from the
|
|
42
57
|
// same probe on both venues (Binance fapi serverTime / Hyperliquid
|
|
@@ -50,9 +65,41 @@ export async function collectReadiness(opts, bootWarmup = false) {
|
|
|
50
65
|
detail: `${opts.toolCount} tools`,
|
|
51
66
|
checkedAt: now,
|
|
52
67
|
}));
|
|
53
|
-
//
|
|
68
|
+
// ★ Probe FIRST, sample the loop delay AFTER. The freeze that makes a probe
|
|
69
|
+
// abort happens *during* the probe, so sampling first would file the evidence
|
|
70
|
+
// in the NEXT cycle's window — reachability would read 'stalled' this cycle
|
|
71
|
+
// while host_responsive still read 'pass', putting the two halves of one
|
|
72
|
+
// incident 5 minutes apart. The checks are still pushed in display order.
|
|
54
73
|
const probe = await opts.publicApi.probeReachability();
|
|
74
|
+
// host_responsive — the signal that actually matters on a trading rig, and the
|
|
75
|
+
// one whose absence let a starved host masquerade as an unreachable venue.
|
|
76
|
+
// A null sample means the monitor is unavailable or has no window yet: report
|
|
77
|
+
// 'unknown', never a fabricated healthy zero — except that a probe which
|
|
78
|
+
// measured its own overshoot IS a stall measurement, so it stands in.
|
|
79
|
+
const stallMs = sampleStall() ?? (probe.outcome === 'stalled' ? (probe.stallMs ?? null) : null);
|
|
80
|
+
if (stallMs == null) {
|
|
81
|
+
checks.push(makeReadinessCheck('host_responsive', 'unknown', { checkedAt: now }));
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const rawStatus = stallMs < HOST_STALL_WARN_MS ? 'pass' : 'warn';
|
|
85
|
+
// Boot congestion (WS start, snapshot, seed all racing) legitimately stalls
|
|
86
|
+
// the loop for seconds — same warm-up rule as the clock check below.
|
|
87
|
+
const status = bootWarmup && rawStatus !== 'pass' ? 'unknown' : rawStatus;
|
|
88
|
+
checks.push(makeReadinessCheck('host_responsive', status, {
|
|
89
|
+
detail: rawStatus === 'pass'
|
|
90
|
+
? `max loop delay ${Math.round(stallMs)}ms`
|
|
91
|
+
: bootWarmup
|
|
92
|
+
? `froze ${(stallMs / 1000).toFixed(1)}s (boot warm-up — rechecking)`
|
|
93
|
+
: `froze ${(stallMs / 1000).toFixed(1)}s`,
|
|
94
|
+
checkedAt: now,
|
|
95
|
+
}));
|
|
96
|
+
if (rawStatus !== 'pass' && !bootWarmup) {
|
|
97
|
+
logger.warn(TAG, `host starved: event loop froze ${(stallMs / 1000).toFixed(1)}s this cycle — order handling and bracket resync can lag by that much`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// venue reachability (+ clock drift from the same probe response)
|
|
55
101
|
if (probe.outcome === 'reachable') {
|
|
102
|
+
state.consecutiveReachFailures = 0;
|
|
56
103
|
checks.push(makeReadinessCheck(reachId, 'pass', { checkedAt: now }));
|
|
57
104
|
const drift = probe.driftMs;
|
|
58
105
|
if (drift == null) {
|
|
@@ -74,21 +121,47 @@ export async function collectReadiness(opts, bootWarmup = false) {
|
|
|
74
121
|
}
|
|
75
122
|
}
|
|
76
123
|
else if (probe.outcome === 'geo_blocked') {
|
|
124
|
+
// The venue ANSWERED (451/403) — so the host reached it, and this is the one
|
|
125
|
+
// reachability verdict we may state as a cause. Clears the debounce counter
|
|
126
|
+
// and carries the assertive copy rather than the cautious default.
|
|
127
|
+
state.consecutiveReachFailures = 0;
|
|
77
128
|
checks.push(makeReadinessCheck(reachId, 'fail', {
|
|
78
129
|
detail: opts.venue === 'binance' ? 'HTTP 451' : 'blocked (HTTP 451/403)',
|
|
130
|
+
fixHint: GEO_BLOCK_FIX_HINT[opts.venue === 'binance' ? 'binance' : 'hyperliquid'],
|
|
131
|
+
checkedAt: now,
|
|
132
|
+
}));
|
|
133
|
+
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
134
|
+
}
|
|
135
|
+
else if (probe.outcome === 'stalled') {
|
|
136
|
+
// ★ Our own process was starved, so the probe proves nothing about the venue
|
|
137
|
+
// (issue #265). Report 'unknown' — which never turns the banner amber — and
|
|
138
|
+
// leave the debounce counter untouched: a non-observation is not evidence
|
|
139
|
+
// either way. host_responsive above carries the signal that IS actionable.
|
|
140
|
+
const secs = probe.stallMs != null ? (probe.stallMs / 1000).toFixed(1) : '?';
|
|
141
|
+
checks.push(makeReadinessCheck(reachId, 'unknown', {
|
|
142
|
+
detail: `not measurable — agent host froze ${secs}s`,
|
|
79
143
|
checkedAt: now,
|
|
80
144
|
}));
|
|
81
145
|
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
82
146
|
}
|
|
83
147
|
else if (probe.outcome === 'unreachable') {
|
|
84
|
-
// Network/DNS/timeout
|
|
85
|
-
//
|
|
86
|
-
// re-checks; only the
|
|
87
|
-
|
|
148
|
+
// Network/DNS/timeout. Debounced: one 5-min sample is not enough to alarm,
|
|
149
|
+
// so the first failure reports 'unknown' and only a SECOND consecutive one
|
|
150
|
+
// goes amber. A persistent problem stays amber across re-checks; only the
|
|
151
|
+
// geo-block is a hard red.
|
|
152
|
+
state.consecutiveReachFailures += 1;
|
|
153
|
+
const confirmed = state.consecutiveReachFailures >= REACH_WARN_AFTER_FAILURES;
|
|
154
|
+
checks.push(makeReadinessCheck(reachId, confirmed ? 'warn' : 'unknown', {
|
|
155
|
+
detail: confirmed
|
|
156
|
+
? `unreachable (${state.consecutiveReachFailures} consecutive checks)`
|
|
157
|
+
: 'one failed check — rechecking',
|
|
158
|
+
checkedAt: now,
|
|
159
|
+
}));
|
|
88
160
|
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
89
161
|
}
|
|
90
162
|
else {
|
|
91
|
-
// 'unknown' — the ban/weight gate paused the probe; don't assert anything
|
|
163
|
+
// 'unknown' — the ban/weight gate paused the probe; don't assert anything,
|
|
164
|
+
// and don't let a non-observation move the debounce counter.
|
|
92
165
|
checks.push(makeReadinessCheck(reachId, 'unknown', { checkedAt: now }));
|
|
93
166
|
checks.push(makeReadinessCheck('clock_in_sync', 'unknown', { checkedAt: now }));
|
|
94
167
|
}
|
|
@@ -142,9 +215,15 @@ export function startReadinessReporter(opts) {
|
|
|
142
215
|
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
143
216
|
const intervalMs = resolveIntervalMs(opts.intervalMs);
|
|
144
217
|
const timeoutMs = opts.requestTimeoutMs ?? 10_000;
|
|
218
|
+
// Start sampling loop delay now so the first cycle measures a real window.
|
|
219
|
+
// Never throws — an unavailable monitor just reports host_responsive unknown.
|
|
220
|
+
startEventLoopMonitor();
|
|
221
|
+
// One state object for the process: the debounce rungs are only meaningful
|
|
222
|
+
// across cycles.
|
|
223
|
+
const state = createReadinessCycleState();
|
|
145
224
|
const cycle = async (bootWarmup) => {
|
|
146
225
|
try {
|
|
147
|
-
const report = await collectReadiness({ venue: opts.venue, publicApi: opts.publicApi, toolCount: opts.toolCount }, bootWarmup);
|
|
226
|
+
const report = await collectReadiness({ venue: opts.venue, publicApi: opts.publicApi, toolCount: opts.toolCount }, bootWarmup, { state });
|
|
148
227
|
await postReadiness(opts.apiBaseUrl, opts.token, report, fetchImpl, timeoutMs);
|
|
149
228
|
if (report.overall === 'fail') {
|
|
150
229
|
const failing = report.checks.filter((c) => c.status === 'fail').map((c) => c.id).join(', ');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Pure parse — exported for tests. Only the first 4 KB is considered. */
|
|
2
|
+
export declare function parseSkillVersion(content: string): string | undefined;
|
|
3
|
+
/**
|
|
4
|
+
* Read the running SKILL.md version, cached until the file changes.
|
|
5
|
+
* Returns undefined when the file is absent, unreadable, or has no parseable
|
|
6
|
+
* frontmatter version — callers omit the stamp in that case.
|
|
7
|
+
*/
|
|
8
|
+
export declare function getSkillVersionCached(filePath?: string): string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Merge the skill-version stamp into an entry-metadata object.
|
|
11
|
+
* Key is snake_case (`skill_version`) per the canonical-JSONB-key rule
|
|
12
|
+
* (CLAUDE.md learning-loop: a camelCase mismatch previously broke matching).
|
|
13
|
+
* No version → base returned untouched (possibly undefined), so rows never
|
|
14
|
+
* gain an empty/noise object just for a failed read.
|
|
15
|
+
*/
|
|
16
|
+
export declare function withSkillVersion(meta: Record<string, unknown> | undefined, version: string | undefined): Record<string, unknown> | undefined;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Best-effort reader for the SKILL.md version the agent is actually running.
|
|
2
|
+
//
|
|
3
|
+
// Purpose: stamp `skill_version` into position_entries.metadata so every
|
|
4
|
+
// doctrine (SKILL.md) release becomes measurable against trade outcomes —
|
|
5
|
+
// capture ratio / scratch rate per version — the same shadow→observe→enforce
|
|
6
|
+
// evidence discipline the repo applies to code gates, applied to the prose.
|
|
7
|
+
// Without this stamp a SKILL.md change is fleet-wide AND unmeasurable: journal
|
|
8
|
+
// rows from before and after the change are indistinguishable.
|
|
9
|
+
//
|
|
10
|
+
// Source of truth: ~/.openclaw/workspace/SKILL.md — the copy the bridge's
|
|
11
|
+
// applySkillUpdate maintains and the agent's OpenClaw loads (same path as
|
|
12
|
+
// skill/src/utils/skill-version.ts SKILL_MD_PATH; duplicated here because
|
|
13
|
+
// plugin and skill are separate deliverables with no shared package).
|
|
14
|
+
//
|
|
15
|
+
// Fail-soft by design: absent/unreadable/unparseable file → undefined → the
|
|
16
|
+
// metadata key is simply omitted. Journal capture must never block or throw
|
|
17
|
+
// on this read (same rule as the rest of the capture path).
|
|
18
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
19
|
+
import { homedir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
const DEFAULT_SKILL_MD_PATH = join(homedir(), '.openclaw', 'workspace', 'SKILL.md');
|
|
22
|
+
/** Frontmatter version line, e.g. `version: 2.20.11`. Anchored to the head of
|
|
23
|
+
* the file (frontmatter is lines 1-10) so a `version:` string later in the
|
|
24
|
+
* body can never shadow it. */
|
|
25
|
+
const VERSION_LINE = /^version:\s*(\d+\.\d+\.\d+)\s*$/m;
|
|
26
|
+
const HEAD_BYTES = 4096;
|
|
27
|
+
/** Pure parse — exported for tests. Only the first 4 KB is considered. */
|
|
28
|
+
export function parseSkillVersion(content) {
|
|
29
|
+
return VERSION_LINE.exec(content.slice(0, HEAD_BYTES))?.[1];
|
|
30
|
+
}
|
|
31
|
+
/** mtime+size-keyed cache per path. Entries are rare (a handful a day), but a
|
|
32
|
+
* cached hit avoids re-reading 110 KB on every fill during a busy burst. */
|
|
33
|
+
const cache = new Map();
|
|
34
|
+
/**
|
|
35
|
+
* Read the running SKILL.md version, cached until the file changes.
|
|
36
|
+
* Returns undefined when the file is absent, unreadable, or has no parseable
|
|
37
|
+
* frontmatter version — callers omit the stamp in that case.
|
|
38
|
+
*/
|
|
39
|
+
export function getSkillVersionCached(filePath = DEFAULT_SKILL_MD_PATH) {
|
|
40
|
+
try {
|
|
41
|
+
const st = statSync(filePath);
|
|
42
|
+
const hit = cache.get(filePath);
|
|
43
|
+
if (hit && hit.mtimeMs === st.mtimeMs && hit.size === st.size)
|
|
44
|
+
return hit.v;
|
|
45
|
+
const v = parseSkillVersion(readFileSync(filePath, 'utf8'));
|
|
46
|
+
cache.set(filePath, { v, mtimeMs: st.mtimeMs, size: st.size });
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Merge the skill-version stamp into an entry-metadata object.
|
|
55
|
+
* Key is snake_case (`skill_version`) per the canonical-JSONB-key rule
|
|
56
|
+
* (CLAUDE.md learning-loop: a camelCase mismatch previously broke matching).
|
|
57
|
+
* No version → base returned untouched (possibly undefined), so rows never
|
|
58
|
+
* gain an empty/noise object just for a failed read.
|
|
59
|
+
*/
|
|
60
|
+
export function withSkillVersion(meta, version) {
|
|
61
|
+
if (!version)
|
|
62
|
+
return meta;
|
|
63
|
+
return { ...(meta ?? {}), skill_version: version };
|
|
64
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { IExchangeAdapter } from '../exchange-adapter.js';
|
|
2
|
+
/** The slice of ProposalDecisionListener this class needs — injected so the
|
|
3
|
+
* lifecycle is testable without gateway credentials. */
|
|
4
|
+
export interface StartableListener {
|
|
5
|
+
start(): void;
|
|
6
|
+
stop(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export interface ApprovalLifecycleDeps {
|
|
9
|
+
/** Re-read config+env — NOT a boot snapshot; a config edit applies on the
|
|
10
|
+
* next swap without a restart. */
|
|
11
|
+
resolveApprovalMode: () => 'off' | 'shadow' | 'per_trade';
|
|
12
|
+
/** Ingest credentials + proposal manager present? Without them a listener
|
|
13
|
+
* cannot claim or report — refuse to start rather than half-run. */
|
|
14
|
+
hasWiring: () => boolean;
|
|
15
|
+
/** Build a listener bound to THIS adapter. Called only when starting. */
|
|
16
|
+
buildListener: (adapter: IExchangeAdapter) => StartableListener;
|
|
17
|
+
}
|
|
18
|
+
export declare class ApprovalListenerLifecycle {
|
|
19
|
+
private readonly deps;
|
|
20
|
+
private listener;
|
|
21
|
+
private chain;
|
|
22
|
+
constructor(deps: ApprovalLifecycleDeps);
|
|
23
|
+
get active(): boolean;
|
|
24
|
+
/** Apply the lifecycle for a freshly published adapter. Serialized. */
|
|
25
|
+
onAdapterSwapped(adapter: IExchangeAdapter): Promise<void>;
|
|
26
|
+
/** Terminal stop (shutdown drain) — also serialized behind pending swaps. */
|
|
27
|
+
stop(): Promise<void>;
|
|
28
|
+
private apply;
|
|
29
|
+
private stopCurrent;
|
|
30
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Approval-listener lifecycle — follows adapter swaps instead of boot state
|
|
2
|
+
// (audit 2026-07-26 F10).
|
|
3
|
+
//
|
|
4
|
+
// The ProposalDecisionListener used to start ONCE at boot, only when the
|
|
5
|
+
// process booted live with approval.mode='per_trade'. Every real trader
|
|
6
|
+
// onboards in PAPER and goes live later from the dashboard, so on their box
|
|
7
|
+
// the listener never existed — approvals would sit unfired. Worse, the
|
|
8
|
+
// listener held the adapter it was CONSTRUCTED with: a live→PAPER flip left
|
|
9
|
+
// it wired to the orphaned live adapter (an operator approval would fire a
|
|
10
|
+
// real exchange order while the dashboard said PAPER), and a credential swap
|
|
11
|
+
// left it firing through the stopped old adapter.
|
|
12
|
+
//
|
|
13
|
+
// This class owns exactly one listener at a time and is driven by
|
|
14
|
+
// PluginRuntime.onAdapterSwapped (boot calls it once with the boot adapter):
|
|
15
|
+
// - live adapter + approval.mode='per_trade' (re-read at swap time) → a
|
|
16
|
+
// fresh listener bound to THAT adapter;
|
|
17
|
+
// - anything else → no listener (stopping any previous one first, awaiting
|
|
18
|
+
// its in-flight tick so a fire's result PATCH lands before teardown).
|
|
19
|
+
//
|
|
20
|
+
// Swaps are serialized through a promise chain: a second swap arriving while
|
|
21
|
+
// the first is still draining queues behind it, so two listeners can never
|
|
22
|
+
// run concurrently (the DB fire-claim makes a double-fire impossible anyway —
|
|
23
|
+
// this keeps the process tidy, not the money safe).
|
|
24
|
+
import { logger, formatError } from '../logger.js';
|
|
25
|
+
const TAG = 'approval-lifecycle';
|
|
26
|
+
export class ApprovalListenerLifecycle {
|
|
27
|
+
deps;
|
|
28
|
+
listener;
|
|
29
|
+
chain = Promise.resolve();
|
|
30
|
+
constructor(deps) {
|
|
31
|
+
this.deps = deps;
|
|
32
|
+
}
|
|
33
|
+
get active() {
|
|
34
|
+
return this.listener !== undefined;
|
|
35
|
+
}
|
|
36
|
+
/** Apply the lifecycle for a freshly published adapter. Serialized. */
|
|
37
|
+
onAdapterSwapped(adapter) {
|
|
38
|
+
this.chain = this.chain
|
|
39
|
+
.then(() => this.apply(adapter))
|
|
40
|
+
.catch((err) => {
|
|
41
|
+
logger.error(TAG, `listener swap failed: ${formatError(err)}`);
|
|
42
|
+
});
|
|
43
|
+
return this.chain;
|
|
44
|
+
}
|
|
45
|
+
/** Terminal stop (shutdown drain) — also serialized behind pending swaps. */
|
|
46
|
+
stop() {
|
|
47
|
+
this.chain = this.chain
|
|
48
|
+
.then(() => this.stopCurrent())
|
|
49
|
+
.catch((err) => {
|
|
50
|
+
logger.warn(TAG, `listener stop failed: ${formatError(err)}`);
|
|
51
|
+
});
|
|
52
|
+
return this.chain;
|
|
53
|
+
}
|
|
54
|
+
async apply(adapter) {
|
|
55
|
+
// Always tear down the previous listener first — it is bound to the OLD
|
|
56
|
+
// adapter and must never fire through it again.
|
|
57
|
+
await this.stopCurrent();
|
|
58
|
+
if (!adapter.isLive)
|
|
59
|
+
return;
|
|
60
|
+
const mode = this.deps.resolveApprovalMode();
|
|
61
|
+
if (mode !== 'per_trade')
|
|
62
|
+
return;
|
|
63
|
+
if (!this.deps.hasWiring()) {
|
|
64
|
+
logger.warn(TAG, "approval.mode='per_trade' but ingest credentials/proposal manager missing — listener NOT started; approvals will not fire");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const fresh = this.deps.buildListener(adapter);
|
|
68
|
+
fresh.start();
|
|
69
|
+
this.listener = fresh;
|
|
70
|
+
logger.info(TAG, 'ProposalDecisionListener started (bound to the current adapter)');
|
|
71
|
+
}
|
|
72
|
+
async stopCurrent() {
|
|
73
|
+
if (!this.listener)
|
|
74
|
+
return;
|
|
75
|
+
const old = this.listener;
|
|
76
|
+
this.listener = undefined;
|
|
77
|
+
await old.stop();
|
|
78
|
+
logger.info(TAG, 'ProposalDecisionListener stopped');
|
|
79
|
+
}
|
|
80
|
+
}
|
package/live/bracket-types.d.ts
CHANGED
|
@@ -20,6 +20,15 @@ export interface BracketLedgerEntry {
|
|
|
20
20
|
symbol: string;
|
|
21
21
|
entrySide: 'buy' | 'sell';
|
|
22
22
|
entryCid: string;
|
|
23
|
+
/** Additional entry clientOrderIds submitted against this SAME bracket row —
|
|
24
|
+
* a scale-in, or a second entry placed while the first is still resting.
|
|
25
|
+
* Each order gets a fresh cloid but the row keeps the original `entryCid`,
|
|
26
|
+
* so a fill matcher keyed on `entryCid` alone would ignore their fills
|
|
27
|
+
* entirely (audit 2026-07-26 F5). HL-only today: Binance legs are
|
|
28
|
+
* `closePosition:true` (whole-position, self-resizing), while HL legs are
|
|
29
|
+
* FIXED SIZE — an unmatched scale-in fill there means NAKED contracts.
|
|
30
|
+
* Bounded; oldest dropped. */
|
|
31
|
+
extraEntryCids?: string[];
|
|
23
32
|
slCid?: string;
|
|
24
33
|
tpCid?: string;
|
|
25
34
|
stopPrice?: number;
|
package/live/live-adapter.d.ts
CHANGED
|
@@ -85,7 +85,6 @@ export declare class LiveAdapter extends EventEmitter implements IExchangeAdapte
|
|
|
85
85
|
/** Session-start NAV, captured once at initialization. Used for drawdown calculation. */
|
|
86
86
|
getSessionStartNav(): number | null;
|
|
87
87
|
constructor(config: ExchangeConfig, mode: 'MICRO_LIVE' | 'LIVE', microLiveConfig?: {
|
|
88
|
-
sizeCapPercent?: number;
|
|
89
88
|
maxPositionUSDT?: number;
|
|
90
89
|
}, bracketMode?: BracketMode, userDataStreamMode?: UserDataStreamMode, userDataStreamTunables?: UserDataStreamTunables,
|
|
91
90
|
/** TRADE_AUDIT_TRAIL_PLAN Phase 1 — optional audit-trail wiring. Wired
|
|
@@ -35,6 +35,9 @@ export interface ProposalDecisionListenerHealth {
|
|
|
35
35
|
running: boolean;
|
|
36
36
|
ticksTotal: number;
|
|
37
37
|
ticksWithFires: number;
|
|
38
|
+
claimsAcquired: number;
|
|
39
|
+
claimConflicts: number;
|
|
40
|
+
claimFailures: number;
|
|
38
41
|
firesAttempted: number;
|
|
39
42
|
firesSucceeded: number;
|
|
40
43
|
firesAbandonedDrift: number;
|
|
@@ -53,6 +56,21 @@ export declare class ProposalDecisionListener {
|
|
|
53
56
|
* that finds ≥1 pending. */
|
|
54
57
|
private currentIntervalMs;
|
|
55
58
|
private health;
|
|
59
|
+
/** Claim tokens are process-local by design. A new process must never steal
|
|
60
|
+
* an old process's durable claim, because it cannot know whether the
|
|
61
|
+
* exchange accepted an order just before the crash. */
|
|
62
|
+
private readonly claimTokens;
|
|
63
|
+
/** In-memory work queue for claims whose POST or fire-result response was
|
|
64
|
+
* lost. The DB poll excludes claimed rows, so only the original process can
|
|
65
|
+
* retry its exact token/outcome; a restarted process cannot steal it. */
|
|
66
|
+
private readonly claimedCandidates;
|
|
67
|
+
private readonly acquiredClaims;
|
|
68
|
+
/** Once present, this process must never invoke createOrderTool for the
|
|
69
|
+
* proposal again. Any subsequent work is result persistence only. */
|
|
70
|
+
private readonly mutationsStarted;
|
|
71
|
+
/** Completed outcomes awaiting a successful fire-result response. Retrying
|
|
72
|
+
* this PATCH is safe; retrying the exchange mutation is not. */
|
|
73
|
+
private readonly pendingResults;
|
|
56
74
|
constructor(options: ProposalDecisionListenerOptions);
|
|
57
75
|
/** Begin the poll loop. Idempotent — subsequent calls are no-ops while
|
|
58
76
|
* the listener is already running. */
|
|
@@ -65,6 +83,8 @@ export declare class ProposalDecisionListener {
|
|
|
65
83
|
private runTick;
|
|
66
84
|
private tick;
|
|
67
85
|
private fetchPending;
|
|
86
|
+
private claimPending;
|
|
87
|
+
private forgetClaim;
|
|
68
88
|
private firePending;
|
|
69
89
|
private patchResult;
|
|
70
90
|
}
|