@senpi/runtime 3.0.186 → 3.0.187

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 (98) 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 +2 -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 +8 -0
  19. package/dist/cli/senpi-commands.js.map +1 -1
  20. package/dist/deploy/register.d.ts +1 -0
  21. package/dist/deploy/register.d.ts.map +1 -1
  22. package/dist/deploy/register.js +2 -1
  23. package/dist/deploy/register.js.map +1 -1
  24. package/dist/dsl/monitor/dsl-failure-events.d.ts +38 -2
  25. package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
  26. package/dist/dsl/monitor/dsl-failure-events.js +67 -4
  27. package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
  28. package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
  29. package/dist/dsl/monitor/process-one-position.js +25 -1
  30. package/dist/dsl/monitor/process-one-position.js.map +1 -1
  31. package/dist/index.d.ts +10 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +484 -146
  34. package/dist/index.js.map +1 -1
  35. package/dist/runtime/auto-update/coordinator.d.ts +19 -2
  36. package/dist/runtime/auto-update/coordinator.d.ts.map +1 -1
  37. package/dist/runtime/auto-update/coordinator.js +104 -20
  38. package/dist/runtime/auto-update/coordinator.js.map +1 -1
  39. package/dist/runtime/auto-update/gateway-restart.d.ts +2 -2
  40. package/dist/runtime/auto-update/gateway-restart.js +1 -1
  41. package/dist/runtime/auto-update/idle-gate.d.ts +45 -9
  42. package/dist/runtime/auto-update/idle-gate.d.ts.map +1 -1
  43. package/dist/runtime/auto-update/idle-gate.js +26 -14
  44. package/dist/runtime/auto-update/idle-gate.js.map +1 -1
  45. package/dist/runtime/auto-update/register-idle-gate.d.ts +1 -1
  46. package/dist/runtime/auto-update/register-idle-gate.d.ts.map +1 -1
  47. package/dist/runtime/auto-update/register-idle-gate.js +8 -4
  48. package/dist/runtime/auto-update/register-idle-gate.js.map +1 -1
  49. package/dist/runtime/auto-update/run-update-command.d.ts +1 -1
  50. package/dist/runtime/auto-update/run-update-command.js +1 -1
  51. package/dist/runtime/auto-update/types.d.ts +2 -1
  52. package/dist/runtime/auto-update/types.d.ts.map +1 -1
  53. package/dist/runtime/auto-update/types.js.map +1 -1
  54. package/dist/runtime/auto-update/update-schedule.d.ts +14 -4
  55. package/dist/runtime/auto-update/update-schedule.d.ts.map +1 -1
  56. package/dist/runtime/auto-update/update-schedule.js +21 -5
  57. package/dist/runtime/auto-update/update-schedule.js.map +1 -1
  58. package/dist/runtime/process-state.d.ts +168 -0
  59. package/dist/runtime/process-state.d.ts.map +1 -0
  60. package/dist/runtime/process-state.js +81 -0
  61. package/dist/runtime/process-state.js.map +1 -0
  62. package/dist/runtime/reconcile-state.d.ts.map +1 -1
  63. package/dist/runtime/reconcile-state.js +18 -4
  64. package/dist/runtime/reconcile-state.js.map +1 -1
  65. package/dist/runtime/strategy-registry.d.ts +16 -0
  66. package/dist/runtime/strategy-registry.d.ts.map +1 -1
  67. package/dist/runtime/strategy-registry.js +7 -3
  68. package/dist/runtime/strategy-registry.js.map +1 -1
  69. package/dist/runtime/update-apply-deps.d.ts +5 -3
  70. package/dist/runtime/update-apply-deps.d.ts.map +1 -1
  71. package/dist/runtime/update-apply-deps.js +15 -3
  72. package/dist/runtime/update-apply-deps.js.map +1 -1
  73. package/dist/senpi/client.d.ts +5 -1
  74. package/dist/senpi/client.d.ts.map +1 -1
  75. package/dist/senpi/client.js +20 -7
  76. package/dist/senpi/client.js.map +1 -1
  77. package/dist/telemetry/metrics.d.ts +1 -1
  78. package/dist/telemetry/metrics.d.ts.map +1 -1
  79. package/dist/telemetry/metrics.js +1 -1
  80. package/dist/telemetry/metrics.js.map +1 -1
  81. package/dist/telemetry/spans/span-hooks.d.ts +1 -1
  82. package/dist/telemetry/spans/span-hooks.js +1 -1
  83. package/dist/test-setup.js +7 -1
  84. package/dist/test-setup.js.map +1 -1
  85. package/dist/tool-surface/crib-hook.d.ts.map +1 -1
  86. package/dist/tool-surface/crib-hook.js +8 -0
  87. package/dist/tool-surface/crib-hook.js.map +1 -1
  88. package/dist/tool-surface/crib-sheet.d.ts +1 -1
  89. package/dist/tool-surface/crib-sheet.js +1 -1
  90. package/dist/tool-surface/mcp-tool-source.d.ts +1 -1
  91. package/dist/tool-surface/mcp-tool-source.js +2 -2
  92. package/dist/utils/event-catalog.d.ts +27 -1
  93. package/dist/utils/event-catalog.d.ts.map +1 -1
  94. package/dist/utils/event-catalog.js +6 -0
  95. package/dist/utils/event-catalog.js.map +1 -1
  96. package/dist/widgets/tool.d.ts +1 -1
  97. package/openclaw.plugin.json +1 -1
  98. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -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";
@@ -81,7 +82,7 @@ import { ingestDispatchMutex, ingestDispatchKey } from "./runtime-api/intake/ing
81
82
  import { buildScaffoldErrorRouter } from "./runtime-api/intake/scaffold-error-router.js";
82
83
  import { loadRuntimeConfig } from "./runtime/load-runtime-config.js";
83
84
  import { errorText } from "./deploy/error-text.js";
