@senpi/runtime 3.0.180 → 3.0.182

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 (135) hide show
  1. package/dist/actions/llm-decision/openclaw-llm-decision.d.ts +8 -0
  2. package/dist/actions/llm-decision/openclaw-llm-decision.d.ts.map +1 -1
  3. package/dist/actions/llm-decision/openclaw-llm-decision.js +56 -1
  4. package/dist/actions/llm-decision/openclaw-llm-decision.js.map +1 -1
  5. package/dist/approvals/approval-store.d.ts +37 -7
  6. package/dist/approvals/approval-store.d.ts.map +1 -1
  7. package/dist/approvals/approval-store.js +127 -23
  8. package/dist/approvals/approval-store.js.map +1 -1
  9. package/dist/approvals/telemetry.d.ts +27 -3
  10. package/dist/approvals/telemetry.d.ts.map +1 -1
  11. package/dist/approvals/telemetry.js +8 -4
  12. package/dist/approvals/telemetry.js.map +1 -1
  13. package/dist/approvals/tool-approval-gate.d.ts +79 -0
  14. package/dist/approvals/tool-approval-gate.d.ts.map +1 -1
  15. package/dist/approvals/tool-approval-gate.js +184 -15
  16. package/dist/approvals/tool-approval-gate.js.map +1 -1
  17. package/dist/approvals/turn-context.d.ts +271 -0
  18. package/dist/approvals/turn-context.d.ts.map +1 -0
  19. package/dist/approvals/turn-context.js +551 -0
  20. package/dist/approvals/turn-context.js.map +1 -0
  21. package/dist/cli/guide-commands.d.ts.map +1 -1
  22. package/dist/cli/guide-commands.js +17 -3
  23. package/dist/cli/guide-commands.js.map +1 -1
  24. package/dist/cli/senpi-commands.d.ts.map +1 -1
  25. package/dist/cli/senpi-commands.js +71 -4
  26. package/dist/cli/senpi-commands.js.map +1 -1
  27. package/dist/deploy/job.d.ts +32 -0
  28. package/dist/deploy/job.d.ts.map +1 -1
  29. package/dist/deploy/job.js +72 -7
  30. package/dist/deploy/job.js.map +1 -1
  31. package/dist/deploy/register.d.ts +11 -0
  32. package/dist/deploy/register.d.ts.map +1 -1
  33. package/dist/deploy/register.js +84 -2
  34. package/dist/deploy/register.js.map +1 -1
  35. package/dist/host/openclaw-host.d.ts +138 -0
  36. package/dist/host/openclaw-host.d.ts.map +1 -0
  37. package/dist/host/openclaw-host.js +177 -0
  38. package/dist/host/openclaw-host.js.map +1 -0
  39. package/dist/index.d.ts +46 -2
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.js +220 -12
  42. package/dist/index.js.map +1 -1
  43. package/dist/runtime/auto-update/coordinator.d.ts +203 -0
  44. package/dist/runtime/auto-update/coordinator.d.ts.map +1 -1
  45. package/dist/runtime/auto-update/coordinator.js +621 -12
  46. package/dist/runtime/auto-update/coordinator.js.map +1 -1
  47. package/dist/runtime/auto-update/gateway-restart.d.ts +60 -1
  48. package/dist/runtime/auto-update/gateway-restart.d.ts.map +1 -1
  49. package/dist/runtime/auto-update/gateway-restart.js +95 -21
  50. package/dist/runtime/auto-update/gateway-restart.js.map +1 -1
  51. package/dist/runtime/auto-update/idle-gate.d.ts +139 -0
  52. package/dist/runtime/auto-update/idle-gate.d.ts.map +1 -0
  53. package/dist/runtime/auto-update/idle-gate.js +217 -0
  54. package/dist/runtime/auto-update/idle-gate.js.map +1 -0
  55. package/dist/runtime/auto-update/install-results.d.ts +130 -0
  56. package/dist/runtime/auto-update/install-results.d.ts.map +1 -0
  57. package/dist/runtime/auto-update/install-results.js +264 -0
  58. package/dist/runtime/auto-update/install-results.js.map +1 -0
  59. package/dist/runtime/auto-update/money-call-tracker.d.ts +36 -0
  60. package/dist/runtime/auto-update/money-call-tracker.d.ts.map +1 -0
  61. package/dist/runtime/auto-update/money-call-tracker.js +129 -0
  62. package/dist/runtime/auto-update/money-call-tracker.js.map +1 -0
  63. package/dist/runtime/auto-update/register-idle-gate.d.ts +70 -0
  64. package/dist/runtime/auto-update/register-idle-gate.d.ts.map +1 -0
  65. package/dist/runtime/auto-update/register-idle-gate.js +123 -0
  66. package/dist/runtime/auto-update/register-idle-gate.js.map +1 -0
  67. package/dist/runtime/auto-update/run-update-command.d.ts +13 -0
  68. package/dist/runtime/auto-update/run-update-command.d.ts.map +1 -1
  69. package/dist/runtime/auto-update/run-update-command.js +18 -0
  70. package/dist/runtime/auto-update/run-update-command.js.map +1 -1
  71. package/dist/runtime/auto-update/state-store.d.ts +14 -1
  72. package/dist/runtime/auto-update/state-store.d.ts.map +1 -1
  73. package/dist/runtime/auto-update/state-store.js +37 -3
  74. package/dist/runtime/auto-update/state-store.js.map +1 -1
  75. package/dist/runtime/auto-update/types.d.ts +51 -0
  76. package/dist/runtime/auto-update/types.d.ts.map +1 -1
  77. package/dist/runtime/auto-update/types.js.map +1 -1
  78. package/dist/runtime/auto-update/update-marker.d.ts +95 -0
  79. package/dist/runtime/auto-update/update-marker.d.ts.map +1 -0
  80. package/dist/runtime/auto-update/update-marker.js +185 -0
  81. package/dist/runtime/auto-update/update-marker.js.map +1 -0
  82. package/dist/runtime/auto-update/update-schedule.d.ts +57 -0
  83. package/dist/runtime/auto-update/update-schedule.d.ts.map +1 -0
  84. package/dist/runtime/auto-update/update-schedule.js +75 -0
  85. package/dist/runtime/auto-update/update-schedule.js.map +1 -0
  86. package/dist/runtime/decision-errors.d.ts +7 -0
  87. package/dist/runtime/decision-errors.d.ts.map +1 -1
  88. package/dist/runtime/decision-errors.js +15 -0
  89. package/dist/runtime/decision-errors.js.map +1 -1
  90. package/dist/senpi/client.d.ts +25 -0
  91. package/dist/senpi/client.d.ts.map +1 -1
  92. package/dist/senpi/client.js +56 -0
  93. package/dist/senpi/client.js.map +1 -1
  94. package/dist/telemetry/metrics.d.ts +14 -0
  95. package/dist/telemetry/metrics.d.ts.map +1 -1
  96. package/dist/telemetry/metrics.js +24 -0
  97. package/dist/telemetry/metrics.js.map +1 -1
  98. package/dist/telemetry/otel-sdk.d.ts +12 -0
  99. package/dist/telemetry/otel-sdk.d.ts.map +1 -1
  100. package/dist/telemetry/otel-sdk.js +47 -1
  101. package/dist/telemetry/otel-sdk.js.map +1 -1
  102. package/dist/telemetry/spans/span-hooks.d.ts +18 -1
  103. package/dist/telemetry/spans/span-hooks.d.ts.map +1 -1
  104. package/dist/telemetry/spans/span-hooks.js +306 -96
  105. package/dist/telemetry/spans/span-hooks.js.map +1 -1
  106. package/dist/telemetry/tool-plane.d.ts +2 -0
  107. package/dist/telemetry/tool-plane.d.ts.map +1 -1
  108. package/dist/telemetry/tool-plane.js +1 -0
  109. package/dist/telemetry/tool-plane.js.map +1 -1
  110. package/dist/tool-surface/crib-cache.d.ts +21 -0
  111. package/dist/tool-surface/crib-cache.d.ts.map +1 -0
  112. package/dist/tool-surface/crib-cache.js +54 -0
  113. package/dist/tool-surface/crib-cache.js.map +1 -0
  114. package/dist/tool-surface/crib-hook.d.ts +78 -0
  115. package/dist/tool-surface/crib-hook.d.ts.map +1 -0
  116. package/dist/tool-surface/crib-hook.js +217 -0
  117. package/dist/tool-surface/crib-hook.js.map +1 -0
  118. package/dist/tool-surface/crib-sheet.d.ts +56 -0
  119. package/dist/tool-surface/crib-sheet.d.ts.map +1 -0
  120. package/dist/tool-surface/crib-sheet.js +129 -0
  121. package/dist/tool-surface/crib-sheet.js.map +1 -0
  122. package/dist/tool-surface/mcp-tool-source.d.ts +64 -0
  123. package/dist/tool-surface/mcp-tool-source.d.ts.map +1 -0
  124. package/dist/tool-surface/mcp-tool-source.js +189 -0
  125. package/dist/tool-surface/mcp-tool-source.js.map +1 -0
  126. package/dist/utils/event-catalog.d.ts +117 -5
  127. package/dist/utils/event-catalog.d.ts.map +1 -1
  128. package/dist/utils/event-catalog.js +19 -0
  129. package/dist/utils/event-catalog.js.map +1 -1
  130. package/dist/widgets/tool.d.ts +9 -0
  131. package/dist/widgets/tool.d.ts.map +1 -1
  132. package/dist/widgets/tool.js +1 -0
  133. package/dist/widgets/tool.js.map +1 -1
  134. package/openclaw.plugin.json +11 -1
  135. package/package.json +5 -2
