@senpi/runtime 3.0.118 → 3.0.122

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 (306) hide show
  1. package/dist/actions/action-events.d.ts +7 -21
  2. package/dist/actions/action-events.d.ts.map +1 -1
  3. package/dist/actions/action-events.js +26 -54
  4. package/dist/actions/action-events.js.map +1 -1
  5. package/dist/actions/action-runtime-module.d.ts.map +1 -1
  6. package/dist/actions/action-runtime-module.js +7 -38
  7. package/dist/actions/action-runtime-module.js.map +1 -1
  8. package/dist/actions/close-position/close-position.action.d.ts.map +1 -1
  9. package/dist/actions/close-position/close-position.action.js +15 -122
  10. package/dist/actions/close-position/close-position.action.js.map +1 -1
  11. package/dist/actions/decision-engine.d.ts.map +1 -1
  12. package/dist/actions/decision-engine.js +2 -15
  13. package/dist/actions/decision-engine.js.map +1 -1
  14. package/dist/actions/event-derivation.d.ts +2 -71
  15. package/dist/actions/event-derivation.d.ts.map +1 -1
  16. package/dist/actions/event-derivation.js +8 -251
  17. package/dist/actions/event-derivation.js.map +1 -1
  18. package/dist/actions/open-position/open-position.action.d.ts +2 -17
  19. package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
  20. package/dist/actions/open-position/open-position.action.js +56 -162
  21. package/dist/actions/open-position/open-position.action.js.map +1 -1
  22. package/dist/actions/position-tracker/position-tracker.action.d.ts +1 -2
  23. package/dist/actions/position-tracker/position-tracker.action.d.ts.map +1 -1
  24. package/dist/actions/position-tracker/position-tracker.action.js +17 -62
  25. package/dist/actions/position-tracker/position-tracker.action.js.map +1 -1
  26. package/dist/actions/store/types.d.ts +2 -9
  27. package/dist/actions/store/types.d.ts.map +1 -1
  28. package/dist/actions/store/types.js.map +1 -1
  29. package/dist/actions/types.d.ts +0 -13
  30. package/dist/actions/types.d.ts.map +1 -1
  31. package/dist/actions/types.js +0 -13
  32. package/dist/actions/types.js.map +1 -1
  33. package/dist/cli/guide-commands.d.ts.map +1 -1
  34. package/dist/cli/guide-commands.js +16 -11
  35. package/dist/cli/guide-commands.js.map +1 -1
  36. package/dist/constants/dsl/index.d.ts +17 -6
  37. package/dist/constants/dsl/index.d.ts.map +1 -1
  38. package/dist/constants/dsl/index.js +17 -6
  39. package/dist/constants/dsl/index.js.map +1 -1
  40. package/dist/dsl/engine/floors.d.ts +25 -1
  41. package/dist/dsl/engine/floors.d.ts.map +1 -1
  42. package/dist/dsl/engine/floors.js +38 -6
  43. package/dist/dsl/engine/floors.js.map +1 -1
  44. package/dist/dsl/engine/index.d.ts +1 -1
  45. package/dist/dsl/engine/index.d.ts.map +1 -1
  46. package/dist/dsl/engine/index.js +1 -1
  47. package/dist/dsl/engine/index.js.map +1 -1
  48. package/dist/dsl/engine/round.d.ts +22 -2
  49. package/dist/dsl/engine/round.d.ts.map +1 -1
  50. package/dist/dsl/engine/round.js +78 -5
  51. package/dist/dsl/engine/round.js.map +1 -1
  52. package/dist/dsl/engine/tick.d.ts.map +1 -1
  53. package/dist/dsl/engine/tick.js +31 -16
  54. package/dist/dsl/engine/tick.js.map +1 -1
  55. package/dist/dsl/engine-client/event-mapping.d.ts.map +1 -1
  56. package/dist/dsl/engine-client/event-mapping.js +0 -14
  57. package/dist/dsl/engine-client/event-mapping.js.map +1 -1
  58. package/dist/dsl/events/build-closed-event.d.ts +1 -16
  59. package/dist/dsl/events/build-closed-event.d.ts.map +1 -1
  60. package/dist/dsl/events/build-closed-event.js +0 -6
  61. package/dist/dsl/events/build-closed-event.js.map +1 -1
  62. package/dist/dsl/events/handlers.d.ts +0 -7
  63. package/dist/dsl/events/handlers.d.ts.map +1 -1
  64. package/dist/dsl/events/handlers.js +7 -37
  65. package/dist/dsl/events/handlers.js.map +1 -1
  66. package/dist/dsl/events/initial-state.d.ts +1 -1
  67. package/dist/dsl/events/initial-state.d.ts.map +1 -1
  68. package/dist/dsl/events/initial-state.js +4 -7
  69. package/dist/dsl/events/initial-state.js.map +1 -1
  70. package/dist/dsl/monitor/create-monitor.d.ts +2 -6
  71. package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
  72. package/dist/dsl/monitor/create-monitor.js +9 -15
  73. package/dist/dsl/monitor/create-monitor.js.map +1 -1
  74. package/dist/dsl/monitor/dsl-failure-events.d.ts +0 -2
  75. package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
  76. package/dist/dsl/monitor/dsl-failure-events.js +1 -2
  77. package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
  78. package/dist/dsl/monitor/exchange-sync.d.ts +8 -3
  79. package/dist/dsl/monitor/exchange-sync.d.ts.map +1 -1
  80. package/dist/dsl/monitor/exchange-sync.js +16 -4
  81. package/dist/dsl/monitor/exchange-sync.js.map +1 -1
  82. package/dist/dsl/monitor/process-one-position.d.ts +0 -1
  83. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  84. package/dist/dsl/monitor/process-one-position.js +113 -62
  85. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  86. package/dist/dsl/plugin/index.d.ts +10 -12
  87. package/dist/dsl/plugin/index.d.ts.map +1 -1
  88. package/dist/dsl/plugin/index.js +14 -12
  89. package/dist/dsl/plugin/index.js.map +1 -1
  90. package/dist/health/types.d.ts +0 -13
  91. package/dist/health/types.d.ts.map +1 -1
  92. package/dist/health/types.js +0 -12
  93. package/dist/health/types.js.map +1 -1
  94. package/dist/index.d.ts +4 -27
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +206 -257
  97. package/dist/index.js.map +1 -1
  98. package/dist/runtime/action-registry.d.ts.map +1 -1
  99. package/dist/runtime/action-registry.js +3 -4
  100. package/dist/runtime/action-registry.js.map +1 -1
  101. package/dist/runtime/boot-skip.d.ts +73 -0
  102. package/dist/runtime/boot-skip.d.ts.map +1 -0
  103. package/dist/runtime/boot-skip.js +113 -0
  104. package/dist/runtime/boot-skip.js.map +1 -0
  105. package/dist/runtime/create-runtime-context.d.ts +2 -0
  106. package/dist/runtime/create-runtime-context.d.ts.map +1 -1
  107. package/dist/runtime/create-runtime-context.js +1 -0
  108. package/dist/runtime/create-runtime-context.js.map +1 -1
  109. package/dist/runtime/domain-events.d.ts.map +1 -1
  110. package/dist/runtime/domain-events.js +0 -7
  111. package/dist/runtime/domain-events.js.map +1 -1
  112. package/dist/runtime/lifecycle-events.d.ts +43 -10
  113. package/dist/runtime/lifecycle-events.d.ts.map +1 -1
  114. package/dist/runtime/lifecycle-events.js +83 -12
  115. package/dist/runtime/lifecycle-events.js.map +1 -1
  116. package/dist/runtime/lifecycle-reaper.d.ts +202 -0
  117. package/dist/runtime/lifecycle-reaper.d.ts.map +1 -0
  118. package/dist/runtime/lifecycle-reaper.js +390 -0
  119. package/dist/runtime/lifecycle-reaper.js.map +1 -0
  120. package/dist/runtime/reconcile-run.d.ts +3 -13
  121. package/dist/runtime/reconcile-run.d.ts.map +1 -1
  122. package/dist/runtime/reconcile-run.js +3 -4
  123. package/dist/runtime/reconcile-run.js.map +1 -1
  124. package/dist/runtime/reconcile-state.d.ts +0 -8
  125. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  126. package/dist/runtime/reconcile-state.js +1 -4
  127. package/dist/runtime/reconcile-state.js.map +1 -1
  128. package/dist/runtime/run.d.ts +5 -1
  129. package/dist/runtime/run.d.ts.map +1 -1
  130. package/dist/runtime/run.js +1 -0
  131. package/dist/runtime/run.js.map +1 -1
  132. package/dist/runtime/runtime-schema.js +3 -3
  133. package/dist/runtime/runtime-schema.js.map +1 -1
  134. package/dist/runtime/runtime.d.ts +2 -1
  135. package/dist/runtime/runtime.d.ts.map +1 -1
  136. package/dist/runtime/runtime.js +4 -27
  137. package/dist/runtime/runtime.js.map +1 -1
  138. package/dist/runtime/supervisor/long-running-supervisor.d.ts +0 -13
  139. package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
  140. package/dist/runtime/supervisor/long-running-supervisor.js +0 -1
  141. package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
  142. package/dist/runtime/update-apply-deps.d.ts +23 -2
  143. package/dist/runtime/update-apply-deps.d.ts.map +1 -1
  144. package/dist/runtime/update-apply-deps.js +11 -5
  145. package/dist/runtime/update-apply-deps.js.map +1 -1
  146. package/dist/runtime/validate-dsl-position-tracking.d.ts.map +1 -1
  147. package/dist/runtime/validate-dsl-position-tracking.js +1 -2
  148. package/dist/runtime/validate-dsl-position-tracking.js.map +1 -1
  149. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  150. package/dist/runtime-api/handlers/ingest-signals.js +0 -1
  151. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  152. package/dist/runtime-api/handlers/types.d.ts +0 -5
  153. package/dist/runtime-api/handlers/types.d.ts.map +1 -1
  154. package/dist/runtime-api/intake/intake-events.d.ts +1 -1
  155. package/dist/runtime-api/intake/intake-events.d.ts.map +1 -1
  156. package/dist/runtime-api/intake/intake-events.js +3 -9
  157. package/dist/runtime-api/intake/intake-events.js.map +1 -1
  158. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  159. package/dist/runtime-api/intake/intake-mount.js +19 -4
  160. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  161. package/dist/runtime-api/intake/process-error-post.d.ts +1 -6
  162. package/dist/runtime-api/intake/process-error-post.d.ts.map +1 -1
  163. package/dist/runtime-api/intake/process-error-post.js +1 -5
  164. package/dist/runtime-api/intake/process-error-post.js.map +1 -1
  165. package/dist/runtime-api/intake/process-signal-post.d.ts +1 -7
  166. package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
  167. package/dist/runtime-api/intake/process-signal-post.js +0 -5
  168. package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
  169. package/dist/runtime-api/intake/scaffold-error-router.js.map +1 -1
  170. package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
  171. package/dist/runtime-api/intake/scanner-launcher.js +0 -15
  172. package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
  173. package/dist/runtime-api/intake/types.d.ts +0 -55
  174. package/dist/runtime-api/intake/types.d.ts.map +1 -1
  175. package/dist/runtime-api/routes/signals.d.ts +0 -2
  176. package/dist/runtime-api/routes/signals.d.ts.map +1 -1
  177. package/dist/runtime-api/routes/signals.js +1 -5
  178. package/dist/runtime-api/routes/signals.js.map +1 -1
  179. package/dist/runtime-api/routes/signals.schema.d.ts +0 -43
  180. package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
  181. package/dist/runtime-api/routes/signals.schema.js +0 -43
  182. package/dist/runtime-api/routes/signals.schema.js.map +1 -1
  183. package/dist/runtime-api/server.d.ts +1 -7
  184. package/dist/runtime-api/server.d.ts.map +1 -1
  185. package/dist/runtime-api/server.js +0 -1
  186. package/dist/runtime-api/server.js.map +1 -1
  187. package/dist/scanners/engine/engine.d.ts +1 -22
  188. package/dist/scanners/engine/engine.d.ts.map +1 -1
  189. package/dist/scanners/engine/engine.js +11 -78
  190. package/dist/scanners/engine/engine.js.map +1 -1
  191. package/dist/scanners/engine/lifecycle.d.ts +1 -7
  192. package/dist/scanners/engine/lifecycle.d.ts.map +1 -1
  193. package/dist/scanners/events.d.ts +0 -4
  194. package/dist/scanners/events.d.ts.map +1 -1
  195. package/dist/scanners/events.js +0 -2
  196. package/dist/scanners/events.js.map +1 -1
  197. package/dist/scanners/external/scaffold/delivery.py +25 -194
  198. package/dist/scanners/external/scaffold/scaffold.py +31 -172
  199. package/dist/scanners/external/scaffold/senpi_mcp.py +8 -150
  200. package/dist/scanners/external-scanner-receiver.d.ts +0 -17
  201. package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
  202. package/dist/scanners/external-scanner-receiver.js +0 -8
  203. package/dist/scanners/external-scanner-receiver.js.map +1 -1
  204. package/dist/scanners/runtime-module.d.ts +0 -1
  205. package/dist/scanners/runtime-module.d.ts.map +1 -1
  206. package/dist/scanners/runtime-module.js +9 -66
  207. package/dist/scanners/runtime-module.js.map +1 -1
  208. package/dist/skills-manager/manifest.d.ts.map +1 -1
  209. package/dist/skills-manager/manifest.js +1 -0
  210. package/dist/skills-manager/manifest.js.map +1 -1
  211. package/dist/skills-manager/skills-manifest.json +1 -0
  212. package/dist/telemetry/attributes.d.ts +0 -20
  213. package/dist/telemetry/attributes.d.ts.map +1 -1
  214. package/dist/telemetry/attributes.js +0 -24
  215. package/dist/telemetry/attributes.js.map +1 -1
  216. package/dist/telemetry/event-builders.d.ts +6 -81
  217. package/dist/telemetry/event-builders.d.ts.map +1 -1
  218. package/dist/telemetry/event-builders.js +17 -141
  219. package/dist/telemetry/event-builders.js.map +1 -1
  220. package/dist/telemetry/event-store.d.ts +0 -6
  221. package/dist/telemetry/event-store.d.ts.map +1 -1
  222. package/dist/telemetry/event-store.js +8 -12
  223. package/dist/telemetry/event-store.js.map +1 -1
  224. package/dist/telemetry/metrics.d.ts +2 -14
  225. package/dist/telemetry/metrics.d.ts.map +1 -1
  226. package/dist/telemetry/metrics.js +4 -19
  227. package/dist/telemetry/metrics.js.map +1 -1
  228. package/dist/telemetry/spans/redact-pii.d.ts.map +1 -1
  229. package/dist/telemetry/spans/redact-pii.js +2 -18
  230. package/dist/telemetry/spans/redact-pii.js.map +1 -1
  231. package/dist/telemetry/tracing.d.ts +0 -9
  232. package/dist/telemetry/tracing.d.ts.map +1 -1
  233. package/dist/telemetry/tracing.js +0 -36
  234. package/dist/telemetry/tracing.js.map +1 -1
  235. package/dist/types/dsl/index.d.ts +1 -12
  236. package/dist/types/dsl/index.d.ts.map +1 -1
  237. package/dist/types/runtime.d.ts +8 -0
  238. package/dist/types/runtime.d.ts.map +1 -1
  239. package/dist/types/scanner.d.ts +0 -15
  240. package/dist/types/scanner.d.ts.map +1 -1
  241. package/dist/utils/event-catalog.d.ts +91 -166
  242. package/dist/utils/event-catalog.d.ts.map +1 -1
  243. package/dist/utils/event-catalog.js +13 -18
  244. package/dist/utils/event-catalog.js.map +1 -1
  245. package/dist/utils/keyed-mutex.d.ts +3 -2
  246. package/dist/utils/keyed-mutex.d.ts.map +1 -1
  247. package/dist/utils/keyed-mutex.js +3 -2
  248. package/dist/utils/keyed-mutex.js.map +1 -1
  249. package/dist/utils/logger.d.ts +1 -4
  250. package/dist/utils/logger.d.ts.map +1 -1
  251. package/dist/utils/logger.js.map +1 -1
  252. package/dist/validate/recipe-checks.d.ts.map +1 -1
  253. package/dist/validate/recipe-checks.js +2 -2
  254. package/dist/validate/recipe-checks.js.map +1 -1
  255. package/openclaw.plugin.json +1 -1
  256. package/package.json +1 -1
  257. package/dist/actions/skipped-signal-results.d.ts +0 -25
  258. package/dist/actions/skipped-signal-results.d.ts.map +0 -1
  259. package/dist/actions/skipped-signal-results.js +0 -71
  260. package/dist/actions/skipped-signal-results.js.map +0 -1
  261. package/dist/runtime-api/intake/ingest-request.d.ts +0 -17
  262. package/dist/runtime-api/intake/ingest-request.d.ts.map +0 -1
  263. package/dist/runtime-api/intake/ingest-request.js +0 -34
  264. package/dist/runtime-api/intake/ingest-request.js.map +0 -1
  265. package/dist/runtime-api/intake/scaffold-tick-router.d.ts +0 -39
  266. package/dist/runtime-api/intake/scaffold-tick-router.d.ts.map +0 -1
  267. package/dist/runtime-api/intake/scaffold-tick-router.js +0 -122
  268. package/dist/runtime-api/intake/scaffold-tick-router.js.map +0 -1
  269. package/dist/scanners/external/scaffold/tick_facts.py +0 -130
  270. package/dist/scanners/external/scaffold/ulid.py +0 -58
  271. package/dist/telemetry/live-telemetry.d.ts +0 -238
  272. package/dist/telemetry/live-telemetry.d.ts.map +0 -1
  273. package/dist/telemetry/live-telemetry.js +0 -853
  274. package/dist/telemetry/live-telemetry.js.map +0 -1
  275. package/dist/telemetry/scanner-stats.d.ts +0 -143
  276. package/dist/telemetry/scanner-stats.d.ts.map +0 -1
  277. package/dist/telemetry/scanner-stats.js +0 -426
  278. package/dist/telemetry/scanner-stats.js.map +0 -1
  279. package/dist/telemetry/scanner-tick-feed.d.ts +0 -87
  280. package/dist/telemetry/scanner-tick-feed.d.ts.map +0 -1
  281. package/dist/telemetry/scanner-tick-feed.js +0 -48
  282. package/dist/telemetry/scanner-tick-feed.js.map +0 -1
  283. package/dist/telemetry/strategy-topology.d.ts +0 -99
  284. package/dist/telemetry/strategy-topology.d.ts.map +0 -1
  285. package/dist/telemetry/strategy-topology.js +0 -93
  286. package/dist/telemetry/strategy-topology.js.map +0 -1
  287. package/dist/telemetry/telemetry-publisher.d.ts +0 -63
  288. package/dist/telemetry/telemetry-publisher.d.ts.map +0 -1
  289. package/dist/telemetry/telemetry-publisher.js +0 -229
  290. package/dist/telemetry/telemetry-publisher.js.map +0 -1
  291. package/dist/telemetry/tick-frame.d.ts +0 -212
  292. package/dist/telemetry/tick-frame.d.ts.map +0 -1
  293. package/dist/telemetry/tick-frame.js +0 -421
  294. package/dist/telemetry/tick-frame.js.map +0 -1
  295. package/dist/telemetry/timeline-buffer.d.ts +0 -95
  296. package/dist/telemetry/timeline-buffer.d.ts.map +0 -1
  297. package/dist/telemetry/timeline-buffer.js +0 -237
  298. package/dist/telemetry/timeline-buffer.js.map +0 -1
  299. package/dist/utils/telemetry-required-log.d.ts +0 -60
  300. package/dist/utils/telemetry-required-log.d.ts.map +0 -1
  301. package/dist/utils/telemetry-required-log.js +0 -105
  302. package/dist/utils/telemetry-required-log.js.map +0 -1
  303. package/dist/utils/ulid.d.ts +0 -9
  304. package/dist/utils/ulid.d.ts.map +0 -1
  305. package/dist/utils/ulid.js +0 -43
  306. package/dist/utils/ulid.js.map +0 -1
