sensorium-mcp 3.1.0-refactor.8 → 3.1.0
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/config/agent-settings.d.ts +6 -0
- package/dist/config/agent-settings.d.ts.map +1 -1
- package/dist/config/agent-settings.js +22 -0
- package/dist/config/agent-settings.js.map +1 -1
- package/dist/config/detail-grant.d.ts +39 -0
- package/dist/config/detail-grant.d.ts.map +1 -0
- package/dist/config/detail-grant.js +61 -0
- package/dist/config/detail-grant.js.map +1 -0
- package/dist/config/index.d.ts +2 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +5 -3
- package/dist/config/index.js.map +1 -1
- package/dist/config/misc-settings.d.ts +2 -0
- package/dist/config/misc-settings.d.ts.map +1 -1
- package/dist/config/misc-settings.js +22 -0
- package/dist/config/misc-settings.js.map +1 -1
- package/dist/config/settings-store.d.ts +41 -2
- package/dist/config/settings-store.d.ts.map +1 -1
- package/dist/config/settings-store.js +151 -14
- package/dist/config/settings-store.js.map +1 -1
- package/dist/dashboard/routes/copilot.d.ts.map +1 -1
- package/dist/dashboard/routes/copilot.js +17 -1
- package/dist/dashboard/routes/copilot.js.map +1 -1
- package/dist/dashboard/routes/settings.d.ts +2 -0
- package/dist/dashboard/routes/settings.d.ts.map +1 -1
- package/dist/dashboard/routes/settings.js +25 -1
- package/dist/dashboard/routes/settings.js.map +1 -1
- package/dist/dashboard/routes/threads-read.d.ts.map +1 -1
- package/dist/dashboard/routes/threads-read.js +15 -3
- package/dist/dashboard/routes/threads-read.js.map +1 -1
- package/dist/dashboard/routes/threads.d.ts.map +1 -1
- package/dist/dashboard/routes/threads.js +13 -4
- package/dist/dashboard/routes/threads.js.map +1 -1
- package/dist/dashboard/routes.d.ts.map +1 -1
- package/dist/dashboard/routes.js +14 -5
- package/dist/dashboard/routes.js.map +1 -1
- package/dist/dashboard/spa.html +11 -11
- package/dist/data/daily-marker.js +3 -3
- package/dist/data/daily-marker.js.map +1 -1
- package/dist/data/file-storage.d.ts.map +1 -1
- package/dist/data/file-storage.js +3 -3
- package/dist/data/file-storage.js.map +1 -1
- package/dist/data/memory/bootstrap.d.ts.map +1 -1
- package/dist/data/memory/bootstrap.js +2 -3
- package/dist/data/memory/bootstrap.js.map +1 -1
- package/dist/data/memory/migration-runner.d.ts.map +1 -1
- package/dist/data/memory/migration-runner.js +4 -7
- package/dist/data/memory/migration-runner.js.map +1 -1
- package/dist/data/memory/schema-ddl.d.ts +1 -1
- package/dist/data/memory/schema-ddl.d.ts.map +1 -1
- package/dist/data/memory/schema-ddl.js +7 -1
- package/dist/data/memory/schema-ddl.js.map +1 -1
- package/dist/data/memory/schema.js +2 -2
- package/dist/data/memory/schema.js.map +1 -1
- package/dist/index.js +67 -21
- package/dist/index.js.map +1 -1
- package/dist/lib/logger.d.ts.map +1 -1
- package/dist/lib/logger.js +21 -2
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/paths.d.ts +29 -0
- package/dist/lib/paths.d.ts.map +1 -0
- package/dist/lib/paths.js +36 -0
- package/dist/lib/paths.js.map +1 -0
- package/dist/lib/types.d.ts +1 -0
- package/dist/lib/types.d.ts.map +1 -1
- package/dist/server/factory.js +16 -6
- package/dist/server/factory.js.map +1 -1
- package/dist/server/http-server.d.ts.map +1 -1
- package/dist/server/http-server.js +52 -20
- package/dist/server/http-server.js.map +1 -1
- package/dist/services/agent-spawn.service.d.ts +3 -3
- package/dist/services/agent-spawn.service.d.ts.map +1 -1
- package/dist/services/agent-spawn.service.js +30 -9
- package/dist/services/agent-spawn.service.js.map +1 -1
- package/dist/services/background-runner.d.ts.map +1 -1
- package/dist/services/background-runner.js +22 -12
- package/dist/services/background-runner.js.map +1 -1
- package/dist/services/broker-client.d.ts +209 -0
- package/dist/services/broker-client.d.ts.map +1 -0
- package/dist/services/broker-client.js +359 -0
- package/dist/services/broker-client.js.map +1 -0
- package/dist/services/claude-spawn.service.d.ts +1 -1
- package/dist/services/claude-spawn.service.d.ts.map +1 -1
- package/dist/services/claude-spawn.service.js +25 -25
- package/dist/services/claude-spawn.service.js.map +1 -1
- package/dist/services/codex-spawn.service.d.ts +1 -1
- package/dist/services/codex-spawn.service.d.ts.map +1 -1
- package/dist/services/codex-spawn.service.js +19 -24
- package/dist/services/codex-spawn.service.js.map +1 -1
- package/dist/services/copilot-spawn.service.d.ts +1 -1
- package/dist/services/copilot-spawn.service.d.ts.map +1 -1
- package/dist/services/copilot-spawn.service.js +86 -37
- package/dist/services/copilot-spawn.service.js.map +1 -1
- package/dist/services/dispatcher/broker.d.ts +4 -3
- package/dist/services/dispatcher/broker.d.ts.map +1 -1
- package/dist/services/dispatcher/broker.js +23 -11
- package/dist/services/dispatcher/broker.js.map +1 -1
- package/dist/services/dispatcher/lock.js +2 -2
- package/dist/services/dispatcher/lock.js.map +1 -1
- package/dist/services/dispatcher/poller.d.ts.map +1 -1
- package/dist/services/dispatcher/poller.js +24 -5
- package/dist/services/dispatcher/poller.js.map +1 -1
- package/dist/services/flight-recorder.service.d.ts +37 -0
- package/dist/services/flight-recorder.service.d.ts.map +1 -0
- package/dist/services/flight-recorder.service.js +210 -0
- package/dist/services/flight-recorder.service.js.map +1 -0
- package/dist/services/keeper.service.d.ts +9 -0
- package/dist/services/keeper.service.d.ts.map +1 -1
- package/dist/services/keeper.service.js +198 -39
- package/dist/services/keeper.service.js.map +1 -1
- package/dist/services/maintenance-signal.d.ts.map +1 -1
- package/dist/services/maintenance-signal.js +2 -3
- package/dist/services/maintenance-signal.js.map +1 -1
- package/dist/services/process.service.d.ts +76 -61
- package/dist/services/process.service.d.ts.map +1 -1
- package/dist/services/process.service.js +224 -608
- package/dist/services/process.service.js.map +1 -1
- package/dist/services/reconnect-snapshot.service.js +2 -2
- package/dist/services/reconnect-snapshot.service.js.map +1 -1
- package/dist/services/scheduler.d.ts +1 -0
- package/dist/services/scheduler.d.ts.map +1 -1
- package/dist/services/scheduler.js +69 -14
- package/dist/services/scheduler.js.map +1 -1
- package/dist/services/self-update.service.d.ts +113 -15
- package/dist/services/self-update.service.d.ts.map +1 -1
- package/dist/services/self-update.service.js +382 -109
- package/dist/services/self-update.service.js.map +1 -1
- package/dist/services/spawn-common.d.ts +49 -23
- package/dist/services/spawn-common.d.ts.map +1 -1
- package/dist/services/spawn-common.js +262 -47
- package/dist/services/spawn-common.js.map +1 -1
- package/dist/services/spawn-exit-watcher.d.ts +43 -0
- package/dist/services/spawn-exit-watcher.d.ts.map +1 -0
- package/dist/services/spawn-exit-watcher.js +127 -0
- package/dist/services/spawn-exit-watcher.js.map +1 -0
- package/dist/services/telegram.d.ts +3 -2
- package/dist/services/telegram.d.ts.map +1 -1
- package/dist/services/telegram.js +8 -5
- package/dist/services/telegram.js.map +1 -1
- package/dist/services/thread-health.service.d.ts +1 -1
- package/dist/services/thread-health.service.d.ts.map +1 -1
- package/dist/services/thread-health.service.js +11 -7
- package/dist/services/thread-health.service.js.map +1 -1
- package/dist/services/thread-lifecycle.service.js +2 -2
- package/dist/services/thread-lifecycle.service.js.map +1 -1
- package/dist/services/topic.service.d.ts +1 -1
- package/dist/services/topic.service.d.ts.map +1 -1
- package/dist/services/topic.service.js +3 -2
- package/dist/services/topic.service.js.map +1 -1
- package/dist/services/worker-cleanup.service.d.ts.map +1 -1
- package/dist/services/worker-cleanup.service.js +25 -99
- package/dist/services/worker-cleanup.service.js.map +1 -1
- package/dist/test-bootstrap.d.ts +39 -0
- package/dist/test-bootstrap.d.ts.map +1 -0
- package/dist/test-bootstrap.js +78 -0
- package/dist/test-bootstrap.js.map +1 -0
- package/dist/tools/defs/session-defs.js +1 -1
- package/dist/tools/defs/session-defs.js.map +1 -1
- package/dist/tools/defs/utility-defs.d.ts.map +1 -1
- package/dist/tools/defs/utility-defs.js +4 -3
- package/dist/tools/defs/utility-defs.js.map +1 -1
- package/dist/tools/delegate-tool.d.ts +1 -1
- package/dist/tools/delegate-tool.d.ts.map +1 -1
- package/dist/tools/delegate-tool.js +37 -6
- package/dist/tools/delegate-tool.js.map +1 -1
- package/dist/tools/session-tools.d.ts.map +1 -1
- package/dist/tools/session-tools.js +34 -20
- package/dist/tools/session-tools.js.map +1 -1
- package/dist/tools/utility-tools.js +1 -1
- package/dist/tools/utility-tools.js.map +1 -1
- package/dist/tools/wait/context.d.ts +13 -0
- package/dist/tools/wait/context.d.ts.map +1 -1
- package/dist/tools/wait/message-delivery.d.ts.map +1 -1
- package/dist/tools/wait/message-delivery.js +2 -3
- package/dist/tools/wait/message-delivery.js.map +1 -1
- package/dist/tools/wait/poll-loop.d.ts.map +1 -1
- package/dist/tools/wait/poll-loop.js +8 -1
- package/dist/tools/wait/poll-loop.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Self-update service — polls the npm registry for the version published on
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Self-update service — polls the npm registry for the version published on the
|
|
3
|
+
* update channel and triggers a graceful update. Update rules:
|
|
4
|
+
* - SAME channel: forward-only — update only when the published version is a
|
|
5
|
+
* strictly newer semver (an equal/older version is ignored, never a
|
|
6
|
+
* downgrade).
|
|
7
|
+
* - Channel CHANGED (operator edited install.config.json):
|
|
8
|
+
* · legacy/self-spawn mode — force-switch to the new channel's published
|
|
9
|
+
* version even if it is an OLDER semver (the self-spawn respawn pins that
|
|
10
|
+
* exact version, so the switch lands).
|
|
11
|
+
* · broker mode — the switch is DEFERRED, not applied: the supervisor
|
|
12
|
+
* respawns its own frozen launch channel and ignores install.config, so
|
|
13
|
+
* a force-switch + /restart would restart-loop. The poller keeps the
|
|
14
|
+
* launch channel forward-only until a full-stack restart (or the pending
|
|
15
|
+
* supervisor-side change that passes targetVersion to /restart / re-reads
|
|
16
|
+
* install.config) makes the switch honourable.
|
|
17
|
+
* See decideUpdate.
|
|
18
|
+
*
|
|
19
|
+
* Single lifecycle owner (plan §4.5, Phase 4): this service no longer spawns
|
|
20
|
+
* its own replacement. It quiesces the agents, then asks the supervisor
|
|
21
|
+
* (POST /restart) to run the shared teardown/respawn choreography — the spawn
|
|
22
|
+
* command re-resolves the channel version by itself (npx). The pre-Phase-4
|
|
23
|
+
* self-spawn path survives only as the legacy fallback for dev mode without a
|
|
24
|
+
* supervisor, gated by the same SENSORIUM_BROKER_SPAWN cutover flag the spawn
|
|
25
|
+
* services use (see brokerSpawnMode in spawn-common.ts).
|
|
5
26
|
*
|
|
6
27
|
* Entry point: startSelfUpdatePoller({ pkgVersion, httpPort })
|
|
7
28
|
* Call once after the HTTP server is listening (HTTP mode only).
|
|
@@ -11,38 +32,62 @@
|
|
|
11
32
|
* SELF_UPDATE_POLL_INTERVAL_MS Registry check interval (default: 60000)
|
|
12
33
|
* SELF_UPDATE_MIN_UPTIME_MS Min uptime before first update (default: 600000)
|
|
13
34
|
* SELF_UPDATE_GRACE_MS Grace period for agents to observe flag (default: 30000)
|
|
14
|
-
*
|
|
35
|
+
* SENSORIUM_BROKER_SPAWN Cutover flag: 1/required = supervisor-only,
|
|
36
|
+
* 0/off = legacy self-spawn, unset = auto
|
|
37
|
+
* MCP_START_COMMAND Launch-time command; its "@<tag>" is the baseline
|
|
38
|
+
* (currently-running) channel and the fallback when
|
|
39
|
+
* install.config.json cannot be read.
|
|
15
40
|
*
|
|
16
41
|
* Update channel:
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
42
|
+
* Resolved LIVE from ~/.remote-copilot-mcp/install.config.json (MCPStartCommand
|
|
43
|
+
* → the npm spec tag in "sensorium-mcp@<tag>", e.g. @latest or @board) on every
|
|
44
|
+
* poll cycle. In legacy/self-spawn mode, editing that file re-points the poller
|
|
45
|
+
* with NO restart or Install-Sensorium.ps1 re-run. In broker/production mode a
|
|
46
|
+
* file-only edit does NOT complete the switch (see above) — the supervisor is
|
|
47
|
+
* frozen on its launch channel, so switching production needs a full-stack
|
|
48
|
+
* restart / installer re-run (or the pending supervisor-side change). If the
|
|
49
|
+
* config is missing/garbled/tag-less the poller falls back to the launch-time
|
|
50
|
+
* MCP_START_COMMAND channel; the baseline it compares against is always what
|
|
51
|
+
* THIS process was launched on. No config / no env / no tag = "latest".
|
|
23
52
|
*/
|
|
24
53
|
import { spawn } from "node:child_process";
|
|
25
|
-
import { closeSync, mkdirSync, openSync, unlinkSync, writeFileSync } from "node:fs";
|
|
26
|
-
import { homedir } from "node:os";
|
|
54
|
+
import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
27
55
|
import { join } from "node:path";
|
|
28
56
|
import { log } from "../lib/logger.js";
|
|
57
|
+
import { dataDir } from "../lib/paths.js";
|
|
29
58
|
import { emitMaintenanceSignal, hasObservedMaintenance } from "./maintenance-signal.js";
|
|
30
59
|
import { writeReconnectSnapshot } from "./reconnect-snapshot.service.js";
|
|
31
60
|
import { getActiveThreadIds } from "./process.service.js";
|
|
32
61
|
import { getLiveWaitingThreadIds } from "../sessions/dashboard-registry.js";
|
|
62
|
+
import { brokerRequestRestart } from "./broker-client.js";
|
|
63
|
+
import { brokerSpawnMode } from "./spawn-common.js";
|
|
33
64
|
// ---------------------------------------------------------------------------
|
|
34
65
|
// Module-level state
|
|
35
66
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const DATA_DIR =
|
|
67
|
+
const DATA_DIR = dataDir();
|
|
37
68
|
const FLAG_PATH = join(DATA_DIR, "maintenance.flag");
|
|
38
69
|
/** Poller state: the config trio is set once by startSelfUpdatePoller;
|
|
39
|
-
* updateInProgress is the re-entrancy guard for check/update cycles
|
|
70
|
+
* updateInProgress is the re-entrancy guard for check/update cycles;
|
|
71
|
+
* restartDelegatedAt records when the supervisor accepted a restart — this
|
|
72
|
+
* process is then living on borrowed time until the supervisor kills it, and
|
|
73
|
+
* the poller must not fire another update cycle in the interim. */
|
|
40
74
|
const state = {
|
|
41
75
|
updateInProgress: false,
|
|
76
|
+
restartDelegatedAt: 0,
|
|
42
77
|
currentVersion: "",
|
|
78
|
+
currentChannel: "",
|
|
79
|
+
// The desired channel we last logged a broker-mode deferral for, so a config
|
|
80
|
+
// switch the supervisor cannot honor is logged once, not every poll cycle.
|
|
81
|
+
deferredSwitchLoggedFor: "",
|
|
43
82
|
httpPort: 0,
|
|
44
83
|
beforeSpawnHook: null,
|
|
45
84
|
};
|
|
85
|
+
// How long a supervisor-accepted restart may leave this process alive before
|
|
86
|
+
// the poller concludes the teardown failed (e.g. the kill EPERM-failed — the
|
|
87
|
+
// exact unkillable class this machine has produced) and resumes update polling.
|
|
88
|
+
// Generous: the supervisor's own ready-timeout is 5 min, so 15 min of still
|
|
89
|
+
// being alive means the restart is not coming. Self-healing over latching dead.
|
|
90
|
+
const DELEGATED_RESTART_GRACE_MS = 15 * 60 * 1000;
|
|
46
91
|
// ---------------------------------------------------------------------------
|
|
47
92
|
// Helpers
|
|
48
93
|
// ---------------------------------------------------------------------------
|
|
@@ -93,8 +138,9 @@ async function notifyTelegram(text) {
|
|
|
93
138
|
const chatId = process.env.TELEGRAM_CHAT_ID;
|
|
94
139
|
if (!token || !chatId)
|
|
95
140
|
return;
|
|
141
|
+
const apiBaseUrl = process.env.TELEGRAM_API_BASE_URL ?? "https://api.telegram.org";
|
|
96
142
|
try {
|
|
97
|
-
await fetch(
|
|
143
|
+
await fetch(`${apiBaseUrl}/bot${token}/sendMessage`, {
|
|
98
144
|
method: "POST",
|
|
99
145
|
headers: { "Content-Type": "application/json" },
|
|
100
146
|
body: JSON.stringify({ chat_id: chatId, text }),
|
|
@@ -135,19 +181,24 @@ async function waitForHealthy(port, targetVersion, timeoutMs = 180_000) {
|
|
|
135
181
|
// ---------------------------------------------------------------------------
|
|
136
182
|
// Core logic
|
|
137
183
|
// ---------------------------------------------------------------------------
|
|
138
|
-
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
184
|
+
/** Extract the npm dist-tag/channel from a "sensorium-mcp@<tag>" spec inside a
|
|
185
|
+
* spawn command. Returns null when there is no parseable @tag (e.g. no spec, or
|
|
186
|
+
* a semver range like @^3.0.0 the poller cannot follow). The negative lookbehind
|
|
187
|
+
* keeps a bare "sensorium-mcp@3.0.5" version pin from being mistaken for a
|
|
188
|
+
* scoped-package boundary. */
|
|
189
|
+
function parseChannelFromCommand(cmd) {
|
|
190
|
+
const m = /(?<![\w.-])sensorium-mcp@([A-Za-z0-9._-]+)/.exec(cmd);
|
|
191
|
+
return m ? m[1] : null;
|
|
192
|
+
}
|
|
193
|
+
/** Launch-time release channel = the dist-tag frozen into MCP_START_COMMAND at
|
|
194
|
+
* process start (what THIS process is actually running). Used as the baseline
|
|
195
|
+
* the live desired-channel is compared against, and as the last-resort fallback
|
|
196
|
+
* when install.config.json cannot be resolved. No env / no @tag = "latest". */
|
|
146
197
|
function getUpdateChannel() {
|
|
147
198
|
const cmd = process.env.MCP_START_COMMAND ?? "";
|
|
148
|
-
const
|
|
149
|
-
if (
|
|
150
|
-
return
|
|
199
|
+
const tag = parseChannelFromCommand(cmd);
|
|
200
|
+
if (tag)
|
|
201
|
+
return tag;
|
|
151
202
|
if (cmd.includes("sensorium-mcp@")) {
|
|
152
203
|
// e.g. a semver range spec like @^3.0.0 — poller can't follow it, so the
|
|
153
204
|
// channel falls back to "latest" while the spawn command keeps the range.
|
|
@@ -156,12 +207,138 @@ function getUpdateChannel() {
|
|
|
156
207
|
return "latest";
|
|
157
208
|
}
|
|
158
209
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
210
|
+
* Resolve the DESIRED release channel LIVE from install.config.json on every
|
|
211
|
+
* call, so editing that file re-points the poller without any process restart
|
|
212
|
+
* or installer re-run (behaviour 1). The channel is the "@<tag>" in the config's
|
|
213
|
+
* MCPStartCommand — the same field Install-Sensorium.ps1 reads.
|
|
214
|
+
*
|
|
215
|
+
* Defensive by construction: any problem — file missing, unreadable, garbled
|
|
216
|
+
* JSON, MCPStartCommand absent/non-string, or no parseable @tag — falls back to
|
|
217
|
+
* the launch-time env channel (getUpdateChannel). A broken config can therefore
|
|
218
|
+
* never crash the poller nor strand it on a bad channel; worst case it keeps
|
|
219
|
+
* following the channel it was launched with.
|
|
220
|
+
*/
|
|
221
|
+
export function resolveDesiredChannel() {
|
|
222
|
+
try {
|
|
223
|
+
const raw = readFileSync(join(dataDir(), "install.config.json"), "utf8");
|
|
224
|
+
const parsed = JSON.parse(raw);
|
|
225
|
+
const cmd = typeof parsed.MCPStartCommand === "string" ? parsed.MCPStartCommand : "";
|
|
226
|
+
const tag = parseChannelFromCommand(cmd);
|
|
227
|
+
if (tag)
|
|
228
|
+
return tag;
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
/* fall through to the launch-time env channel */
|
|
232
|
+
}
|
|
233
|
+
return getUpdateChannel();
|
|
234
|
+
}
|
|
235
|
+
/** Parse "3.1.0-refactor.21" → { core:[3,1,0], pre:["refactor",21] }, or null if
|
|
236
|
+
* the string is not a recognisable semver core. Numeric prerelease identifiers
|
|
237
|
+
* are coerced to numbers so they compare numerically (per semver §11). */
|
|
238
|
+
function parseSemver(v) {
|
|
239
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(v.trim());
|
|
240
|
+
if (!m)
|
|
241
|
+
return null;
|
|
242
|
+
const core = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
243
|
+
const pre = m[4] ? m[4].split(".").map((id) => (/^\d+$/.test(id) ? Number(id) : id)) : [];
|
|
244
|
+
return { core, pre };
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* True iff `a` is a strictly NEWER semver than `b`. Used to keep same-channel
|
|
248
|
+
* updates forward-only. Defensive: if either version fails to parse, returns
|
|
249
|
+
* false — an unparseable version must never justify a same-channel update, which
|
|
250
|
+
* could otherwise be an accidental downgrade.
|
|
251
|
+
*/
|
|
252
|
+
export function isStrictlyNewer(a, b) {
|
|
253
|
+
const pa = parseSemver(a);
|
|
254
|
+
const pb = parseSemver(b);
|
|
255
|
+
if (!pa || !pb)
|
|
256
|
+
return false;
|
|
257
|
+
for (let i = 0; i < 3; i++) {
|
|
258
|
+
if (pa.core[i] !== pb.core[i])
|
|
259
|
+
return pa.core[i] > pb.core[i];
|
|
260
|
+
}
|
|
261
|
+
// Equal cores: a release (no prerelease) outranks any prerelease.
|
|
262
|
+
if (pa.pre.length === 0 && pb.pre.length === 0)
|
|
263
|
+
return false;
|
|
264
|
+
if (pa.pre.length === 0)
|
|
265
|
+
return true;
|
|
266
|
+
if (pb.pre.length === 0)
|
|
267
|
+
return false;
|
|
268
|
+
// Both prereleases: compare identifier by identifier.
|
|
269
|
+
const n = Math.max(pa.pre.length, pb.pre.length);
|
|
270
|
+
for (let i = 0; i < n; i++) {
|
|
271
|
+
const x = pa.pre[i];
|
|
272
|
+
const y = pb.pre[i];
|
|
273
|
+
if (x === undefined)
|
|
274
|
+
return false; // a has fewer identifiers → lower precedence
|
|
275
|
+
if (y === undefined)
|
|
276
|
+
return true; // b has fewer identifiers → a is higher
|
|
277
|
+
if (x === y)
|
|
278
|
+
continue;
|
|
279
|
+
const xNum = typeof x === "number";
|
|
280
|
+
const yNum = typeof y === "number";
|
|
281
|
+
if (xNum && yNum)
|
|
282
|
+
return x > y;
|
|
283
|
+
if (xNum !== yNum)
|
|
284
|
+
return yNum; // numeric identifiers rank lower than strings
|
|
285
|
+
return String(x) > String(y);
|
|
286
|
+
}
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* Decide what a single poll cycle should do, given the running version/channel,
|
|
291
|
+
* the freshly-resolved desired channel, and whether we are in broker mode.
|
|
292
|
+
*
|
|
293
|
+
* `remoteVersion` is the published version of the channel this cycle actually
|
|
294
|
+
* TRACKS: the desired channel in legacy/self-spawn mode, but the LAUNCH channel
|
|
295
|
+
* (currentChannel) in broker mode — because the supervisor can only respawn its
|
|
296
|
+
* own frozen launch channel (see the caller).
|
|
297
|
+
*
|
|
298
|
+
* - Legacy/self-spawn mode, channel CHANGED (behaviour 2): force-switch to the
|
|
299
|
+
* desired channel's published version even if it is an OLDER semver — the
|
|
300
|
+
* self-spawn path pins that exact version, so the switch lands. If that
|
|
301
|
+
* version is already running, just adopt the new channel label.
|
|
302
|
+
* - Broker mode, channel CHANGED: DO NOT force-switch. The supervisor respawns
|
|
303
|
+
* its frozen launch channel and ignores install.config, so a force-switch +
|
|
304
|
+
* /restart would loop forever (each child comes up on the launch channel and
|
|
305
|
+
* re-detects the mismatch). We defer the switch and keep tracking the launch
|
|
306
|
+
* channel forward-only, so a genuine forward move on the launch channel still
|
|
307
|
+
* applies while the switch waits for a full-stack restart.
|
|
308
|
+
* - SAME channel (either mode): forward-only — update only when the published
|
|
309
|
+
* version is strictly newer (an equal/older version is ignored, so a poll can
|
|
310
|
+
* never downgrade).
|
|
311
|
+
*/
|
|
312
|
+
export function decideUpdate(args) {
|
|
313
|
+
const { currentVersion, currentChannel, desiredChannel, remoteVersion, brokerMode } = args;
|
|
314
|
+
const channelChanged = desiredChannel !== currentChannel;
|
|
315
|
+
// Legacy/self-spawn honours a live channel switch (the respawn pins the exact
|
|
316
|
+
// resolved version). Broker mode cannot, so it falls through to forward-only.
|
|
317
|
+
if (channelChanged && !brokerMode) {
|
|
318
|
+
if (remoteVersion === currentVersion) {
|
|
319
|
+
return { action: "adopt-channel", channel: desiredChannel };
|
|
320
|
+
}
|
|
321
|
+
return { action: "update", targetVersion: remoteVersion, reason: "channel-switch" };
|
|
322
|
+
}
|
|
323
|
+
// Forward-only on the tracked channel (launch channel in broker mode).
|
|
324
|
+
const forwardUpdate = remoteVersion !== currentVersion && isStrictlyNewer(remoteVersion, currentVersion);
|
|
325
|
+
if (forwardUpdate) {
|
|
326
|
+
return { action: "update", targetVersion: remoteVersion, reason: "forward" };
|
|
327
|
+
}
|
|
328
|
+
// No forward move available. Surface a deferred broker-mode switch so the
|
|
329
|
+
// caller can log it once; otherwise nothing to do.
|
|
330
|
+
if (channelChanged && brokerMode) {
|
|
331
|
+
return { action: "defer-channel-switch", channel: desiredChannel };
|
|
332
|
+
}
|
|
333
|
+
return { action: "none" };
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Fetch the published version of sensorium-mcp for the given update channel
|
|
337
|
+
* (npm dist-tag) from the registry. Defaults to the live desired channel when no
|
|
338
|
+
* channel is supplied, so external callers keep the previous behaviour.
|
|
161
339
|
* Returns null on any error so the caller can silently skip this poll cycle.
|
|
162
340
|
*/
|
|
163
|
-
export async function getRemoteVersion() {
|
|
164
|
-
const channel = getUpdateChannel();
|
|
341
|
+
export async function getRemoteVersion(channel = resolveDesiredChannel()) {
|
|
165
342
|
try {
|
|
166
343
|
const response = await fetch(`https://registry.npmjs.org/sensorium-mcp/${encodeURIComponent(channel)}`, { signal: AbortSignal.timeout(15_000) });
|
|
167
344
|
if (!response.ok) {
|
|
@@ -181,15 +358,17 @@ export async function getRemoteVersion() {
|
|
|
181
358
|
}
|
|
182
359
|
}
|
|
183
360
|
/**
|
|
184
|
-
* Full update sequence:
|
|
361
|
+
* Full update sequence (plan §4.5 — the supervisor owns the mechanics):
|
|
185
362
|
* 1. Write maintenance flag (wakes all active poll loops via fs.watch)
|
|
186
363
|
* 2. Emit in-process signal (belt-and-suspenders for loops not using fs.watch)
|
|
187
364
|
* 3. Grace period (agents observe flag and return maintenance response)
|
|
188
|
-
* 4.
|
|
189
|
-
* 5.
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
365
|
+
* 4. ACK-gated quiesce, then write reconnect snapshot
|
|
366
|
+
* 5. POST /restart to the supervisor → 202, and THIS process just keeps
|
|
367
|
+
* serving until the supervisor tears it down and respawns the channel
|
|
368
|
+
* version. The supervisor owns the maintenance flag from here (it rewrites
|
|
369
|
+
* it and always clears it after the respawn) — we must not touch it again.
|
|
370
|
+
* Fallback (supervisor unreachable / old binary without /restart): the
|
|
371
|
+
* legacy self-spawn path, allowed only outside "required" broker mode.
|
|
193
372
|
*/
|
|
194
373
|
async function performUpdate(targetVersion) {
|
|
195
374
|
state.updateInProgress = true;
|
|
@@ -200,9 +379,12 @@ async function performUpdate(targetVersion) {
|
|
|
200
379
|
// Write maintenance flag
|
|
201
380
|
try {
|
|
202
381
|
mkdirSync(DATA_DIR, { recursive: true });
|
|
382
|
+
// `timestamp` is what checkMaintenanceFlag's 5-min stale auto-clear keys
|
|
383
|
+
// on (and what the supervisor's MaintenanceFlagWriter writes) — without it
|
|
384
|
+
// a flag leaked by a failed update would suppress agents forever.
|
|
203
385
|
writeFileSync(FLAG_PATH, JSON.stringify({
|
|
204
386
|
version: targetVersion,
|
|
205
|
-
|
|
387
|
+
timestamp: new Date().toISOString(),
|
|
206
388
|
}));
|
|
207
389
|
}
|
|
208
390
|
catch (err) {
|
|
@@ -222,7 +404,7 @@ async function performUpdate(targetVersion) {
|
|
|
222
404
|
await quiesceAgents();
|
|
223
405
|
// Write reconnect snapshot so the new instance offers lightweight reconnect
|
|
224
406
|
try {
|
|
225
|
-
const threadIds = getActiveThreadIds();
|
|
407
|
+
const threadIds = await getActiveThreadIds();
|
|
226
408
|
if (threadIds.length > 0) {
|
|
227
409
|
writeReconnectSnapshot(threadIds);
|
|
228
410
|
}
|
|
@@ -230,73 +412,33 @@ async function performUpdate(targetVersion) {
|
|
|
230
412
|
catch (err) {
|
|
231
413
|
log.warn(`[self-update] Reconnect snapshot failed (non-fatal): ${err}`);
|
|
232
414
|
}
|
|
233
|
-
//
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
const spawnLogPath = join(DATA_DIR, "update-spawn.log");
|
|
245
|
-
mkdirSync(DATA_DIR, { recursive: true });
|
|
246
|
-
const logFd = openSync(spawnLogPath, "a");
|
|
247
|
-
const child = spawn(cmd, [], {
|
|
248
|
-
detached: true,
|
|
249
|
-
shell: true,
|
|
250
|
-
stdio: ["ignore", logFd, logFd],
|
|
251
|
-
env: { ...process.env },
|
|
252
|
-
});
|
|
253
|
-
closeSync(logFd);
|
|
254
|
-
child.unref();
|
|
255
|
-
if (child.pid == null) {
|
|
256
|
-
throw new Error("Spawned child process has no PID — cannot proceed");
|
|
257
|
-
}
|
|
258
|
-
log.info(`[self-update] Replacement PID=${child.pid}, polling health on port ${state.httpPort}…`);
|
|
259
|
-
const healthy = await waitForHealthy(state.httpPort, targetVersion);
|
|
260
|
-
if (healthy) {
|
|
261
|
-
// The new server writes its own server.pid on startup (index.ts) with the
|
|
262
|
-
// real node PID. Do NOT overwrite here — child.pid is the transient shell
|
|
263
|
-
// PID on both Windows (cmd.exe) and Unix (/bin/sh), dead by this point.
|
|
264
|
-
// Remove maintenance flag — watcher MCPs unblock, new keepAlive threads start
|
|
265
|
-
try {
|
|
266
|
-
unlinkSync(FLAG_PATH);
|
|
267
|
-
}
|
|
268
|
-
catch {
|
|
269
|
-
/* best-effort */
|
|
270
|
-
}
|
|
271
|
-
log.info(`[self-update] Update to v${targetVersion} complete. Exiting.`);
|
|
272
|
-
process.exit(0);
|
|
273
|
-
}
|
|
274
|
-
else {
|
|
275
|
-
log.error("[self-update] New process failed to become healthy. Aborting update.");
|
|
276
|
-
// On Windows with shell:true, child.pid is the transient cmd.exe shell (already dead).
|
|
277
|
-
// Attempt kill anyway, then fall back to port-based kill on Windows.
|
|
278
|
-
try {
|
|
279
|
-
child.kill();
|
|
415
|
+
// Delegate the restart to the supervisor — the single process-lifecycle
|
|
416
|
+
// owner. It answers 202 and then runs the shared teardown/respawn
|
|
417
|
+
// choreography (PrepareShutdown → kill → spawn → wait-ready → clear flag);
|
|
418
|
+
// the spawn command re-resolves the channel version via npx by itself.
|
|
419
|
+
const mode = brokerSpawnMode();
|
|
420
|
+
if (mode !== "off") {
|
|
421
|
+
const verdict = await brokerRequestRestart(`self-update v${state.currentVersion} -> v${targetVersion}`);
|
|
422
|
+
if (verdict !== null) {
|
|
423
|
+
state.restartDelegatedAt = Date.now();
|
|
424
|
+
log.info(`[self-update] Restart ${verdict === "accepted" ? "delegated to" : "already in progress at"} the supervisor — awaiting teardown (it owns the maintenance flag now).`);
|
|
425
|
+
return;
|
|
280
426
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
427
|
+
if (mode === "required") {
|
|
428
|
+
// No fallback allowed and no restart is coming: unblock the agents by
|
|
429
|
+
// clearing our flag and retry on a later poll cycle.
|
|
430
|
+
log.error("[self-update] Supervisor unreachable (or predates /restart) and SENSORIUM_BROKER_SPAWN=required — update aborted, will retry next poll.");
|
|
285
431
|
try {
|
|
286
|
-
|
|
287
|
-
// Find and kill whatever is bound to our HTTP port
|
|
288
|
-
execSync(`for /f "tokens=5" %a in ('netstat -ano ^| findstr :${state.httpPort} ^| findstr LISTENING') do taskkill /F /PID %a`, { shell: "cmd.exe", stdio: "ignore", timeout: 10_000 });
|
|
289
|
-
log.info(`[self-update] Killed orphan process on port ${state.httpPort}`);
|
|
432
|
+
unlinkSync(FLAG_PATH);
|
|
290
433
|
}
|
|
291
434
|
catch {
|
|
292
|
-
|
|
435
|
+
/* best-effort */
|
|
293
436
|
}
|
|
437
|
+
return;
|
|
294
438
|
}
|
|
295
|
-
|
|
296
|
-
// with an orphaned spawned child that may still be starting.
|
|
297
|
-
log.error("[self-update] Exiting (HTTP server closed, cannot recover). Supervisor will restart.");
|
|
298
|
-
process.exit(1);
|
|
439
|
+
log.warn("[self-update] Supervisor unreachable (or predates /restart) — falling back to legacy self-spawn so an old supervisor binary cannot strand updates.");
|
|
299
440
|
}
|
|
441
|
+
await legacySelfSpawnUpdate(targetVersion);
|
|
300
442
|
}
|
|
301
443
|
catch (err) {
|
|
302
444
|
log.error(`[self-update] Update sequence failed: ${err}`);
|
|
@@ -312,10 +454,98 @@ async function performUpdate(targetVersion) {
|
|
|
312
454
|
}
|
|
313
455
|
}
|
|
314
456
|
/**
|
|
315
|
-
*
|
|
316
|
-
*
|
|
457
|
+
* Legacy self-spawn update (pre-Phase-4): close the HTTP listener, spawn the
|
|
458
|
+
* replacement ourselves, health-poll it, and exit. Kept ONLY for dev mode
|
|
459
|
+
* without a supervisor (SENSORIUM_BROKER_SPAWN=off / non-Windows) and as the
|
|
460
|
+
* auto-mode fallback behind an old supervisor binary. The netstat/taskkill
|
|
461
|
+
* orphan-hunt this path once had is gone — no shell-outs anywhere in the stack
|
|
462
|
+
* (plan §3.2); a failed replacement is now cleaned up by whoever owns process
|
|
463
|
+
* mechanics (the supervisor's kill-by-port on its next restart) or manually in
|
|
464
|
+
* true dev mode.
|
|
465
|
+
*/
|
|
466
|
+
async function legacySelfSpawnUpdate(targetVersion) {
|
|
467
|
+
// Close existing HTTP server to free the port for the replacement process
|
|
468
|
+
const beforeSpawn = state.beforeSpawnHook;
|
|
469
|
+
if (beforeSpawn) {
|
|
470
|
+
log.info("[self-update] Closing HTTP server to free port...");
|
|
471
|
+
await beforeSpawn();
|
|
472
|
+
}
|
|
473
|
+
// Spawn the replacement process (detached so it survives our exit).
|
|
474
|
+
// Pin the EXACT resolved version rather than re-running the frozen
|
|
475
|
+
// MCP_START_COMMAND: its floating "@<tag>" would re-resolve to the tag's
|
|
476
|
+
// CURRENT version, which on a channel switch is a different build than the
|
|
477
|
+
// one we just decided to install. Pinning also bypasses the stale npx cache
|
|
478
|
+
// (EPERM prevents a cache clear on Windows). This path is the dev/no-supervisor
|
|
479
|
+
// fallback; the supervisor path re-resolves the channel itself.
|
|
480
|
+
const cmd = `npx -y sensorium-mcp@${targetVersion} --prefer-online`;
|
|
481
|
+
log.info(`[self-update] Spawning replacement (legacy self-spawn): ${cmd}`);
|
|
482
|
+
const spawnLogPath = join(DATA_DIR, "update-spawn.log");
|
|
483
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
484
|
+
const logFd = openSync(spawnLogPath, "a");
|
|
485
|
+
const child = spawn(cmd, [], {
|
|
486
|
+
detached: true,
|
|
487
|
+
shell: true,
|
|
488
|
+
stdio: ["ignore", logFd, logFd],
|
|
489
|
+
env: { ...process.env },
|
|
490
|
+
});
|
|
491
|
+
closeSync(logFd);
|
|
492
|
+
child.unref();
|
|
493
|
+
if (child.pid == null) {
|
|
494
|
+
throw new Error("Spawned child process has no PID — cannot proceed");
|
|
495
|
+
}
|
|
496
|
+
log.info(`[self-update] Replacement PID=${child.pid}, polling health on port ${state.httpPort}…`);
|
|
497
|
+
const healthy = await waitForHealthy(state.httpPort, targetVersion);
|
|
498
|
+
if (healthy) {
|
|
499
|
+
// The new server writes its own server.pid on startup (index.ts) with the
|
|
500
|
+
// real node PID. Do NOT overwrite here — child.pid is the transient shell
|
|
501
|
+
// PID on both Windows (cmd.exe) and Unix (/bin/sh), dead by this point.
|
|
502
|
+
// Remove maintenance flag — watcher MCPs unblock, new keepAlive threads start
|
|
503
|
+
try {
|
|
504
|
+
unlinkSync(FLAG_PATH);
|
|
505
|
+
}
|
|
506
|
+
catch {
|
|
507
|
+
/* best-effort */
|
|
508
|
+
}
|
|
509
|
+
log.info(`[self-update] Update to v${targetVersion} complete. Exiting.`);
|
|
510
|
+
process.exit(0);
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
log.error("[self-update] New process failed to become healthy. Aborting update.");
|
|
514
|
+
// On Windows with shell:true, child.pid is the transient cmd.exe shell
|
|
515
|
+
// (already dead) — this kill is best-effort only. The old netstat/taskkill
|
|
516
|
+
// port-hunt is deliberately gone (no shell-outs).
|
|
517
|
+
try {
|
|
518
|
+
child.kill();
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
/* may already be dead */
|
|
522
|
+
}
|
|
523
|
+
// Leave maintenance.flag intact so a supervisor (if any) doesn't immediately
|
|
524
|
+
// race with an orphaned spawned child that may still be starting.
|
|
525
|
+
log.error("[self-update] Exiting (HTTP server closed, cannot recover). Supervisor will restart.");
|
|
526
|
+
process.exit(1);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Run one check cycle. The desired channel is resolved LIVE from
|
|
531
|
+
* install.config.json each cycle (behaviour 1). A change vs the running channel
|
|
532
|
+
* force-switches to that channel's published version even if it is older
|
|
533
|
+
* (behaviour 2); a same-channel poll updates only when the published version is
|
|
534
|
+
* strictly newer. See decideUpdate for the full rule set.
|
|
317
535
|
*/
|
|
318
536
|
export async function checkForUpdate() {
|
|
537
|
+
// Once the supervisor has accepted a restart, this process is only alive
|
|
538
|
+
// until the teardown reaches it — don't start another update cycle. But a
|
|
539
|
+
// bounded wait, not a latch: if the teardown never arrives (kill failed and
|
|
540
|
+
// we survived), resume polling rather than staying pinned to a stale version
|
|
541
|
+
// forever. The orchestrator clears the maintenance flag even on failure, so
|
|
542
|
+
// a retry starts from a clean slate.
|
|
543
|
+
if (state.restartDelegatedAt !== 0) {
|
|
544
|
+
if (Date.now() - state.restartDelegatedAt < DELEGATED_RESTART_GRACE_MS)
|
|
545
|
+
return;
|
|
546
|
+
log.warn(`[self-update] Supervisor accepted a restart ${Math.round((Date.now() - state.restartDelegatedAt) / 60_000)}m ago but this process is still alive — teardown presumed failed, resuming update polling.`);
|
|
547
|
+
state.restartDelegatedAt = 0;
|
|
548
|
+
}
|
|
319
549
|
if (state.updateInProgress)
|
|
320
550
|
return;
|
|
321
551
|
state.updateInProgress = true;
|
|
@@ -326,17 +556,56 @@ export async function checkForUpdate() {
|
|
|
326
556
|
log.debug(`[self-update] Deferring — uptime ${Math.round(process.uptime())}s < min ${minUptimeMs / 1000}s`);
|
|
327
557
|
return;
|
|
328
558
|
}
|
|
329
|
-
const
|
|
559
|
+
const desiredChannel = resolveDesiredChannel();
|
|
560
|
+
// Broker mode can only respawn the supervisor's own frozen launch channel
|
|
561
|
+
// (it ignores install.config), so acting on a channel CHANGE there would
|
|
562
|
+
// restart-loop. We therefore TRACK the launch channel in broker mode and
|
|
563
|
+
// defer the switch; only the legacy/self-spawn path (which pins the exact
|
|
564
|
+
// resolved version on respawn) follows the desired channel live.
|
|
565
|
+
const brokerMode = brokerSpawnMode() !== "off";
|
|
566
|
+
const trackedChannel = brokerMode ? state.currentChannel : desiredChannel;
|
|
567
|
+
const remoteVersion = await getRemoteVersion(trackedChannel);
|
|
330
568
|
if (remoteVersion === null)
|
|
331
569
|
return;
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
570
|
+
const decision = decideUpdate({
|
|
571
|
+
currentVersion: state.currentVersion,
|
|
572
|
+
currentChannel: state.currentChannel,
|
|
573
|
+
desiredChannel,
|
|
574
|
+
remoteVersion,
|
|
575
|
+
brokerMode,
|
|
576
|
+
});
|
|
577
|
+
switch (decision.action) {
|
|
578
|
+
case "none":
|
|
579
|
+
// Routine "up to date" result — not logged. It fired every check (~once
|
|
580
|
+
// a minute) and was pure noise; the meaningful events (update found/
|
|
581
|
+
// applied/failed) are logged on their own branches.
|
|
582
|
+
return;
|
|
583
|
+
case "defer-channel-switch":
|
|
584
|
+
// Broker mode saw a config channel change it cannot honor yet. Log once
|
|
585
|
+
// per distinct target (not every cycle), keep currentChannel = launch so
|
|
586
|
+
// it never thrashes, and stay on the launch channel forward-only.
|
|
587
|
+
if (state.deferredSwitchLoggedFor !== decision.channel) {
|
|
588
|
+
state.deferredSwitchLoggedFor = decision.channel;
|
|
589
|
+
log.warn(`[self-update] channel-switch to "${decision.channel}" deferred: supervisor cannot honor targetVersion yet — staying on "${state.currentChannel}" (forward-only). A full-stack restart / installer re-run is required to switch.`);
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
case "adopt-channel":
|
|
593
|
+
// The new channel resolves to the exact version already running, so no
|
|
594
|
+
// respawn is needed — just adopt the label so we don't re-evaluate the
|
|
595
|
+
// switch every cycle.
|
|
596
|
+
log.info(`[self-update] Channel changed "${state.currentChannel}" -> "${decision.channel}"; already on v${state.currentVersion} — adopting channel, no restart.`);
|
|
597
|
+
state.currentChannel = decision.channel;
|
|
598
|
+
return;
|
|
599
|
+
case "update":
|
|
600
|
+
if (decision.reason === "channel-switch") {
|
|
601
|
+
log.info(`[self-update] Channel changed "${state.currentChannel}" -> "${desiredChannel}"; force-switching to v${decision.targetVersion} (current v${state.currentVersion}).`);
|
|
602
|
+
}
|
|
603
|
+
else {
|
|
604
|
+
log.info(`[self-update] Newer version on channel "${trackedChannel}": v${decision.targetVersion} (current v${state.currentVersion}) — updating.`);
|
|
605
|
+
}
|
|
606
|
+
await performUpdate(decision.targetVersion);
|
|
607
|
+
return;
|
|
337
608
|
}
|
|
338
|
-
log.info(`[self-update] Channel version changed: v${remoteVersion} (current: v${state.currentVersion}) — updating`);
|
|
339
|
-
await performUpdate(remoteVersion);
|
|
340
609
|
}
|
|
341
610
|
finally {
|
|
342
611
|
state.updateInProgress = false;
|
|
@@ -358,12 +627,16 @@ export function startSelfUpdatePoller(config) {
|
|
|
358
627
|
return;
|
|
359
628
|
}
|
|
360
629
|
state.currentVersion = config.pkgVersion;
|
|
630
|
+
// Baseline channel = what THIS process was actually launched on (frozen env).
|
|
631
|
+
// Each poll compares the live install.config.json channel against this to
|
|
632
|
+
// detect an operator-driven channel switch.
|
|
633
|
+
state.currentChannel = getUpdateChannel();
|
|
361
634
|
state.httpPort = config.httpPort;
|
|
362
635
|
if (config.onBeforeSpawn)
|
|
363
636
|
state.beforeSpawnHook = config.onBeforeSpawn;
|
|
364
637
|
const rawInterval = parseInt(process.env.SELF_UPDATE_POLL_INTERVAL_MS ?? "", 10);
|
|
365
638
|
const interval = Number.isFinite(rawInterval) && rawInterval > 0 ? rawInterval : 60_000;
|
|
366
|
-
log.info(`[self-update] Poller started (current: v${state.currentVersion}, interval: ${interval}ms)`);
|
|
639
|
+
log.info(`[self-update] Poller started (current: v${state.currentVersion} @${state.currentChannel}, interval: ${interval}ms)`);
|
|
367
640
|
setInterval(() => {
|
|
368
641
|
void checkForUpdate();
|
|
369
642
|
}, interval);
|