@@ -1,10 +1,13 @@
1
1
  import { runCommand } from "./command-runner.js";
2
- import { evaluateGatewayRestartCapability } from "./gateway-restart.js";
2
+ import { evaluateGatewayRestartCapability, evaluateModernRestartCapability, triggerGatewayRestartViaMutate, } from "./gateway-restart.js";
3
3
  import { classifyUpdate, parseSemver } from "./policy.js";
4
4
  import { evaluatePreflight } from "./preflight.js";
5
5
  import { resolveConfiguredSpec } from "./resolve-package-name.js";
6
6
  import { AutoUpdateStateStore } from "./state-store.js";
7
7
  import { createDefaultAutoUpdateState, } from "./types.js";
8
+ import { consumeInstallResults, describeInstallResult, installResultLevel, } from "./install-results.js";
9
+ import { buildExactUpdateSpec, clearUpdateMarker, readUpdateMarker, writeUpdateMarker } from "./update-marker.js";
10
+ import { eligibleAt as computeEligibleAt, mustForce } from "./update-schedule.js";
8
11
  import { NpmVersionSource } from "./version-source.js";
9
12
  import { EVENT_NAME, eventAttrs } from "../../utils/event-catalog.js";
10
13
  /**
@@ -22,6 +25,50 @@ const CHECK_NOW_REASON = {
22
25
  disabledInConfig: "Auto-update is turned off in config; no check was run.",
23
26
  coordinatorStopped: "The auto-update coordinator is not running; no check was run.",
24
27
  };
28
+ /**
29
+ * How long a requested restart may take to load its target before the
30
+ * awaiting-restart state is treated as failed (the 3.0.171 latch fix). A 10.x
31
+ * restart-to-apply measured about 71 s on Box A, with starts up to 93 s; 30 min
32
+ * leaves room for the gateway's own deferral (forced after 300 s) and a slow
33
+ * offline install.
34
+ */
35
+ export const RESTART_LOAD_TIMEOUT_MS = 30 * 60_000;
36
+ /**
37
+ * Restarts requested for one target that may fail to load before the box
38
+ * stops trying that version and waits for a newer one. Bounds the loop of a
39
+ * version that can never install (a blocked scan, a yanked version, a wrapper
40
+ * without the marker step): without it an idle box would restart about every
41
+ * 80 min forever.
42
+ */
43
+ export const MAX_APPLY_ATTEMPTS = 3;
44
+ /**
45
+ * How often a 10.x box that is `pending-idle` re-reads its apply gate (the
46
+ * spread time and the idle gate) between registry polls. The idle gate has no
47
+ * change notification, so the change is caught by this re-read; when the gate
48
+ * opens, an ordinary check runs at once instead of up to one poll interval
49
+ * (60 min by default) later. The re-read itself is local: no registry call,
50
+ * no `onTickComplete`.
51
+ */
52
+ export const PENDING_IDLE_RECHECK_MS = 3 * 60_000;
53
+ /**
54
+ * The only idle-gate blockers the 24 h forced restart may override: a busy
55
+ * user (`user-run`, `recent-message`). Every other blocker holds it, money
56
+ * first: an order call in flight (the restart would cut it), an open trade
57
+ * approval (it would be cancelled), a running `senpi deploy` (it would leave a
58
+ * funded wallet with no runtime), a queued user message, and a gate that
59
+ * threw (it cannot rule any of those out).
60
+ */
61
+ const FORCE_MAY_OVERRIDE = new Set(["user-run", "recent-message"]);
62
+ /** Whether every blocker in `blockers` is one the forced restart may override. */
63
+ function onlyOverridable(blockers) {
64
+ return blockers.every((b) => FORCE_MAY_OVERRIDE.has(b));
65
+ }
66
+ /** Whether `loaded` is at or past `target` (major.minor.patch; both must parse). */
67
+ function isAtLeast(loaded, target) {
68
+ if (!parseSemver(loaded) || !parseSemver(target))
69
+ return false;
70
+ return classifyUpdate(loaded, target) === "none";
71
+ }
25
72
  /** Cap a registry-supplied string before it reaches a log line or an event body. */