package/dist/index.js CHANGED
@@ -16,10 +16,11 @@ import { parse as parseYaml } from "yaml";
16
16
  import { join, dirname } from "node:path";
17
17
  import { SenpiClient } from "./senpi/client.js";
18
18
  import { buildWalletStatusHint } from "./senpi/strategy-status.js";
19
+ import { bootSkippedStatusLabel, partitionByWalletStatus, readBootWalletStatuses, trackableWalletKeys, } from "./runtime/boot-skip.js";
19
20
  import { DeployJobController } from "./deploy/job.js";
20
21
  import { registerDeployGatewayMethods } from "./deploy/register.js";
21
22
  import { registerUpdateGatewayMethod } from "./runtime/update-register.js";
22
- import { checkProofCoversRecipe, makeApplyToRuntime, makeWriteRegistry, } from "./runtime/update-apply-deps.js";
23
+ import { checkProofCoversRecipe, makeApplyToRuntime, makeWriteRegistry, runtimeChangeLock, } from "./runtime/update-apply-deps.js";
23
24
  import { INSTALL_DEADLINE_MS, WALLET_INSTALL_IN_FLIGHT_CODE } from "./deploy/deadlines.js";
24
25
  import { AuditQueryError } from "./runtime/audit-query.types.js";
25
26
  import { buildRuntimeStateDir, archiveStateDir } from "./runtime/state-layout.js";
