@senpi/runtime 3.0.185 → 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.
- package/dist/actions/llm-decision/openclaw-llm-decision.d.ts +1 -1
- package/dist/actions/llm-decision/openclaw-llm-decision.js +1 -1
- package/dist/approvals/telemetry.d.ts +3 -2
- package/dist/approvals/telemetry.d.ts.map +1 -1
- package/dist/approvals/telemetry.js.map +1 -1
- package/dist/approvals/tool-approval-gate.d.ts +9 -3
- package/dist/approvals/tool-approval-gate.d.ts.map +1 -1
- package/dist/approvals/tool-approval-gate.js +18 -5
- package/dist/approvals/tool-approval-gate.js.map +1 -1
- package/dist/approvals/turn-context.d.ts +43 -18
- package/dist/approvals/turn-context.d.ts.map +1 -1
- package/dist/approvals/turn-context.js +67 -24
- package/dist/approvals/turn-context.js.map +1 -1
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +2 -1
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +8 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/deploy/register.d.ts +1 -0
- package/dist/deploy/register.d.ts.map +1 -1
- package/dist/deploy/register.js +2 -1
- package/dist/deploy/register.js.map +1 -1
- package/dist/dsl/monitor/dsl-failure-events.d.ts +38 -2
- package/dist/dsl/monitor/dsl-failure-events.d.ts.map +1 -1
- package/dist/dsl/monitor/dsl-failure-events.js +67 -4
- package/dist/dsl/monitor/dsl-failure-events.js.map +1 -1
- package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
- package/dist/dsl/monitor/process-one-position.js +25 -1
- package/dist/dsl/monitor/process-one-position.js.map +1 -1
- package/dist/index.d.ts +10 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +484 -146
- package/dist/index.js.map +1 -1
- package/dist/runtime/auto-update/coordinator.d.ts +19 -2
- package/dist/runtime/auto-update/coordinator.d.ts.map +1 -1
- package/dist/runtime/auto-update/coordinator.js +104 -20
- package/dist/runtime/auto-update/coordinator.js.map +1 -1
- package/dist/runtime/auto-update/gateway-restart.d.ts +2 -2
- package/dist/runtime/auto-update/gateway-restart.js +1 -1
- package/dist/runtime/auto-update/idle-gate.d.ts +45 -9
- package/dist/runtime/auto-update/idle-gate.d.ts.map +1 -1
- package/dist/runtime/auto-update/idle-gate.js +26 -14
- package/dist/runtime/auto-update/idle-gate.js.map +1 -1
- package/dist/runtime/auto-update/register-idle-gate.d.ts +1 -1
- package/dist/runtime/auto-update/register-idle-gate.d.ts.map +1 -1
- package/dist/runtime/auto-update/register-idle-gate.js +8 -4
- package/dist/runtime/auto-update/register-idle-gate.js.map +1 -1
- package/dist/runtime/auto-update/run-update-command.d.ts +1 -1
- package/dist/runtime/auto-update/run-update-command.js +1 -1
- package/dist/runtime/auto-update/types.d.ts +2 -1
- package/dist/runtime/auto-update/types.d.ts.map +1 -1
- package/dist/runtime/auto-update/types.js.map +1 -1
- package/dist/runtime/auto-update/update-schedule.d.ts +14 -4
- package/dist/runtime/auto-update/update-schedule.d.ts.map +1 -1
- package/dist/runtime/auto-update/update-schedule.js +21 -5
- package/dist/runtime/auto-update/update-schedule.js.map +1 -1
- package/dist/runtime/process-state.d.ts +168 -0
- package/dist/runtime/process-state.d.ts.map +1 -0
- package/dist/runtime/process-state.js +81 -0
- package/dist/runtime/process-state.js.map +1 -0
- package/dist/runtime/reconcile-state.d.ts.map +1 -1
- package/dist/runtime/reconcile-state.js +18 -4
- package/dist/runtime/reconcile-state.js.map +1 -1
- package/dist/runtime/strategy-registry.d.ts +16 -0
- package/dist/runtime/strategy-registry.d.ts.map +1 -1
- package/dist/runtime/strategy-registry.js +7 -3
- package/dist/runtime/strategy-registry.js.map +1 -1
- package/dist/runtime/update-apply-deps.d.ts +5 -3
- package/dist/runtime/update-apply-deps.d.ts.map +1 -1
- package/dist/runtime/update-apply-deps.js +15 -3
- package/dist/runtime/update-apply-deps.js.map +1 -1
- package/dist/senpi/client.d.ts +5 -1
- package/dist/senpi/client.d.ts.map +1 -1
- package/dist/senpi/client.js +20 -7
- package/dist/senpi/client.js.map +1 -1
- package/dist/telemetry/metrics.d.ts +1 -1
- package/dist/telemetry/metrics.d.ts.map +1 -1
- package/dist/telemetry/metrics.js +1 -1
- package/dist/telemetry/metrics.js.map +1 -1
- package/dist/telemetry/spans/span-hooks.d.ts +1 -1
- package/dist/telemetry/spans/span-hooks.js +1 -1
- package/dist/test-setup.js +7 -1
- package/dist/test-setup.js.map +1 -1
- package/dist/tool-surface/crib-hook.d.ts.map +1 -1
- package/dist/tool-surface/crib-hook.js +8 -0
- package/dist/tool-surface/crib-hook.js.map +1 -1
- package/dist/tool-surface/crib-sheet.d.ts +1 -1
- package/dist/tool-surface/crib-sheet.js +1 -1
- package/dist/tool-surface/mcp-tool-source.d.ts +1 -1
- package/dist/tool-surface/mcp-tool-source.js +2 -2
- package/dist/utils/event-catalog.d.ts +27 -1
- package/dist/utils/event-catalog.d.ts.map +1 -1
- package/dist/utils/event-catalog.js +6 -0
- package/dist/utils/event-catalog.js.map +1 -1
- package/dist/widgets/tool.d.ts +1 -1
- package/openclaw.plugin.json +1 -1
- 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
|
|
186
|
-
*
|
|
187
|
-
*
|
|
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
|
-
|
|
192
|
+
function factsSendLock() {
|
|
193
|
+
const state = runtimeProcessState();
|
|
194
|
+
state.factsSendLock ??= new KeyedMutex();
|
|
195
|
+
return state.factsSendLock;
|
|
196
|
+
}
|
|
190
197
|
/**
|
|
191
|
-
*
|
|
198
|
+
* Clears the process-state latch for the 5.x "cannot classify runs" notice
|
|
199
|
+
* (`legacyTurnClassificationNoticeLogged`). Tests only.
|
|
192
200
|
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
* registration and does not read
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
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
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
-
|
|
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) =>
|
|
2191
|
-
warn: (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:
|
|
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:
|
|
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;
|