84
- import { initOpenClawHost } from "./host/openclaw-host.js";
85
+ import { getOpenClawHost, initOpenClawHost } from "./host/openclaw-host.js";
85
86
  import { createRegisterPluginLogger, withEventLogger } from "./host/plugin-logger.js";
86
87
  import { hostname } from "node:os";
87
88
  import { BOOT_FACTS_CALL_TIMEOUT_MS, planBootFacts, syncBootFacts, } from "./strategy/facts-sync.js";
@@ -182,23 +183,29 @@ const WALLET_INSTALL_IN_FLIGHT = `[${WALLET_INSTALL_IN_FLIGHT_CODE}]`;
182
183
  */
183
184
  const RUNTIME_ID_TAKEN = "[E_RUNTIME_ID_TAKEN]";
184
185
  /**
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.
186
+ * One StrategyFacts send per runtime at a time, keyed on runtime id, across the whole process.
187
+ * Held in the process state (`runtime/process-state.ts`), not this module: a 10.x plugin reload
188
+ * evaluates a new copy of this module, and the previous registration's send may still be running.
189
+ * Separate from `runtimeChangeLock`. Delete does not take it; an apply queues its send here and
190
+ * does not wait.
188
191
  */
189
- const factsSendLock = new KeyedMutex();
192
+ function factsSendLock() {
193
+ const state = runtimeProcessState();
194
+ state.factsSendLock ??= new KeyedMutex();
195
+ return state.factsSendLock;
196
+ }
190
197
  /**
191
- * Whether this process has already logged the 5.x "cannot classify runs" notice.
198
+ * Clears the process-state latch for the 5.x "cannot classify runs" notice
199
+ * (`legacyTurnClassificationNoticeLogged`). Tests only.
192
200
  *
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.
201
+ * The latch lives in the process state so a plugin reload (which re-registers the plugin and
202
+ * restarts its service in the same process, from a new copy of this module) does not repeat the
203
+ * notice. The 5.x case is a host-version limit with no config fix, so one info line per process is
204
+ * the whole message; the fixable 10.x case stays an ERROR at every registration and does not read
205
+ * this latch.
197
206
  */
198
- let legacyTurnClassificationNoticeLogged = false;
199
- /** Clears {@link legacyTurnClassificationNoticeLogged}. Tests only. */
200
207
  export function resetLegacyTurnClassificationNoticeForTests() {
201
- legacyTurnClassificationNoticeLogged = false;
208
+ runtimeProcessState().legacyTurnClassificationNoticeLogged = false;
202
209
  }
203
210
  /**
204
211
  * The whole of `register()` in the `"cli-metadata"` pass: the `senpi` CLI root, under the same
@@ -261,8 +268,17 @@ const senpiRuntimePlugin = {
261
268
  registerSenpiCliMetadata(api);
262
269
  return;
263
270
  }
264
- // Host facts first: the logger and every version-gated registration below read them.
265
- const openClawHost = initOpenClawHost(api);
271
+ // Host facts first: the logger and every version-gated registration below read them. 10.x also
272
+ // registers plugins in non-full modes inside the gateway (`tool-discovery` for tools.catalog);
273
+ // that pass must keep registering tools, but its throwaway api must not replace the record the
274
+ // live registration made.
275
+ const fullRegistration = api.registrationMode === undefined || api.registrationMode === "full";
276
+ const openClawHost = (!fullRegistration && getOpenClawHost()) || initOpenClawHost(api);
277
+ // State kept across a reload (the deploy controller, the gate trackers) logs through the latest
278
+ // full registration's logger, never through an `api` a reload has replaced.
279
+ const processState = runtimeProcessState();
280
+ if (fullRegistration)
281
+ bindProcessLogger(api.logger);
266
282
  const pluginLogger = createRegisterPluginLogger(api.logger, {
267
283
  jsonMode: process.argv.includes("--json"),
268
284
  hostRoutesJsonLogsToStderr: openClawHost.modern,
@@ -403,10 +419,14 @@ const senpiRuntimePlugin = {
403
419
  // decisions persist in <stateDir>/tool-approvals.json.
404
420
  // See docs/mcp-tool-approvals-design.md.
405
421
  // Open trade-approval prompts, read by the self-update idle gate (R2).
406
- const pendingApprovalTracker = new PendingApprovalTracker();
422
+ // This and the turn tracker are process state: a 10.x reload re-registers in the same process
423
+ // while a prompt is open or a run is in flight (r4467406809, r4427720076).
424
+ processState.pendingApprovals ??= new PendingApprovalTracker();
425
+ const pendingApprovalTracker = processState.pendingApprovals;
407
426
  // The turn kind (chat / cron / heartbeat) and session key of each gated
408
427
  // call, for approval telemetry and the 10.x scheduled-turn block (R6, D13).
409
- const turnContextTracker = new TurnContextTracker();
428
+ processState.turnContext ??= new TurnContextTracker();
429
+ const turnContextTracker = processState.turnContext;
410
430
  // One boot-time fact (D15): can this host classify runs at all? Only then
411
431
  // does the gate stop prompts outside a direct chat turn. Without it every
412
432
  // run reads unknown and keeps the pre-10.x prompt, so a box missing the
@@ -442,7 +462,7 @@ const senpiRuntimePlugin = {
442
462
  ? "on this OpenClaw version; a 10.x host lifts this."
443
463
  : "until the runtime registers its turn-context hooks.");
444
464
  const legacyTurnClassificationNotice = turnClassificationHostLimit ? turnClassificationNotice : undefined;
445
- if (turnClassificationNotice !== undefined && !turnClassificationHostLimit) {
465
+ if (turnClassificationNotice !== undefined && !turnClassificationHostLimit && fullRegistration) {
446
466
  pluginLogger.error(turnClassificationNotice);
447
467
  }
448
468
  const approvalsHostStatus = turnClassification.available
@@ -452,12 +472,28 @@ const senpiRuntimePlugin = {
452
472
  reason: turnClassification.reason,
453
473
  ...(turnClassificationHostLimit ? { hostLimit: true } : {}),
454
474
  };
475
+ // Process state, like the run-kind tracker beside it: a reload must not zero the count that
476
+ // `senpi status` warns about while chat trades are being refused.
477
+ const approvalsStatus = () => approvalsHostStatus.turnClassification === "available"
478
+ ? {
479
+ ...approvalsHostStatus,
480
+ runsRecorded: turnContextTracker.runsRecorded,
481
+ unclassifiedBlocks: processState.unclassifiedBlocks,
482
+ ...(processState.lastUnclassifiedBlockAt
483
+ ? { lastUnclassifiedBlockAt: processState.lastUnclassifiedBlockAt }
484
+ : {}),
485
+ }
486
+ : approvalsHostStatus;
455
487
  try {
456
488
  registerToolApprovalGate({
457
489
  api,
458
490
  pendingApprovals: pendingApprovalTracker,
459
491
  turnContext: turnContextTracker,
460
492
  turnClassification: turnClassification.available,
493
+ onUnclassifiedBlock: (at) => {
494
+ processState.unclassifiedBlocks += 1;
495
+ processState.lastUnclassifiedBlockAt = new Date(at).toISOString();
496
+ },
461
497
  mode: resolveToolApprovalsMode({
462
498
  pluginConfig: config,
463
499
  env: process.env,
@@ -605,8 +641,21 @@ const senpiRuntimePlugin = {
605
641
  * row, sees no live handle, deletes it and reinstalls — and the first install then lands on top,
606
642
  * clobbering `runtimeHandles` under a runtime id both installs derive identically. Two exit
607
643
  * engines, one margin account: the exact state `[E_WALLET_HAS_RUNTIME]` exists to prevent.
644
+ *
645
+ * Process state (`runtime/process-state.ts`), not this registration: a 10.x plugin reload
646
+ * re-registers in the same process while a deploy job's install from the previous registration
647
+ * runs on, and the new registration must still refuse a second install on that wallet.
648
+ */
649
+ const installsInFlight = processState.installsInFlight;
650
+ /**
651
+ * This registration's service lifecycle, for an install or adoption that outlives it. A 10.x
652
+ * plugin reload stops this service and starts the next registration's in the same process;
653
+ * `serviceStops` counts this registration's stops, so a stop followed by a later start is still
654
+ * seen. Only a stop counts: an install that began before the first start (the gateway may call
655
+ * installRuntime before start()) and spans it is not orphaned.
608
656
  */
