@senpi/runtime 3.0.81 → 3.0.83

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 (102) hide show
  1. package/dist/actions/decision-engine.d.ts +16 -0
  2. package/dist/actions/decision-engine.d.ts.map +1 -1
  3. package/dist/actions/decision-engine.js +137 -1
  4. package/dist/actions/decision-engine.js.map +1 -1
  5. package/dist/cli/guide-commands.d.ts.map +1 -1
  6. package/dist/cli/guide-commands.js +33 -0
  7. package/dist/cli/guide-commands.js.map +1 -1
  8. package/dist/cli/senpi-commands.d.ts.map +1 -1
  9. package/dist/cli/senpi-commands.js +12 -0
  10. package/dist/cli/senpi-commands.js.map +1 -1
  11. package/dist/cli/update-command.d.ts +70 -0
  12. package/dist/cli/update-command.d.ts.map +1 -0
  13. package/dist/cli/update-command.js +132 -0
  14. package/dist/cli/update-command.js.map +1 -0
  15. package/dist/dsl/plugin/index.d.ts +35 -1
  16. package/dist/dsl/plugin/index.d.ts.map +1 -1
  17. package/dist/dsl/plugin/index.js +62 -7
  18. package/dist/dsl/plugin/index.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +87 -6
  21. package/dist/index.js.map +1 -1
  22. package/dist/runtime/effective-config.d.ts +67 -0
  23. package/dist/runtime/effective-config.d.ts.map +1 -0
  24. package/dist/runtime/effective-config.js +324 -0
  25. package/dist/runtime/effective-config.js.map +1 -0
  26. package/dist/runtime/lifecycle-events.d.ts +19 -0
  27. package/dist/runtime/lifecycle-events.d.ts.map +1 -1
  28. package/dist/runtime/lifecycle-events.js +27 -0
  29. package/dist/runtime/lifecycle-events.js.map +1 -1
  30. package/dist/runtime/rebuild-errors.d.ts +26 -0
  31. package/dist/runtime/rebuild-errors.d.ts.map +1 -0
  32. package/dist/runtime/rebuild-errors.js +36 -0
  33. package/dist/runtime/rebuild-errors.js.map +1 -0
  34. package/dist/runtime/run.d.ts +10 -0
  35. package/dist/runtime/run.d.ts.map +1 -1
  36. package/dist/runtime/run.js +13 -1
  37. package/dist/runtime/run.js.map +1 -1
  38. package/dist/runtime/runtime.d.ts +99 -1
  39. package/dist/runtime/runtime.d.ts.map +1 -1
  40. package/dist/runtime/runtime.js +321 -127
  41. package/dist/runtime/runtime.js.map +1 -1
  42. package/dist/runtime/update-apply-deps.d.ts +43 -0
  43. package/dist/runtime/update-apply-deps.d.ts.map +1 -0
  44. package/dist/runtime/update-apply-deps.js +62 -0
  45. package/dist/runtime/update-apply-deps.js.map +1 -0
  46. package/dist/runtime/update-handler.d.ts +158 -0
  47. package/dist/runtime/update-handler.d.ts.map +1 -0
  48. package/dist/runtime/update-handler.js +252 -0
  49. package/dist/runtime/update-handler.js.map +1 -0
  50. package/dist/runtime/update-plan.d.ts +60 -0
  51. package/dist/runtime/update-plan.d.ts.map +1 -0
  52. package/dist/runtime/update-plan.js +164 -0
  53. package/dist/runtime/update-plan.js.map +1 -0
  54. package/dist/runtime/update-register.d.ts +72 -0
  55. package/dist/runtime/update-register.d.ts.map +1 -0
  56. package/dist/runtime/update-register.js +146 -0
  57. package/dist/runtime/update-register.js.map +1 -0
  58. package/dist/runtime/update-report.d.ts +93 -0
  59. package/dist/runtime/update-report.d.ts.map +1 -0
  60. package/dist/runtime/update-report.js +133 -0
  61. package/dist/runtime/update-report.js.map +1 -0
  62. package/dist/skills-manager/manifest.d.ts.map +1 -1
  63. package/dist/skills-manager/manifest.js +1 -0
  64. package/dist/skills-manager/manifest.js.map +1 -1
  65. package/dist/skills-manager/skills-manifest.json +1 -0
  66. package/dist/utils/event-catalog.d.ts +26 -0
  67. package/dist/utils/event-catalog.d.ts.map +1 -1
  68. package/dist/utils/event-catalog.js +1 -0
  69. package/dist/utils/event-catalog.js.map +1 -1
  70. package/dist/widgets/errors.d.ts +3 -15
  71. package/dist/widgets/errors.d.ts.map +1 -1
  72. package/dist/widgets/errors.js +3 -20
  73. package/dist/widgets/errors.js.map +1 -1
  74. package/dist/widgets/index.d.ts +3 -10
  75. package/dist/widgets/index.d.ts.map +1 -1
  76. package/dist/widgets/index.js +3 -6
  77. package/dist/widgets/index.js.map +1 -1
  78. package/dist/widgets/registry.d.ts.map +1 -1
  79. package/dist/widgets/registry.js +10 -3
  80. package/dist/widgets/registry.js.map +1 -1
  81. package/dist/widgets/tool.d.ts +6 -9
  82. package/dist/widgets/tool.d.ts.map +1 -1
  83. package/dist/widgets/tool.js +37 -147
  84. package/dist/widgets/tool.js.map +1 -1
  85. package/dist/widgets/types.d.ts +20 -58
  86. package/dist/widgets/types.d.ts.map +1 -1
  87. package/dist/widgets/types.js +4 -4
  88. package/dist/widgets/widgets/fund-user-wallet.d.ts +4 -5
  89. package/dist/widgets/widgets/fund-user-wallet.d.ts.map +1 -1
  90. package/dist/widgets/widgets/fund-user-wallet.js +27 -67
  91. package/dist/widgets/widgets/fund-user-wallet.js.map +1 -1
  92. package/openclaw.plugin.json +1 -4
  93. package/package.json +2 -3
  94. package/dist/widgets/strategy-lookup.d.ts +0 -39
  95. package/dist/widgets/strategy-lookup.d.ts.map +0 -1
  96. package/dist/widgets/strategy-lookup.js +0 -70
  97. package/dist/widgets/strategy-lookup.js.map +0 -1
  98. package/dist/widgets/widgets/fund-strategy-wallet.d.ts +0 -13
  99. package/dist/widgets/widgets/fund-strategy-wallet.d.ts.map +0 -1
  100. package/dist/widgets/widgets/fund-strategy-wallet.js +0 -139
  101. package/dist/widgets/widgets/fund-strategy-wallet.js.map +0 -1
  102. package/skills/senpi-widgets/SKILL.md +0 -167
