@senpi/runtime 3.0.186 → 3.0.190

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 (173) hide show
  1. package/dist/actions/llm-decision/openclaw-llm-decision.d.ts +1 -1
  2. package/dist/actions/llm-decision/openclaw-llm-decision.js +1 -1
  3. package/dist/approvals/telemetry.d.ts +3 -2
  4. package/dist/approvals/telemetry.d.ts.map +1 -1
  5. package/dist/approvals/telemetry.js.map +1 -1
  6. package/dist/approvals/tool-approval-gate.d.ts +9 -3
  7. package/dist/approvals/tool-approval-gate.d.ts.map +1 -1
  8. package/dist/approvals/tool-approval-gate.js +18 -5
  9. package/dist/approvals/tool-approval-gate.js.map +1 -1
  10. package/dist/approvals/turn-context.d.ts +43 -18
  11. package/dist/approvals/turn-context.d.ts.map +1 -1
  12. package/dist/approvals/turn-context.js +67 -24
  13. package/dist/approvals/turn-context.js.map +1 -1
  14. package/dist/cli/guide-commands.d.ts.map +1 -1
  15. package/dist/cli/guide-commands.js +8 -1
  16. package/dist/cli/guide-commands.js.map +1 -1
  17. package/dist/cli/senpi-commands.d.ts.map +1 -1
  18. package/dist/cli/senpi-commands.js +12 -2
  19. package/dist/cli/senpi-commands.js.map +1 -1
  20. package/dist/deploy/box-cap.d.ts +7 -0
  21. package/dist/deploy/box-cap.d.ts.map +1 -0
  22. package/dist/deploy/box-cap.js +13 -0
  23. package/dist/deploy/box-cap.js.map +1 -0
  24. package/dist/deploy/funding.d.ts +13 -1
  25. package/dist/deploy/funding.d.ts.map +1 -1
  26. package/dist/deploy/funding.js +13 -4
  27. package/dist/deploy/funding.js.map +1 -1
  28. package/dist/deploy/job.d.ts +2 -0
  29. package/dist/deploy/job.d.ts.map +1 -1
  30. package/dist/deploy/job.js +2 -2
  31. package/dist/deploy/job.js.map +1 -1
  32. package/dist/deploy/messages.d.ts +14 -0
  33. package/dist/deploy/messages.d.ts.map +1 -1
  34. package/dist/deploy/messages.js +59 -25
  35. package/dist/deploy/messages.js.map +1 -1
  36. package/dist/deploy/orchestrator.d.ts +23 -1
  37. package/dist/deploy/orchestrator.d.ts.map +1 -1
  38. package/dist/deploy/orchestrator.js +87 -30
  39. package/dist/deploy/orchestrator.js.map +1 -1
  40. package/dist/deploy/package.d.ts +14 -1
  41. package/dist/deploy/package.d.ts.map +1 -1
  42. package/dist/deploy/package.js +37 -2
  43. package/dist/deploy/package.js.map +1 -1
  44. package/dist/deploy/register.d.ts +3 -0
  45. package/dist/deploy/register.d.ts.map +1 -1
  46. package/dist/deploy/register.js +50 -33
  47. package/dist/deploy/register.js.map +1 -1
  48. package/dist/dsl/monitor/dsl-failure-events.d.ts +38 -2
  49. package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
  50. package/dist/dsl/monitor/dsl-failure-events.js +67 -4
  51. package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
  52. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  53. package/dist/dsl/monitor/process-one-position.js +25 -1
  54. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  55. package/dist/index.d.ts +9 -4
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +527 -158
  58. package/dist/index.js.map +1 -1
  59. package/dist/runtime/auto-update/coordinator.d.ts +19 -2
  60. package/dist/runtime/auto-update/coordinator.d.ts.map +1 -1
  61. package/dist/runtime/auto-update/coordinator.js +104 -20
  62. package/dist/runtime/auto-update/coordinator.js.map +1 -1
  63. package/dist/runtime/auto-update/gateway-restart.d.ts +2 -2
  64. package/dist/runtime/auto-update/gateway-restart.js +1 -1
  65. package/dist/runtime/auto-update/idle-gate.d.ts +45 -9
  66. package/dist/runtime/auto-update/idle-gate.d.ts.map +1 -1
  67. package/dist/runtime/auto-update/idle-gate.js +26 -14
  68. package/dist/runtime/auto-update/idle-gate.js.map +1 -1
  69. package/dist/runtime/auto-update/register-idle-gate.d.ts +1 -1
  70. package/dist/runtime/auto-update/register-idle-gate.d.ts.map +1 -1
  71. package/dist/runtime/auto-update/register-idle-gate.js +8 -4
  72. package/dist/runtime/auto-update/register-idle-gate.js.map +1 -1
  73. package/dist/runtime/auto-update/run-update-command.d.ts +1 -1
  74. package/dist/runtime/auto-update/run-update-command.js +1 -1
  75. package/dist/runtime/auto-update/types.d.ts +2 -1
  76. package/dist/runtime/auto-update/types.d.ts.map +1 -1
  77. package/dist/runtime/auto-update/types.js.map +1 -1
  78. package/dist/runtime/auto-update/update-schedule.d.ts +14 -4
  79. package/dist/runtime/auto-update/update-schedule.d.ts.map +1 -1
  80. package/dist/runtime/auto-update/update-schedule.js +21 -5
  81. package/dist/runtime/auto-update/update-schedule.js.map +1 -1
  82. package/dist/runtime/boot-skip.d.ts +18 -0
  83. package/dist/runtime/boot-skip.d.ts.map +1 -1
  84. package/dist/runtime/boot-skip.js +25 -0
  85. package/dist/runtime/boot-skip.js.map +1 -1
  86. package/dist/runtime/lifecycle-events.d.ts +2 -2
  87. package/dist/runtime/lifecycle-events.d.ts.map +1 -1
  88. package/dist/runtime/lifecycle-events.js.map +1 -1
  89. package/dist/runtime/lifecycle-reaper.d.ts +11 -6
  90. package/dist/runtime/lifecycle-reaper.d.ts.map +1 -1
  91. package/dist/runtime/lifecycle-reaper.js +25 -15
  92. package/dist/runtime/lifecycle-reaper.js.map +1 -1
  93. package/dist/runtime/notifications.d.ts +10 -0
  94. package/dist/runtime/notifications.d.ts.map +1 -1
  95. package/dist/runtime/notifications.js +17 -6
  96. package/dist/runtime/notifications.js.map +1 -1
  97. package/dist/runtime/process-state.d.ts +168 -0
  98. package/dist/runtime/process-state.d.ts.map +1 -0
  99. package/dist/runtime/process-state.js +81 -0
  100. package/dist/runtime/process-state.js.map +1 -0
  101. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  102. package/dist/runtime/reconcile-state.js +18 -4
  103. package/dist/runtime/reconcile-state.js.map +1 -1
  104. package/dist/runtime/runtime.d.ts.map +1 -1
  105. package/dist/runtime/runtime.js +4 -1
  106. package/dist/runtime/runtime.js.map +1 -1
  107. package/dist/runtime/strategy-registry.d.ts +16 -0
  108. package/dist/runtime/strategy-registry.d.ts.map +1 -1
  109. package/dist/runtime/strategy-registry.js +7 -3
  110. package/dist/runtime/strategy-registry.js.map +1 -1
  111. package/dist/runtime/update-apply-deps.d.ts +5 -3
  112. package/dist/runtime/update-apply-deps.d.ts.map +1 -1
  113. package/dist/runtime/update-apply-deps.js +15 -3
  114. package/dist/runtime/update-apply-deps.js.map +1 -1
  115. package/dist/scanners/external/scaffold/preflight.py +492 -12
  116. package/dist/senpi/__tests__/helpers/fake-paper-mcp.d.ts +44 -0
  117. package/dist/senpi/__tests__/helpers/fake-paper-mcp.d.ts.map +1 -0
  118. package/dist/senpi/__tests__/helpers/fake-paper-mcp.js +129 -0
  119. package/dist/senpi/__tests__/helpers/fake-paper-mcp.js.map +1 -0
  120. package/dist/senpi/client.d.ts +40 -2
  121. package/dist/senpi/client.d.ts.map +1 -1
  122. package/dist/senpi/client.js +82 -15
  123. package/dist/senpi/client.js.map +1 -1
  124. package/dist/senpi/paper.d.ts +28 -0
  125. package/dist/senpi/paper.d.ts.map +1 -0
  126. package/dist/senpi/paper.js +35 -0
  127. package/dist/senpi/paper.js.map +1 -0
  128. package/dist/senpi/strategy-status.d.ts.map +1 -1
  129. package/dist/senpi/strategy-status.js +2 -0
  130. package/dist/senpi/strategy-status.js.map +1 -1
  131. package/dist/senpi/types.d.ts +8 -1
  132. package/dist/senpi/types.d.ts.map +1 -1
  133. package/dist/telemetry/box-memory.d.ts +29 -0
  134. package/dist/telemetry/box-memory.d.ts.map +1 -0
  135. package/dist/telemetry/box-memory.js +113 -0
  136. package/dist/telemetry/box-memory.js.map +1 -0
  137. package/dist/telemetry/metrics.d.ts +22 -1
  138. package/dist/telemetry/metrics.d.ts.map +1 -1
  139. package/dist/telemetry/metrics.js +83 -1
  140. package/dist/telemetry/metrics.js.map +1 -1
  141. package/dist/telemetry/spans/span-hooks.d.ts +1 -1
  142. package/dist/telemetry/spans/span-hooks.d.ts.map +1 -1
  143. package/dist/telemetry/spans/span-hooks.js +5 -2
  144. package/dist/telemetry/spans/span-hooks.js.map +1 -1
  145. package/dist/telemetry/trace-identity.d.ts.map +1 -1
  146. package/dist/telemetry/trace-identity.js +2 -0
  147. package/dist/telemetry/trace-identity.js.map +1 -1
  148. package/dist/test-setup.js +7 -1
  149. package/dist/test-setup.js.map +1 -1
  150. package/dist/tool-surface/crib-hook.d.ts.map +1 -1
  151. package/dist/tool-surface/crib-hook.js +8 -0
  152. package/dist/tool-surface/crib-hook.js.map +1 -1
  153. package/dist/tool-surface/crib-sheet.d.ts +1 -1
  154. package/dist/tool-surface/crib-sheet.js +1 -1
  155. package/dist/tool-surface/mcp-tool-source.d.ts +1 -1
  156. package/dist/tool-surface/mcp-tool-source.js +2 -2
  157. package/dist/universe/package-universe.js +1 -1
  158. package/dist/universe/package-universe.js.map +1 -1
  159. package/dist/utils/event-catalog.d.ts +34 -4
  160. package/dist/utils/event-catalog.d.ts.map +1 -1
  161. package/dist/utils/event-catalog.js +6 -0
  162. package/dist/utils/event-catalog.js.map +1 -1
  163. package/dist/validate/codes.d.ts +2 -0
  164. package/dist/validate/codes.d.ts.map +1 -1
  165. package/dist/validate/codes.js +6 -0
  166. package/dist/validate/codes.js.map +1 -1
  167. package/dist/validate/live-stage.d.ts +12 -0
  168. package/dist/validate/live-stage.d.ts.map +1 -1
  169. package/dist/validate/live-stage.js +16 -0
  170. package/dist/validate/live-stage.js.map +1 -1
  171. package/dist/widgets/tool.d.ts +1 -1
  172. package/openclaw.plugin.json +1 -1
  173. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import { join, dirname } from "node:path";