609
- const installsInFlight = new Map();
657
+ let serviceState = "idle";
658
+ let serviceStops = 0;
610
659
  // Dedicated logger for the runtime-api subtree — required because the
611
660
  // PluginLogger surface (info/warn/error) lacks `debug`, while the
612
661
  // Logger contract used by `runtime-api/` includes it.
@@ -663,7 +712,10 @@ const senpiRuntimePlugin = {
663
712
  // the agent's senpi__ calls (direct or through the dispatcher), seen at the
664
713
  // tool hooks. A deploy job is read from the controller (this process) and
665
714
  // its journals (any process).
666
- const moneyCalls = new MoneyCallTracker();
715
+ // Process state, like the approval trackers: a call that started under the previous
716
+ // registration still counts, and its after-hook (now this registration's) closes it.
717
+ processState.moneyCalls ??= new MoneyCallTracker();
718
+ const moneyCalls = processState.moneyCalls;
667
719
  try {
668
720
  registerMoneyCallHooks({
669
721
  api: api,
@@ -681,6 +733,13 @@ const senpiRuntimePlugin = {
681
733
  ordersInFlight: () => senpiOrderCallsInFlight() + moneyCalls.count(Date.now()),
682
734
  // Read only from ticks, after register() has constructed the controller below.
683
735
  deploysInFlight: () => deployController.inFlight(Date.now()),
736
+ // The process start, not this registration: a config edit under `plugins` re-registers in
737
+ // the same process (10.10 reloadGatewayPlugins), and repeated reloads under 15 min apart
738
+ // must not hold an update until the forced window.
739
+ startedAt: Date.now() - Math.round(process.uptime() * 1000),
740
+ // Process state: a reload builds this gate again, and must not forget a user turn that is
741
+ // running, a message that is queued, or when the user last wrote.
742
+ activity: (processState.idleGateActivity ??= createIdleGateActivity()),
684
743
  logger: pluginLogger,
685
744
  });
686
745
  try {
@@ -774,11 +833,20 @@ const senpiRuntimePlugin = {
774
833
  // boot scan: declaring it after would leave that reference in the temporal dead zone for any
775
834
  // future refactor that starts the service earlier. The rest of the deploy wiring lives in
776
835
  // 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
- });
836
+ //
837
+ // One controller per deploys dir per process (process state): a full re-registration (10.10
838
+ // reloadGatewayPlugins, same process) reuses the one that may be running a deploy, so its
839
+ // `running()` keeps single-flight and `inFlight()` keeps the idle gate's deploy blocker. It
840
+ // logs through `processLog`, the current registration's logger, so a job that outlives its
841
+ // registration still narrates and writes its terminal journal line.
842
+ const deploysDir = join(baseStateDir, "deploys");
843
+ const deployController = processState.deployControllers.get(deploysDir) ??
844
+ new DeployJobController({
845
+ deploysDir,
846
+ now: () => Date.now(),
847
+ log: (line) => processLog.info(`[senpi-runtime] ${line}`),
848
+ });
849
+ processState.deployControllers.set(deploysDir, deployController);
782
850
  // ── lifecycle reaper ─────────────────────────────────────────────────────────────────────────
783
851
  // A runtime exists iff its strategy is open. Every close path ends at the backend and none of
784
852
  // them tells this box, so the reaper asks: once after boot, hourly, and whenever the backend
@@ -816,15 +884,20 @@ const senpiRuntimePlugin = {
816
884
  api.registerService({
817
885
  id: "senpi-runtime",
818
886
  async start() {
887
+ serviceState = "running";
819
888
  // The 5.x classification notice (see the approval gate above), once per process.
820
- if (legacyTurnClassificationNotice !== undefined && !legacyTurnClassificationNoticeLogged) {
821
- legacyTurnClassificationNoticeLogged = true;
889
+ if (legacyTurnClassificationNotice !== undefined && !processState.legacyTurnClassificationNoticeLogged) {
890
+ processState.legacyTurnClassificationNoticeLogged = true;
822
891
  pluginLogger.info(legacyTurnClassificationNotice);
823
892
  }
824
893
  // Deploy-journal janitor: stamp anything a restart left unterminated as `interrupted`.
825
894
  // It never re-enters a deploy state machine — discovery is pull-based via
826
- // `senpi deploy status`.
827
- await deployController.scanOnBoot();
895
+ // `senpi deploy status`. Once per process: after a reload an unterminated journal may be
896
+ // the deploy this process is still running (r4469086155).
897
+ if (!processState.bootScanned.has(deploysDir)) {
898
+ processState.bootScanned.add(deploysDir);
899
+ await deployController.scanOnBoot();
900
+ }
828
901
  // Quant runs a restart cut short belong to a process that no longer exists: stamp them
829
902
  // failed with the re-run command, so `senpi quant status` never reports a dead run as live.
830
903
  // The controller also gates every quant start/read on this same (memoized) janitor, since
@@ -1156,6 +1229,17 @@ const senpiRuntimePlugin = {
1156
1229
  }
1157
1230
  }
1158
1231
  }
1232
+ // This registration now runs the box's runtimes: publish it, then start what an install from a
1233
+ // stopped registration handed over while none was live (see `handOffRuntime`). Detached and
1234
+ // never throwing, like the facts send: one adoption must not hold or fail the boot.
1235
+ processState.runtimeHost = runtimeHost;
1236
+ const pendingHandOffs = [...processState.pendingRuntimeHandOffs];
1237
+ processState.pendingRuntimeHandOffs.clear();
1238
+ for (const [runtimeId, walletKey] of pendingHandOffs) {
1239
+ void runtimeHost.adoptRuntime({ runtimeId, walletKey }).then((handOff) => {
1240
+ pluginLogger.info("[senpi-runtime] handed-over runtime settled at service start", { runtimeId, outcome: handOff.outcome });
1241
+ });
1242
+ }
1159
1243
  // Ticks once now, with every booted runtime in the store, then every 30 s.
1160
1244
  strategiesPublisher.start();
1161
1245
  // Sends the events added since the previous send, every 1 s.
@@ -1177,6 +1261,12 @@ const senpiRuntimePlugin = {
1177
1261
  }
1178
1262
  },
1179
1263
  async stop() {
1264
+ // First: an install or adoption still running in this registration sees the stop from here
1265
+ // on and hands its runtime to the next registration instead of keeping it in this one.
1266
+ serviceState = "stopped";
1267
+ serviceStops += 1;
1268
+ if (processState.runtimeHost === runtimeHost)
1269
+ processState.runtimeHost = null;
1180
1270
  // Stop the reaper first so no pass starts deleting while the runtimes are torn down below,
1181
1271
  // and wait (bounded) for a pass already under way, so its delete cannot stop a handle the
1182
1272
  // teardown below is stopping too.
@@ -1228,7 +1318,310 @@ const senpiRuntimePlugin = {
1228
1318
  * `senpi deploy` installs through the SAME gates (wallet ownership, one-runtime-per-wallet,
1229
1319
  * scanner-path resolution) instead of a parallel path that could drift.
1230
1320
  */
1321
+ /**
1322
+ * Start an already-registered runtime in THIS registration while its service runs: `run()`,
1323
+ * the handle in `runtimeHandles`, then the hot-install scanner wire (the API is already
1324
+ * listening, so the launcher starts at once). Shared by `installRuntimeCore` and by
1325
+ * {@link runtimeHost}'s adoption of a runtime another registration handed over.
1326
+ *
1327
+ * `orphaned` says whether this registration's service stopped since the caller began (a 10.x
1328
+ * plugin reload). It is read after `run()` returns and again after the wire: a stopped
1329
+ * registration's `stop()` has already torn its runtimes down, so a runtime started here now
1330
+ * would keep ticking in a registration nothing can stop or delete. In that case this stops what
1331
+ * it started (the launcher, then the handle), removes them from this registration's maps, and
1332
+ * returns `orphaned`; the caller hands the runtime to the live registration.
1333
+ *
1334
+ * Throws what `run()` throws. Wire failures do not throw: they are reported as `unwiredPhase`,
1335
+ * exactly as the install has always reported them.
1336
+ */
1337
+ async function startRuntimeLive(spec, orphaned,
1338
+ /** Called once the runtime runs in this registration, before the scanner wire. */
1339
+ onRunning) {
1340
+ const { runtimeId, pathOrContent, fromPath, recipeDir, wallet, parsedConfig } = spec;
1341
+ /**
1342
+ * The unwired verdict for THIS start, tracked locally rather than read back out of
1343
+ * `unwiredRuntimes`, so the result reports what this call did and never a leftover verdict
1344
+ * from an earlier runtime that happened to share the id. Set at each of the three sites that
1345
+ * leave the runtime without live entry scanners; cleared by the wire-success path.
1346
+ */
1347
+ let unwiredPhase;
1348
+ let hotLauncher = null;
1349
+ const scannerProviders = await createScannerProvidersForRun({
1350
+ mcpTransport,
1351
+ apiKey,
1352
+ });
1353
+ const entryStateDir = buildRuntimeStateDir(baseStateDir, runtimeId, wallet);
1354
+ const handle = await run(pathOrContent, {
1355
+ fromPath,
1356
+ stateDir: entryStateDir,
1357
+ mcpTransport,
1358
+ apiKey,
1359
+ scannerProviders,
1360
+ runtimeId,
1361
+ onStrategyNotActive: (address) => reaper.checkWallet(address),
1362
+ });
1363
+ /** Stops what this call started and leaves this registration's maps as they were. */
1364
+ const abandon = async () => {
1365
+ if (hotLauncher) {
1366
+ await hotLauncher.stop().catch(() => { });
1367
+ if (launchers.get(runtimeId) === hotLauncher)
1368
+ launchers.delete(runtimeId);
1369
+ }
1370
+ await handle.stop().catch((err) => {
1371
+ pluginLogger.warn("[senpi-runtime] could not stop a runtime started by a stopped registration", {
1372
+ runtimeId,
1373
+ err: err instanceof Error ? err.message : String(err),
1374
+ });
1375
+ });
1376
+ if (runtimeHandles.get(runtimeId) === handle) {
1377
+ runtimeHandles.delete(runtimeId);
1378
+ unwiredRuntimes.delete(runtimeId);
1379
+ }
1380
+ return { status: "orphaned" };
1381
+ };
1382
+ if (orphaned())
1383
+ return abandon();
1384
+ runtimeHandles.set(runtimeId, handle);
1385
+ onRunning?.();
1386
+ // ── hot-install supervised scanner wire ───────────────────────────────
1387
+ // Live-install path: used when a runtime is registered after boot via
1388
+ // senpi.installRuntime, while the API is already listening — the
1389
+ // counterpart to the boot-time `launcher` above. A runtime takes exactly
1390
+ // one path: boot-present → `launcher` (deferred start); installed live →
1391
+ // `hotLauncher` (immediate start). The two are mutually exclusive because
1392
+ // installRuntime rejects any id/wallet already in the registry before
1393
+ // wiring (the hasId guard), so a boot-present runtime never re-enters here;
1394
+ // an adoption starts only a row the live registration does not run.
1395
+ // The API is already listening at this point, so we wire and start
1396
+ // immediately (no deferred start needed). Any failure is swallowed so
1397
+ // the install itself is not rolled back — the runtime is already up.
1398
+ // Gate on the same API-disabled flag as the boot path: when the HTTP API
1399
+ // is off (a test-only seam), the /signals route is never mounted, so
1400
+ // starting scanners would only spawn children that POST to a closed port.
1401
+ if (parsedConfig &&
1402
+ sharedIntakeContext &&
1403
+ process.env.SENPI_RUNTIME_API_DISABLED !== "true") {
1404
+ try {
1405
+ const apiConfig = loadApiConfig(api.pluginConfig);
1406
+ const scaffoldPackageDir = fileURLToPath(new URL("./scanners/external", import.meta.url));
1407
+ hotLauncher = wireRuntime({
1408
+ runtimeConfig: parsedConfig,
1409
+ recipeDir,
1410
+ runtimeId,
1411
+ wallet,
1412
+ sharedCtx: sharedIntakeContext,
1413
+ runtimeHandles,
1414
+ intakeUrl: intakeUrlFor(apiConfig.host, apiConfig.port),
1415
+ scaffoldPackageDir,
1416
+ runtimeStateDir: entryStateDir,
1417
+ logger: apiLogger,
1418
+ });
1419
+ if (hotLauncher) {
1420
+ const launcher = hotLauncher;
1421
+ try {
1422
+ await launcher.start();
1423
+ launchers.set(runtimeId, launcher);
1424
+ unwiredRuntimes.delete(runtimeId);
1425
+ unwiredPhase = undefined;
1426
+ runtimeHandles.get(runtimeId)?.attachSupervisionSource(() => ({
1427
+ unwired: false,
1428
+ rows: launcher.describeSupervision(),
1429
+ }));
1430
+ }
1431
+ catch (startErr) {
1432
+ await launcher.stop().catch(() => { });
1433
+ hotLauncher = null;
1434
+ unwiredRuntimes.set(runtimeId, "install_launch");
1435
+ unwiredPhase = "install_launch";
1436
+ runtimeHandles.get(runtimeId)?.attachSupervisionSource(() => ({
1437
+ unwired: true,
1438
+ unwiredPhase: "install_launch",
1439
+ rows: [],
1440
+ }));
1441
+ eventScannersUnwired("install_launch", runtimeId, wallet, appendCause("[senpi-runtime] hot-install supervised scanner launch failed — runtime running without it", startErr), startErr);
1442
+ }
1443
+ }
1444
+ }
1445
+ catch (wireErr) {
1446
+ // Wire failure must NOT fail the install — the runtime is already up. It IS reported
1447
+ // across the result boundary (payload.unwired) so a funding caller can refuse it.
1448
+ unwiredRuntimes.set(runtimeId, "install_wire");
1449
+ unwiredPhase = "install_wire";
1450
+ runtimeHandles.get(runtimeId)?.attachSupervisionSource(() => ({
1451
+ unwired: true,
1452
+ unwiredPhase: "install_wire",
1453
+ rows: [],
1454
+ }));
1455
+ eventScannersUnwired("install_wire", runtimeId, wallet, appendCause("[senpi-runtime] hot-install wiring failed — runtime running without supervised scanners", wireErr), wireErr);
1456
+ }
1457
+ }
1458
+ else if (parsedConfig && !sharedIntakeContext && selectScanners(parsedConfig).length > 0) {
1459
+ // Supervised intake is DISABLED for the whole box (corrupt acceptance record).
1460
+ // The wire block above is skipped, so this hot-installed runtime declares
1461
+ // external scanners but has NO live entry scanners. Record that fail-closed
1462
+ // state (state only, no new event) so listRuntimes/health don't lie.
1463
+ unwiredRuntimes.set(runtimeId, "no_intake");
1464
+ unwiredPhase = "no_intake";
1465
+ runtimeHandles.get(runtimeId)?.attachSupervisionSource(() => ({
1466
+ unwired: true,
1467
+ unwiredPhase: "no_intake",
1468
+ rows: [],
1469
+ }));
1470
+ }
1471
+ // ──────────────────────────────────────────────────────────────────────
1472
+ // The stop may have landed during the wire: its teardown then missed the launcher above.
1473
+ if (orphaned())
1474
+ return abandon();
1475
+ return unwiredPhase ? { status: "started", unwiredPhase } : { status: "started" };
1476
+ }
1477
+ /**
1478
+ * Give a runtime an orphaned install (or adoption) started to the live registration
1479
+ * ({@link RuntimeProcessState.runtimeHost}), or queue it for the next service start when no
1480
+ * registration is live. Never throws.
1481
+ */
1482
+ async function handOffRuntime(runtimeId, walletKey) {
1483
+ const host = processState.runtimeHost;
1484
+ if (!host) {
1485
+ // The strategies store is left as it is: a registration that is starting may already run this
1486
+ // row from its boot, and the next start's boot or adoption sets the entry either way.
1487
+ processState.pendingRuntimeHandOffs.set(runtimeId, walletKey);
1488
+ pluginLogger.info("[senpi-runtime] runtime handed to the next service start (no live registration)", { runtimeId });
1489
+ return { outcome: "pending" };
1490
+ }
1491
+ try {
1492
+ return await host.adoptRuntime({ runtimeId, walletKey });
1493
+ }
1494
+ catch (err) {
1495
+ // Nothing runs it: drop the entry the stopped start wrote, so the apps do not draw it.
1496
+ strategiesStore.delete(runtimeId);
1497
+ return { outcome: "failed", message: err instanceof Error ? err.message : String(err) };
1498
+ }
1499
+ }
1500
+ /**
1501
+ * The live registration, when this registration's service has stopped (a 10.x plugin reload)
1502
+ * and another one is running. A deploy job from before the reload keeps this registration's
1503
+ * closures; its deletes and liveness reads go to the registration that holds the runtimes.
1504
+ */
1505
+ function liveHostIfStopped() {
1506
+ if (serviceState !== "stopped")
1507
+ return undefined;
1508
+ const host = processState.runtimeHost;
1509
+ return host && host !== runtimeHost ? host : undefined;
1510
+ }
1511
+ /**
1512
+ * The install result for a runtime this install handed over. A stopped runtime is never
1513
+ * reported as running here: `handedOff` names who runs it, and the result is an error when
1514
+ * nothing does.
1515
+ */
1516
+ function installHandOffResult(handOff, runtime) {
1517
+ switch (handOff.outcome) {
1518
+ case "running":
1519
+ case "booted":
1520
+ return {
1521
+ ok: true,
1522
+ payload: {
1523
+ ...runtime,
1524
+ id: handOff.runtimeId,
1525
+ ...(handOff.unwired ? { unwired: handOff.unwired } : {}),
1526
+ handedOff: "The runtime plugin's service stopped during this install (a plugin reload or a gateway " +
1527
+ "stop), so the plugin registration that is running now runs this runtime " +
1528
+ `(runtime "${handOff.runtimeId}"). Check it: openclaw senpi runtime list.`,
1529
+ },
1530
+ };
1531
+ case "pending":
1532
+ return {
1533
+ ok: true,
1534
+ payload: {
1535
+ ...runtime,
1536
+ handedOff: "The runtime plugin's service stopped during this install (a plugin reload or a gateway " +
1537
+ "stop), and no plugin registration had finished starting when it ended. The runtime is " +
1538
+ "installed; the plugin's service runs it when its start finishes. Check it: " +
1539
+ "openclaw senpi runtime list.",
1540
+ },
1541
+ };
1542
+ case "no-row":
1543
+ return { ok: false, error: {
1544
+ code: "UNAVAILABLE",
1545
+ message: `The runtime plugin's service stopped during this install and runtime "${runtime.id}" is no ` +
1546
+ "longer in the registry, so nothing runs it. Nothing is trading on this wallet from this " +
1547
+ "install. Check: openclaw senpi runtime list.",
1548
+ } };
1549
+ case "failed":
1550
+ return { ok: false, error: {
1551
+ code: "UNAVAILABLE",
1552
+ message: `The runtime plugin's service stopped during this install and the running plugin ` +
1553
+ `registration could not start runtime "${runtime.id}": ${handOff.message}. It is installed; ` +
1554
+ "the next restart tries to start it. Check: openclaw senpi runtime list.",
1555
+ } };
1556
+ }
1557
+ }
1558
+ /**
1559
+ * This registration as the process's {@link RuntimeHost}: published at the end of the service
1560
+ * start, withdrawn at the start of its stop.
1561
+ */
1562
+ const runtimeHost = {
1563
+ async adoptRuntime(request) {
1564
+ const handOff = await adoptRuntimeCore(request);
1565
+ // Nothing runs it here: drop the entry the stopped registration's start wrote (run() sets it,
1566
+ // and that start's abandon does not clear it), so the apps do not draw a strategy that is not
1567
+ // running. A different id on the wallet leaves this id's entry just as stale.
1568
+ if (handOff.outcome === "no-row" ||
1569
+ handOff.outcome === "failed" ||
1570
+ (handOff.outcome === "running" && handOff.runtimeId !== request.runtimeId)) {
1571
+ strategiesStore.delete(request.runtimeId);
1572
+ }
1573
+ return handOff;
1574
+ },
1575
+ deleteRuntime: (params) => deleteRuntimeCore(params),
1576
+ runtimeIsLive: async (id) => runtimeHandles.has(id) && !unwiredRuntimes.has(id),
1577
+ getScannerRows: (id) => readScannerRows(id),
1578
+ };
1579
+ async function adoptRuntimeCore({ runtimeId, walletKey }) {
1580
+ const stopsAtAdopt = serviceStops;
1581
+ const orphaned = () => serviceState !== "running" || serviceStops !== stopsAtAdopt;
1582
+ try {
1583
+ const onWallet = runtimeHandles.has(runtimeId)
1584
+ ? runtimeId
1585
+ : walletKey
1586
+ ? [...runtimeHandles].find(([, h]) => (h.wallet ?? "").trim().toLowerCase() === walletKey)?.[0]
1587
+ : undefined;
1588
+ if (onWallet !== undefined) {
1589
+ // The live verdict, as for a start: a runtime running blind is not a clean install.
1590
+ const unwired = unwiredRuntimes.get(onWallet);
1591
+ return unwired ? { outcome: "running", runtimeId: onWallet, unwired } : { outcome: "running", runtimeId: onWallet };
1592
+ }
1593
+ const { readRegistry } = await import("./runtime/strategy-registry.js");
1594
+ const entry = (await readRegistry(baseStateDir)).runtimes.find((e) => e.id === runtimeId);
1595
+ if (!entry)
1596
+ return { outcome: "no-row" };
1597
+ const inputs = factsPlanInputs(entry);
1598
+ if (!inputs)
1599
+ return { outcome: "failed", message: "its registry row has no readable recipe" };
1600
+ const parsedConfig = await loadRuntimeConfig(inputs.pathOrContent, { fromPath: inputs.fromPath });
1601
+ const started = await startRuntimeLive({
1602
+ runtimeId: entry.id,
1603
+ pathOrContent: inputs.pathOrContent,
1604
+ fromPath: inputs.fromPath,
1605
+ recipeDir: inputs.runtimeYamlDir,
1606
+ wallet: entry.wallet ?? "",
1607
+ parsedConfig,
1608
+ }, orphaned);
1609
+ if (started.status === "orphaned")
1610
+ return handOffRuntime(runtimeId, walletKey);
1611
+ pluginLogger.info("[senpi-runtime] started a runtime handed over by a stopped registration", {
1612
+ runtimeId: entry.id,
1613
+ });
1614
+ return started.unwiredPhase
1615
+ ? { outcome: "booted", runtimeId: entry.id, unwired: started.unwiredPhase }
1616
+ : { outcome: "booted", runtimeId: entry.id };
1617
+ }
1618
+ catch (err) {
1619
+ return { outcome: "failed", message: err instanceof Error ? err.message : String(err) };
1620
+ }
1621
+ }
1231
1622
  async function installRuntimeCore(params) {
1623
+ // How many times this registration's service had stopped when this install began; see `orphaned` below.
1624
+ const stopsAtInstall = serviceStops;
1232
1625
  const toError = (message) => ({ code: "INVALID_REQUEST", message });
1233
1626
  const toUnavailable = (message) => ({ code: "UNAVAILABLE", message });
1234
1627
  const { readRegistry, writeRegistry, withRegistryLock, validateEntry, hasId, findByWallet, deriveRuntimeId, sanitizeId } = await import("./runtime/strategy-registry.js");
@@ -1496,127 +1889,64 @@ const senpiRuntimePlugin = {
1496
1889
  const current = await readRegistry(baseStateDir);
1497
1890
  await writeRegistry(baseStateDir, { ...current, runtimes: [...current.runtimes, entry] });
1498
1891
  });
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;
1892
+ // The registration's service stopped since this install began (a 10.x plugin reload): the
1893
+ // runtime belongs to the live registration, so this install hands it over instead of keeping
1894
+ // one in a stopped registration that nothing can stop or delete.
1895
+ const orphaned = () => serviceState === "stopped" || serviceStops !== stopsAtInstall;
1896
+ // Registry create fact — distinct from the runtime.started boot transition (which also fires
1897
+ // on a transient restart). Once per install, as soon as a registration runs the row: right
1898
+ // after run() here, or at the hand-off. Inside emitEvent's guard so telemetry never fails the
1899
+ // install.
1900
+ let createdEmitted = false;
1901
+ const emitCreated = () => {
1902
+ if (createdEmitted)
1903
+ return;
1904
+ createdEmitted = true;
1905
+ emitEvent(apiLogger, () => buildRuntimeCreatedEvent({ runtimeId: resolvedId, address: wallet, recipeName: runtimeName }));
1906
+ };
1907
+ const handOffInstall = async () => {
1908
+ const handOff = await handOffRuntime(resolvedId, walletKey);
1909
+ if (handOff.outcome === "running" || handOff.outcome === "booted" || handOff.outcome === "pending")
1910
+ emitCreated();
1911
+ return installHandOffResult(handOff, { id: resolvedId, name: runtimeName, wallet });
1912
+ };
1506
1913
  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,
1914
+ // Stopped before the start: do not start a runtime here at all.
1915
+ if (orphaned())
1916
+ return await handOffInstall();
1917
+ const started = await startRuntimeLive({
1518
1918
  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
- // ──────────────────────────────────────────────────────────────────────
1919
+ pathOrContent,
1920
+ fromPath: hasPath,
1921
+ recipeDir: hasPath ? dirname(pathOrContent) : runtimeYamlDir,
1922
+ wallet,
1923
+ parsedConfig,
1924
+ }, orphaned, emitCreated);
1925
+ if (started.status === "orphaned")
1926
+ return await handOffInstall();
1609
1927
  return {
1610
1928
  ok: true,
1611
1929
  payload: {
1612
1930
  id: resolvedId,
1613
1931
  name: runtimeName,
1614
1932
  wallet,
1615
- ...(unwiredPhase ? { unwired: unwiredPhase } : {}),
1933
+ ...(started.unwiredPhase ? { unwired: started.unwiredPhase } : {}),
1616
1934
  },
1617
1935
  };
1618
1936
  }
1619
1937
  catch (err) {
1938
+ // run() threw after this registration's service stopped: the live registration may already
1939
+ // run this row (its boot read it), so it decides, not this catch. It runs it, or starts it,
1940
+ // or (no registration live yet) the next start does; only when it cannot start the row
1941
+ // either is the row removed, as below for an install with no reload.
1942
+ if (orphaned()) {
1943
+ const handOff = await handOffRuntime(resolvedId, walletKey);
1944
+ if (handOff.outcome !== "failed") {
1945
+ if (handOff.outcome !== "no-row")
1946
+ emitCreated();
1947
+ return installHandOffResult(handOff, { id: resolvedId, name: runtimeName, wallet });
1948
+ }
1949
+ }
1620
1950
  await withRegistryLock(baseStateDir, async () => {
1621
1951
  const current = await readRegistry(baseStateDir);
1622
1952
  await writeRegistry(baseStateDir, { ...current, runtimes: current.runtimes.filter((e) => e.id !== resolvedId) });
@@ -1681,6 +2011,12 @@ const senpiRuntimePlugin = {
1681
2011
  * unwired-verdict cleanup all happen exactly once, here.
1682
2012
  */
1683
2013
  async function deleteRuntimeCore(params) {
2014
+ // This registration's service stopped (a 10.x reload) and holds no runtimes; the live one does.
2015
+ // A delete from here (a deploy job from before the reload) would remove the row and archive the
2016
+ // state dir under a runtime that keeps trading.
2017
+ const live = liveHostIfStopped();
2018
+ if (live)
2019
+ return live.deleteRuntime(params);
1684
2020
  let targetId = typeof params?.id === "string" ? params.id : undefined;
1685
2021
  const targetAddress = typeof params?.address === "string" ? params.address : undefined;
1686
2022
  // Set only by the lifecycle reaper: why the strategy counts as gone (stamped on runtime.deleted)
@@ -1918,7 +2254,7 @@ const senpiRuntimePlugin = {
1918
2254
  * the strategy id is read for this one wallet; a failed read logs its reason and sends nothing.
1919
2255
  */
1920
2256
  function queueFactsSend(runtimeId, strategyIds) {
1921
- return factsSendLock.withKeyLock(runtimeId, async () => {
2257
+ return factsSendLock().withKeyLock(runtimeId, async () => {
1922
2258
  const { readRegistry } = await import("./runtime/strategy-registry.js");
1923
2259
  const entry = (await readRegistry(baseStateDir)).runtimes.find((e) => e.id === runtimeId);
1924
2260
  const inputs = entry ? factsPlanInputs(entry) : null;
@@ -2177,18 +2513,20 @@ const senpiRuntimePlugin = {
2177
2513
  },
2178
2514
  registryFindByWallet: findRegistryEntryByWallet,
2179
2515
  registryFindById: findRegistryEntryById,
2180
- getScannerRows: readScannerRows,
2516
+ // A deploy job outlives a reload; it reads the runtime where it runs (see `liveHostIfStopped`).
2517
+ getScannerRows: (id) => liveHostIfStopped()?.getScannerRows(id) ?? readScannerRows(id),
2181
2518
  // H4: `runtimeHandles` holds the live `run()` handles. A registry row without one is a
2182
2519
  // previous install whose runtime died before start — deploy must reinstall, not report it
2183
2520
  // adopted. `unwiredRuntimes` is the same judgement one step further in: a runtime that is up
2184
2521
  // but whose entry scanners never wired is running BLIND (the health surface already renders
2185
2522
  // it "running — NO ENTRY SCANNERS"), and reporting `install: skipped` over it would be the
2186
2523
  // same clean line over a runtime that cannot tick.
2187
- runtimeIsLive: async (id) => runtimeHandles.has(id) && !unwiredRuntimes.has(id),
2524
+ runtimeIsLive: async (id) => liveHostIfStopped()?.runtimeIsLive(id) ?? (runtimeHandles.has(id) && !unwiredRuntimes.has(id)),
2188
2525
  installPendingOnWallet: async (wallet) => installsInFlight.has(wallet.trim().toLowerCase()),
2526
+ // The process logger, not this registration's: a deploy job outlives a reload.
2189
2527
  logger: {
2190
- info: (line) => api.logger.info(`[senpi-runtime] ${line}`),
2191
- warn: (line) => api.logger.warn(`[senpi-runtime] ${line}`),
2528
+ info: (line) => processLog.info(`[senpi-runtime] ${line}`),
2529
+ warn: (line) => processLog.warn(`[senpi-runtime] ${line}`),
2192
2530
  },
2193
2531
  });
2194
2532
  /**
@@ -2392,7 +2730,7 @@ const senpiRuntimePlugin = {
2392
2730
  respond(true, {
2393
2731
  status: status ? { ...status, runtimeId } : null,
2394
2732
  updater,
2395
- approvals: approvalsHostStatus,
2733
+ approvals: approvalsStatus(),
2396
2734
  deploy: { capabilities: DEPLOY_CAPABILITIES },
2397
2735
  });
2398
2736
  }
@@ -2406,7 +2744,7 @@ const senpiRuntimePlugin = {
2406
2744
  return;
2407
2745
  }
2408
2746
  const statuses = await collectRuntimeHealthStatuses(runtimeHandles, api.logger);
2409
- respond(true, { statuses, updater, approvals: approvalsHostStatus, deploy: { capabilities: DEPLOY_CAPABILITIES } });
2747
+ respond(true, { statuses, updater, approvals: approvalsStatus(), deploy: { capabilities: DEPLOY_CAPABILITIES } });
2410
2748
  }, { scope: "operator.read" });
2411
2749
  api.registerGatewayMethod("senpi.getSystemState", async (ctx) => {
2412
2750
  const respond = ctx.respond;