@@ -52,7 +53,8 @@ import { registerToolApprovalGate, resolveToolApprovalsMode, } from "./approvals
52
53
  import { registerShowWidgetTool } from "./widgets/index.js";
53
54
  import { EVENT_NAME, SENPI_ERROR_CODE, eventAttrs, scannerUnwiredErrorCode } from "./utils/event-catalog.js";
54
55
  import { emitEvent } from "./actions/event-derivation.js";
55
- import { buildRuntimeCreatedEvent, buildRuntimeDeletedEvent, buildRuntimeUpdatedEvent } from "./runtime/lifecycle-events.js";
56
+ import { createLifecycleReaper, resolveReaperBootDelayMs, resolveReaperEnforcement, } from "./runtime/lifecycle-reaper.js";
57
+ import { buildRuntimeBootSkippedEvent, buildRuntimeBootStatusUnreadEvent, buildRuntimeCreatedEvent, buildRuntimeDeletedEvent, buildRuntimeUpdatedEvent, } from "./runtime/lifecycle-events.js";
56
58
  import { isWalletAddress } from "./utils/wallet-address.js";
57
59
  import { fileURLToPath } from "node:url";
58
60
  import { loadApiConfig } from "./runtime-api/config.js";
@@ -62,11 +64,7 @@ import { createIntakeContext } from "./runtime-api/intake/intake-context.js";
62
64
  import { wireRuntime, intakeUrlFor, selectScanners } from "./runtime-api/intake/intake-mount.js";
63
65
  import { ingestDispatchMutex, ingestDispatchKey } from "./runtime-api/intake/ingest-mutex.js";
64
66
  import { buildScaffoldErrorRouter } from "./runtime-api/intake/scaffold-error-router.js";
65
- import { buildScaffoldTickRouter } from "./runtime-api/intake/scaffold-tick-router.js";
66
- import { toIngestRequest } from "./runtime-api/intake/ingest-request.js";
67
67
  import { loadRuntimeConfig } from "./runtime/load-runtime-config.js";
68
- import { createLiveTelemetry } from "./telemetry/live-telemetry.js";
69
- import { resolveTickTelemetry } from "./telemetry/tick-frame.js";
70
68
  /** Initialize all core features (DSL, risk, health) on the given event bus. */