26
73
  function summarizeForLog(value, maxLength = 120) {
27
74
  const collapsed = value.replace(/\s+/g, " ").trim();
@@ -49,6 +96,8 @@ export class AutoUpdateCoordinator {
49
96
  versionSource;
50
97
  runUpdateCommand;
51
98
  timer = null;
99
+ /** 10.x `pending-idle` gate re-read; see {@link PENDING_IDLE_RECHECK_MS}. */
100
+ idleRecheckTimer = null;
52
101
  running = false;
53
102
  stopped = true;
54
103
  runToken = 0;
@@ -58,8 +107,24 @@ export class AutoUpdateCoordinator {
58
107
  /** Spec the last resolve used. Seeded in the constructor, so the snapshot
59
108
  * reports a line before the first tick. */
60
109
  updateSpec;
110
+ /** Why the modern path is still waiting; reset every tick. See {@link AutoUpdateSnapshot.pendingBlockers}. */
111
+ pendingBlockers = [];
112
+ now;
113
+ rand;
114
+ loadedVersion;
115
+ /**
116
+ * 10.x: `<version>|<sorted blockers>` of the last `auto_update.deferred`, so the event fires on
117
+ * entering `pending-idle` and on a change only. Cleared when a tick ends outside `pending-idle` or fails.
118
+ * In memory: a new gateway process reports a wait that is still on once.
119
+ */
120
+ lastDeferredKey = null;
121
+ /** The install-results report the last {@link start} began; see {@link whenInstallResultsReported}. */
122
+ installResultsReport = Promise.resolve();
61
123
  constructor(options) {
62
124
  this.options = options;
125
+ this.now = options.now ?? Date.now;
126
+ this.rand = options.rand ?? Math.random;
127
+ this.loadedVersion = options.loadedVersion ?? options.currentVersion;
63
128
  this.store = new AutoUpdateStateStore(options.stateDir, options.logger);
64
129
  this.versionSource = options.versionSource ?? new NpmVersionSource();
65
130
  this.runUpdateCommand = options.runUpdateCommand ?? defaultRunUpdateCommand;
@@ -79,6 +144,8 @@ export class AutoUpdateCoordinator {
79
144
  this.stopped = true;
80
145
  this.snapshot = this.buildSnapshot("disabled", "auto-update-disabled");
81
146
  logger.info(`[senpi-auto-update] disabled by config (pluginEnabled=${String(this.options.enabled)}, configEnabled=${String(config.enabled)})`);
147
+ // Operator markers and the boot floor still install on a box with auto-update off.
148
+ this.startInstallResultsReport(null);
82
149
  return;
83
150
  }
84
151
  logger.info(`[senpi-auto-update] starting | localVersion=${this.options.currentVersion} package=${this.options.packageName} spec=${this.resolveUpdateSpec()} mode=${config.mode} pollInterval=${config.pollIntervalMinutes}m startupDelay=${config.startupDelaySeconds}s failureBackoff=${config.failureBackoffMinutes}m maxBackoff=${config.maxBackoffMinutes}m`);
@@ -90,17 +157,188 @@ export class AutoUpdateCoordinator {
90
157
  logger.info(`[senpi-auto-update] preflight result | ok=${String(preflight.ok)} effectiveMode=${preflight.effectiveMode} reasons=${preflight.reasons.join(",") || "none"}`);
91
158
  this.state = await this.store.load(this.options.currentVersion, preflight.effectiveMode);
92
159
  this.state.effectiveMode = preflight.effectiveMode;
160
+ const reconciled = await this.reconcileLoadedVersion(this.state);
93
161
  try {
94
162
  await this.store.save(this.state);
95
163
  }
96
164
  catch (error) {
97
165
  this.options.logger.warn("[senpi-auto-update] failed to persist initial state", error);
98
166
  }
99
- this.snapshot = this.buildSnapshot("healthy", "started");
167
+ this.startInstallResultsReport(this.state);
168
+ this.snapshot = this.buildSnapshot("healthy", reconciled ?? "started");
100
169
  const delayMs = config.startupDelaySeconds * 1000;
101
170
  logger.info(`[senpi-auto-update] first check scheduled | delayMs=${delayMs} nextCheckAt=${new Date(Date.now() + delayMs).toISOString()}`);
102
171
  this.scheduleNext(delayMs, this.runToken);
103
172
  }
173
+ /**
174
+ * Startup half of the latch fix: compare what this process loaded with what
175
+ * a previous process asked for.
176
+ *
177
+ * When the loaded version reaches the requested restart's target, the update
178
+ * took effect: the pending restart and pending update are cleared, a stale
179
+ * install marker is removed, and on a 10.x host `auto_update.applied` fires
180
+ * (nothing was installed before this boot there). On 5.x `applied` already
181
+ * fired when the restart was requested, so it is not counted twice.
182
+ *
183
+ * A target the loaded version has not reached is left for the tick, which
184
+ * expires it once the request is older than {@link RESTART_LOAD_TIMEOUT_MS}.
185
+ *
186
+ * @returns `updated:<v>` when a requested update is now loaded, else `undefined`.
187
+ */
188
+ async reconcileLoadedVersion(state) {
189
+ const loaded = this.loadedVersion;
190
+ let action;
191
+ const pending = state.pendingRestart;
192
+ if (pending && isAtLeast(loaded, pending.toVersion)) {
193
+ action = `updated:${pending.toVersion}`;
194
+ this.options.logger.info(`[senpi-auto-update] update to v${pending.toVersion} is loaded (requested ${pending.requestedAt}, mode ${pending.mode})`);
195
+ // A 5.x pending restart already reported `applied` when it was requested.
196
+ if (this.options.modernHost && pending.mode === "restart-to-apply") {
197
+ // How long the apply took: request to this boot (stop + wrapper install + start), and
198
+ // first seen to this boot (spread + idle wait + apply). Read before pendingUpdate is cleared.
199
+ const now = this.now();
200
+ const sinceMs = (iso) => {
201
+ const ms = iso ? Date.parse(iso) : Number.NaN;
202
+ return Number.isFinite(ms) ? Math.max(0, now - ms) : undefined;
203
+ };
204
+ const firstSeenAt = state.pendingUpdate?.firstSeenAt;
205
+ const pendingMs = sinceMs(firstSeenAt);
206
+ this.emitEvent(EVENT_NAME.autoUpdateApplied, "info", {
207
+ body: `Update applied: ${pending.fromVersion} -> v${pending.toVersion} (${pending.mode})`,
208
+ attributes: eventAttrs({
209
+ "senpi.auto_update.from_version": pending.fromVersion,
210
+ "senpi.auto_update.to_version": pending.toVersion,
211
+ "senpi.auto_update.restart_mode": pending.mode,
212
+ "senpi.auto_update.requested_at": pending.requestedAt,
213
+ "senpi.auto_update.first_seen_at": pendingMs === undefined ? undefined : firstSeenAt,
214
+ "senpi.auto_update.apply_ms": sinceMs(pending.requestedAt),
215
+ "senpi.auto_update.pending_ms": pendingMs,
216
+ }),
217
+ });
218
+ }
219
+ state.pendingRestart = undefined;
220
+ }
221
+ if (state.pendingUpdate && isAtLeast(loaded, state.pendingUpdate.version)) {
222
+ state.pendingUpdate = undefined;
223
+ }
224
+ if (state.applyFailures && isAtLeast(loaded, state.applyFailures.version)) {
225
+ state.applyFailures = undefined;
226
+ }
227
+ if (state.lastAppliedVersion && isAtLeast(loaded, state.lastAppliedVersion)) {
228
+ state.lastLoggedAwaitingRestartVersion = undefined;
229
+ }
230
+ const markerPath = this.options.updateMarkerPath;
231
+ if (markerPath) {
232
+ const marker = await readUpdateMarker(markerPath);
233
+ if (marker && isAtLeast(loaded, marker.targetVersion)) {
234
+ await this.clearMarkerQuietly();
235
+ }
236
+ }
237
+ return action;
238
+ }
239
+ /**
240
+ * Resolves when the install-results report begun by the last {@link start} has finished.
241
+ * `start` does not wait for it: the report waits for the OTel SDK first.
242
+ */
243
+ whenInstallResultsReported() {
244
+ return this.installResultsReport;
245
+ }
246
+ /**
247
+ * 10.x: start reporting the wrapper's install outcomes in the background. `liveState` is the
248
+ * running coordinator's state (the ids are saved with it); `null` when auto-update is
249
+ * disabled, which loads only the ids and patches only them.
250
+ */
251
+ startInstallResultsReport(liveState) {
252
+ if (!this.options.modernHost || !this.options.installResultsPath)
253
+ return;
254
+ this.installResultsReport = this.reportInstallResults(this.options.installResultsPath, liveState);
255
+ }
256
+ /**
257
+ * One `auto_update.install_result` per entry not reported before (`install-results.ts`), then
258
+ * the file is deleted. Runs from {@link start}, which only the gateway's plugin service calls,
259
+ * so a CLI process never consumes the file. Never throws: telemetry must not break the
260
+ * coordinator.
261
+ */
262
+ async reportInstallResults(path, liveState) {
263
+ try {
264
+ const reportedIds = liveState
265
+ ? liveState.reportedInstallResultIds
266
+ : (await this.store.load(this.options.currentVersion, this.options.config.mode)).reportedInstallResultIds;
267
+ const result = await consumeInstallResults({
268
+ path,
269
+ reportedIds: Array.isArray(reportedIds) ? reportedIds : [],
270
+ emit: (entry) => this.emitInstallResult(entry),
271
+ persistReportedIds: async (ids) => {
272
+ if (!liveState)
273
+ return this.store.patch({ reportedInstallResultIds: ids });
274
+ // The running coordinator saves this same object on every tick; saving it whole
275
+ // never writes back an older copy.
276
+ liveState.reportedInstallResultIds = ids;
277
+ await this.store.save(liveState);
278
+ },
279
+ telemetry: this.options.installResultsTelemetry,
280
+ logger: this.options.logger,
281
+ });
282
+ if (result.emitted > 0 || result.skipped > 0) {
283
+ this.options.logger.info(`[senpi-auto-update] reported ${result.emitted} wrapper install result(s)` +
284
+ (result.skipped > 0 ? ` (${result.skipped} already reported)` : ""));
285
+ }
286
+ }
287
+ catch (error) {
288
+ this.options.logger.warn(`[senpi-auto-update] reading the wrapper's install results failed | err=${formatErr(error)}`);
289
+ }
290
+ }
291
+ emitInstallResult(e) {
292
+ const opt = (v) => (v === null ? undefined : v);
293
+ this.emitEvent(EVENT_NAME.autoUpdateInstallResult, installResultLevel(e.outcome), {
294
+ body: describeInstallResult(e),
295
+ attributes: eventAttrs({
296
+ "senpi.auto_update.source": opt(e.source),
297
+ "senpi.auto_update.outcome": e.outcome,
298
+ "senpi.auto_update.spec": opt(e.spec),
299
+ "senpi.auto_update.from_version": opt(e.fromVersion),
300
+ "senpi.auto_update.to_version": opt(e.toVersion),
301
+ "senpi.auto_update.target_version": opt(e.targetVersion),
302
+ "senpi.auto_update.duration_ms": opt(e.durationMs),
303
+ "senpi.auto_update.reason": opt(e.reason),
304
+ "senpi.auto_update.ended_at": opt(e.at),
305
+ "senpi.auto_update.result_id": opt(e.id),
306
+ "senpi.auto_update.openclaw_version": opt(e.openclawVersion),
307
+ }),
308
+ });
309
+ }
310
+ /**
311
+ * 10.x: the box waits (`pending-idle:<v>`). `auto_update.deferred` on entering the wait and when
312
+ * the target or the sorted blocker set changes; an unchanged re-check or poll is silent.
313
+ */
314
+ noteDeferred(pending, latest, blockers) {
315
+ const sorted = blockerList(blockers);
316
+ const key = `${latest}|${sorted}`;
317
+ if (key === this.lastDeferredKey)
318
+ return;
319
+ this.lastDeferredKey = key;
320
+ this.emitEvent(EVENT_NAME.autoUpdateDeferred, "info", {
321
+ body: `Restart to apply v${latest} deferred: waiting for ${sorted || "nothing"} (eligible at ${pending.eligibleAt})`,
322
+ attributes: eventAttrs({
323
+ "senpi.auto_update.to_version": latest,
324
+ "senpi.auto_update.blockers": sorted,
325
+ "senpi.auto_update.eligible_at": pending.eligibleAt,
326
+ "senpi.auto_update.first_seen_at": pending.firstSeenAt,
327
+ }),
328
+ });
329
+ }
330
+ /** Remove the install marker; a failure is logged, never thrown (the wrapper deletes it too). */
331
+ async clearMarkerQuietly() {
332
+ const markerPath = this.options.updateMarkerPath;
333
+ if (!markerPath)
334
+ return;
335
+ try {
336
+ await clearUpdateMarker(markerPath);
337
+ }
338
+ catch (error) {
339
+ this.options.logger.warn(`[senpi-auto-update] failed to clear the update marker | path=${markerPath} err=${formatErr(error)}`);
340
+ }
341
+ }
104
342
  /** Cancel the next scheduled poll and prevent in-flight ticks from rescheduling. */
105
343
  async stop() {
106
344
  this.runToken += 1;
@@ -109,6 +347,7 @@ export class AutoUpdateCoordinator {
109
347
  clearTimeout(this.timer);
110
348
  this.timer = null;
111
349
  }
350
+ this.clearIdleRecheck();
112
351
  }
113
352
  /** Return the latest point-in-time snapshot for health/state gateway RPCs. */
114
353
  getSnapshot() {
@@ -154,9 +393,7 @@ export class AutoUpdateCoordinator {
154
393
  // way to tell a completed check from a failed one is whether it counted a
155
394
  // failure. Reporting "ran" either way tells an operator testing a config
156
395
  // change that it worked when nothing reached the registry.
157
- // Baseline on live state, not the snapshot: `runTick` builds the healthy
158
- // snapshot before `tick` zeroes the counter, so after a recovery the
159
- // snapshot still carries the old total and 0 -> 1 reads as no change.
396
+ // Baseline on live state: it is what `tick` changes.
160
397
  const failuresBefore = this.state.consecutiveFailures;
161
398
  await this.tick(this.runToken);
162
399
  if (this.state.consecutiveFailures > failuresBefore) {
@@ -194,15 +431,24 @@ export class AutoUpdateCoordinator {
194
431
  return;
195
432
  }
196
433
  this.running = true;
434
+ this.clearIdleRecheck();
197
435
  this.options.logger.info(`[senpi-auto-update] tick started | localVersion=${this.options.currentVersion}`);
198
436
  const state = this.state;
199
437
  let nextDelayMs = null;
438
+ let recheckIdle = false;
200
439
  try {
201
440
  await this.runTick(state);
202
441
  state.consecutiveFailures = 0;
203
442
  state.lastFailureAt = undefined;
204
443
  state.lastFailureReason = undefined;
205
444
  await this.persistState(state);
445
+ // `runTick` built the snapshot before the reset above; rebuild it so it
446
+ // carries the state that was just written.
447
+ this.snapshot = this.buildSnapshot(this.snapshot.health, this.snapshot.lastAction, this.snapshot.lastError);
448
+ recheckIdle = this.options.modernHost === true && this.snapshot.lastAction.startsWith("pending-idle:");
449
+ // The wait ended (restart requested, no update, a restart failure): the next wait is new.
450
+ if (!recheckIdle)
451
+ this.lastDeferredKey = null;
206
452
  nextDelayMs = this.options.config.pollIntervalMinutes * 60_000;
207
453
  this.options.logger.info(`[senpi-auto-update] tick succeeded | nextDelayMs=${nextDelayMs}`);
208
454
  }
@@ -211,6 +457,8 @@ export class AutoUpdateCoordinator {
211
457
  state.lastFailureAt = new Date().toISOString();
212
458
  state.lastFailureReason = formatErr(error);
213
459
  await this.persistState(state);
460
+ // A failed tick ends the wait it was in: the next wait is reported as new.
461
+ this.lastDeferredKey = null;
214
462
  const base = this.options.config.failureBackoffMinutes * 60_000;
215
463
  const max = this.options.config.maxBackoffMinutes * 60_000;
216
464
  const backoff = Math.min(max, base * Math.max(1, state.consecutiveFailures));
@@ -247,8 +495,64 @@ export class AutoUpdateCoordinator {
247
495
  this.options.logger.info(`[senpi-auto-update] scheduling next tick after onTickComplete | nextDelayMs=${nextDelayMs}`);
248
496
  this.scheduleNext(nextDelayMs, token);
249
497
  }
498
+ if (recheckIdle)
499
+ this.scheduleIdleRecheck(token);
250
500
  }
251
501
  }
502
+ /** Re-read the apply gate of a `pending-idle` box after {@link PENDING_IDLE_RECHECK_MS}. */
503
+ scheduleIdleRecheck(token) {
504
+ if (this.stopped || token !== this.runToken)
505
+ return;
506
+ this.clearIdleRecheck();
507
+ this.idleRecheckTimer = setTimeout(() => {
508
+ this.idleRecheckTimer = null;
509
+ this.recheckPendingIdle(token);
510
+ }, PENDING_IDLE_RECHECK_MS);
511
+ }
512
+ clearIdleRecheck() {
513
+ if (this.idleRecheckTimer) {
514
+ clearTimeout(this.idleRecheckTimer);
515
+ this.idleRecheckTimer = null;
516
+ }
517
+ }
518
+ /**
519
+ * Run a check now when the gate that held the pending update has opened,
520
+ * else re-read it again later. A check already in flight re-arms this
521
+ * itself when it ends `pending-idle`.
522
+ */
523
+ recheckPendingIdle(token) {
524
+ if (this.running || this.stopped || token !== this.runToken)
525
+ return;
526
+ const pending = this.state.pendingUpdate;
527
+ if (!pending)
528
+ return;
529
+ const now = this.now();
530
+ const gate = this.applyGate(now, Date.parse(pending.eligibleAt));
531
+ if (!gate.open) {
532
+ // Still waiting; a blocker set that changed since the last poll is a new deferred.
533
+ this.noteDeferred(pending, pending.version, gate.blockers);
534
+ this.scheduleIdleRecheck(token);
535
+ return;
536
+ }
537
+ this.options.logger.info(`[senpi-auto-update] pending v${pending.version}: apply gate open; checking now`);
538
+ void this.tick(token);
539
+ }
540
+ /**
541
+ * The 10.x apply gate: wait for `eligibleAt` (blocker `spread`), then for an
542
+ * idle box, unless {@link mustForce} applies and every blocker is one the
543
+ * forced restart may override.
544
+ */
545
+ applyGate(now, eligibleMs) {
546
+ if (Number.isFinite(eligibleMs) && now < eligibleMs) {
547
+ return { open: false, forced: false, blockers: ["spread"] };
548
+ }
549
+ const decision = this.readIdle(now);
550
+ // Forcing overrides a busy user, never money in motion (FORCE_MAY_OVERRIDE).
551
+ const forced = !decision.idle &&
552
+ onlyOverridable(decision.blockers) &&
553
+ mustForce(now, Number.isFinite(eligibleMs) ? eligibleMs : now);
554
+ return { open: decision.idle || forced, forced, blockers: [...decision.blockers] };
555
+ }
252
556
  /**
253
557
  * Emit a catalogued `auto_update.*` event, guarded so neither a throwing sink nor a bad
254
558
  * attribute shape can disturb the tick — telemetry must never break auto-update itself.
@@ -274,6 +578,7 @@ export class AutoUpdateCoordinator {
274
578
  /** Core update logic: fetch latest version, classify, and take action. */
275
579
  async runTick(state) {
276
580
  const { logger } = this.options;
581
+ this.pendingBlockers = [];
277
582
  // Stamped before the tag is resolved so a rejected tag reads as "checked
278
583
  // just now and failed", not as "stopped polling a while ago".
279
584
  state.lastCheckedAt = new Date().toISOString();
@@ -324,29 +629,311 @@ export class AutoUpdateCoordinator {
324
629
  "senpi.auto_update.will_update": willUpdate,
325
630
  }),
326
631
  });
632
+ // A pending 10.x update lives only while an applicable update is seen, so
633
+ // a dist-tag rollback or a mode change does not leave a stale eligibility
634
+ // time that would let the next release skip the spread.
327
635
  if (updateType === "none") {
636
+ state.pendingUpdate = undefined;
328
637
  this.snapshot = this.buildSnapshot("healthy", `no-update:${latest}`);
329
638
  return;
330
639
  }
331
640
  if (updateType === "major") {
641
+ state.pendingUpdate = undefined;
332
642
  await this.handleMajorAvailable(state, latest, spec);
333
643
  return;
334
644
  }
335
645
  if (!this.isAllowedByPolicy(updateType)) {
646
+ state.pendingUpdate = undefined;
336
647
  logger.info(`[senpi-auto-update] update blocked by policy | updateType=${updateType} remoteVersion=${latest}`);
337
648
  this.snapshot = this.buildSnapshot("healthy", `policy-blocked:${updateType}:${latest}`);
338
649
  return;
339
650
  }
340
651
  if (state.effectiveMode !== "auto-apply") {
652
+ state.pendingUpdate = undefined;
341
653
  await this.logUpdateAvailable(state, latest, updateType, spec);
342
654
  return;
343
655
  }
344
- if (state.lastAppliedVersion === latest) {
345
- this.handleAlreadyAppliedAwaitingRestart(state, latest);
656
+ if (state.applyFailures && state.applyFailures.version !== latest) {
657
+ // A newer release after failed applies of an older one starts a new
658
+ // episode with a fresh spread wait. Keeping the old `eligibleAt` (long
659
+ // past) would restart every such box on its first idle tick, and a
660
+ // fleet-wide apply failure makes that the whole fleet at once.
661
+ state.applyFailures = undefined;
662
+ state.pendingUpdate = undefined;
663
+ }
664
+ // The latch fix: a persisted target equal to `latest` means a restart was
665
+ // requested for it and the loaded version is still behind (classifyUpdate
666
+ // above said so). That is trusted only while the request is recent.
667
+ const target = state.pendingRestart?.toVersion ?? state.lastAppliedVersion;
668
+ if (target === latest) {
669
+ if (this.restartStillPending(state) || this.awaitingManualRestart(state)) {
670
+ this.handleAlreadyAppliedAwaitingRestart(state, latest);
671
+ return;
672
+ }
673
+ await this.expireUnloadedUpdate(state, latest);
674
+ }
675
+ if ((state.applyFailures?.count ?? 0) >= MAX_APPLY_ATTEMPTS) {
676
+ this.handleParkedUpdate(state, latest);
677
+ return;
678
+ }
679
+ if (this.options.modernHost) {
680
+ await this.runRestartToApply(state, latest, spec);
346
681
  return;
347
682
  }
348
683
  await this.runUpdateTransaction(state, latest, updateType, spec);
349
684
  }
685
+ /**
686
+ * Whether the restart requested for the persisted target may still land:
687
+ * its request time (the pending restart's, else the last restart trigger,
688
+ * else the last install) is under {@link RESTART_LOAD_TIMEOUT_MS} old. With
689
+ * no time on record the persisted version is not trusted at all.
690
+ */
691
+ restartStillPending(state) {
692
+ // The latest of the recorded times: an older trigger for a previous
693
+ // version must not expire a fresh install.
694
+ const times = [state.pendingRestart?.requestedAt, state.lastRestartTriggerAt, state.lastSuccessAt]
695
+ .map((iso) => (iso ? Date.parse(iso) : Number.NaN))
696
+ .filter((ms) => Number.isFinite(ms));
697
+ if (times.length === 0)
698
+ return false;
699
+ return this.now() - Math.max(...times) < RESTART_LOAD_TIMEOUT_MS;
700
+ }
701
+ /**
702
+ * The 5.x latch, as before R2: once this flow installed `latest` (an
703
+ * install or restart time is on record), the box waits for a restart and
704
+ * never runs the in-process install and config-bump restart for that
705
+ * version again, however long it waits. 5.x has no idle gate, so a retry
706
+ * would restart the gateway over an order call or an open approval. A
707
+ * restart trigger that threw is latched too (the warning asks for a manual
708
+ * restart). A persisted applied version with no time at all is still not
709
+ * trusted. 10.x expires its wait after 30 min instead: its retries are
710
+ * idle-gated.
711
+ */
712
+ awaitingManualRestart(state) {
713
+ if (this.options.modernHost)
714
+ return false;
715
+ return [state.pendingRestart?.requestedAt, state.lastRestartTriggerAt, state.lastSuccessAt].some((iso) => typeof iso === "string" && Number.isFinite(Date.parse(iso)));
716
+ }
717
+ /**
718
+ * {@link MAX_APPLY_ATTEMPTS} restarts for `latest` did not load it. Stop
719
+ * trying this version: no install and no restart until a newer one ships
720
+ * (or a restart loads it some other way). Reported, not thrown, so the poll
721
+ * keeps its normal cadence.
722
+ */
723
+ handleParkedUpdate(state, latest) {
724
+ const count = state.applyFailures?.count ?? 0;
725
+ const message = `update to v${latest} did not load after ${count} restarts; not retrying this version. ` +
726
+ `The gateway stays on v${this.loadedVersion} until a newer release ships or an operator installs it`;
727
+ if (state.lastLoggedAwaitingRestartVersion !== `parked:${latest}`) {
728
+ this.options.logger.warn(`[senpi-auto-update] ${message}`);
729
+ state.lastLoggedAwaitingRestartVersion = `parked:${latest}`;
730
+ }
731
+ this.snapshot = this.buildSnapshot("degraded", `update-not-loaded:${latest}`, message);
732
+ }
733
+ /**
734
+ * The requested update did not load. Drop the awaiting-restart state and the
735
+ * marker, report `auto_update.apply_failed`, and fail this tick (so the
736
+ * backoff applies). The next tick starts over: a new install and restart on
737
+ * 5.x, a new idle-gated restart request on 10.x. The pending update keeps
738
+ * its eligibility time, so the box does not wait out its spread again.
739
+ *
740
+ * @throws Always.
741
+ */
742
+ async expireUnloadedUpdate(state, target) {
743
+ const loaded = this.loadedVersion;
744
+ const requestedAt = state.pendingRestart?.requestedAt ?? state.lastRestartTriggerAt ?? "never";
745
+ const message = `update to v${target} did not load: the gateway still runs v${loaded} ` +
746
+ `(restart requested ${requestedAt}); retrying from the start`;
747
+ this.emitEvent(EVENT_NAME.autoUpdateApplyFailed, "warn", {
748
+ body: `Update to v${target} did not load; loaded v${loaded}`,
749
+ attributes: eventAttrs({
750
+ "senpi.auto_update.to_version": target,
751
+ "senpi.auto_update.loaded_version": loaded,
752
+ }),
753
+ });
754
+ this.options.logger.warn(`[senpi-auto-update] ${message}`);
755
+ state.lastAppliedVersion = undefined;
756
+ state.pendingRestart = undefined;
757
+ state.lastLoggedAwaitingRestartVersion = undefined;
758
+ state.applyFailures = {
759
+ version: target,
760
+ count: (state.applyFailures?.version === target ? state.applyFailures.count : 0) + 1,
761
+ };
762
+ await this.clearMarkerQuietly();
763
+ this.snapshot = this.buildSnapshot("degraded", `update-not-loaded:${target}`, message);
764
+ throw new Error(message);
765
+ }
766
+ /**
767
+ * The OpenClaw 10.x apply path (restart-to-apply). Nothing is installed in
768
+ * this process: 10.10 refuses `plugins update` while the gateway runs.
769
+ *
770
+ * 1. Record the pending update once per episode (`firstSeenAt`,
771
+ * `eligibleAt` = first seen + spread offset + jitter).
772
+ * 2. Before `eligibleAt`, wait (`pending-idle:<v>`, blocker `spread`). After
773
+ * it, continue only when the idle gate says idle, or when
774
+ * {@link mustForce} (24 h past eligibility, inside 03:00–05:00 UTC).
775
+ * 3. Build the exact `<name>@<version>` spec, check restart capability,
776
+ * write the marker, and request the restart through `mutateConfigFile`.
777
+ * 4. A failure at any step clears the marker, reports
778
+ * `auto_update.restart_failed` and fails the tick (backoff; `checkNow`
779
+ * answers `failed`). Waiting for the spread or an idle box is not a failure.
780
+ */
781
+ async runRestartToApply(state, latest, spec) {
782
+ const { logger } = this.options;
783
+ const now = this.now();
784
+ if (!state.pendingUpdate) {
785
+ const firstSeenAt = new Date(now).toISOString();
786
+ const eligible = computeEligibleAt(now, this.options.mid ?? "", this.rand);
787
+ state.pendingUpdate = { version: latest, firstSeenAt, eligibleAt: new Date(eligible).toISOString() };
788
+ logger.info(`[senpi-auto-update] v${latest} seen; restart-to-apply eligible at ${state.pendingUpdate.eligibleAt}`);
789
+ }
790
+ else if (state.pendingUpdate.version !== latest) {
791
+ // A newer release during the wait keeps the box's place in the spread.
792
+ state.pendingUpdate = { ...state.pendingUpdate, version: latest };
793
+ }
794
+ const gate = this.applyGate(now, Date.parse(state.pendingUpdate.eligibleAt));
795
+ const forced = gate.forced;
796
+ if (!gate.open) {
797
+ this.pendingBlockers = gate.blockers;
798
+ this.snapshot = this.buildSnapshot("healthy", `pending-idle:${latest}`);
799
+ this.noteDeferred(state.pendingUpdate, latest, gate.blockers);
800
+ return;
801
+ }
802
+ let exactSpec;
803
+ try {
804
+ exactSpec = buildExactUpdateSpec(spec, latest);
805
+ }
806
+ catch (error) {
807
+ await this.reportModernRestartFailure(latest, formatErr(error), `restart-unavailable:${latest}`);
808
+ return;
809
+ }
810
+ const capability = evaluateModernRestartCapability({
811
+ readLiveConfig: this.options.readLiveConfig,
812
+ mutateConfigFile: this.options.mutateConfigFile,
813
+ pluginId: this.options.pluginId,
814
+ });
815
+ state.restartCapability = capability.capable ? "capable" : "not-capable";
816
+ state.restartCapabilityReasons = capability.reasons;
817
+ state.restartCapabilityMode = capability.mode;
818
+ const mutateConfigFile = this.options.mutateConfigFile;
819
+ const markerPath = this.options.updateMarkerPath;
820
+ if (!capability.capable || !mutateConfigFile) {
821
+ await this.reportModernRestartFailure(latest, capability.reasons.join(", ") || "unknown", `restart-unavailable:${latest}`);
822
+ return;
823
+ }
824
+ if (!markerPath) {
825
+ state.restartCapability = "not-capable";
826
+ state.restartCapabilityReasons = [...capability.reasons, "update-marker-path-unavailable"];
827
+ await this.reportModernRestartFailure(latest, "update-marker-path-unavailable", `restart-unavailable:${latest}`);
828
+ return;
829
+ }
830
+ const requestedAt = new Date(now).toISOString();
831
+ state.lastAttemptAt = requestedAt;
832
+ // The pending restart is saved before the restart is requested: the
833
+ // gateway may stop this process right after, and a lost pending restart
834
+ // would never count toward the retry cap. Rolled back if the request fails.
835
+ const before = {
836
+ pendingRestart: state.pendingRestart,
837
+ lastRestartTriggerAt: state.lastRestartTriggerAt,
838
+ lastRestartTriggerMode: state.lastRestartTriggerMode,
839
+ };
840
+ state.pendingRestart = {
841
+ fromVersion: this.loadedVersion,
842
+ toVersion: latest,
843
+ requestedAt,
844
+ mode: "restart-to-apply",
845
+ };
846
+ state.lastRestartTriggerAt = requestedAt;
847
+ state.lastRestartTriggerMode = "restart-to-apply";
848
+ const rollBack = () => {
849
+ state.pendingRestart = before.pendingRestart;
850
+ state.lastRestartTriggerAt = before.lastRestartTriggerAt;
851
+ state.lastRestartTriggerMode = before.lastRestartTriggerMode;
852
+ };
853
+ try {
854
+ await this.store.save(state);
855
+ }
856
+ catch (error) {
857
+ // Not a restart-capability problem: the state dir is not writable. No
858
+ // restart without a saved pending restart, or the retry cap is lost.
859
+ rollBack();
860
+ await this.reportModernRestartFailure(latest, `state-save-failed:${formatErr(error)}`, `state-save-failed:${latest}`, error);
861
+ return;
862
+ }
863
+ try {
864
+ await writeUpdateMarker(markerPath, { spec: exactSpec, targetVersion: latest, requestedAt });
865
+ // Read the gate again after the awaits above: a money call or a user
866
+ // turn that started meanwhile holds the restart, as at the first read.
867
+ const again = this.readIdle(this.now());
868
+ if (!again.idle && !(forced && onlyOverridable(again.blockers))) {
869
+ rollBack();
870
+ await this.clearMarkerQuietly();
871
+ this.pendingBlockers = [...again.blockers];
872
+ this.snapshot = this.buildSnapshot("healthy", `pending-idle:${latest}`);
873
+ if (state.pendingUpdate)
874
+ this.noteDeferred(state.pendingUpdate, latest, again.blockers);
875
+ return;
876
+ }
877
+ await triggerGatewayRestartViaMutate({ mutateConfigFile, pluginId: this.options.pluginId, triggerValue: requestedAt });
878
+ }
879
+ catch (error) {
880
+ rollBack();
881
+ const reason = formatErr(error);
882
+ state.restartCapability = "not-capable";
883
+ state.restartCapabilityReasons = [...state.restartCapabilityReasons, `restart-trigger-failed:${reason}`];
884
+ await this.reportModernRestartFailure(latest, reason, `restart-request-failed:${latest}`, error);
885
+ return;
886
+ }
887
+ this.emitEvent(EVENT_NAME.autoUpdateRestartRequested, "info", {
888
+ body: `Restart requested to apply v${latest} (from v${this.loadedVersion}${forced ? ", forced after 24 h busy" : ""})`,
889
+ attributes: eventAttrs({
890
+ "senpi.auto_update.from_version": this.loadedVersion,
891
+ "senpi.auto_update.to_version": latest,
892
+ "senpi.auto_update.restart_mode": "restart-to-apply",
893
+ "senpi.auto_update.forced": forced,
894
+ "senpi.auto_update.blockers": forced ? blockerList(gate.blockers) : undefined,
895
+ }),
896
+ });
897
+ logger.info(`[senpi-auto-update] marker written (${exactSpec}); gateway restart requested to apply v${latest}` +
898
+ (forced ? ` (forced: busy for 24 h past eligibility; blockers ${gate.blockers.join(",")})` : ""));
899
+ this.snapshot = this.buildSnapshot("healthy", `updated-restart-requested:${latest}`);
900
+ }
901
+ /** The idle gate's answer; a throwing gate counts as busy. */
902
+ readIdle(now) {
903
+ const gate = this.options.idleGate;
904
+ if (!gate)
905
+ return { idle: true, blockers: [] };
906
+ try {
907
+ return gate.isIdle(now);
908
+ }
909
+ catch (error) {
910
+ this.options.logger.warn(`[senpi-auto-update] idle gate threw; treating the box as busy | err=${formatErr(error)}`);
911
+ return { idle: false, blockers: ["idle-gate-error"] };
912
+ }
913
+ }
914
+ /**
915
+ * Clear the marker, report `auto_update.restart_failed` for the 10.x path
916
+ * (snapshot degraded), and fail the tick: no restart was requested, so the
917
+ * tick must not count as a success. The throw makes `tick()` back off and
918
+ * `checkNow()` answer `failed`.
919
+ *
920
+ * @throws Always, with `reason`.
921
+ */
922
+ async reportModernRestartFailure(latest, reason, lastAction, error) {
923
+ await this.clearMarkerQuietly();
924
+ this.emitEvent(EVENT_NAME.autoUpdateRestartFailed, "error", {
925
+ body: `Restart to apply v${latest} was not requested: ${reason}`,
926
+ attributes: eventAttrs({
927
+ "senpi.auto_update.to_version": latest,
928
+ "senpi.auto_update.reason": reason,
929
+ }),
930
+ ...(error === undefined ? {} : { error }),
931
+ });
932
+ this.options.logger.warn(`[senpi-auto-update] v${latest} is available but the gateway restart that applies it was not requested. ` +
933
+ `Nothing was installed; the loaded version stays v${this.loadedVersion}. Reason(s): ${reason}`);
934
+ this.snapshot = this.buildSnapshot("degraded", lastAction, reason);
935
+ throw new Error(`restart to apply v${latest} was not requested: ${reason}`);
936
+ }
350
937
  /**
351
938
  * `currentVersion` is a compile-time constant frozen for the life of the
352
939
  * process, so it cannot reflect an install that already happened this
@@ -359,19 +946,30 @@ export class AutoUpdateCoordinator {
359
946
  * so this branch stops being reached on its own.
360
947
  */
361
948
  handleAlreadyAppliedAwaitingRestart(state, latest) {
949
+ const modern = this.options.modernHost === true;
362
950
  if (state.lastLoggedAwaitingRestartVersion !== latest) {
363
- this.options.logger.info(`[senpi-auto-update] update to v${latest} already applied, awaiting gateway restart to take effect | skipping reinstall`);
951
+ this.options.logger.info(modern
952
+ ? `[senpi-auto-update] restart to apply v${latest} requested, awaiting gateway restart | not requesting again`
953
+ : `[senpi-auto-update] update to v${latest} already applied, awaiting gateway restart to take effect | skipping reinstall`);
364
954
  state.lastLoggedAwaitingRestartVersion = latest;
365
955
  }
366
956
  // Fired on every guard hit (not deduped like the log line above) — an operator watching for
367
957
  // "how long has this box been stuck awaiting restart" needs every occurrence, not just the first.
368
958
  this.emitEvent(EVENT_NAME.autoUpdateAwaitingRestart, "warn", {
369
- body: `Update to v${latest} already applied, still awaiting gateway restart`,
959
+ body: modern
960
+ ? `Restart to apply v${latest} requested, still awaiting gateway restart`
961
+ : `Update to v${latest} already applied, still awaiting gateway restart`,
370
962
  attributes: eventAttrs({
371
963
  "senpi.auto_update.to_version": latest,
372
964
  }),
373
965
  });
374
- this.snapshot = this.buildSnapshot("degraded", `already-applied-awaiting-restart:${latest}`);
966
+ // On 10.x nothing is installed until the wrapper consumes the marker, so
967
+ // "already applied" would be false there.
968
+ this.snapshot = this.buildSnapshot("degraded", modern ? `awaiting-restart:${latest}` : `already-applied-awaiting-restart:${latest}`);
969
+ }
970
+ /** OpenClaw 10.x refuses `plugins update` while the gateway runs, so a manual instruction says so. */
971
+ manualUpdateSuffix() {
972
+ return this.options.modernHost ? " with the gateway stopped" : "";
375
973
  }
376
974
  isAllowedByPolicy(kind) {
377
975
  if (kind === "patch")
@@ -394,7 +992,7 @@ export class AutoUpdateCoordinator {
394
992
  });
395
993
  this.options.logger.info(`[senpi-auto-update] Senpi update available: v${latest} (major). ` +
396
994
  `Auto-update will not apply major versions. ` +
397
- `Review changelog and run: openclaw plugins update ${spec}`);
995
+ `Review changelog and run: openclaw plugins update ${spec}${this.manualUpdateSuffix()}`);
398
996
  state.lastNotifiedMajorVersion = latest;
399
997
  this.snapshot = this.buildSnapshot("healthy", `major-notified:${latest}`);
400
998
  }
@@ -412,7 +1010,7 @@ export class AutoUpdateCoordinator {
412
1010
  });
413
1011
  this.options.logger.info(`[senpi-auto-update] Senpi update available: v${latest} (${kind}). ` +
414
1012
  `Auto-update mode is notify-only. ` +
415
- `Run: openclaw plugins update ${spec}`);
1013
+ `Run: openclaw plugins update ${spec}${this.manualUpdateSuffix()}`);
416
1014
  state.lastNotifiedUpdateVersion = latest;