@@ -25,7 +25,8 @@ export interface RuntimeOptions {
25
25
  unresolvedConfig?: Record<string, unknown>;
26
26
  }
27
27
  export declare class Runtime {
28
- private readonly config;
28
+ /** Not readonly: {@link Runtime.rebuildComponents} swaps it, which is the whole point. */
29
+ private config;
29
30
  private readonly options;
30
31
  private bus;
31
32
  private hookSystem;
@@ -60,6 +61,8 @@ export declare class Runtime {
60
61
  private actionRuntimeModule;
61
62
  /** Stored so we can call off() in stop() and avoid retaining Runtime via scanner EventEmitter. */
62
63
  private onScannerRunComplete;
64
+ /** Set at the end of start()'s dependency phase; read by every build and rebuild. */
65
+ private componentDeps;
63
66
  /** Cached strategy id resolved from the runtime wallet on first {@link auditQuery} call. */
64
67
  private cachedStrategyId;
65
68
  /** In-flight resolution promise; ensures concurrent first-burst callers reuse one `listStrategies`. */
@@ -184,6 +187,101 @@ export declare class Runtime {
184
187
  * Cancel exchange SLs and archive DSL state for the strategy, then log removal.
185
188
  */
186
189
  /** @returns Count of active DSL positions archived during teardown. */
190
+ /**
191
+ * Build every component derived from the recipe: scanners, actions, the DSL plugin, and the
192
+ * health surface that holds references to all three.
193
+ *
194
+ * Called by `start()` and again by {@link rebuildComponents}. Deliberately does NOT create the
195
+ * reconcile gate, launch the boot-reconcile loop, or call `initRisk` — those are once-per-runtime
196
+ * and re-running them would re-block trading on an already-converged runtime, duplicate a
197
+ * detached loop, and double-subscribe the bus respectively.
198
+ */
199
+ private buildComponents;
200
+ /**
201
+ * Phase one of a build: everything the recipe determines, constructed but NOT installed on `this`
202
+ * and NOT started.
203
+ *
204
+ * The split exists so a rebuild can do all of its fallible work while the OLD components are
205
+ * still running. A recipe that names a scanner which will not construct, or an action class that
206
+ * does not exist, throws here — with the live runtime untouched, still scanning and still holding
207
+ * its stops. The alternative (the previous shape) tore everything down first and then discovered
208
+ * the new config was unbuildable, leaving a funded wallet with an open book, no scanners and no
209
+ * exit engine.
210
+ *
211
+ * Nothing in here may subscribe to the bus, start a timer, or touch disk. That is what makes it
212
+ * safe to run twice over — once for the attempt, once more for a rollback. `DslPlugin.create` is
213
+ * deliberately NOT called here for exactly that reason: it subscribes position handlers and
214
+ * starts the monitor loop, so pre-building it would put two plugins on one bus, both ticking the
215
+ * same positions. Its config is parsed here instead, which is where a bad preset actually throws.
216
+ */
217
+ private constructComponents;
218
+ /**
219
+ * Phase two of a build: install the constructed components on `this`, then create the one
220
+ * component that cannot be constructed without side effects.
221
+ *
222
+ * Everything fallible that COULD be done earlier already has been, in
223
+ * {@link constructComponents}. What remains is assignment — which cannot throw — plus
224
+ * `DslPlugin.create`, whose config this build already parsed and whose state manager, senpi
225
+ * client and strategy-state map are all passed in (each of those resolvers returns immediately
226
+ * when supplied). That is why the teardown window in {@link rebuildComponents} is narrow enough
227
+ * to be worth taking, and why its rollback is a backstop rather than the plan.
228
+ */
229
+ private commitComponents;
230
+ /**
231
+ * Register the scan-complete listener on the CURRENT scanner module.
232
+ *
233
+ * Separate from {@link buildComponents} because the listener is held against a specific module
234
+ * instance: a rebuild replaces the module, so the old registration goes with it and a fresh one
235
+ * has to be made against the new one.
236
+ */
237
+ private wireScannerRunListener;
238
+ /**
239
+ * Swap this runtime onto a new recipe without restarting it or touching its state.
240
+ *
241
+ * Teardown is the reverse of the build, and both halves matter. The scan listener is released
242
+ * before the module it is registered on; `scannerModule.stop()` clears its timers and awaits
243
+ * in-flight runs; `dslPlugin.stop()` is AWAITED, because it releases its bus subscriptions and
244
+ * drains the tick that may still be mid-flight at the venue — without that the outgoing monitor
245
+ * keeps writing state while the incoming one is already ticking the same positions, and the two
246
+ * hold independent StateManagers so nothing else serialises them.
247
+ *
248
+ * State on disk is untouched throughout: DSL position files, scanner stores and action history
249
+ * all survive, which is the entire reason an update is worth having over a delete-and-recreate.
250
+ */
251
+ rebuildComponents(config: RuntimeConfig): Promise<void>;
252
+ /**
253
+ * Install the freshly built components, serialized against the trade loop.
254
+ *
255
+ * The signal consumer runs every action pass inside
256
+ * `reconcileMutex.withKeyLock("reconcile", () => this.runActionsForSignal(...))`, reading
257
+ * `runActionsForSignal` at call time from inside the lock. Taking the same lock here means a
258
+ * signal already in flight finishes against the OLD actions before the swap happens, and the next
259
+ * one picks up the new ones — no pass can straddle the change.
260
+ *
261
+ * The lock is scoped to the SWAP and deliberately NOT to the teardown that precedes it.
262
+ * `teardownComponentsForRebuild` awaits the DSL plugin's in-flight tick, and that tick takes this
263
+ * very lock for its periodic reconcile (`runReconcile` is handed `reconcileSerialize`). Holding
264
+ * the lock across the drain would deadlock the runtime — permanently, with an open book and
265
+ * nothing managing it — because `KeyedMutex` is a plain FIFO and grants no reentrancy.
266
+ *
267
+ * Creating the DSL plugin inside the lock is safe for the mirror-image reason: `create()` starts
268
+ * the monitor loop but does not await its first tick, so that tick queues behind this lock
269
+ * instead of blocking the call that holds it.
270
+ */
271
+ private swapInComponents;
272
+ /**
273
+ * Drop the scan listener, stop the scanner module, stop and release the DSL plugin.
274
+ *
275
+ * Shared by the rebuild and its rollback so the two cannot drift — the rollback runs it a second
276
+ * time to clear whatever the failed commit had already installed, and every step is idempotent
277
+ * for exactly that reason.
278
+ *
279
+ * Order matters: the listener comes off before the module it is registered on;
280
+ * `scannerModule.stop()` clears its timers and awaits in-flight runs; `dslPlugin.stop()` is
281
+ * AWAITED, because it releases its bus subscriptions and drains the tick that may still be
282
+ * mid-flight at the venue.
283
+ */
284
+ private teardownComponentsForRebuild;
187
285
  removeStrategy(address: string): Promise<number>;
188
286
  /** Tear down DSL for the runtime strategy before deleting the runtime (gateway / CLI). */
189
287
  teardownDslForRuntimeDeletion(): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/runtime/runtime.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../../src/runtime/runtime.ts"],"names":[],"mappings":"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"}
@@ -26,6 +26,8 @@ import { validateDslPositionTracking } from "./validate-dsl-position-tracking.js
26
26
  import { ScannerRegistry } from "./scanner-registry.js";
27
27
  import { ActionRegistry } from "./action-registry.js";
28
28
  import { initRisk } from "../risk/index.js";
29
+ import { parseDslPluginConfig } from "../dsl/config/parse-plugin-config.js";
30
+ import { RuntimeRebuildRollbackFailedError } from "./rebuild-errors.js";
29
31
  import { createHealthStateModule, } from "../health/index.js";
30
32
  import { DslPlugin as DslPluginClass } from "../dsl/plugin/index.js";
31
33
  import { HOOK_EVENT } from "../types/hooks.js";
@@ -59,6 +61,7 @@ const SIGNAL_QUEUE_BACKPRESSURE_THRESHOLD = 10;
59
61
  /** Default liveness-heartbeat window (~15 min) when `health.window_seconds` is unset. */
60
62
  const DEFAULT_LIVENESS_WINDOW_SECONDS = 900;
61
63
  export class Runtime {
64
+ /** Not readonly: {@link Runtime.rebuildComponents} swaps it, which is the whole point. */
62
65
  config;
63
66
  options;
64
67
  bus;
@@ -95,6 +98,8 @@ export class Runtime {
95
98
  actionRuntimeModule;
96
99
  /** Stored so we can call off() in stop() and avoid retaining Runtime via scanner EventEmitter. */
97
100
  onScannerRunComplete = null;
101
+ /** Set at the end of start()'s dependency phase; read by every build and rebuild. */
102
+ componentDeps;
98
103
  /** Cached strategy id resolved from the runtime wallet on first {@link auditQuery} call. */
99
104
  cachedStrategyId = null;
100
105
  /** In-flight resolution promise; ensures concurrent first-burst callers reuse one `listStrategies`. */
@@ -436,50 +441,6 @@ export class Runtime {
436
441
  })
437
442
  : createRiskGuardStub();
438
443
  logger.info("Runtime.start() creating scanner module");
439
- const layout = buildStateLayout(stateDir);
440
- // Augment caller-supplied providers with StateManager-backed DSL state access
441
- const augmentedProviders = {
442
- ...scannerProviders,
443
- getDslStatesForStrategy: scannerProviders?.getDslStatesForStrategy
444
- ?? (async (address) => this.stateManager.listDslStates(address)),
445
- };
446
- this.scannerModule = createScannerModuleFromRuntimeConfig(this.config, layout.scannerRoot, augmentedProviders, undefined, buildIdentityEnvelope);
447
- logger.info("Runtime.start() scanner module created, calling start()");
448
- // Pre-load max leverage cache (non-blocking — log warning on failure)
449
- const maxLeverageCache = new MaxLeverageCache(senpi);
450
- try {
451
- await maxLeverageCache.load();
452
- }
453
- catch (err) {
454
- logger.warn("failed to pre-load max leverage cache, will retry on first use", { err });
455
- }
456
- // Build RuntimeContext once per trading recipe and reuse for all actions.
457
- const runtimeContext = createRuntimeContext(this.strategyConfig, {
458
- senpi,
459
- hookSystem: this.hookSystem,
460
- llmDecision,
461
- contextBuilder,
462
- riskGuard,
463
- notifications,
464
- scannerModule: this.scannerModule,
465
- maxLeverageCache,
466
- positionIdRegistry: this.positionIdRegistry,
467
- notificationLabel,
468
- });
469
- this.actionRuntimeModule = new ActionRuntimeModule({ stateDir: layout.actionRoot });
470
- const { runActionsForSignal } = await createActions(this.config, {
471
- bus: this.bus,
472
- senpi,
473
- stateManager: this.stateManager,
474
- hookSystem: this.hookSystem,
475
- llmDecision,
476
- contextBuilder,
477
- riskGuard,
478
- notifications,
479
- runtimeContext,
480
- actionModule: this.actionRuntimeModule,
481
- });
482
- this.runActionsForSignal = runActionsForSignal;
483
444
  // Create the reconcile gate BEFORE DslPlugin.create() so the monitor's
484
445
  // first synchronous tick (fired inside create()) sees a gate that already exists.
485
446
  this.reconcileGate = (() => {
@@ -496,25 +457,24 @@ export class Runtime {
496
457
  })();
497
458
  // Build strategyStates map once — shared by DslPlugin and the reconcile loop.
498
459
  const strategyStatesMap = createStrategyStatesMap(this.strategyConfig, senpi);
499
- if (hasDsl) {
500
- this.dslPlugin = await DslPluginClass.create({
501
- config: exitConfig,
502
- bus: this.bus,
503
- stateDir,
504
- stateManager: this.stateManager,
505
- senpiClient: senpi,
506
- strategyStates: strategyStatesMap,
507
- notifications,
508
- // Monitor runs the unified periodic reconcile (adopt/resize/gone), serialized
509
- // against the trade loop on the same reconcileMutex key, and can resolve the boot gate.
510
- reconcileSerialize: (fn) => this.reconcileMutex.withKeyLock("reconcile", fn),
511
- reconcileGateResolve: () => this.resolveReconcileGate(),
512
- identity: buildIdentityEnvelope,
513
- });
514
- }
515
- else {
516
- this.dslPlugin = null;
517
- }
460
+ // Everything a rebuild reuses. Config-derived values are deliberately absent: those are
461
+ // recomputed from the config each build, which is what makes a rebuild a rebuild.
462
+ this.componentDeps = {
463
+ senpi,
464
+ llmDecision,
465
+ contextBuilder,
466
+ riskGuard,
467
+ notifications,
468
+ notificationLabel,
469
+ stateDir,
470
+ scannerProviders,
471
+ buildIdentityEnvelope,
472
+ strategyStates: strategyStatesMap,
473
+ };
474
+ // Once per runtime, not once per build: a rebuild must not re-run it. (It is a no-op today,
475
+ // kept for backward compatibility, but the placement is the contract.)
476
+ initRisk(this.bus);
477
+ await this.buildComponents(this.config);
518
478
  // Launch detached boot-reconcile loop (does NOT block start()).
519
479
  // Last-resort guard: an unexpected throw here would otherwise become an
520
480
  // unhandled rejection and silently kill the loop. Do NOT resolve the gate
@@ -523,70 +483,7 @@ export class Runtime {
523
483
  void this.runBootReconcileLoop(strategyStatesMap).catch((err) => {
524
484
  logger.error("[runtime][reconcile] boot reconcile loop crashed", { err });
525
485
  });
526
- initRisk(this.bus);
527
- this.healthStateModule = createHealthStateModule({
528
- runtimeName: this.config.name,
529
- stateDir,
530
- startedAt: this.startedAt,
531
- providers: {
532
- scanners: this.scannerModule,
533
- dsl: this.dslPlugin ?? createDisabledDslProvider(),
534
- actions: this.actionRuntimeModule,
535
- risk: riskBlock
536
- ? createRiskHealthProvider(riskGuard, strategyAddress)
537
- : undefined,
538
- },
539
- });
540
- this.onScannerRunComplete = (event) => {
541
- // Per-scan-run id: stamped on the scanner_run emit and echoed through the pass so scan +
542
- // pass land in one trace. Prefer the id minted at the scanner.run span so scan + pass
543
- // share one senpi.run.id.
544
- const runId = event.result.runId ?? randomUUID();
545
- this.signalQueue.push({ result: event.result, enqueuedAt: Date.now(), runId });
546
- // Edge-triggered backpressure alarm: depth rises one enqueue at a time, so `=== threshold`
547
- // fires exactly once as it crosses up and re-arms only after the consumer drains back below.
548
- if (this.signalQueue.length === SIGNAL_QUEUE_BACKPRESSURE_THRESHOLD) {
549
- emitEvent(logger, () => buildQueueBackpressureEvent({
550
- depth: this.signalQueue.length,
551
- threshold: SIGNAL_QUEUE_BACKPRESSURE_THRESHOLD,
552
- scannerId: event.result.scannerId,
553
- address: this.strategyConfig.address,
554
- }));
555
- }
556
- if (this.signalQueueWait) {
557
- const w = this.signalQueueWait;
558
- this.signalQueueWait = null;
559
- w();
560
- }
561
- // Scanner-run summary payload (startedAt is data stamping for telemetry durations).
562
- const scannerRunPayload = {
563
- address: event.result.address,
564
- data: {
565
- ...event.result,
566
- startedAt: event.startedAt,
567
- runId,
568
- },
569
- timestamp: new Date().toISOString(),
570
- };
571
- // First-class event log. The build runs inside emitEvent's guard — telemetry never
572
- // disturbs the scan path.
573
- emitEvent(logger, () => buildScannerSummaryEvent(scannerRunPayload, Date.now()));
574
- // The once-per-scan record, emitted for empty runs too — the grain scanner.summary drops.
575
- emitEvent(logger, () => buildScannerRunTickEvent(event, Date.now()));
576
- recordScannerRun({ scannerId: event.result.scannerId, outcome: event.result.status });
577
- // TODO: Market-scanner alerts are default-OFF and MUST NOT be delivered
578
- // until per-user default-mute-list support exists (today we have mute-list-only
579
- // settings, which can't honour a default-OFF notification). When that lands,
580
- // forward `event.result.summary?.alerts` as a MARKET_SCANNER notification with:
581
- // in-app: `🔔 <b>${n} market alert(s)</b><br>${message}<br>Signals only — no trade placed yet.`
582
- // push: no title (the notification service resolves it), body `🔔 ${message}`
583
- // Address-less by design (the scanner watches the market, not one strategy), so
584
- // it opens a chat by category alone and the strategy wallet must not leak into
585
- // the seed. Re-add per-(scannerId, message) dedupe at that time.
586
- // Resolve this TODO when default mute list functionality is available.
587
- };
588
- this.scannerModule.on("scanner:run:complete", this.onScannerRunComplete);
589
- this.signalConsumerDone = this.runSignalConsumer();
486
+ this.wireScannerRunListener();
590
487
  logger.info("Runtime.start() invoking scannerModule.start()");
591
488
  await this.scannerModule.start();
592
489
  logger.info("Runtime.start() scannerModule.start() returned");
@@ -707,6 +604,303 @@ export class Runtime {
707
604
  * Cancel exchange SLs and archive DSL state for the strategy, then log removal.
708
605
  */
709
606
  /** @returns Count of active DSL positions archived during teardown. */
607
+ /**
608
+ * Build every component derived from the recipe: scanners, actions, the DSL plugin, and the
609
+ * health surface that holds references to all three.
610
+ *
611
+ * Called by `start()` and again by {@link rebuildComponents}. Deliberately does NOT create the
612
+ * reconcile gate, launch the boot-reconcile loop, or call `initRisk` — those are once-per-runtime
613
+ * and re-running them would re-block trading on an already-converged runtime, duplicate a
614
+ * detached loop, and double-subscribe the bus respectively.
615
+ */
616
+ async buildComponents(config) {
617
+ const constructed = await this.constructComponents(config);
618
+ await this.commitComponents(config, constructed);
619
+ }
620
+ /**
621
+ * Phase one of a build: everything the recipe determines, constructed but NOT installed on `this`
622
+ * and NOT started.
623
+ *
624
+ * The split exists so a rebuild can do all of its fallible work while the OLD components are
625
+ * still running. A recipe that names a scanner which will not construct, or an action class that
626
+ * does not exist, throws here — with the live runtime untouched, still scanning and still holding
627
+ * its stops. The alternative (the previous shape) tore everything down first and then discovered
628
+ * the new config was unbuildable, leaving a funded wallet with an open book, no scanners and no
629
+ * exit engine.
630
+ *
631
+ * Nothing in here may subscribe to the bus, start a timer, or touch disk. That is what makes it
632
+ * safe to run twice over — once for the attempt, once more for a rollback. `DslPlugin.create` is
633
+ * deliberately NOT called here for exactly that reason: it subscribes position handlers and
634
+ * starts the monitor loop, so pre-building it would put two plugins on one bus, both ticking the
635
+ * same positions. Its config is parsed here instead, which is where a bad preset actually throws.
636
+ */
637
+ async constructComponents(config) {
638
+ const { senpi, llmDecision, contextBuilder, riskGuard, notifications, notificationLabel, stateDir, scannerProviders, buildIdentityEnvelope, } = this.componentDeps;
639
+ const exitConfig = mapRuntimeExitToDslConfig(config.exit);
640
+ const hasDsl = exitConfig.dsl_preset != null &&
641
+ typeof exitConfig.dsl_preset === "object" &&
642
+ !Array.isArray(exitConfig.dsl_preset);
643
+ // Parse the preset now rather than inside `DslPlugin.create`, so a malformed exit block is
644
+ // rejected while the old plugin is still holding its stops. The result is discarded — this is
645
+ // a validation, and `create` re-parses from the same input.
646
+ if (hasDsl)
647
+ parseDslPluginConfig(exitConfig);
648
+ const layout = buildStateLayout(stateDir);
649
+ // Augment caller-supplied providers with StateManager-backed DSL state access
650
+ const augmentedProviders = {
651
+ ...scannerProviders,
652
+ getDslStatesForStrategy: scannerProviders?.getDslStatesForStrategy
653
+ ?? (async (address) => this.stateManager.listDslStates(address)),
654
+ };
655
+ const scannerModule = createScannerModuleFromRuntimeConfig(config, layout.scannerRoot, augmentedProviders, undefined, buildIdentityEnvelope);
656
+ logger.info("Runtime.start() scanner module created, calling start()");
657
+ // Pre-load max leverage cache (non-blocking — log warning on failure)
658
+ const maxLeverageCache = new MaxLeverageCache(senpi);
659
+ try {
660
+ await maxLeverageCache.load();
661
+ }
662
+ catch (err) {
663
+ logger.warn("failed to pre-load max leverage cache, will retry on first use", { err });
664
+ }
665
+ // Build RuntimeContext once per trading recipe and reuse for all actions.
666
+ const runtimeContext = createRuntimeContext(this.strategyConfig, {
667
+ senpi,
668
+ hookSystem: this.hookSystem,
669
+ llmDecision,
670
+ contextBuilder,
671
+ riskGuard,
672
+ notifications,
673
+ scannerModule,
674
+ maxLeverageCache,
675
+ positionIdRegistry: this.positionIdRegistry,
676
+ notificationLabel,
677
+ });
678
+ const actionRuntimeModule = new ActionRuntimeModule({ stateDir: layout.actionRoot });
679
+ const { runActionsForSignal } = await createActions(config, {
680
+ bus: this.bus,
681
+ senpi,
682
+ stateManager: this.stateManager,
683
+ hookSystem: this.hookSystem,
684
+ llmDecision,
685
+ contextBuilder,
686
+ riskGuard,
687
+ notifications,
688
+ runtimeContext,
689
+ actionModule: actionRuntimeModule,
690
+ });
691
+ return { scannerModule, actionRuntimeModule, runActionsForSignal, exitConfig, hasDsl };
692
+ }
693
+ /**
694
+ * Phase two of a build: install the constructed components on `this`, then create the one
695
+ * component that cannot be constructed without side effects.
696
+ *
697
+ * Everything fallible that COULD be done earlier already has been, in
698
+ * {@link constructComponents}. What remains is assignment — which cannot throw — plus
699
+ * `DslPlugin.create`, whose config this build already parsed and whose state manager, senpi
700
+ * client and strategy-state map are all passed in (each of those resolvers returns immediately
701
+ * when supplied). That is why the teardown window in {@link rebuildComponents} is narrow enough
702
+ * to be worth taking, and why its rollback is a backstop rather than the plan.
703
+ */
704
+ async commitComponents(config, built) {
705
+ const { senpi, riskGuard, notifications, stateDir, buildIdentityEnvelope, strategyStates: strategyStatesMap, } = this.componentDeps;
706
+ const riskBlock = config.risk;
707
+ const strategyAddress = this.strategyConfig.address;
708
+ this.scannerModule = built.scannerModule;
709
+ this.actionRuntimeModule = built.actionRuntimeModule;
710
+ this.runActionsForSignal = built.runActionsForSignal;
711
+ if (built.hasDsl) {
712
+ this.dslPlugin = await DslPluginClass.create({
713
+ config: built.exitConfig,
714
+ bus: this.bus,
715
+ stateDir,
716
+ stateManager: this.stateManager,
717
+ senpiClient: senpi,
718
+ strategyStates: strategyStatesMap,
719
+ notifications,
720
+ // Monitor runs the unified periodic reconcile (adopt/resize/gone), serialized
721
+ // against the trade loop on the same reconcileMutex key, and can resolve the boot gate.
722
+ reconcileSerialize: (fn) => this.reconcileMutex.withKeyLock("reconcile", fn),
723
+ reconcileGateResolve: () => this.resolveReconcileGate(),
724
+ identity: buildIdentityEnvelope,
725
+ });
726
+ }
727
+ else {
728
+ this.dslPlugin = null;
729
+ }
730
+ this.healthStateModule = createHealthStateModule({
731
+ runtimeName: config.name,
732
+ stateDir,
733
+ startedAt: this.startedAt ?? undefined,
734
+ providers: {
735
+ scanners: this.scannerModule,
736
+ dsl: this.dslPlugin ?? createDisabledDslProvider(),
737
+ actions: this.actionRuntimeModule,
738
+ risk: riskBlock
739
+ ? createRiskHealthProvider(riskGuard, strategyAddress)
740
+ : undefined,
741
+ },
742
+ });
743
+ }
744
+ /**
745
+ * Register the scan-complete listener on the CURRENT scanner module.
746
+ *
747
+ * Separate from {@link buildComponents} because the listener is held against a specific module
748
+ * instance: a rebuild replaces the module, so the old registration goes with it and a fresh one
749
+ * has to be made against the new one.
750
+ */
751
+ wireScannerRunListener() {
752
+ const { senpi } = this.componentDeps;
753
+ this.onScannerRunComplete = (event) => {
754
+ // Per-scan-run id: stamped on the scanner_run emit and echoed through the pass so scan +
755
+ // pass land in one trace. Prefer the id minted at the scanner.run span so scan + pass
756
+ // share one senpi.run.id.
757
+ const runId = event.result.runId ?? randomUUID();
758
+ this.signalQueue.push({ result: event.result, enqueuedAt: Date.now(), runId });
759
+ // Edge-triggered backpressure alarm: depth rises one enqueue at a time, so `=== threshold`
760
+ // fires exactly once as it crosses up and re-arms only after the consumer drains back below.
761
+ if (this.signalQueue.length === SIGNAL_QUEUE_BACKPRESSURE_THRESHOLD) {
762
+ emitEvent(logger, () => buildQueueBackpressureEvent({
763
+ depth: this.signalQueue.length,
764
+ threshold: SIGNAL_QUEUE_BACKPRESSURE_THRESHOLD,
765
+ scannerId: event.result.scannerId,
766
+ address: this.strategyConfig.address,
767
+ }));
768
+ }
769
+ if (this.signalQueueWait) {
770
+ const w = this.signalQueueWait;
771
+ this.signalQueueWait = null;
772
+ w();
773
+ }
774
+ // Scanner-run summary payload (startedAt is data stamping for telemetry durations).
775
+ const scannerRunPayload = {
776
+ address: event.result.address,
777
+ data: {
778
+ ...event.result,
779
+ startedAt: event.startedAt,
780
+ runId,
781
+ },
782
+ timestamp: new Date().toISOString(),
783
+ };
784
+ // First-class event log. The build runs inside emitEvent's guard — telemetry never
785
+ // disturbs the scan path.
786
+ emitEvent(logger, () => buildScannerSummaryEvent(scannerRunPayload, Date.now()));
787
+ // The once-per-scan record, emitted for empty runs too — the grain scanner.summary drops.
788
+ emitEvent(logger, () => buildScannerRunTickEvent(event, Date.now()));
789
+ recordScannerRun({ scannerId: event.result.scannerId, outcome: event.result.status });
790
+ // TODO: Market-scanner alerts are default-OFF and MUST NOT be delivered
791
+ // until per-user default-mute-list support exists (today we have mute-list-only
792
+ // settings, which can't honour a default-OFF notification). When that lands,
793
+ // forward `event.result.summary?.alerts` as a MARKET_SCANNER notification with:
794
+ // in-app: `🔔 <b>${n} market alert(s)</b><br>${message}<br>Signals only — no trade placed yet.`
795
+ // push: no title (the notification service resolves it), body `🔔 ${message}`
796
+ // Address-less by design (the scanner watches the market, not one strategy), so
797
+ // it opens a chat by category alone and the strategy wallet must not leak into
798
+ // the seed. Re-add per-(scannerId, message) dedupe at that time.
799
+ // Resolve this TODO when default mute list functionality is available.
800
+ };
801
+ this.scannerModule.on("scanner:run:complete", this.onScannerRunComplete);
802
+ this.signalConsumerDone = this.runSignalConsumer();
803
+ }
804
+ /**
805
+ * Swap this runtime onto a new recipe without restarting it or touching its state.
806
+ *
807
+ * Teardown is the reverse of the build, and both halves matter. The scan listener is released
808
+ * before the module it is registered on; `scannerModule.stop()` clears its timers and awaits
809
+ * in-flight runs; `dslPlugin.stop()` is AWAITED, because it releases its bus subscriptions and
810
+ * drains the tick that may still be mid-flight at the venue — without that the outgoing monitor
811
+ * keeps writing state while the incoming one is already ticking the same positions, and the two
812
+ * hold independent StateManagers so nothing else serialises them.
813
+ *
814
+ * State on disk is untouched throughout: DSL position files, scanner stores and action history
815
+ * all survive, which is the entire reason an update is worth having over a delete-and-recreate.
816
+ */
817
+ async rebuildComponents(config) {
818
+ // Everything fallible happens FIRST, while the old components are still running. A recipe that
819
+ // will not build throws here, and the live runtime never notices: it is still scanning, still
820
+ // ticking, still holding the stops it has already promised.
821
+ const next = await this.constructComponents(config);
822
+ const previousConfig = this.config;
823
+ await this.teardownComponentsForRebuild();
824
+ this.config = config;
825
+ try {
826
+ await this.swapInComponents(config, next);
827
+ }
828
+ catch (err) {
829
+ // Backstop, not the plan. Reaching here means something threw in the narrow window that
830
+ // construction could not cover — realistically `DslPlugin.create` failing to resolve state
831
+ // it has already been handed. The runtime is currently torn down with an open book, so the
832
+ // only acceptable outcome is to get the PREVIOUS recipe running again.
833
+ logger.error("[runtime] rebuild failed after teardown — restoring the previous recipe", { err });
834
+ try {
835
+ await this.teardownComponentsForRebuild();
836
+ const restored = await this.constructComponents(previousConfig);
837
+ this.config = previousConfig;
838
+ await this.swapInComponents(previousConfig, restored);
839
+ }
840
+ catch (rollbackErr) {
841
+ // Both the new recipe and the old one failed to build. Nothing is managing the book, and
842
+ // no further automatic recovery is available — say so in the loudest terms the process has,
843
+ // naming the wallet, because a human has to act.
844
+ logger.error("[runtime] ROLLBACK FAILED — runtime is down with an open book", {
845
+ address: this.strategyConfig.address,
846
+ rebuildError: err,
847
+ rollbackError: rollbackErr,
848
+ });
849
+ throw new RuntimeRebuildRollbackFailedError(this.strategyConfig.address, err, rollbackErr);
850
+ }
851
+ // The runtime is healthy again on its previous recipe; the update itself did not apply.
852
+ throw err;
853
+ }
854
+ }
855
+ /**
856
+ * Install the freshly built components, serialized against the trade loop.
857
+ *
858
+ * The signal consumer runs every action pass inside
859
+ * `reconcileMutex.withKeyLock("reconcile", () => this.runActionsForSignal(...))`, reading
860
+ * `runActionsForSignal` at call time from inside the lock. Taking the same lock here means a
861
+ * signal already in flight finishes against the OLD actions before the swap happens, and the next
862
+ * one picks up the new ones — no pass can straddle the change.
863
+ *
864
+ * The lock is scoped to the SWAP and deliberately NOT to the teardown that precedes it.
865
+ * `teardownComponentsForRebuild` awaits the DSL plugin's in-flight tick, and that tick takes this
866
+ * very lock for its periodic reconcile (`runReconcile` is handed `reconcileSerialize`). Holding
867
+ * the lock across the drain would deadlock the runtime — permanently, with an open book and
868
+ * nothing managing it — because `KeyedMutex` is a plain FIFO and grants no reentrancy.
869
+ *
870
+ * Creating the DSL plugin inside the lock is safe for the mirror-image reason: `create()` starts
871
+ * the monitor loop but does not await its first tick, so that tick queues behind this lock
872
+ * instead of blocking the call that holds it.
873
+ */
874
+ async swapInComponents(config, built) {
875
+ await this.reconcileMutex.withKeyLock("reconcile", async () => {
876
+ await this.commitComponents(config, built);
877
+ this.wireScannerRunListener();
878
+ await this.scannerModule.start();
879
+ });
880
+ }
881
+ /**
882
+ * Drop the scan listener, stop the scanner module, stop and release the DSL plugin.
883
+ *
884
+ * Shared by the rebuild and its rollback so the two cannot drift — the rollback runs it a second
885
+ * time to clear whatever the failed commit had already installed, and every step is idempotent
886
+ * for exactly that reason.
887
+ *
888
+ * Order matters: the listener comes off before the module it is registered on;
889
+ * `scannerModule.stop()` clears its timers and awaits in-flight runs; `dslPlugin.stop()` is
890
+ * AWAITED, because it releases its bus subscriptions and drains the tick that may still be
891
+ * mid-flight at the venue.
892
+ */
893
+ async teardownComponentsForRebuild() {
894
+ if (this.onScannerRunComplete != null) {
895
+ this.scannerModule.off("scanner:run:complete", this.onScannerRunComplete);
896
+ this.onScannerRunComplete = null;
897
+ }
898
+ await this.scannerModule.stop();
899
+ if (this.dslPlugin) {
900
+ await this.dslPlugin.stop();
901
+ this.dslPlugin = null;
902
+ }
903
+ }
710
904
  async removeStrategy(address) {
711
905
  let abandoned = 0;
712
906
  if (this.dslPlugin) {