71
69
  export function initCore(bus) {
72
70
  initDsl(bus);
@@ -225,16 +223,6 @@ const senpiRuntimePlugin = {
225
223
  ? "env SENPI_LOG_LEVEL"
226
224
  : "default";
227
225
  pluginLogger.info(`[senpi-runtime] log level: ${startupLogLevel} (source: ${logLevelSourceLabel}). Set plugin config "logLevel" or env SENPI_LOG_LEVEL to debug|info|warn|error to override.`);
228
- // Same shape as the log-level line above, and for the same reason: a box publishing less than
229
- // an operator expects must say so at boot rather than be diagnosed from an empty screen. No env
230
- // var is named because there is none — see `tickTelemetry` on SenpiRuntimePluginConfig.
231
- const tickTelemetry = resolveTickTelemetry(config.tickTelemetry);
232
- if (tickTelemetry.invalid !== undefined) {
233
- pluginLogger.warn(`[senpi-runtime] Ignoring invalid plugin config ${tickTelemetry.invalid} (expected off|summary|verbose); keeping mcp=${tickTelemetry.settings.mcp} state=${tickTelemetry.settings.state}.`);
234
- }
235
- pluginLogger.info(tickTelemetry.settings.enabled
236
- ? `[senpi-runtime] tick telemetry: mcp=${tickTelemetry.settings.mcp} state=${tickTelemetry.settings.state} (source: ${config.tickTelemetry === undefined ? "default" : "plugin config tickTelemetry"}). Set plugin config "tickTelemetry" to off|summary|verbose, or {mcp,state}, to override.`
237
- : `[senpi-runtime] tick telemetry: off (source: plugin config tickTelemetry). No per-tick frame is published.`);
238
226
  const senpiCliStore = readSenpiCliStore();
239
227
  const baseStateDir = resolveSenpiBaseStateDir({
240
228
  store: senpiCliStore,
@@ -367,13 +355,16 @@ const senpiRuntimePlugin = {
367
355
  }
368
356
  let routeAcceptedSignalToRuntime;
369
357
  let routeScaffoldErrorToRuntime;
370
- let routeScaffoldTickToTelemetry;
371
358
  const launchers = new Map();
372
359
  // Runtimes that are UP but whose external scanners never wired (mount/launch/install
373
360
  // failure), keyed runtimeId → failed phase. Populated at the four unwired sites,
374
361
  // cleared on a successful wire/install. senpi.listRuntimes reads it to render
375
362
  // "running — NO ENTRY SCANNERS" instead of a bare "running" for a blind runtime.
376
363
  const unwiredRuntimes = new Map();
364
+ // Registered runtimes boot left down because the platform reports their strategy wallet dead,
365
+ // keyed runtimeId → that status. senpi.listRuntimes reads it so the row says why it is stopped;
366
+ // cleared when the row is deleted.
367
+ const bootSkippedRuntimes = new Map();
377
368
  /**
378
369
  * Wallets with an install IN FLIGHT in this process, keyed by lowercased address.
379
370
  *
@@ -391,149 +382,10 @@ const senpiRuntimePlugin = {
391
382
  * engines, one margin account: the exact state `[E_WALLET_HAS_RUNTIME]` exists to prevent.
392
383
  */
393
384
  const installsInFlight = new Map();
394
- // ── Live telemetry ────────────────────────────────────────────────
395
- // Every runtime this box knows about, running or not, keyed runtimeId. The topology payload has
396
- // to name a stopped strategy too, so this is populated from the registry rather than from
397
- // `runtimeHandles` (which holds only the live ones) and is the source of the two facts the
398
- // registry entry does not carry: the strategy's display name and its declared scanners. Both come
399
- // from the parsed YAML — `name` is the same `config.name` that `RuntimeHealthStatus.runtimeName`
400
- // is built from, so a runtime whose config never parsed falls back to its id rather than to a
401
- // guess, and its scanner list stays empty rather than inventing rows.
402
- const telemetryStrategies = new Map();
403
- const noteTelemetryRuntime = (runtimeId, address) => {
404
- const existing = telemetryStrategies.get(runtimeId);
405
- telemetryStrategies.set(runtimeId, {
406
- address,
407
- name: existing?.name ?? runtimeId,
408
- scanners: existing?.scanners ?? [],
409
- });
410
- };
411
- const noteTelemetryRuntimeConfig = (runtimeId, config) => {
412
- telemetryStrategies.set(runtimeId, {
413
- address: config.strategy?.wallet ?? telemetryStrategies.get(runtimeId)?.address ?? "",
414
- name: config.name,
415
- scanners: config.scanners ?? [],
416
- });
417
- };
418
385
  // Dedicated logger for the runtime-api subtree — required because the
419
386
  // PluginLogger surface (info/warn/error) lacks `debug`, while the
420
387
  // Logger contract used by `runtime-api/` includes it.
421
388
  const apiLogger = createLogger("[senpi-core][api]");
422
- /**
423
- * One gather per publish tick. Health and system state are read together because the two payloads
424
- * need both: the topology reads health and `runtimeName`, the stats read the per-scanner rows,
425
- * and `restartCount` / `degradedReason` live only on the health rows while every other scanner
426
- * field lives only on the system-state rows — so they are joined here on `(runtimeId, scannerId)`.
427
- */
428
- const collectTelemetryFacts = async () => {
429
- const [statuses, states] = await Promise.all([
430
- collectRuntimeHealthStatuses(runtimeHandles, api.logger),
431
- collectRuntimeSystemStates(runtimeHandles, api.logger, baseStateDir),
432
- ]);
433
- const healthByRuntime = new Map();
434
- for (const status of statuses) {
435
- if (status.runtimeId !== undefined)
436
- healthByRuntime.set(status.runtimeId, status);
437
- }
438
- const scannerState = [];
439
- // Registration facts per (runtimeId, scannerId), so the topology can report a scanner's live
440
- // schedule rather than only what its YAML declared.
441
- const registrations = new Map();
442
- for (const state of states) {
443
- const runtimeId = state.runtimeId;
444
- const healthRows = runtimeId === undefined
445
- ? []
446
- : healthByRuntime.get(runtimeId)?.components.scanners.scanners ?? [];
447
- for (const row of state.components.scanners.state.scanners) {
448
- const supervision = healthRows.find((r) => r.scannerId === row.scannerId);
449
- const snapshot = {
450
- address: row.address,
451
- scannerId: row.scannerId,
452
- health: row.health,
453
- degradedReason: supervision?.degradedReason ?? null,
454
- enabled: row.enabled,
455
- inFlightSince: row.inFlightSince,
456
- lastRunStartedAt: row.lastRunStartedAt,
457
- lastRunFinishedAt: row.lastRunFinishedAt,
458
- lastRunStatus: row.lastRunStatus,
459
- lastRunReason: row.lastRunReason,
460
- nextRunAt: row.nextRunAt,
461
- lastAliveAt: row.lastAliveAt,
462
- // No supervision row means the restart count is unknown, not zero. A zero here would
463
- // read as "this scanner has never restarted" on a scanner nobody is supervising.
464
- restartCount: supervision?.restartCount ?? null,
465
- };
466
- scannerState.push(snapshot);
467
- if (runtimeId !== undefined) {
468
- registrations.set(`${runtimeId}::${row.scannerId}`, {
469
- ...snapshot,
470
- scheduleMode: row.scheduleMode,
471
- configVersion: row.configVersion,
472
- intervalSeconds: row.intervalSeconds,
473
- });
474
- }
475
- }
476
- }
477
- const strategies = [];
478
- for (const [runtimeId, entry] of telemetryStrategies) {
479
- const health = healthByRuntime.get(runtimeId);
480
- const scanners = entry.scanners.map((declared) => {
481
- const live = registrations.get(`${runtimeId}::${declared.name}`);
482
- return live === undefined
483
- ? { entry: declared }
484
- : {
485
- entry: declared,
486
- registration: {
487
- enabled: live.enabled,
488
- scheduleMode: live.scheduleMode,
489
- // The engine's own cadence, which is what the scanner actually polls on — a
490
- // recipe may omit it and take the scanner definition's default. An external
491
- // scanner registers zero because it is POSTed to rather than polled, and only
492
- // the recipe knows the cadence its scaffold was told to tick at.
493
- intervalSeconds: live.intervalSeconds > 0 ? live.intervalSeconds : declared.interval_seconds ?? null,
494
- configVersion: live.configVersion,
495
- },
496
- };
497
- });
498
- strategies.push({
499
- runtimeId,
500
- address: entry.address,
501
- // `runtimeName` is built from the same `config.name`; the indexed value is used because it
502
- // is available for a stopped runtime too, where there is no health status to read.
503
- name: health?.runtimeName ?? entry.name,
504
- scanners,
505
- });
506
- }
507
- return {
508
- strategies,
509
- runningRuntimeIds: runtimeHandles,
510
- unwiredRuntimeIds: unwiredRuntimes,
511
- readHealth: (runtimeId) => healthByRuntime.get(runtimeId),
512
- scannerState,
513
- };
514
- };
515
- const liveTelemetry = createLiveTelemetry({
516
- collectFacts: collectTelemetryFacts,
517
- tickTelemetry: config.tickTelemetry,
518
- });
519
- /**
520
- * Wrapped gateway methods capture the broadcast handle on the FIRST request to ANY of them —
521
- * not only on subscribe. `ctx.context` is the only route to the websocket push layer and it
522
- * exists only inside a handler, so capturing it in the subscribe method alone would mean a box
523
- * whose client never subscribes can never publish anything, silently.
524
- *
525
- * The wrapper covers only what is registered through it. `senpi.deploy.start`,
526
- * `senpi.deploy.status` and `senpi.update` are registered straight onto the plugin api by their
527
- * own registrars and never arm the handle. That is not a gap in practice: one wrapped call arms
528
- * it for the life of the process, and the websocket client admits `senpi.telemetry.subscribe`,
529
- * which is wrapped.
530
- */
531
- const registerGatewayMethod = (name, handler, opts) => {
532
- api.registerGatewayMethod(name, (ctx) => {
533
- liveTelemetry.captureBroadcast(ctx, name);
534
- return handler(ctx);
535
- }, opts);
536
- };
537
389
  // A gateway seam failed to wire/launch a runtime's external scanner stack: the runtime is up but
538
390
  // runs blind on internal scanners only. Two records, by design: a first-class `runtime.scanners_unwired`
539
391
  // event (the queryable/alertable fact, by phase) and the migrated exception-form `logger.error`
@@ -638,6 +490,39 @@ const senpiRuntimePlugin = {
638
490
  now: () => Date.now(),
639
491
  log: (line) => api.logger.info(`[senpi-runtime] ${line}`),
640
492
  });
493
+ // ── lifecycle reaper ─────────────────────────────────────────────────────────────────────────
494
+ // A runtime exists iff its strategy is open. Every close path ends at the backend and none of
495
+ // them tells this box, so the reaper asks: once after boot, hourly, and whenever the backend
496
+ // rejects an order with STRATEGY_NOT_ACTIVE. Observe-only unless enforcement is on (see
497
+ // runtime/lifecycle-reaper.ts for the safety rule). Constructed here so both `run()` call sites
498
+ // (boot and install) can hand it their not-active rejections; it does no I/O until started.
499
+ const reaperBootDelayOverride = resolveReaperBootDelayMs(process.env);
500
+ const reaper = createLifecycleReaper({
501
+ readRegistry: async () => {
502
+ const { readRegistry } = await import("./runtime/strategy-registry.js");
503
+ return readRegistry(baseStateDir);
504
+ },
505
+ readStatuses: async (wallets, signal) => {
506
+ const client = mcpTransport ? new SenpiClient(mcpTransport) : new SenpiClient({ apiKey });
507
+ if (!mcpTransport)
508
+ await client.connect();
509
+ try {
510
+ return await client.listStrategies({ strategyAddresses: wallets }, { signal });
511
+ }
512
+ finally {
513
+ if (!mcpTransport)
514
+ await client.disconnect().catch(() => { });
515
+ }
516
+ },
517
+ deleteRuntime: async (id, reason, wallet) => {
518
+ const result = await deleteRuntimeCore({ id, deleteReason: reason, expectWallet: wallet });
519
+ return result.ok ? { ok: true } : { ok: false, code: result.error.code, message: result.error.message };
520
+ },
521
+ emit: (build) => emitEvent(apiLogger, build),
522
+ enforce: resolveReaperEnforcement(process.env),
523
+ logger: pluginLogger,
524
+ ...(reaperBootDelayOverride !== undefined ? { bootDelayMs: reaperBootDelayOverride } : {}),
525
+ });
641
526
  api.registerService({
642
527
  id: "senpi-runtime",
643
528
  async start() {
@@ -674,14 +559,26 @@ const senpiRuntimePlugin = {
674
559
  // /signals will not be mounted and no signals will be routed.
675
560
  if (sharedIntakeContext) {
676
561
  const ctx = sharedIntakeContext;
677
- routeAcceptedSignalToRuntime = (signal, correlationId, scannerId) => {
562
+ routeAcceptedSignalToRuntime = (signal, _correlationId, scannerId) => {
678
563
  const resolved = ctx.registry.resolve(scannerId);
679
564
  if (!resolved)
680
565
  return;
681
566
  const h = runtimeHandles.get(resolved.runtimeId);
682
567
  if (!h)
683
568
  return;
684
- const request = toIngestRequest(signal, correlationId, resolved.scannerName);
569
+ const request = {
570
+ scanner: resolved.scannerName,
571
+ signal_id: signal.signal_id, // greppable handle across intake→ingest (obs only)
572
+ asset: signal.asset,
573
+ direction: signal.direction,
574
+ ...(signal.signal_type !== undefined && { signal_type: signal.signal_type }),
575
+ data: signal.data,
576
+ // Per-signal sizing rides at the top level; the open-position action
577
+ // reads request.marginPct / request.leverage directly. Without this a
578
+ // supervised signal's sizing is lost ("no margin source resolved").
579
+ ...(signal.marginPct !== undefined && { marginPct: signal.marginPct }),
580
+ ...(signal.leverage !== undefined && { leverage: signal.leverage }),
581
+ };
685
582
  // Trace the sizing-relevant fields the narrowed request carries, so a
686
583
  // field dropped at this seam is visible instead of vanishing silently.
687
584
  apiLogger.debug(`[intake] ingest req signal_id=${signal.signal_id} scanner=${resolved.scannerName} asset=${signal.asset} direction=${signal.direction} marginPct=${signal.marginPct} leverage=${signal.leverage}`);
@@ -701,22 +598,58 @@ const senpiRuntimePlugin = {
701
598
  // The single server-level onError closure — the `/errors` counterpart of
702
599
  // routeAcceptedSignalToRuntime. It records a scaffold-reported tick failure into
703
600
  // the owning runtime's scanner telemetry so a scanner that fails every tick stops
704
- // rendering "healthy". Filter: only tick-level failure types count — a
705
- // `candidate_rejected` body is a per-candidate reject on an otherwise-OK tick, not
706
- // a failed run.
601
+ // rendering "healthy".
707
602
  //
708
- // This is the SHARED implementation (scaffold-error-router.ts), so this route and
709
- // buildIntakeMount's onScaffoldError apply one filter, and scaffold-error-router.test.ts
710
- // drives THIS closure.
603
+ // This is the SHARED implementation (scaffold-error-router.ts), not a copy of
604
+ // buildIntakeMount's onScaffoldError. It used to be a copy, and the copy had its
605
+ // own hardcoded three-type filter: when `mcp_error` was added to the tick-failure
606
+ // list it reached the tested seam and never reached this route, so the
607
+ // wedged-scanner signal was green in CI and inert in production. There is now one
608
+ // filter, and scaffold-error-router.test.ts drives THIS closure.
711
609
  routeScaffoldErrorToRuntime = buildScaffoldErrorRouter(ctx, runtimeHandles);
712
- // The per-tick level for a supervised external scanner. Both intake routes call it, so a
713
- // quiet tick and a failed tick are equally visible; it needs no runtime handle because it
714
- // publishes a level rather than recording a run.
715
- routeScaffoldTickToTelemetry = buildScaffoldTickRouter(ctx);
610
+ }
611
+ // One bounded read: which registered wallets the platform says are dead, so a restart does
612
+ // not re-scaffold runtimes on closed wallets (fail-open — see runtime/boot-skip.ts).
613
+ let walletStatuses = null;
614
+ // Lower-cased and trackable-only: the backend matches addresses case-sensitively against
615
+ // lower-case storage, and rejects the whole batch on one malformed address.
616
+ const registeredWallets = trackableWalletKeys(registry.runtimes);
617
+ if (registeredWallets.length > 0) {
618
+ const read = await readBootWalletStatuses(registeredWallets, async (wallets, signal) => {
619
+ const client = mcpTransport ? new SenpiClient(mcpTransport) : new SenpiClient({ apiKey });
620
+ if (!mcpTransport)
621
+ await client.connect();
622
+ try {
623
+ return await client.listStrategies({ strategyAddresses: wallets }, { signal });
624
+ }
625
+ finally {
626
+ if (!mcpTransport)
627
+ await client.disconnect().catch(() => { });
628
+ }
629
+ });
630
+ if (read.ok) {
631
+ walletStatuses = read.statuses;
632
+ reaper.recordStatuses(registeredWallets, read.statuses);
633
+ }
634
+ else {
635
+ pluginLogger.warn("[senpi-runtime] Could not read strategy statuses at boot; booting every registered runtime", { reason: read.reason, err: read.message });
636
+ emitEvent(apiLogger, () => buildRuntimeBootStatusUnreadEvent({ reason: read.reason, registeredCount: registeredWallets.length }));
637
+ }
638
+ }
639
+ const { boot: entriesToBoot, skipped } = partitionByWalletStatus(registry.runtimes, walletStatuses);
640
+ bootSkippedRuntimes.clear();
641
+ for (const { entry, status } of skipped) {
642
+ bootSkippedRuntimes.set(entry.id, status);
643
+ pluginLogger.info("[senpi-runtime] Not booting runtime: its strategy wallet is closed", {
644
+ runtimeId: entry.id,
645
+ wallet: entry.wallet,
646
+ status,
647
+ });
648
+ emitEvent(apiLogger, () => buildRuntimeBootSkippedEvent({ runtimeId: entry.id, address: entry.wallet ?? "", status }));
716
649
  }
717
650
  const started = [];
718
651
  try {
719
- for (const entry of registry.runtimes) {
652
+ for (const entry of entriesToBoot) {
720
653
  const hasPath = typeof entry.runtimeYamlPath === "string" && entry.runtimeYamlPath.length > 0;
721
654
  const hasContent = typeof entry.runtimeYamlContent === "string";
722
655
  if (hasPath && hasContent)
@@ -725,9 +658,6 @@ const senpiRuntimePlugin = {
725
658
  continue;
726
659
  const pathOrContent = entry.runtimeYamlPath ?? entry.runtimeYamlContent;
727
660
  const entryStateDir = buildRuntimeStateDir(baseStateDir, entry.id, entry.wallet ?? "");
728
- // Indexed before the boot attempt: a runtime whose start throws is installed but stopped,
729
- // and the telemetry topology has to be able to say so.
730
- noteTelemetryRuntime(entry.id, entry.wallet ?? "");
731
661
  const handle = await run(pathOrContent, {
732
662
  fromPath: hasPath,
733
663
  stateDir: entryStateDir,
@@ -735,6 +665,7 @@ const senpiRuntimePlugin = {
735
665
  apiKey,
736
666
  scannerProviders,
737
667
  runtimeId: entry.id,
668
+ onStrategyNotActive: (address) => reaper.checkWallet(address),
738
669
  });
739
670
  runtimeHandles.set(entry.id, handle);
740
671
  started.push(entry.id);
@@ -750,7 +681,6 @@ const senpiRuntimePlugin = {
750
681
  if (sharedIntakeContext) {
751
682
  try {
752
683
  const runtimeConfig = await loadRuntimeConfig(pathOrContent, { fromPath: hasPath });
753
- noteTelemetryRuntimeConfig(entry.id, runtimeConfig);
754
684
  // Content-installed runtimes carry their recipe dir in the registry
755
685
  // entry (runtimeYamlDir) so relative scanner paths still resolve
756
686
  // after a restart; path-installed ones derive it from the path.
@@ -802,7 +732,6 @@ const senpiRuntimePlugin = {
802
732
  // event) so listRuntimes/health don't render a bare "running".
803
733
  try {
804
734
  const runtimeConfig = await loadRuntimeConfig(pathOrContent, { fromPath: hasPath });
805
- noteTelemetryRuntimeConfig(entry.id, runtimeConfig);
806
735
  if (selectScanners(runtimeConfig).length > 0) {
807
736
  unwiredRuntimes.set(entry.id, "no_intake");
808
737
  handle.attachSupervisionSource(() => ({
@@ -861,7 +790,6 @@ const senpiRuntimePlugin = {
861
790
  intakeContext: sharedIntakeContext,
862
791
  onAccepted: routeAcceptedSignalToRuntime,
863
792
  onError: routeScaffoldErrorToRuntime,
864
- onTick: routeScaffoldTickToTelemetry,
865
793
  });
866
794
  apiHandle.ready();
867
795
  }
@@ -905,19 +833,27 @@ const senpiRuntimePlugin = {
905
833
  }
906
834
  }
907
835
  }
908
- // Publish ticks start only once the runtimes are up, so the first frames describe a real
909
- // box. Gated by the same switch the skills coordinator uses: unit tests boot `register()`
910
- // and must not acquire background timers. The subscribe method still answers without them.
911
- if (process.env.SENPI_RUNTIME_API_DISABLED !== "true") {
912
- liveTelemetry.start();
836
+ // Last, once everything above is up: the reaper's post-boot pass and hourly sweep. Its
837
+ // not-active escalation is also off until here, so no delete can land while the boot loop is
838
+ // still wiring runtimes and launchers. Nothing here may fail start() — a throw from inside the boot loop takes every runtime down, which
839
+ // is exactly the failure a lifecycle check must never cause. The unit-test seam
840
+ // SENPI_RUNTIME_API_DISABLED keeps its timers off unless a boot delay is set explicitly.
841
+ try {
842
+ if (process.env.SENPI_RUNTIME_API_DISABLED !== "true" || reaperBootDelayOverride !== undefined) {
843
+ reaper.start();
844
+ }
845
+ }
846
+ catch (err) {
847
+ pluginLogger.warn("[senpi-runtime] lifecycle reaper did not start", {
848
+ err: err instanceof Error ? err.message : String(err),
849
+ });
913
850
  }
914
851
  },
915
852
  async stop() {
916
- // Live telemetry goes first: it owns a timer and an in-memory buffer, nothing downstream
917
- // depends on it, and stopping it here guarantees no publish tick reads a half-torn-down
918
- // runtime handle. Records emitted during the teardown below land in a buffer nobody drains,
919
- // which is the right trade for a process on its way out.
920
- liveTelemetry.stop();
853
+ // Stop the reaper first so no pass starts deleting while the runtimes are torn down below,
854
+ // and wait (bounded) for a pass already under way, so its delete cannot stop a handle the
855
+ // teardown below is stopping too.
856
+ await reaper.stop();
921
857
  // Drain the HTTP server FIRST so in-flight POST /signals requests
922
858
  // finish before the underlying runtime they target is torn down.
923
859
  // `apiHandle.close()` never throws.
@@ -1250,12 +1186,9 @@ const senpiRuntimePlugin = {
1250
1186
  apiKey,
1251
1187
  scannerProviders,
1252
1188
  runtimeId: resolvedId,
1189
+ onStrategyNotActive: (address) => reaper.checkWallet(address),
1253
1190
  });
1254
1191
  runtimeHandles.set(resolvedId, handle);
1255
- if (parsedConfig)
1256
- noteTelemetryRuntimeConfig(resolvedId, parsedConfig);
1257
- else
1258
- noteTelemetryRuntime(resolvedId, wallet);
1259
1192
  // Registry create fact — distinct from the runtime.started boot transition (which also fires
1260
1193
  // on a transient restart). Emitted inside emitEvent's guard so telemetry never fails the install.
1261
1194
  emitEvent(apiLogger, () => buildRuntimeCreatedEvent({ runtimeId: resolvedId, address: wallet, recipeName: runtimeName }));
@@ -1368,7 +1301,7 @@ const senpiRuntimePlugin = {
1368
1301
  }
1369
1302
  }
1370
1303
  /** Gateway respond accepts (ok, payload?, error?) with error as { code, message }; plugin SDK types may omit the third arg. */
1371
- registerGatewayMethod("senpi.installRuntime", async (ctx) => {
1304
+ api.registerGatewayMethod("senpi.installRuntime", async (ctx) => {
1372
1305
  const respond = ctx.respond;
1373
1306
  const result = await installRuntimeCore(ctx.params);
1374
1307
  if (result.ok)
@@ -1376,7 +1309,7 @@ const senpiRuntimePlugin = {
1376
1309
  else
1377
1310
  respond(false, undefined, result.error);
1378
1311
  }, { scope: "operator.write" });
1379
- registerGatewayMethod("senpi.listRuntimes", async (ctx) => {
1312
+ api.registerGatewayMethod("senpi.listRuntimes", async (ctx) => {
1380
1313
  const respond = ctx.respond;
1381
1314
  const { readRegistry } = await import("./runtime/strategy-registry.js");
1382
1315
  const registry = await readRegistry(baseStateDir);
@@ -1385,8 +1318,11 @@ const senpiRuntimePlugin = {
1385
1318
  // say so rather than reporting a bare "running" the operator would read as healthy.
1386
1319
  // unwiredRuntimes is populated at the four wire/launch failure sites (and is
1387
1320
  // exactly the set of "declared external scanners but has no launcher").
1321
+ const bootSkipStatus = bootSkippedRuntimes.get(e.id);
1388
1322
  const status = !runtimeHandles.has(e.id)
1389
- ? "stopped"
1323
+ ? bootSkipStatus !== undefined
1324
+ ? bootSkippedStatusLabel(bootSkipStatus)
1325
+ : "stopped"
1390
1326
  : unwiredRuntimes.has(e.id)
1391
1327
  ? "running — NO ENTRY SCANNERS"
1392
1328
  : "running";
@@ -1397,6 +1333,10 @@ const senpiRuntimePlugin = {
1397
1333
  ? e.runtimeYamlPath
1398
1334
  : "(inline)",
1399
1335
  status,
1336
+ // What the strategy behind this runtime last read as (open / closing / closed / absent /
1337
+ // unchecked / unknown) — separate from `status`, whose running…/stopped… prefixes other
1338
+ // readers key on.
1339
+ ...reaper.lifecycleOf(e.wallet),
1400
1340
  // The deploy path installs by CONTENT, not path (orchestrator.ts installRuntime), so
1401
1341
  // `source` is "(inline)" for every deployed strategy and the YAML is unreachable through
1402
1342
  // it. The engine renders the facts instead of shipping a blob to re-interpret.
@@ -1411,13 +1351,19 @@ const senpiRuntimePlugin = {
1411
1351
  * unwired-verdict cleanup all happen exactly once, here.
1412
1352
  */
1413
1353
  async function deleteRuntimeCore(params) {
1414
- const { readRegistry, writeRegistry, withRegistryLock } = await import("./runtime/strategy-registry.js");
1415
- const registry = await readRegistry(baseStateDir);
1416
1354
  let targetId = typeof params?.id === "string" ? params.id : undefined;
1417
1355
  const targetAddress = typeof params?.address === "string" ? params.address : undefined;
1356
+ // Set only by the lifecycle reaper: why the strategy counts as gone (stamped on runtime.deleted)
1357
+ // and which wallet that verdict was about (the concurrency guard below).
1358
+ const deleteReason = params?.deleteReason === "closed_status" ? params.deleteReason
1359
+ : undefined;
1360
+ const expectWallet = typeof params?.expectWallet === "string" && params.expectWallet.trim().length > 0
1361
+ ? params.expectWallet.trim().toLowerCase()
1362
+ : undefined;
1418
1363
  // Resolve target by address if id not provided
1419
1364
  if (!targetId && targetAddress) {
1420
- const entry = registry.runtimes.find((e) => e.wallet === targetAddress);
1365
+ const { readRegistry } = await import("./runtime/strategy-registry.js");
1366
+ const entry = (await readRegistry(baseStateDir)).runtimes.find((e) => e.wallet === targetAddress);
1421
1367
  if (!entry) {
1422
1368
  return { ok: false, error: {
1423
1369
  code: "NOT_FOUND",
@@ -1432,6 +1378,24 @@ const senpiRuntimePlugin = {
1432
1378
  message: "Please provide the runtime id or wallet address. Example: openclaw senpi runtime delete --id my-runtime",
1433
1379
  } };
1434
1380
  }
1381
+ // One delete or update apply per runtime at a time (see `runtimeChangeLock`). Operator, agent
1382
+ // and deploy deletes wait their turn; the lifecycle reaper never queues behind someone else's
1383
+ // change to the runtime it judged — it backs off and the next lifecycle check re-reads it.
1384
+ // The check and the acquire below run in one synchronous step.
1385
+ const id = targetId;
1386
+ if (expectWallet !== undefined && runtimeChangeLock.hasKey(id)) {
1387
+ return { ok: false, error: {
1388
+ code: "CONFLICT",
1389
+ message: `Runtime "${id}" was not removed: another delete or an update is already running on it. Nothing was changed; the next lifecycle check re-reads it.`,
1390
+ } };
1391
+ }
1392
+ return runtimeChangeLock.withKeyLock(id, () => deleteRuntimeLocked(id, deleteReason, expectWallet));
1393
+ }
1394
+ /** {@link deleteRuntimeCore} past target resolution, under the runtime's change lock. */
1395
+ async function deleteRuntimeLocked(targetId, deleteReason, expectWallet) {
1396
+ const { readRegistry, writeRegistry, withRegistryLock } = await import("./runtime/strategy-registry.js");
1397
+ // Read under the lock: a delete that waited behind another change sees what that change left.
1398
+ const registry = await readRegistry(baseStateDir);
1435
1399
  const deletedEntry = registry.runtimes.find((e) => e.id === targetId);
1436
1400
  const handle = runtimeHandles.get(targetId);
1437
1401
  if (!deletedEntry && !handle) {
@@ -1440,6 +1404,35 @@ const senpiRuntimePlugin = {
1440
1404
  message: `No runtime found with id "${targetId}".`,
1441
1405
  } };
1442
1406
  }
1407
+ /**
1408
+ * The lifecycle reaper's guard. Its verdict was about one wallet at one moment; the delete
1409
+ * below must not land on anything else. Evaluated synchronously between the registry read
1410
+ * above and the first teardown step, so nothing can interleave between check and act:
1411
+ * - an install on that wallet is running (it could land a live runtime under this id);
1412
+ * - a deploy job holds the single-flight slot (it retires and reinstalls runtimes);
1413
+ * - the row under this id no longer carries that wallet (ids derive from name + wallet and
1414
+ * are reused, so a reinstall onto a new wallet can take the old id);
1415
+ * - a live handle under this id trades a different wallet.
1416
+ * Operator, agent and deploy deletes pass no `expectWallet` and are unaffected.
1417
+ */
1418
+ if (expectWallet !== undefined) {
1419
+ const fold = (w) => (w ?? "").trim().toLowerCase();
1420
+ const conflict = installsInFlight.has(expectWallet)
1421
+ ? "an install is running on that wallet"
1422
+ : deployController.running() !== null
1423
+ ? "a deploy is in progress"
1424
+ : !deletedEntry || fold(deletedEntry.wallet) !== expectWallet
1425
+ ? "the registry row under that id no longer carries that wallet"
1426
+ : handle && fold(handle.wallet) !== expectWallet
1427
+ ? "the running runtime under that id trades a different wallet"
1428
+ : undefined;
1429
+ if (conflict !== undefined) {
1430
+ return { ok: false, error: {
1431
+ code: "CONFLICT",
1432
+ message: `Runtime "${targetId}" was not removed: ${conflict}. Nothing was changed; the next lifecycle check re-reads it.`,
1433
+ } };
1434
+ }
1435
+ }
1443
1436
  if (handle) {
1444
1437
  await handle.teardownDslForRuntimeDeletion();
1445
1438
  await handle.stop();
@@ -1459,9 +1452,7 @@ const senpiRuntimePlugin = {
1459
1452
  // "running — NO ENTRY SCANNERS" forever. The map is otherwise only cleared on a
1460
1453
  // successful wire/install, which a scanner-less reinstall never reaches.
1461
1454
  unwiredRuntimes.delete(targetId);
1462
- // Same reuse hazard: a deleted id left in the index would keep publishing a strategy that no
1463
- // longer exists, and would hand a reinstall the old name and scanner list.
1464
- telemetryStrategies.delete(targetId);
1455
+ bootSkippedRuntimes.delete(targetId);
1465
1456
  // Archive state directory before removing from registry
1466
1457
  if (deletedEntry) {
1467
1458
  const runtimeStateDir = buildRuntimeStateDir(baseStateDir, targetId, deletedEntry.wallet ?? "");
@@ -1487,10 +1478,11 @@ const senpiRuntimePlugin = {
1487
1478
  runtimeId: targetId,
1488
1479
  address: deletedEntry?.wallet,
1489
1480
  hadLiveHandle: Boolean(handle),
1481
+ deleteReason,
1490
1482
  }));
1491
1483
  return { ok: true, payload: { id: targetId } };
1492
1484
  }
1493
- registerGatewayMethod("senpi.deleteRuntime", async (ctx) => {
1485
+ api.registerGatewayMethod("senpi.deleteRuntime", async (ctx) => {
1494
1486
  const respond = ctx.respond;
1495
1487
  const result = await deleteRuntimeCore(ctx.params);
1496
1488
  if (result.ok)
@@ -1597,17 +1589,6 @@ const senpiRuntimePlugin = {
1597
1589
  return checkProofCoversRecipe(packageDir, verifyProof(packageDir), appliedRecipe, (f) => readFileSync(f, "utf8"));
1598
1590
  },
1599
1591
  applyToRuntime: makeApplyToRuntime(runtimeHandles, async (runtimeId, next, recipe) => {
1600
- // Re-index the topology facts FIRST, ahead of every early return and every failure path
1601
- // below. `makeApplyToRuntime` only reaches this callback once the in-process swap has
1602
- // succeeded, so `next` is already the recipe this box runs: its display name and its
1603
- // declared scanner list are the ones the topology payload must name. Leaving the index on
1604
- // the previous config would publish strategy rows the new scanner stats cannot join to.
1605
- //
1606
- // Ahead of the early return because an apply on a box with no intake still swapped the
1607
- // recipe, and ahead of the launcher because a launcher that fails is a runtime running the
1608
- // new recipe with no external scanners — the degraded state a dashboard most needs to show,
1609
- // and one it can only render if the declared scanners are the new ones.
1610
- noteTelemetryRuntimeConfig(runtimeId, next);
1611
1592
  // Restart this runtime's external scanner children against the new package.
1612
1593
  //
1613
1594
  // The rebuild swaps in-process components; these are separate OS processes, spawned once
@@ -1755,7 +1736,7 @@ const senpiRuntimePlugin = {
1755
1736
  * producers — slated for removal in Task 5 once the migration counter
1756
1737
  * decays to zero (see `runtime-phase-2-api-plan.md` Section 8).
1757
1738
  */
1758
- registerGatewayMethod("senpi.ingestExternalScannerData", async (ctx) => {
1739
+ api.registerGatewayMethod("senpi.ingestExternalScannerData", async (ctx) => {
1759
1740
  const respond = ctx.respond;
1760
1741
  // TODO(s11): wire actual telemetry counter for
1761
1742
  // runtime.deprecated_gateway_ingest_call (event name, properties:
@@ -1774,7 +1755,6 @@ const senpiRuntimePlugin = {
1774
1755
  ...(request.direction !== undefined && { direction: request.direction }),
1775
1756
  ...(request.score !== undefined && { score: request.score }),
1776
1757
  ...(request.signal_type !== undefined && { signal_type: request.signal_type }),
1777
- ...(request.tick_id !== undefined && { tick_id: request.tick_id }),
1778
1758
  ...(request.data !== undefined && { data: request.data }),
1779
1759
  ...(request.signals !== undefined && { signals: request.signals }),
1780
1760
  };
@@ -1832,7 +1812,7 @@ const senpiRuntimePlugin = {
1832
1812
  });
1833
1813
  }
1834
1814
  }, { scope: "operator.write" });
1835
- registerGatewayMethod("senpi.listDslPositions", async (ctx) => {
1815
+ api.registerGatewayMethod("senpi.listDslPositions", async (ctx) => {
1836
1816
  const respond = ctx.respond;
1837
1817
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1838
1818
  const address = typeof ctx.params?.address === "string" ? ctx.params.address : undefined;
@@ -1857,7 +1837,7 @@ const senpiRuntimePlugin = {
1857
1837
  }
1858
1838
  respond(true, { positions });
1859
1839
  }, { scope: "operator.read" });
1860
- registerGatewayMethod("senpi.getDslPositionState", async (ctx) => {
1840
+ api.registerGatewayMethod("senpi.getDslPositionState", async (ctx) => {
1861
1841
  const respond = ctx.respond;
1862
1842
  const asset = typeof ctx.params?.asset === "string" ? ctx.params.asset : undefined;
1863
1843
  if (!asset) {
@@ -1898,7 +1878,7 @@ const senpiRuntimePlugin = {
1898
1878
  }
1899
1879
  respond(true, { positions: matches });
1900
1880
  }, { scope: "operator.read" });
1901
- registerGatewayMethod("senpi.listDslArchives", async (ctx) => {
1881
+ api.registerGatewayMethod("senpi.listDslArchives", async (ctx) => {
1902
1882
  const respond = ctx.respond;
1903
1883
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
1904
1884
  const address = typeof ctx.params?.address === "string" ? ctx.params.address : undefined;
@@ -1924,7 +1904,7 @@ const senpiRuntimePlugin = {
1924
1904
  closes = closes.slice(0, limit);
1925
1905
  respond(true, { closes });
1926
1906
  }, { scope: "operator.read" });
1927
- registerGatewayMethod("senpi.getHealthStatus", async (ctx) => {
1907
+ api.registerGatewayMethod("senpi.getHealthStatus", async (ctx) => {
1928
1908
  const respond = ctx.respond;
1929
1909
  const updater = autoUpdateCoordinator.getSnapshot();
1930
1910
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
@@ -1952,7 +1932,7 @@ const senpiRuntimePlugin = {
1952
1932
  const statuses = await collectRuntimeHealthStatuses(runtimeHandles, api.logger);
1953
1933
  respond(true, { statuses, updater });
1954
1934
  }, { scope: "operator.read" });
1955
- registerGatewayMethod("senpi.getSystemState", async (ctx) => {
1935
+ api.registerGatewayMethod("senpi.getSystemState", async (ctx) => {
1956
1936
  const respond = ctx.respond;
1957
1937
  const updater = autoUpdateCoordinator.getSnapshot();
1958
1938
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
@@ -1980,36 +1960,6 @@ const senpiRuntimePlugin = {
1980
1960
  const states = await collectRuntimeSystemStates(runtimeHandles, api.logger, baseStateDir);
1981
1961
  respond(true, { states, updater });
1982
1962
  }, { scope: "operator.read" });
1983
- /**
1984
- * Live telemetry — the one method of the stream, called once per connect.
1985
- *
1986
- * It returns the current value of all three broadcast payloads plus the recent record window, so
1987
- * a client joining a box that has been running for hours renders a full screen immediately
1988
- * instead of waiting for the next thing to happen. `(afterEpoch, afterSeq)` resumes a client that
1989
- * already holds part of this epoch's run — a cursor naming any other epoch is dropped, because a
1990
- * seq from a previous run cannot address these records. `truncated` says when the records between
1991
- * a cursor of this epoch and the oldest record still held are gone, which nothing in `recent`
1992
- * alone can express.
1993
- *
1994
- * Read-only, like every other inspection method here. The scope that decides who RECEIVES the
1995
- * broadcasts is a property of the client's own connection, not of this method.
1996
- */
1997
- registerGatewayMethod("senpi.telemetry.subscribe", async (ctx) => {
1998
- const respond = ctx.respond;
1999
- try {
2000
- respond(true, await liveTelemetry.snapshot({
2001
- afterEpoch: ctx.params?.afterEpoch,
2002
- afterSeq: ctx.params?.afterSeq,
2003
- }));
2004
- }
2005
- catch (err) {
2006
- api.logger.warn("[senpi-runtime] telemetry subscribe error", { err });
2007
- respond(false, undefined, {
2008
- code: "INTERNAL_ERROR",
2009
- message: `Failed to build the telemetry snapshot: ${formatGatewayError(err)}`,
2010
- });
2011
- }
2012
- }, { scope: "operator.read" });
2013
1963
  /**
2014
1964
  * Force the auto-update poll to run now instead of waiting for its timer.
2015
1965
  *
@@ -2068,7 +2018,7 @@ const senpiRuntimePlugin = {
2068
2018
  * persisted action-store machinery (`src/actions/store/`) usable
2069
2019
  * from the operator-facing CLI again.
2070
2020
  */
2071
- registerGatewayMethod("senpi.listActions", async (ctx) => {
2021
+ api.registerGatewayMethod("senpi.listActions", async (ctx) => {
2072
2022
  const respond = ctx.respond;
2073
2023
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
2074
2024
  const address = typeof ctx.params?.address === "string" ? ctx.params.address : undefined;
@@ -2098,7 +2048,7 @@ const senpiRuntimePlugin = {
2098
2048
  }
2099
2049
  respond(true, { actions: merged });
2100
2050
  }, { scope: "operator.read" });
2101
- registerGatewayMethod("senpi.getActionState", async (ctx) => {
2051
+ api.registerGatewayMethod("senpi.getActionState", async (ctx) => {
2102
2052
  const respond = ctx.respond;
2103
2053
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
2104
2054
  const actionName = typeof ctx.params?.actionName === "string" ? ctx.params.actionName : undefined;
@@ -2135,7 +2085,7 @@ const senpiRuntimePlugin = {
2135
2085
  });
2136
2086
  }
2137
2087
  }, { scope: "operator.read" });
2138
- registerGatewayMethod("senpi.getActionHistory", async (ctx) => {
2088
+ api.registerGatewayMethod("senpi.getActionHistory", async (ctx) => {
2139
2089
  const respond = ctx.respond;
2140
2090
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
2141
2091
  const actionName = typeof ctx.params?.actionName === "string" ? ctx.params.actionName : undefined;
@@ -2203,7 +2153,7 @@ const senpiRuntimePlugin = {
2203
2153
  // reachable on HTTP `/audit`. Passes through the caller-controlled subset
2204
2154
  // of the audit_query contract; resource scoping (wallet → strategyId) is
2205
2155
  // owned by handle.auditQuery, never the caller.
2206
- registerGatewayMethod("senpi.auditQuery", async (ctx) => {
2156
+ api.registerGatewayMethod("senpi.auditQuery", async (ctx) => {
2207
2157
  const respond = ctx.respond;
2208
2158
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
2209
2159
  if (!runtimeId) {
@@ -2251,7 +2201,7 @@ const senpiRuntimePlugin = {
2251
2201
  // its state root private, so re-derive it from baseStateDir + the handle's
2252
2202
  // wallet (same helper the writer's stateDir was built with); the writer
2253
2203
  // nests entries under `<root>/<address>/risk-guard/audit.jsonl`.
2254
- registerGatewayMethod("senpi.getRiskAudit", async (ctx) => {
2204
+ api.registerGatewayMethod("senpi.getRiskAudit", async (ctx) => {
2255
2205
  const respond = ctx.respond;
2256
2206
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
2257
2207
  if (!runtimeId) {
@@ -2294,7 +2244,7 @@ const senpiRuntimePlugin = {
2294
2244
  // Per-strategy local domain-event ring (on-disk JSONL) — the trade narrative, queryable locally.
2295
2245
  // Same state-root re-derivation as getRiskAudit: the Runtime keeps its root private, so rebuild it
2296
2246
  // from baseStateDir + the handle's wallet; the writer nests rows under `<root>/strategies/<address>/events/`.
2297
- registerGatewayMethod("senpi.getEvents", async (ctx) => {
2247
+ api.registerGatewayMethod("senpi.getEvents", async (ctx) => {
2298
2248
  const respond = ctx.respond;
2299
2249
  const runtimeId = typeof ctx.params?.runtimeId === "string" ? ctx.params.runtimeId : undefined;
2300
2250
  if (!runtimeId) {
@@ -2351,12 +2301,12 @@ const senpiRuntimePlugin = {
2351
2301
  }
2352
2302
  }, { scope: "operator.read" });
2353
2303
  // ─── Skills manager — read + mutate ──────────────────────────────────────
2354
- registerGatewayMethod("senpi.skills.status", async (ctx) => {
2304
+ api.registerGatewayMethod("senpi.skills.status", async (ctx) => {
2355
2305
  const respond = ctx.respond;
2356
2306
  const snapshot = skillsCoordinator.getSnapshot();
2357
2307
  respond(true, { snapshot });
2358
2308
  }, { scope: "operator.read" });
2359
- registerGatewayMethod("senpi.skills.update", async (ctx) => {
2309
+ api.registerGatewayMethod("senpi.skills.update", async (ctx) => {
2360
2310
  const respond = ctx.respond;
2361
2311
  const name = typeof ctx.params?.name === "string" ? ctx.params.name.trim() : undefined;
2362
2312
  if (!name) {
@@ -2381,7 +2331,7 @@ const senpiRuntimePlugin = {
2381
2331
  });
2382
2332
  }
2383
2333
  }, { scope: "operator.write" });
2384
- registerGatewayMethod("senpi.skills.updateAll", async (ctx) => {
2334
+ api.registerGatewayMethod("senpi.skills.updateAll", async (ctx) => {
2385
2335
  const respond = ctx.respond;
2386
2336
  try {
2387
2337
  const results = await skillsCoordinator.updateAll();
@@ -2447,7 +2397,6 @@ function parseGatewayExternalScannerIngestParams(params) {
2447
2397
  direction: params?.direction,
2448
2398
  score: params?.score,
2449
2399
  signal_type: params?.signal_type,
2450
- tick_id: params?.tick_id,
2451
2400
  data: params?.data,
2452
2401
  signals: params?.signals,
2453
2402
  },