417
1015
  this.snapshot = this.buildSnapshot("healthy", `update-notified:${kind}:${latest}`);
418
1016
  }
@@ -495,6 +1093,12 @@ export class AutoUpdateCoordinator {
495
1093
  });
496
1094
  state.lastRestartTriggerAt = nowIso;
497
1095
  state.lastRestartTriggerMode = capability.mode ?? "unknown";
1096
+ state.pendingRestart = {
1097
+ fromVersion: this.loadedVersion,
1098
+ toVersion: latest,
1099
+ requestedAt: nowIso,
1100
+ mode: state.lastRestartTriggerMode,
1101
+ };
498
1102
  this.emitEvent(EVENT_NAME.autoUpdateApplied, "info", {
499
1103
  body: `Update applied: ${this.options.currentVersion} -> v${latest}; restart requested (${state.lastRestartTriggerMode})`,
500
1104
  attributes: eventAttrs({
@@ -553,6 +1157,7 @@ export class AutoUpdateCoordinator {
553
1157
  restartCapabilityReasons: state.restartCapabilityReasons,
554
1158
  lastRestartTriggerAt: state.lastRestartTriggerAt ?? null,
555
1159
  lastRestartTriggerMode: state.lastRestartTriggerMode ?? null,
1160
+ pendingBlockers: this.pendingBlockers,
556
1161
  };
557
1162
  }
558
1163
  async persistState(state) {
@@ -573,6 +1178,10 @@ async function defaultRunUpdateCommand(packageName) {
573
1178
  throw new Error(result.stderr || `openclaw plugins update failed with code ${result.code}`);
574
1179
  }
575
1180
  }
1181
+ /** A blocker set as one stable value: de-duplicated, sorted, comma-joined. */
1182
+ function blockerList(blockers) {
1183
+ return [...new Set(blockers)].sort().join(",");
1184
+ }
576
1185
  function formatErr(error) {
577
1186
  return error instanceof Error ? error.message : String(error);
578
1187
  }