17
17
  import { isMoneyCallTool, SenpiClient, senpiOrderCallsInFlight } from "./senpi/client.js";
18
18
  import { MoneyCallTracker, registerMoneyCallHooks } from "./runtime/auto-update/money-call-tracker.js";
19
19
  import { buildWalletStatusHint } from "./senpi/strategy-status.js";
20
- import { bootSkippedStatusLabel, partitionByWalletStatus, readBootWalletStatuses, trackableWalletKeys, } from "./runtime/boot-skip.js";
20
+ import { bootSkippedStatusLabel, partitionByWalletStatus, readBootWalletStatuses, readWalletStatusesByWorld, trackableWalletKeys, } from "./runtime/boot-skip.js";
21
21
  import { DeployJobController } from "./deploy/job.js";
22
22
  import { DEPLOY_CAPABILITIES, registerDeployGatewayMethods } from "./deploy/register.js";
23
23
  import { createQuantDesk } from "./quant/register.js";
@@ -25,6 +25,7 @@ import { registerUpdateGatewayMethod } from "./runtime/update-register.js";
25
25
  import { createStrategiesPublisher } from "./runtime/strategies-telemetry.js";
26
26
  import { buildEventsReplay, createEventsPublisher, } from "./runtime/events-telemetry.js";
27
27
  import { strategiesStore, strategyRecordFromConfig } from "./runtime/strategies-store.js";
28
+ import { bindProcessLogger, processLog, runtimeProcessState, } from "./runtime/process-state.js";
28
29
  import { checkProofCoversRecipe, makeApplyToRuntime, makeWriteRegistry, runtimeChangeLock, } from "./runtime/update-apply-deps.js";
29
30
  import { INSTALL_DEADLINE_MS, WALLET_INSTALL_IN_FLIGHT_CODE } from "./deploy/deadlines.js";
30
31
  import { AuditQueryError } from "./runtime/audit-query.types.js";
@@ -49,7 +50,7 @@ import { readEvents } from "./telemetry/event-store.js";
49
50
  import { VERSION } from "./version.js";
50
51
  import { resolvePackageName } from "./runtime/auto-update/resolve-package-name.js";
51
52
  import { runOpenClawPluginsUpdate } from "./runtime/auto-update/run-update-command.js";
52
- import { UpdateIdleGate } from "./runtime/auto-update/idle-gate.js";
53
+ import { UpdateIdleGate, createIdleGateActivity } from "./runtime/auto-update/idle-gate.js";
53
54
  import { registerIdleGateHooks, resolveUpdateMid } from "./runtime/auto-update/register-idle-gate.js";
54
55
  import { resolveUpdateMarkerPath } from "./runtime/auto-update/update-marker.js";
55
56
  import { INSTALL_RESULTS_OTEL_FLUSH_MS, INSTALL_RESULTS_OTEL_READY_MS, resolveInstallResultsPath, } from "./runtime/auto-update/install-results.js";
@@ -59,6 +60,9 @@ import { registerSkillRouterHook } from "./prompt/skill-router-hook.js";
59
60
  import { registerCribSheet } from "./tool-surface/crib-hook.js";
60
61
  import { getDefaultPiiRedactor, initDefaultPiiRedactor } from "./telemetry/spans/redact-pii.js";
61
62
  import { flushOtelLogs, initOtelSdk, shutdownOtelSdk, waitForOtelInit } from "./telemetry/otel-sdk.js";
63
+ import { createBoxMemoryReader } from "./telemetry/box-memory.js";
64
+ import { registerBoxMemoryGauges } from "./telemetry/metrics.js";
65
+ import { walletMode } from "./senpi/paper.js";
62
66
  import { getLogLevel, getLogLevelSource, parseLogLevel, setLogLevel, } from "./utils/logger.js";
63
67
  import { ExternalScannerIngestError, } from "./scanners/external-scanner-receiver.js";
64
68
  import { createLogger } from "./utils/logger.js";
@@ -81,7 +85,7 @@ import { ingestDispatchMutex, ingestDispatchKey } from "./runtime-api/intake/ing
81
85
  import { buildScaffoldErrorRouter } from "./runtime-api/intake/scaffold-error-router.js";
82
86
  import { loadRuntimeConfig } from "./runtime/load-runtime-config.js";
83
87
  import { errorText } from "./deploy/error-text.js";
84
- import { initOpenClawHost } from "./host/openclaw-host.js";
88
+ import { getOpenClawHost, initOpenClawHost } from "./host/openclaw-host.js";
85
89
  import { createRegisterPluginLogger, withEventLogger } from "./host/plugin-logger.js";
86
90
  import { hostname } from "node:os";
87
91
  import { BOOT_FACTS_CALL_TIMEOUT_MS, planBootFacts, syncBootFacts, } from "./strategy/facts-sync.js";
@@ -182,23 +186,29 @@ const WALLET_INSTALL_IN_FLIGHT = `[${WALLET_INSTALL_IN_FLIGHT_CODE}]`;
182
186
  */
183
187
  const RUNTIME_ID_TAKEN = "[E_RUNTIME_ID_TAKEN]";
184
188
  /**
185
- * One StrategyFacts send per runtime at a time, keyed on runtime id, across the whole process: a
186
- * second `register()` in the same process shares it. Separate from `runtimeChangeLock`. Delete does
187
- * not take it; an apply queues its send here and does not wait.
189
+ * One StrategyFacts send per runtime at a time, keyed on runtime id, across the whole process.
190
+ * Held in the process state (`runtime/process-state.ts`), not this module: a 10.x plugin reload
191
+ * evaluates a new copy of this module, and the previous registration's send may still be running.
192
+ * Separate from `runtimeChangeLock`. Delete does not take it; an apply queues its send here and
193
+ * does not wait.
188
194
  */
