@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.
Files changed (67) hide show
  1. package/bridge/gateway/event-parser.d.ts +19 -0
  2. package/bridge/gateway/event-parser.js +52 -0
  3. package/bridge/gateway/gateway-config.d.ts +16 -5
  4. package/bridge/gateway/gateway-config.js +68 -12
  5. package/bridge/gateway/heartbeat-cron.d.ts +1 -0
  6. package/bridge/gateway/heartbeat-cron.js +22 -1
  7. package/bridge/gateway/poller.js +18 -8
  8. package/bridge/providers/emergency-commands.d.ts +9 -1
  9. package/bridge/providers/emergency-commands.js +38 -1
  10. package/bridge/providers/gateway.d.ts +72 -1
  11. package/bridge/providers/gateway.js +241 -29
  12. package/bridge/providers/onboarding-commands.d.ts +8 -0
  13. package/bridge/providers/onboarding-commands.js +4 -4
  14. package/bridge/types.d.ts +30 -0
  15. package/bridge/utils/identity-name.d.ts +24 -0
  16. package/bridge/utils/identity-name.js +54 -0
  17. package/ccxt/binance-public.d.ts +21 -7
  18. package/ccxt/binance-public.js +70 -6
  19. package/config/operator-provenance.d.ts +6 -0
  20. package/config/operator-provenance.js +50 -0
  21. package/config/plugin-config-io.d.ts +15 -1
  22. package/config/plugin-config-io.js +24 -0
  23. package/index.js +216 -173
  24. package/ingest/event-loop-monitor.d.ts +11 -0
  25. package/ingest/event-loop-monitor.js +113 -0
  26. package/ingest/position-auto-capture.d.ts +5 -0
  27. package/ingest/position-auto-capture.js +14 -5
  28. package/ingest/readiness-reporter.d.ts +17 -6
  29. package/ingest/readiness-reporter.js +88 -9
  30. package/ingest/skill-version-reader.d.ts +16 -0
  31. package/ingest/skill-version-reader.js +64 -0
  32. package/live/approval-lifecycle.d.ts +30 -0
  33. package/live/approval-lifecycle.js +80 -0
  34. package/live/bracket-types.d.ts +9 -0
  35. package/live/live-adapter.d.ts +0 -1
  36. package/live/proposal-decision-listener.d.ts +20 -0
  37. package/live/proposal-decision-listener.js +211 -48
  38. package/onboarding/runtime.d.ts +34 -1
  39. package/onboarding/runtime.js +56 -5
  40. package/openclaw.plugin.json +1 -1
  41. package/package.json +2 -2
  42. package/simulator/exchange-simulator.d.ts +45 -2
  43. package/simulator/exchange-simulator.js +96 -4
  44. package/simulator/types.d.ts +17 -0
  45. package/tools/attach-brackets.js +50 -1
  46. package/tools/create-order.d.ts +11 -0
  47. package/tools/create-order.js +23 -2
  48. package/tools/get-risk-summary.d.ts +4 -0
  49. package/tools/get-risk-summary.js +62 -23
  50. package/venues/hyperliquid/hl-bracket-coordinator.d.ts +29 -1
  51. package/venues/hyperliquid/hl-bracket-coordinator.js +59 -2
  52. package/venues/hyperliquid/hl-brackets.d.ts +10 -0
  53. package/venues/hyperliquid/hl-brackets.js +45 -13
  54. package/venues/hyperliquid/hl-fill-ingest.d.ts +18 -0
  55. package/venues/hyperliquid/hl-fill-ingest.js +69 -0
  56. package/venues/hyperliquid/hl-live-adapter.d.ts +36 -0
  57. package/venues/hyperliquid/hl-live-adapter.js +155 -12
  58. package/venues/hyperliquid/hl-order.d.ts +35 -0
  59. package/venues/hyperliquid/hl-order.js +123 -0
  60. package/venues/hyperliquid/hl-position.d.ts +36 -0
  61. package/venues/hyperliquid/hl-position.js +127 -0
  62. package/venues/hyperliquid/hl-private.d.ts +20 -3
  63. package/venues/hyperliquid/hl-private.js +37 -6
  64. package/venues/hyperliquid/hl-public.d.ts +12 -5
  65. package/venues/hyperliquid/hl-public.js +24 -3
  66. package/venues/hyperliquid/hl-user-stream.d.ts +13 -1
  67. 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: 'stop_watcher',
436
+ closeReason: leg,
435
437
  closeAssessment: {
436
- note: 'Position auto-closed by the stop-watcher: mark crossed the pinned stopPrice. ' +
437
- 'Journaled from the watcher close fill no close_position call (issue #199).',
438
- observedFrom: 'stop_watcher',
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
- metadata: buildEntryPlanMetadata(md),
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): Promise<ReadinessReport>;
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
- // venue reachability (+ clock drift from the same probe response)
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 could be transient, so warn (amber) rather than
85
- // asserting a definitive failure. A persistent problem stays amber across
86
- // re-checks; only the geo-block is a hard red.
87
- checks.push(makeReadinessCheck(reachId, 'warn', { detail: 'unreachable', checkedAt: now }));
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
+ }
@@ -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;
@@ -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
  }