189
- const factsSendLock = new KeyedMutex();
195
+ function factsSendLock() {
196
+ const state = runtimeProcessState();
197
+ state.factsSendLock ??= new KeyedMutex();
198
+ return state.factsSendLock;
199
+ }
190
200
  /**
191
- * Whether this process has already logged the 5.x "cannot classify runs" notice.
201
+ * Clears the process-state latch for the 5.x "cannot classify runs" notice
202
+ * (`legacyTurnClassificationNoticeLogged`). Tests only.
192
203
  *
193
- * Module-scoped so a gateway reload (which re-registers the plugin and restarts its service in the
194
- * same process) does not repeat it. The 5.x case is a host-version limit with no config fix, so
195
- * one info line per process is the whole message; the fixable 10.x case stays an ERROR at every
196
- * registration and does not read this latch.
204
+ * The latch lives in the process state so a plugin reload (which re-registers the plugin and
205
+ * restarts its service in the same process, from a new copy of this module) does not repeat the
206
+ * notice. The 5.x case is a host-version limit with no config fix, so one info line per process is
207
+ * the whole message; the fixable 10.x case stays an ERROR at every registration and does not read
208
+ * this latch.
197
209
  */
198
- let legacyTurnClassificationNoticeLogged = false;
199
- /** Clears {@link legacyTurnClassificationNoticeLogged}. Tests only. */
200
210
  export function resetLegacyTurnClassificationNoticeForTests() {
201
- legacyTurnClassificationNoticeLogged = false;
211
+ runtimeProcessState().legacyTurnClassificationNoticeLogged = false;
202
212
  }
203
213
  /**
204
214
  * The whole of `register()` in the `"cli-metadata"` pass: the `senpi` CLI root, under the same
@@ -220,10 +230,19 @@ function registerSenpiCliMetadata(api) {
220
230
  registerSenpiCli(ctx.program);
221
231
  }, { commands: ["senpi"] });
222
232
  }
233
+ /** Running runtimes by world, read from each handle's wallet: a paper wallet is a simulation. */
234
+ function countRuntimesByMode(handles) {
235
+ const counts = { live: 0, paper: 0 };
236
+ for (const handle of handles)
237
+ counts[walletMode(handle.wallet)] += 1;
238
+ return counts;
239
+ }
223
240
  /**
224
241
  * OpenClaw plugin entry.
225
242
  * Boots from internal registry (installed_runtimes.json) in stateDir; does not read runtime list from config.
226
243
  */
244
+ /** How long service start waits for the OTel SDK before registering the box memory gauges anyway. */
245
+ const BOX_MEMORY_OTEL_READY_MS = 120_000;
227
246
  const senpiRuntimePlugin = {
228
247
  // Must match openclaw.plugin.json `id` — openclaw loader rejects the plugin
229
248
  // when `installed_plugins.json` record id (sourced from the manifest) and
@@ -261,8 +280,17 @@ const senpiRuntimePlugin = {
261
280
  registerSenpiCliMetadata(api);
262
281
  return;
263
282
  }
264
- // Host facts first: the logger and every version-gated registration below read them.
265
- const openClawHost = initOpenClawHost(api);
283
+ // Host facts first: the logger and every version-gated registration below read them. 10.x also
284
+ // registers plugins in non-full modes inside the gateway (`tool-discovery` for tools.catalog);
285
+ // that pass must keep registering tools, but its throwaway api must not replace the record the
286
+ // live registration made.
287
+ const fullRegistration = api.registrationMode === undefined || api.registrationMode === "full";
288
+ const openClawHost = (!fullRegistration && getOpenClawHost()) || initOpenClawHost(api);
289
+ // State kept across a reload (the deploy controller, the gate trackers) logs through the latest
290
+ // full registration's logger, never through an `api` a reload has replaced.
291
+ const processState = runtimeProcessState();
292
+ if (fullRegistration)
293
+ bindProcessLogger(api.logger);
266
294
  const pluginLogger = createRegisterPluginLogger(api.logger, {
267
295
  jsonMode: process.argv.includes("--json"),
268
296
  hostRoutesJsonLogsToStderr: openClawHost.modern,
@@ -403,10 +431,14 @@ const senpiRuntimePlugin = {
403
431
  // decisions persist in <stateDir>/tool-approvals.json.
404
432
  // See docs/mcp-tool-approvals-design.md.
405
433
  // Open trade-approval prompts, read by the self-update idle gate (R2).
406
- const pendingApprovalTracker = new PendingApprovalTracker();
434
+ // This and the turn tracker are process state: a 10.x reload re-registers in the same process
435
+ // while a prompt is open or a run is in flight (r4467406809, r4427720076).
436
+ processState.pendingApprovals ??= new PendingApprovalTracker();
437
+ const pendingApprovalTracker = processState.pendingApprovals;
407
438
  // The turn kind (chat / cron / heartbeat) and session key of each gated
408
439
  // call, for approval telemetry and the 10.x scheduled-turn block (R6, D13).
409
- const turnContextTracker = new TurnContextTracker();
440
+ processState.turnContext ??= new TurnContextTracker();
441
+ const turnContextTracker = processState.turnContext;
410
442
  // One boot-time fact (D15): can this host classify runs at all? Only then
411
443
  // does the gate stop prompts outside a direct chat turn. Without it every
412
444
  // run reads unknown and keeps the pre-10.x prompt, so a box missing the
@@ -442,7 +474,7 @@ const senpiRuntimePlugin = {
442
474
  ? "on this OpenClaw version; a 10.x host lifts this."
443
475
  : "until the runtime registers its turn-context hooks.");
444
476
  const legacyTurnClassificationNotice = turnClassificationHostLimit ? turnClassificationNotice : undefined;
445
- if (turnClassificationNotice !== undefined && !turnClassificationHostLimit) {
477
+ if (turnClassificationNotice !== undefined && !turnClassificationHostLimit && fullRegistration) {
446
478
  pluginLogger.error(turnClassificationNotice);
447
479
  }
448
480
  const approvalsHostStatus = turnClassification.available
@@ -452,12 +484,28 @@ const senpiRuntimePlugin = {
452
484
  reason: turnClassification.reason,
453
485
  ...(turnClassificationHostLimit ? { hostLimit: true } : {}),
454
486
  };
487
+ // Process state, like the run-kind tracker beside it: a reload must not zero the count that
488
+ // `senpi status` warns about while chat trades are being refused.
489
+ const approvalsStatus = () => approvalsHostStatus.turnClassification === "available"
490
+ ? {
491
+ ...approvalsHostStatus,
492
+ runsRecorded: turnContextTracker.runsRecorded,
493
+ unclassifiedBlocks: processState.unclassifiedBlocks,
494
+ ...(processState.lastUnclassifiedBlockAt
495
+ ? { lastUnclassifiedBlockAt: processState.lastUnclassifiedBlockAt }
496
+ : {}),
497
+ }
498
+ : approvalsHostStatus;
455
499
  try {
456
500
  registerToolApprovalGate({
457
501
  api,
458
502
  pendingApprovals: pendingApprovalTracker,
459
503
  turnContext: turnContextTracker,
460
504
  turnClassification: turnClassification.available,
505
+ onUnclassifiedBlock: (at) => {
506
+ processState.unclassifiedBlocks += 1;
507
+ processState.lastUnclassifiedBlockAt = new Date(at).toISOString();
508
+ },
461
509
  mode: resolveToolApprovalsMode({
462
510
  pluginConfig: config,
463
511
  env: process.env,
@@ -605,8 +653,22 @@ const senpiRuntimePlugin = {
605
653
  * row, sees no live handle, deletes it and reinstalls — and the first install then lands on top,
606
654
  * clobbering `runtimeHandles` under a runtime id both installs derive identically. Two exit
607
655
  * engines, one margin account: the exact state `[E_WALLET_HAS_RUNTIME]` exists to prevent.
656
+ *
657
+ * Process state (`runtime/process-state.ts`), not this registration: a 10.x plugin reload
658
+ * re-registers in the same process while a deploy job's install from the previous registration
659
+ * runs on, and the new registration must still refuse a second install on that wallet.
608
660
  */
609
- const installsInFlight = new Map();
661
+ const installsInFlight = processState.installsInFlight;
662
+ /**
663
+ * This registration's service lifecycle, for an install or adoption that outlives it. A 10.x
664
+ * plugin reload stops this service and starts the next registration's in the same process;
665
+ * `serviceStops` counts this registration's stops, so a stop followed by a later start is still
666
+ * seen. Only a stop counts: an install that began before the first start (the gateway may call
667
+ * installRuntime before start()) and spans it is not orphaned.
668
+ */
669
+ let serviceState = "idle";
670
+ let serviceStops = 0;
671
+ let disposeBoxMemoryGauges;
610
672
  // Dedicated logger for the runtime-api subtree — required because the
611
673
  // PluginLogger surface (info/warn/error) lacks `debug`, while the
612
674
  // Logger contract used by `runtime-api/` includes it.
@@ -663,7 +725,10 @@ const senpiRuntimePlugin = {
663
725
  // the agent's senpi__ calls (direct or through the dispatcher), seen at the
664
726
  // tool hooks. A deploy job is read from the controller (this process) and
665
727
  // its journals (any process).
666
- const moneyCalls = new MoneyCallTracker();
728
+ // Process state, like the approval trackers: a call that started under the previous
729
+ // registration still counts, and its after-hook (now this registration's) closes it.
730
+ processState.moneyCalls ??= new MoneyCallTracker();
731
+ const moneyCalls = processState.moneyCalls;
667
732
  try {
668
733
  registerMoneyCallHooks({
669
734
  api: api,
@@ -681,6 +746,13 @@ const senpiRuntimePlugin = {
681
746
  ordersInFlight: () => senpiOrderCallsInFlight() + moneyCalls.count(Date.now()),
682
747
  // Read only from ticks, after register() has constructed the controller below.
683
748
  deploysInFlight: () => deployController.inFlight(Date.now()),
749
+ // The process start, not this registration: a config edit under `plugins` re-registers in
750
+ // the same process (10.10 reloadGatewayPlugins), and repeated reloads under 15 min apart
751
+ // must not hold an update until the forced window.
752
+ startedAt: Date.now() - Math.round(process.uptime() * 1000),
753
+ // Process state: a reload builds this gate again, and must not forget a user turn that is
754
+ // running, a message that is queued, or when the user last wrote.
755
+ activity: (processState.idleGateActivity ??= createIdleGateActivity()),
684
756
  logger: pluginLogger,
685
757
  });
686
758
  try {
@@ -774,11 +846,20 @@ const senpiRuntimePlugin = {
774
846
  // boot scan: declaring it after would leave that reference in the temporal dead zone for any
775
847
  // future refactor that starts the service earlier. The rest of the deploy wiring lives in
776
848
  // src/deploy/register.ts behind an explicit context; this closure contributes collaborators only.
777
- const deployController = new DeployJobController({
778
- deploysDir: join(baseStateDir, "deploys"),
779
- now: () => Date.now(),
780
- log: (line) => api.logger.info(`[senpi-runtime] ${line}`),
781
- });
849
+ //
850
+ // One controller per deploys dir per process (process state): a full re-registration (10.10
851
+ // reloadGatewayPlugins, same process) reuses the one that may be running a deploy, so its
852
+ // `running()` keeps single-flight and `inFlight()` keeps the idle gate's deploy blocker. It
853
+ // logs through `processLog`, the current registration's logger, so a job that outlives its
854
+ // registration still narrates and writes its terminal journal line.
855
+ const deploysDir = join(baseStateDir, "deploys");
856
+ const deployController = processState.deployControllers.get(deploysDir) ??
857
+ new DeployJobController({
858
+ deploysDir,
859
+ now: () => Date.now(),
860
+ log: (line) => processLog.info(`[senpi-runtime] ${line}`),
861
+ });
862
+ processState.deployControllers.set(deploysDir, deployController);
782
863
  // ── lifecycle reaper ─────────────────────────────────────────────────────────────────────────
783
864
  // A runtime exists iff its strategy is open. Every close path ends at the backend and none of
784
865
  // them tells this box, so the reaper asks: once after boot, hourly, and whenever the backend
@@ -797,7 +878,7 @@ const senpiRuntimePlugin = {
797
878
  if (!mcpTransport)
798
879
  await client.connect();
799
880
  try {
800
- return await client.listStrategies({ strategyAddresses: wallets }, { signal });
881
+ return await client.listStrategiesForWallets(wallets, { signal });
801
882
  }
802
883
  finally {
803
884
  if (!mcpTransport)
@@ -816,15 +897,20 @@ const senpiRuntimePlugin = {
816
897
  api.registerService({
817
898
  id: "senpi-runtime",
818
899
  async start() {
900
+ serviceState = "running";
819
901
  // The 5.x classification notice (see the approval gate above), once per process.
820
- if (legacyTurnClassificationNotice !== undefined && !legacyTurnClassificationNoticeLogged) {
821
- legacyTurnClassificationNoticeLogged = true;
902
+ if (legacyTurnClassificationNotice !== undefined && !processState.legacyTurnClassificationNoticeLogged) {
903
+ processState.legacyTurnClassificationNoticeLogged = true;
822
904
  pluginLogger.info(legacyTurnClassificationNotice);
823
905
  }
824
906
  // Deploy-journal janitor: stamp anything a restart left unterminated as `interrupted`.
825
907
  // It never re-enters a deploy state machine — discovery is pull-based via
826
- // `senpi deploy status`.
827
- await deployController.scanOnBoot();
908
+ // `senpi deploy status`. Once per process: after a reload an unterminated journal may be
909
+ // the deploy this process is still running (r4469086155).
910
+ if (!processState.bootScanned.has(deploysDir)) {
911
+ processState.bootScanned.add(deploysDir);
912
+ await deployController.scanOnBoot();
913
+ }
828
914
  // Quant runs a restart cut short belong to a process that no longer exists: stamp them
829
915
  // failed with the re-run command, so `senpi quant status` never reports a dead run as live.
830
916
  // The controller also gates every quant start/read on this same (memoized) janitor, since
@@ -924,26 +1010,26 @@ const senpiRuntimePlugin = {
924
1010
  // lower-case storage, and rejects the whole batch on one malformed address.
925
1011
  const registeredWallets = trackableWalletKeys(registry.runtimes);
926
1012
  if (registeredWallets.length > 0) {
927
- const read = await readBootWalletStatuses(registeredWallets, async (wallets, signal) => {
1013
+ const read = await readWalletStatusesByWorld(registeredWallets, async (wallets, signal) => {
928
1014
  const client = mcpTransport ? new SenpiClient(mcpTransport) : new SenpiClient({ apiKey });
929
1015
  if (!mcpTransport)
930
1016
  await client.connect();
931
1017
  try {
932
- return await client.listStrategies({ strategyAddresses: wallets }, { signal });
1018
+ return await client.listStrategiesForWallets(wallets, { signal });
933
1019
  }
934
1020
  finally {
935
1021
  if (!mcpTransport)
936
1022
  await client.disconnect().catch(() => { });
937
1023
  }
938
1024
  });
939
- if (read.ok) {
1025
+ if (read.answered.size > 0) {
940
1026
  walletStatuses = read.statuses;
941
1027
  bootStrategyIds = read.strategyIds;
942
- reaper.recordStatuses(registeredWallets, read.statuses);
1028
+ reaper.recordStatuses([...read.answered], read.statuses);
943
1029
  }
944
- else {
945
- pluginLogger.warn("[senpi-runtime] Could not read strategy statuses at boot; booting every registered runtime", { reason: read.reason, err: read.message });
946
- emitEvent(apiLogger, () => buildRuntimeBootStatusUnreadEvent({ reason: read.reason, registeredCount: registeredWallets.length }));
1030
+ for (const failure of read.failures) {
1031
+ pluginLogger.warn("[senpi-runtime] Could not read strategy statuses at boot; booting every registered runtime of that world", { reason: failure.reason, err: failure.message, paper: failure.paper });
1032
+ emitEvent(apiLogger, () => buildRuntimeBootStatusUnreadEvent({ reason: failure.reason, registeredCount: failure.walletCount }));
947
1033
  }
948
1034
  }
949
1035
  const { boot: entriesToBoot, skipped } = partitionByWalletStatus(registry.runtimes, walletStatuses);
@@ -1156,10 +1242,32 @@ const senpiRuntimePlugin = {
1156
1242
  }
1157
1243
  }
1158
1244
  }
1245
+ // This registration now runs the box's runtimes: publish it, then start what an install from a
1246
+ // stopped registration handed over while none was live (see `handOffRuntime`). Detached and
1247
+ // never throwing, like the facts send: one adoption must not hold or fail the boot.
1248
+ processState.runtimeHost = runtimeHost;
1249
+ const pendingHandOffs = [...processState.pendingRuntimeHandOffs];
1250
+ processState.pendingRuntimeHandOffs.clear();
1251
+ for (const [runtimeId, walletKey] of pendingHandOffs) {
1252
+ void runtimeHost.adoptRuntime({ runtimeId, walletKey }).then((handOff) => {
1253
+ pluginLogger.info("[senpi-runtime] handed-over runtime settled at service start", { runtimeId, outcome: handOff.outcome });
1254
+ });
1255
+ }
1159
1256
  // Ticks once now, with every booted runtime in the store, then every 30 s.
1160
1257
  strategiesPublisher.start();
1161
1258
  // Sends the events added since the previous send, every 1 s.
1162
1259
  eventsPublisher.start();
1260
+ // The metrics API has no proxy meter: gauges made before the SDK sets its provider never export.
1261
+ const stopsAtStart = serviceStops;
1262
+ void waitForOtelInit(BOX_MEMORY_OTEL_READY_MS).then(() => {
1263
+ if (serviceStops !== stopsAtStart)
1264
+ return;
1265
+ disposeBoxMemoryGauges?.();
1266
+ disposeBoxMemoryGauges = registerBoxMemoryGauges({
1267
+ read: createBoxMemoryReader({ warn: (message, meta) => pluginLogger.warn(message, meta) }),
1268
+ runtimes: () => countRuntimesByMode(runtimeHandles.values()),
1269
+ });
1270
+ });
1163
1271
  // Last, once everything above is up: the reaper's post-boot pass and hourly sweep. Its
1164
1272
  // not-active escalation is also off until here, so no delete can land while the boot loop is
1165
1273
  // still wiring runtimes and launchers. Nothing here may fail start() — a throw from inside the boot loop takes every runtime down, which
@@ -1177,12 +1285,20 @@ const senpiRuntimePlugin = {
1177
1285
  }
1178
1286
  },
1179
1287
  async stop() {
1288
+ // First: an install or adoption still running in this registration sees the stop from here
1289
+ // on and hands its runtime to the next registration instead of keeping it in this one.
1290
+ serviceState = "stopped";
1291
+ serviceStops += 1;
1292
+ if (processState.runtimeHost === runtimeHost)
1293
+ processState.runtimeHost = null;
1180
1294
  // Stop the reaper first so no pass starts deleting while the runtimes are torn down below,
1181
1295
  // and wait (bounded) for a pass already under way, so its delete cannot stop a handle the
1182
1296
  // teardown below is stopping too.
1183
1297
  await reaper.stop();
1184
1298
  strategiesPublisher.stop();
1185
1299
  eventsPublisher.stop();
1300
+ disposeBoxMemoryGauges?.();
1301
+ disposeBoxMemoryGauges = undefined;
1186
1302
  // Drain the HTTP server FIRST so in-flight POST /signals requests
1187
1303
  // finish before the underlying runtime they target is torn down.
1188
1304
  // `apiHandle.close()` never throws.
@@ -1228,7 +1344,310 @@ const senpiRuntimePlugin = {
1228
1344
  * `senpi deploy` installs through the SAME gates (wallet ownership, one-runtime-per-wallet,
1229
1345
  * scanner-path resolution) instead of a parallel path that could drift.
1230
1346
  */
1347
+ /**
1348
+ * Start an already-registered runtime in THIS registration while its service runs: `run()`,
1349
+ * the handle in `runtimeHandles`, then the hot-install scanner wire (the API is already
1350
+ * listening, so the launcher starts at once). Shared by `installRuntimeCore` and by
1351
+ * {@link runtimeHost}'s adoption of a runtime another registration handed over.
1352
+ *
1353
+ * `orphaned` says whether this registration's service stopped since the caller began (a 10.x
1354
+ * plugin reload). It is read after `run()` returns and again after the wire: a stopped
1355
+ * registration's `stop()` has already torn its runtimes down, so a runtime started here now
1356
+ * would keep ticking in a registration nothing can stop or delete. In that case this stops what
1357
+ * it started (the launcher, then the handle), removes them from this registration's maps, and
1358
+ * returns `orphaned`; the caller hands the runtime to the live registration.
1359
+ *
1360
+ * Throws what `run()` throws. Wire failures do not throw: they are reported as `unwiredPhase`,
1361
+ * exactly as the install has always reported them.
1362
+ */
1363
+ async function startRuntimeLive(spec, orphaned,
1364
+ /** Called once the runtime runs in this registration, before the scanner wire. */
1365
+ onRunning) {
1366
+ const { runtimeId, pathOrContent, fromPath, recipeDir, wallet, parsedConfig } = spec;
1367
+ /**
1368
+ * The unwired verdict for THIS start, tracked locally rather than read back out of
1369
+ * `unwiredRuntimes`, so the result reports what this call did and never a leftover verdict
1370
+ * from an earlier runtime that happened to share the id. Set at each of the three sites that
1371
+ * leave the runtime without live entry scanners; cleared by the wire-success path.
1372
+ */
1373
+ let unwiredPhase;
1374
+ let hotLauncher = null;
1375
+ const scannerProviders = await createScannerProvidersForRun({
1376
+ mcpTransport,
1377
+ apiKey,
1378
+ });
1379
+ const entryStateDir = buildRuntimeStateDir(baseStateDir, runtimeId, wallet);
1380
+ const handle = await run(pathOrContent, {
1381
+ fromPath,
1382
+ stateDir: entryStateDir,
1383
+ mcpTransport,
1384
+ apiKey,
1385
+ scannerProviders,
1386
+ runtimeId,
1387
+ onStrategyNotActive: (address) => reaper.checkWallet(address),
1388
+ });
1389
+ /** Stops what this call started and leaves this registration's maps as they were. */
1390
+ const abandon = async () => {
1391
+ if (hotLauncher) {
1392
+ await hotLauncher.stop().catch(() => { });
1393
+ if (launchers.get(runtimeId) === hotLauncher)
1394
+ launchers.delete(runtimeId);
1395
+ }
1396
+ await handle.stop().catch((err) => {
1397
+ pluginLogger.warn("[senpi-runtime] could not stop a runtime started by a stopped registration", {
1398
+ runtimeId,
1399
+ err: err instanceof Error ? err.message : String(err),
1400
+ });
1401
+ });
1402
+ if (runtimeHandles.get(runtimeId) === handle) {
1403
+ runtimeHandles.delete(runtimeId);
1404
+ unwiredRuntimes.delete(runtimeId);
1405
+ }
1406
+ return { status: "orphaned" };
1407
+ };
1408
+ if (orphaned())
1409
+ return abandon();
1410
+ runtimeHandles.set(runtimeId, handle);
1411
+ onRunning?.();
1412
+ // ── hot-install supervised scanner wire ───────────────────────────────
1413
+ // Live-install path: used when a runtime is registered after boot via
1414
+ // senpi.installRuntime, while the API is already listening — the
1415
+ // counterpart to the boot-time `launcher` above. A runtime takes exactly
1416
+ // one path: boot-present → `launcher` (deferred start); installed live →
1417
+ // `hotLauncher` (immediate start). The two are mutually exclusive because
1418
+ // installRuntime rejects any id/wallet already in the registry before
1419
+ // wiring (the hasId guard), so a boot-present runtime never re-enters here;
1420
+ // an adoption starts only a row the live registration does not run.
1421
+ // The API is already listening at this point, so we wire and start
1422
+ // immediately (no deferred start needed). Any failure is swallowed so
1423
+ // the install itself is not rolled back — the runtime is already up.
1424
+ // Gate on the same API-disabled flag as the boot path: when the HTTP API
1425
+ // is off (a test-only seam), the /signals route is never mounted, so
1426
+ // starting scanners would only spawn children that POST to a closed port.
1427
+ if (parsedConfig &&
1428
+ sharedIntakeContext &&
1429
+ process.env.SENPI_RUNTIME_API_DISABLED !== "true") {
1430
+ try {
1431
+ const apiConfig = loadApiConfig(api.pluginConfig);
1432
+ const scaffoldPackageDir = fileURLToPath(new URL("./scanners/external", import.meta.url));
1433
+ hotLauncher = wireRuntime({
1434
+ runtimeConfig: parsedConfig,
1435
+ recipeDir,
1436
+ runtimeId,
1437
+ wallet,
1438
+ sharedCtx: sharedIntakeContext,
1439
+ runtimeHandles,
1440
+ intakeUrl: intakeUrlFor(apiConfig.host, apiConfig.port),
1441
+ scaffoldPackageDir,
1442
+ runtimeStateDir: entryStateDir,
1443
+ logger: apiLogger,
1444
+ });
1445
+ if (hotLauncher) {
1446
+ const launcher = hotLauncher;
1447
+ try {
1448
+ await launcher.start();
1449
+ launchers.set(runtimeId, launcher);
1450
+ unwiredRuntimes.delete(runtimeId);
1451
+ unwiredPhase = undefined;
1452
+ runtimeHandles.get(runtimeId)?.attachSupervisionSource(() => ({
1453
+ unwired: false,
1454
+ rows: launcher.describeSupervision(),
1455
+ }));
1456
+ }
1457
+ catch (startErr) {
1458
+ await launcher.stop().catch(() => { });
1459
+ hotLauncher = null;
1460
+ unwiredRuntimes.set(runtimeId, "install_launch");
1461
+ unwiredPhase = "install_launch";
1462
+ runtimeHandles.get(runtimeId)?.attachSupervisionSource(() => ({
1463
+ unwired: true,
1464
+ unwiredPhase: "install_launch",
1465
+ rows: [],
1466
+ }));
1467
+ eventScannersUnwired("install_launch", runtimeId, wallet, appendCause("[senpi-runtime] hot-install supervised scanner launch failed — runtime running without it", startErr), startErr);
1468
+ }
1469
+ }
1470
+ }
1471
+ catch (wireErr) {
1472
+ // Wire failure must NOT fail the install — the runtime is already up. It IS reported
1473
+ // across the result boundary (payload.unwired) so a funding caller can refuse it.
1474
+ unwiredRuntimes.set(runtimeId, "install_wire");
1475
+ unwiredPhase = "install_wire";
1476
+ runtimeHandles.get(runtimeId)?.attachSupervisionSource(() => ({
1477
+ unwired: true,
1478
+ unwiredPhase: "install_wire",
1479
+ rows: [],
1480
+ }));
1481
+ eventScannersUnwired("install_wire", runtimeId, wallet, appendCause("[senpi-runtime] hot-install wiring failed — runtime running without supervised scanners", wireErr), wireErr);
1482
+ }
1483
+ }
1484
+ else if (parsedConfig && !sharedIntakeContext && selectScanners(parsedConfig).length > 0) {
1485
+ // Supervised intake is DISABLED for the whole box (corrupt acceptance record).
1486
+ // The wire block above is skipped, so this hot-installed runtime declares
1487
+ // external scanners but has NO live entry scanners. Record that fail-closed
1488
+ // state (state only, no new event) so listRuntimes/health don't lie.
1489
+ unwiredRuntimes.set(runtimeId, "no_intake");
1490
+ unwiredPhase = "no_intake";
1491
+ runtimeHandles.get(runtimeId)?.attachSupervisionSource(() => ({
1492
+ unwired: true,
1493
+ unwiredPhase: "no_intake",
1494
+ rows: [],
1495
+ }));
1496
+ }
1497
+ // ──────────────────────────────────────────────────────────────────────
1498
+ // The stop may have landed during the wire: its teardown then missed the launcher above.
1499
+ if (orphaned())
1500
+ return abandon();
1501
+ return unwiredPhase ? { status: "started", unwiredPhase } : { status: "started" };
1502
+ }
1503
+ /**
1504
+ * Give a runtime an orphaned install (or adoption) started to the live registration
1505
+ * ({@link RuntimeProcessState.runtimeHost}), or queue it for the next service start when no
1506
+ * registration is live. Never throws.
1507
+ */
1508
+ async function handOffRuntime(runtimeId, walletKey) {
1509
+ const host = processState.runtimeHost;
1510
+ if (!host) {
1511
+ // The strategies store is left as it is: a registration that is starting may already run this
1512
+ // row from its boot, and the next start's boot or adoption sets the entry either way.
1513
+ processState.pendingRuntimeHandOffs.set(runtimeId, walletKey);
1514
+ pluginLogger.info("[senpi-runtime] runtime handed to the next service start (no live registration)", { runtimeId });
1515
+ return { outcome: "pending" };
1516
+ }
1517
+ try {
1518
+ return await host.adoptRuntime({ runtimeId, walletKey });
1519
+ }
1520
+ catch (err) {
1521
+ // Nothing runs it: drop the entry the stopped start wrote, so the apps do not draw it.
1522
+ strategiesStore.delete(runtimeId);
1523
+ return { outcome: "failed", message: err instanceof Error ? err.message : String(err) };
1524
+ }
1525
+ }
1526
+ /**
1527
+ * The live registration, when this registration's service has stopped (a 10.x plugin reload)
1528
+ * and another one is running. A deploy job from before the reload keeps this registration's
1529
+ * closures; its deletes and liveness reads go to the registration that holds the runtimes.
1530
+ */
1531
+ function liveHostIfStopped() {
1532
+ if (serviceState !== "stopped")
1533
+ return undefined;
1534
+ const host = processState.runtimeHost;
1535
+ return host && host !== runtimeHost ? host : undefined;
1536
+ }
1537
+ /**
1538
+ * The install result for a runtime this install handed over. A stopped runtime is never
1539
+ * reported as running here: `handedOff` names who runs it, and the result is an error when
1540
+ * nothing does.
1541
+ */
1542
+ function installHandOffResult(handOff, runtime) {
1543
+ switch (handOff.outcome) {
1544
+ case "running":
1545
+ case "booted":
1546
+ return {
1547
+ ok: true,
1548
+ payload: {
1549
+ ...runtime,
1550
+ id: handOff.runtimeId,
1551
+ ...(handOff.unwired ? { unwired: handOff.unwired } : {}),
1552
+ handedOff: "The runtime plugin's service stopped during this install (a plugin reload or a gateway " +
1553
+ "stop), so the plugin registration that is running now runs this runtime " +
1554
+ `(runtime "${handOff.runtimeId}"). Check it: openclaw senpi runtime list.`,
1555
+ },
1556
+ };
1557
+ case "pending":
1558
+ return {
1559
+ ok: true,
1560
+ payload: {
1561
+ ...runtime,
1562
+ handedOff: "The runtime plugin's service stopped during this install (a plugin reload or a gateway " +
1563
+ "stop), and no plugin registration had finished starting when it ended. The runtime is " +
1564
+ "installed; the plugin's service runs it when its start finishes. Check it: " +
1565
+ "openclaw senpi runtime list.",
1566
+ },
1567
+ };
1568
+ case "no-row":
1569
+ return { ok: false, error: {
1570
+ code: "UNAVAILABLE",
1571
+ message: `The runtime plugin's service stopped during this install and runtime "${runtime.id}" is no ` +
1572
+ "longer in the registry, so nothing runs it. Nothing is trading on this wallet from this " +
1573
+ "install. Check: openclaw senpi runtime list.",
1574
+ } };
1575
+ case "failed":
1576
+ return { ok: false, error: {
1577
+ code: "UNAVAILABLE",
1578
+ message: `The runtime plugin's service stopped during this install and the running plugin ` +
1579
+ `registration could not start runtime "${runtime.id}": ${handOff.message}. It is installed; ` +
1580
+ "the next restart tries to start it. Check: openclaw senpi runtime list.",
1581
+ } };
1582
+ }
1583
+ }
1584
+ /**
1585
+ * This registration as the process's {@link RuntimeHost}: published at the end of the service
1586
+ * start, withdrawn at the start of its stop.
1587
+ */
1588
+ const runtimeHost = {
1589
+ async adoptRuntime(request) {
1590
+ const handOff = await adoptRuntimeCore(request);
1591
+ // Nothing runs it here: drop the entry the stopped registration's start wrote (run() sets it,
1592
+ // and that start's abandon does not clear it), so the apps do not draw a strategy that is not
1593
+ // running. A different id on the wallet leaves this id's entry just as stale.
1594
+ if (handOff.outcome === "no-row" ||
1595
+ handOff.outcome === "failed" ||
1596
+ (handOff.outcome === "running" && handOff.runtimeId !== request.runtimeId)) {
1597
+ strategiesStore.delete(request.runtimeId);
1598
+ }
1599
+ return handOff;
1600
+ },
1601
+ deleteRuntime: (params) => deleteRuntimeCore(params),
1602
+ runtimeIsLive: async (id) => runtimeHandles.has(id) && !unwiredRuntimes.has(id),
1603
+ getScannerRows: (id) => readScannerRows(id),
1604
+ };
1605
+ async function adoptRuntimeCore({ runtimeId, walletKey }) {
1606
+ const stopsAtAdopt = serviceStops;
1607
+ const orphaned = () => serviceState !== "running" || serviceStops !== stopsAtAdopt;
1608
+ try {
1609
+ const onWallet = runtimeHandles.has(runtimeId)
1610
+ ? runtimeId
1611
+ : walletKey
1612
+ ? [...runtimeHandles].find(([, h]) => (h.wallet ?? "").trim().toLowerCase() === walletKey)?.[0]
1613
+ : undefined;
1614
+ if (onWallet !== undefined) {
1615
+ // The live verdict, as for a start: a runtime running blind is not a clean install.
1616
+ const unwired = unwiredRuntimes.get(onWallet);
1617
+ return unwired ? { outcome: "running", runtimeId: onWallet, unwired } : { outcome: "running", runtimeId: onWallet };
1618
+ }
1619
+ const { readRegistry } = await import("./runtime/strategy-registry.js");
1620
+ const entry = (await readRegistry(baseStateDir)).runtimes.find((e) => e.id === runtimeId);
1621
+ if (!entry)
1622
+ return { outcome: "no-row" };
1623
+ const inputs = factsPlanInputs(entry);
1624
+ if (!inputs)
1625
+ return { outcome: "failed", message: "its registry row has no readable recipe" };
1626
+ const parsedConfig = await loadRuntimeConfig(inputs.pathOrContent, { fromPath: inputs.fromPath });
1627
+ const started = await startRuntimeLive({
1628
+ runtimeId: entry.id,
1629
+ pathOrContent: inputs.pathOrContent,
1630
+ fromPath: inputs.fromPath,
1631
+ recipeDir: inputs.runtimeYamlDir,
1632
+ wallet: entry.wallet ?? "",
1633
+ parsedConfig,
1634
+ }, orphaned);
1635
+ if (started.status === "orphaned")
1636
+ return handOffRuntime(runtimeId, walletKey);
1637
+ pluginLogger.info("[senpi-runtime] started a runtime handed over by a stopped registration", {
1638
+ runtimeId: entry.id,
1639
+ });
1640
+ return started.unwiredPhase
1641
+ ? { outcome: "booted", runtimeId: entry.id, unwired: started.unwiredPhase }
1642
+ : { outcome: "booted", runtimeId: entry.id };
1643
+ }
1644
+ catch (err) {
1645
+ return { outcome: "failed", message: err instanceof Error ? err.message : String(err) };
1646
+ }
1647
+ }
1231
1648
  async function installRuntimeCore(params) {
1649
+ // How many times this registration's service had stopped when this install began; see `orphaned` below.
1650
+ const stopsAtInstall = serviceStops;
1232
1651
  const toError = (message) => ({ code: "INVALID_REQUEST", message });
1233
1652
  const toUnavailable = (message) => ({ code: "UNAVAILABLE", message });
1234
1653
  const { readRegistry, writeRegistry, withRegistryLock, validateEntry, hasId, findByWallet, deriveRuntimeId, sanitizeId } = await import("./runtime/strategy-registry.js");
@@ -1440,7 +1859,7 @@ const senpiRuntimePlugin = {
1440
1859
  if (!mcpTransport)
1441
1860
  await client.connect();
1442
1861
  try {
1443
- const strategies = await client.listStrategies({ strategyAddresses: [wallet] });
1862
+ const strategies = await client.listStrategiesForWallets([wallet]);
1444
1863
  const match = strategies.find((s) => s.strategyWalletAddress.toLowerCase() === wallet.toLowerCase());
1445
1864
  if (!match) {
1446
1865
  pluginLogger.info("[senpi-runtime] Wallet validation failed: not owned", { wallet });
@@ -1496,127 +1915,64 @@ const senpiRuntimePlugin = {
1496
1915
  const current = await readRegistry(baseStateDir);
1497
1916
  await writeRegistry(baseStateDir, { ...current, runtimes: [...current.runtimes, entry] });
1498
1917
  });
1499
- /**
1500
- * The unwired verdict for THIS install, tracked locally rather than read back out of
1501
- * `unwiredRuntimes`, so the result reports what this call did and never a leftover verdict
1502
- * from an earlier runtime that happened to share the id. Set at each of the three sites that
1503
- * leave the runtime without live entry scanners; cleared by the wire-success path.
1504
- */
1505
- let unwiredPhase;
1918
+ // The registration's service stopped since this install began (a 10.x plugin reload): the
1919
+ // runtime belongs to the live registration, so this install hands it over instead of keeping
1920
+ // one in a stopped registration that nothing can stop or delete.
1921
+ const orphaned = () => serviceState === "stopped" || serviceStops !== stopsAtInstall;
1922
+ // Registry create fact — distinct from the runtime.started boot transition (which also fires
1923
+ // on a transient restart). Once per install, as soon as a registration runs the row: right
1924
+ // after run() here, or at the hand-off. Inside emitEvent's guard so telemetry never fails the
1925
+ // install.
1926
+ let createdEmitted = false;
1927
+ const emitCreated = () => {
1928
+ if (createdEmitted)
1929
+ return;
1930
+ createdEmitted = true;
1931
+ emitEvent(apiLogger, () => buildRuntimeCreatedEvent({ runtimeId: resolvedId, address: wallet, recipeName: runtimeName }));
1932
+ };
1933
+ const handOffInstall = async () => {
1934
+ const handOff = await handOffRuntime(resolvedId, walletKey);
1935
+ if (handOff.outcome === "running" || handOff.outcome === "booted" || handOff.outcome === "pending")
1936
+ emitCreated();
1937
+ return installHandOffResult(handOff, { id: resolvedId, name: runtimeName, wallet });
1938
+ };
1506
1939
  try {
1507
- const scannerProviders = await createScannerProvidersForRun({
1508
- mcpTransport,
1509
- apiKey,
1510
- });
1511
- const entryStateDir = buildRuntimeStateDir(baseStateDir, resolvedId, wallet);
1512
- const handle = await run(pathOrContent, {
1513
- fromPath: hasPath,
1514
- stateDir: entryStateDir,
1515
- mcpTransport,
1516
- apiKey,
1517
- scannerProviders,
1940
+ // Stopped before the start: do not start a runtime here at all.
1941
+ if (orphaned())
1942
+ return await handOffInstall();
1943
+ const started = await startRuntimeLive({
1518
1944
  runtimeId: resolvedId,
1519
- onStrategyNotActive: (address) => reaper.checkWallet(address),
1520
- });
1521
- runtimeHandles.set(resolvedId, handle);
1522
- // Registry create fact — distinct from the runtime.started boot transition (which also fires
1523
- // on a transient restart). Emitted inside emitEvent's guard so telemetry never fails the install.
1524
- emitEvent(apiLogger, () => buildRuntimeCreatedEvent({ runtimeId: resolvedId, address: wallet, recipeName: runtimeName }));
1525
- // ── hot-install supervised scanner wire ───────────────────────────────
1526
- // Live-install path: used when a runtime is registered after boot via
1527
- // senpi.installRuntime, while the API is already listening — the
1528
- // counterpart to the boot-time `launcher` above. A runtime takes exactly
1529
- // one path: boot-present → `launcher` (deferred start); installed live →
1530
- // `hotLauncher` (immediate start). The two are mutually exclusive because
1531
- // installRuntime rejects any id/wallet already in the registry before
1532
- // wiring (the hasId guard), so a boot-present runtime never re-enters here.
1533
- // The API is already listening at this point, so we wire and start
1534
- // immediately (no deferred start needed). Any failure is swallowed so
1535
- // the install itself is not rolled back — the runtime is already up.
1536
- // Gate on the same API-disabled flag as the boot path: when the HTTP API
1537
- // is off (a test-only seam), the /signals route is never mounted, so
1538
- // starting scanners would only spawn children that POST to a closed port.
1539
- if (parsedConfig &&
1540
- sharedIntakeContext &&
1541
- process.env.SENPI_RUNTIME_API_DISABLED !== "true") {
1542
- try {
1543
- const apiConfig = loadApiConfig(api.pluginConfig);
1544
- const scaffoldPackageDir = fileURLToPath(new URL("./scanners/external", import.meta.url));
1545
- const entryStateDir = buildRuntimeStateDir(baseStateDir, resolvedId, wallet);
1546
- const hotLauncher = wireRuntime({
1547
- runtimeConfig: parsedConfig,
1548
- recipeDir: hasPath ? dirname(pathOrContent) : runtimeYamlDir,
1549
- runtimeId: resolvedId,
1550
- wallet,
1551
- sharedCtx: sharedIntakeContext,
1552
- runtimeHandles,
1553
- intakeUrl: intakeUrlFor(apiConfig.host, apiConfig.port),
1554
- scaffoldPackageDir,
1555
- runtimeStateDir: entryStateDir,
1556
- logger: apiLogger,
1557
- });
1558
- if (hotLauncher) {
1559
- try {
1560
- await hotLauncher.start();
1561
- launchers.set(resolvedId, hotLauncher);
1562
- unwiredRuntimes.delete(resolvedId);
1563
- unwiredPhase = undefined;
1564
- runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1565
- unwired: false,
1566
- rows: hotLauncher.describeSupervision(),
1567
- }));
1568
- }
1569
- catch (startErr) {
1570
- await hotLauncher.stop().catch(() => { });
1571
- unwiredRuntimes.set(resolvedId, "install_launch");
1572
- unwiredPhase = "install_launch";
1573
- runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1574
- unwired: true,
1575
- unwiredPhase: "install_launch",
1576
- rows: [],
1577
- }));
1578
- eventScannersUnwired("install_launch", resolvedId, wallet, appendCause("[senpi-runtime] hot-install supervised scanner launch failed — runtime running without it", startErr), startErr);
1579
- }
1580
- }
1581
- }
1582
- catch (wireErr) {
1583
- // Wire failure must NOT fail the install — the runtime is already up. It IS reported
1584
- // across the result boundary (payload.unwired) so a funding caller can refuse it.
1585
- unwiredRuntimes.set(resolvedId, "install_wire");
1586
- unwiredPhase = "install_wire";
1587
- runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1588
- unwired: true,
1589
- unwiredPhase: "install_wire",
1590
- rows: [],
1591
- }));
1592
- eventScannersUnwired("install_wire", resolvedId, wallet, appendCause("[senpi-runtime] hot-install wiring failed — runtime running without supervised scanners", wireErr), wireErr);
1593
- }
1594
- }
1595
- else if (parsedConfig && !sharedIntakeContext && selectScanners(parsedConfig).length > 0) {
1596
- // Supervised intake is DISABLED for the whole box (corrupt acceptance record).
1597
- // The wire block above is skipped, so this hot-installed runtime declares
1598
- // external scanners but has NO live entry scanners. Record that fail-closed
1599
- // state (state only, no new event) so listRuntimes/health don't lie.
1600
- unwiredRuntimes.set(resolvedId, "no_intake");
1601
- unwiredPhase = "no_intake";
1602
- runtimeHandles.get(resolvedId)?.attachSupervisionSource(() => ({
1603
- unwired: true,
1604
- unwiredPhase: "no_intake",
1605
- rows: [],
1606
- }));
1607
- }
1608
- // ──────────────────────────────────────────────────────────────────────
1945
+ pathOrContent,
1946
+ fromPath: hasPath,
1947
+ recipeDir: hasPath ? dirname(pathOrContent) : runtimeYamlDir,
1948
+ wallet,
1949
+ parsedConfig,
1950
+ }, orphaned, emitCreated);
1951
+ if (started.status === "orphaned")
1952
+ return await handOffInstall();
1609
1953
  return {
1610
1954
  ok: true,
1611
1955
  payload: {
1612
1956
  id: resolvedId,
1613
1957
  name: runtimeName,
1614
1958
  wallet,
1615
- ...(unwiredPhase ? { unwired: unwiredPhase } : {}),
1959
+ ...(started.unwiredPhase ? { unwired: started.unwiredPhase } : {}),
1616
1960
  },
1617
1961
  };
1618
1962
  }
1619
1963
  catch (err) {
1964
+ // run() threw after this registration's service stopped: the live registration may already
1965
+ // run this row (its boot read it), so it decides, not this catch. It runs it, or starts it,
1966
+ // or (no registration live yet) the next start does; only when it cannot start the row
1967
+ // either is the row removed, as below for an install with no reload.
1968
+ if (orphaned()) {
1969
+ const handOff = await handOffRuntime(resolvedId, walletKey);
1970
+ if (handOff.outcome !== "failed") {
1971
+ if (handOff.outcome !== "no-row")
1972
+ emitCreated();
1973
+ return installHandOffResult(handOff, { id: resolvedId, name: runtimeName, wallet });
1974
+ }
1975
+ }
1620
1976
  await withRegistryLock(baseStateDir, async () => {
1621
1977
  const current = await readRegistry(baseStateDir);
1622
1978
  await writeRegistry(baseStateDir, { ...current, runtimes: current.runtimes.filter((e) => e.id !== resolvedId) });
@@ -1681,11 +2037,18 @@ const senpiRuntimePlugin = {
1681
2037
  * unwired-verdict cleanup all happen exactly once, here.
1682
2038
  */
1683
2039
  async function deleteRuntimeCore(params) {
2040
+ // This registration's service stopped (a 10.x reload) and holds no runtimes; the live one does.
2041
+ // A delete from here (a deploy job from before the reload) would remove the row and archive the
2042
+ // state dir under a runtime that keeps trading.
2043
+ const live = liveHostIfStopped();
2044
+ if (live)
2045
+ return live.deleteRuntime(params);
1684
2046
  let targetId = typeof params?.id === "string" ? params.id : undefined;
1685
2047
  const targetAddress = typeof params?.address === "string" ? params.address : undefined;
1686
2048
  // Set only by the lifecycle reaper: why the strategy counts as gone (stamped on runtime.deleted)
1687
2049
  // and which wallet that verdict was about (the concurrency guard below).
1688
- const deleteReason = params?.deleteReason === "closed_status" ? params.deleteReason
2050
+ const deleteReason = params?.deleteReason === "closed_status" || params?.deleteReason === "paper_failed_status"
2051
+ ? params.deleteReason
1689
2052
  : undefined;
1690
2053
  const expectWallet = typeof params?.expectWallet === "string" && params.expectWallet.trim().length > 0
1691
2054
  ? params.expectWallet.trim().toLowerCase()
@@ -1918,7 +2281,7 @@ const senpiRuntimePlugin = {
1918
2281
  * the strategy id is read for this one wallet; a failed read logs its reason and sends nothing.
1919
2282
  */
1920
2283
  function queueFactsSend(runtimeId, strategyIds) {
1921
- return factsSendLock.withKeyLock(runtimeId, async () => {
2284
+ return factsSendLock().withKeyLock(runtimeId, async () => {
1922
2285
  const { readRegistry } = await import("./runtime/strategy-registry.js");
1923
2286
  const entry = (await readRegistry(baseStateDir)).runtimes.find((e) => e.id === runtimeId);
1924
2287
  const inputs = entry ? factsPlanInputs(entry) : null;
@@ -1929,7 +2292,7 @@ const senpiRuntimePlugin = {
1929
2292
  return;
1930
2293
  let ids = strategyIds ?? null;
1931
2294
  if (strategyIds === undefined && !plan.unchanged) {
1932
- const read = await readBootWalletStatuses([plan.wallet.trim().toLowerCase()], (wallets, signal) => withSenpiClient((client) => client.listStrategies({ strategyAddresses: wallets }, { signal })));
2295
+ const read = await readBootWalletStatuses([plan.wallet.trim().toLowerCase()], (wallets, signal) => withSenpiClient((client) => client.listStrategiesForWallets(wallets, { signal })));
1933
2296
  if (!read.ok) {
1934
2297
  factsLogger.warn("strategy facts failed: could not read the strategy id; retrying next boot", undefined, {
1935
2298
  runtimeId: plan.runtimeId,
@@ -2177,18 +2540,24 @@ const senpiRuntimePlugin = {
2177
2540
  },
2178
2541
  registryFindByWallet: findRegistryEntryByWallet,
2179
2542
  registryFindById: findRegistryEntryById,
2180
- getScannerRows: readScannerRows,
2543
+ countRegistryRuntimes: async () => {
2544
+ const { readRegistry } = await import("./runtime/strategy-registry.js");
2545
+ return (await readRegistry(baseStateDir)).runtimes.length;
2546
+ },
2547
+ // A deploy job outlives a reload; it reads the runtime where it runs (see `liveHostIfStopped`).
2548
+ getScannerRows: (id) => liveHostIfStopped()?.getScannerRows(id) ?? readScannerRows(id),
2181
2549
  // H4: `runtimeHandles` holds the live `run()` handles. A registry row without one is a
2182
2550
  // previous install whose runtime died before start — deploy must reinstall, not report it
2183
2551
  // adopted. `unwiredRuntimes` is the same judgement one step further in: a runtime that is up
2184
2552
  // but whose entry scanners never wired is running BLIND (the health surface already renders
2185
2553
  // it "running — NO ENTRY SCANNERS"), and reporting `install: skipped` over it would be the
2186
2554
  // same clean line over a runtime that cannot tick.
2187
- runtimeIsLive: async (id) => runtimeHandles.has(id) && !unwiredRuntimes.has(id),
2555
+ runtimeIsLive: async (id) => liveHostIfStopped()?.runtimeIsLive(id) ?? (runtimeHandles.has(id) && !unwiredRuntimes.has(id)),
2188
2556
  installPendingOnWallet: async (wallet) => installsInFlight.has(wallet.trim().toLowerCase()),
2557
+ // The process logger, not this registration's: a deploy job outlives a reload.
2189
2558
  logger: {
2190
- info: (line) => api.logger.info(`[senpi-runtime] ${line}`),
2191
- warn: (line) => api.logger.warn(`[senpi-runtime] ${line}`),
2559
+ info: (line) => processLog.info(`[senpi-runtime] ${line}`),
2560
+ warn: (line) => processLog.warn(`[senpi-runtime] ${line}`),
2192
2561
  },
2193
2562
  });
2194
2563
  /**
@@ -2392,7 +2761,7 @@ const senpiRuntimePlugin = {
2392
2761
  respond(true, {
2393
2762
  status: status ? { ...status, runtimeId } : null,
2394
2763
  updater,
2395
- approvals: approvalsHostStatus,
2764
+ approvals: approvalsStatus(),
2396
2765
  deploy: { capabilities: DEPLOY_CAPABILITIES },
2397
2766
  });
2398
2767
  }
@@ -2406,7 +2775,7 @@ const senpiRuntimePlugin = {
2406
2775
  return;
2407
2776
  }
2408
2777
  const statuses = await collectRuntimeHealthStatuses(runtimeHandles, api.logger);
2409
- respond(true, { statuses, updater, approvals: approvalsHostStatus, deploy: { capabilities: DEPLOY_CAPABILITIES } });
2778
+ respond(true, { statuses, updater, approvals: approvalsStatus(), deploy: { capabilities: DEPLOY_CAPABILITIES } });
2410
2779
  }, { scope: "operator.read" });
2411
2780
  api.registerGatewayMethod("senpi.getSystemState", async (ctx) => {
2412
2781
  const respond = ctx.respond;