@pleri/olam-cli 0.1.173 → 0.1.175

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 (33) hide show
  1. package/dist/commands/auth.d.ts +22 -7
  2. package/dist/commands/auth.d.ts.map +1 -1
  3. package/dist/commands/auth.js +414 -46
  4. package/dist/commands/auth.js.map +1 -1
  5. package/dist/commands/create.d.ts.map +1 -1
  6. package/dist/commands/create.js +45 -1
  7. package/dist/commands/create.js.map +1 -1
  8. package/dist/commands/services.d.ts +39 -0
  9. package/dist/commands/services.d.ts.map +1 -1
  10. package/dist/commands/services.js +64 -9
  11. package/dist/commands/services.js.map +1 -1
  12. package/dist/from-manifest.d.ts +53 -0
  13. package/dist/from-manifest.d.ts.map +1 -0
  14. package/dist/from-manifest.js +95 -0
  15. package/dist/from-manifest.js.map +1 -0
  16. package/dist/image-digests.json +8 -8
  17. package/dist/index.js +907 -136
  18. package/dist/lib/auth-remote.d.ts +130 -0
  19. package/dist/lib/auth-remote.d.ts.map +1 -0
  20. package/dist/lib/auth-remote.js +307 -0
  21. package/dist/lib/auth-remote.js.map +1 -0
  22. package/dist/mcp-server.js +254 -57
  23. package/hermes-bundle/version.json +1 -1
  24. package/host-cp/k8s/manifests/50-deployment.yaml +1 -1
  25. package/host-cp/k8s/manifests/auth-service/50-deployment.yaml +1 -1
  26. package/host-cp/k8s/manifests/kg-service/50-deployment.yaml +1 -1
  27. package/host-cp/k8s/manifests/mcp-auth-service/50-deployment.yaml +1 -1
  28. package/host-cp/k8s/manifests/memory-service/50-deployment.yaml +1 -1
  29. package/host-cp/src/boot-reconciler.mjs +238 -0
  30. package/host-cp/src/port-bridge-manager.mjs +116 -10
  31. package/host-cp/src/server.mjs +32 -0
  32. package/host-cp/src/world-activity-tracker.mjs +392 -0
  33. package/package.json +1 -1
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Boot-time reconciler — sync worlds.db with live docker state.
3
+ *
4
+ * Problem (issue #963): after Colima / userspace restart, host-cp can
5
+ * start with worlds.db rows that no longer reflect docker reality. The
6
+ * existing `worlds-db-source.mjs` reconciler runs DB→registry (reads
7
+ * 'running' rows and adds them to in-memory WORLDS). It does NOT heal
8
+ * the inverse case: a container is alive on docker but worlds.db has
9
+ * no row (Hazel coral-sky-2478 scenario), or worlds.db says a world is
10
+ * running but the container is gone (orphaned row).
11
+ *
12
+ * This module fills both gaps with a one-shot pass at boot:
13
+ *
14
+ * 1. List `olam-*-devbox` containers via the docker API.
15
+ * 2. For each container, derive the worldId (strip prefix + suffix).
16
+ * 3. Cross-check against worlds.db rows:
17
+ * - container alive, row exists → no-op
18
+ * - container alive, row missing → INSERT (status=reconciled)
19
+ * - row says running/active, container missing → UPDATE status=orphaned
20
+ *
21
+ * Fail-soft: if the docker daemon is unreachable OR better-sqlite3 is
22
+ * not available, the function logs a warning and returns without
23
+ * throwing. Server boot continues.
24
+ *
25
+ * Idempotent: a second invocation against the same docker + DB state
26
+ * produces no further changes (existing rows are skipped at step 3a,
27
+ * already-orphaned rows are skipped at step 3c).
28
+ *
29
+ * Coordination with issue #962: the dedup logic in `olam create` handles
30
+ * per-call deduplication; this reconciler handles boot-time cleanup.
31
+ * They don't conflict — both operate on the worlds.db source-of-truth.
32
+ */
33
+
34
+ import { createRequire } from 'node:module';
35
+
36
+ const require = createRequire(import.meta.url);
37
+
38
+ const CONTAINER_NAME_PATTERN = /^\/?(olam-(.+)-devbox)$/;
39
+
40
+ /**
41
+ * @typedef {object} ReconcileDeps
42
+ * @property {string} dbPath Path to worlds.db
43
+ * @property {() => Promise<string[] | null>} listContainerNames Returns null when docker is unreachable
44
+ * @property {(msg: string) => void} [log] Defaults to console.log
45
+ * @property {() => string} [now] ISO timestamp generator (overridable for tests)
46
+ * @property {(path: string) => unknown | null} [openDb] Overridable DB opener (tests inject fakes)
47
+ */
48
+
49
+ /**
50
+ * @typedef {object} ReconcileSummary
51
+ * @property {number} inserted Number of new rows inserted (reconciled containers)
52
+ * @property {number} orphaned Number of rows transitioned to status='orphaned'
53
+ * @property {number} skipped Containers/rows where no change was needed
54
+ * @property {boolean} dockerUnreachable
55
+ * @property {boolean} dbUnavailable
56
+ */
57
+
58
+ /**
59
+ * Extract a worldId from a docker container name.
60
+ * Accepts either `olam-foo-bar-1234-devbox` or `/olam-foo-bar-1234-devbox`
61
+ * (the docker API prefixes container names with a slash).
62
+ *
63
+ * @param {string} name
64
+ * @returns {string | null}
65
+ */
66
+ export function extractWorldIdFromContainerName(name) {
67
+ if (typeof name !== 'string') return null;
68
+ const match = CONTAINER_NAME_PATTERN.exec(name);
69
+ if (!match) return null;
70
+ const worldId = match[2];
71
+ if (!worldId || worldId.length === 0) return null;
72
+ return worldId;
73
+ }
74
+
75
+ /**
76
+ * Default docker container lister. Hits the Docker Engine API.
77
+ * Returns null on any failure (fail-soft).
78
+ *
79
+ * @param {string} dockerApiBase e.g. 'http://docker-socket-proxy:2375'
80
+ * @param {(msg: string) => void} log
81
+ * @returns {Promise<string[] | null>}
82
+ */
83
+ export async function defaultListContainerNames(dockerApiBase, log) {
84
+ if (!dockerApiBase || dockerApiBase === 'http://localhost:2375') {
85
+ // 'docker-cli' sentinel; no API available in this deployment mode.
86
+ log('[boot-reconciler] docker API unavailable (bare-node mode); skipping');
87
+ return null;
88
+ }
89
+ try {
90
+ const filters = encodeURIComponent(JSON.stringify({ name: ['olam-'] }));
91
+ const url = `${dockerApiBase}/containers/json?filters=${filters}`;
92
+ const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
93
+ if (!res.ok) {
94
+ log(`[boot-reconciler] docker /containers/json returned ${res.status}; skipping`);
95
+ return null;
96
+ }
97
+ const data = await res.json();
98
+ if (!Array.isArray(data)) return [];
99
+ const names = [];
100
+ for (const container of data) {
101
+ const list = container?.Names;
102
+ if (!Array.isArray(list)) continue;
103
+ for (const n of list) {
104
+ if (typeof n === 'string') names.push(n);
105
+ }
106
+ }
107
+ return names;
108
+ } catch (err) {
109
+ log(`[boot-reconciler] docker query failed: ${err.message}; skipping`);
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Default DB opener. Loads better-sqlite3 dynamically so a missing
116
+ * native build degrades gracefully instead of crashing host-cp boot.
117
+ *
118
+ * @param {string} dbPath
119
+ * @param {(msg: string) => void} log
120
+ * @returns {unknown | null}
121
+ */
122
+ export function defaultOpenDb(dbPath, log) {
123
+ try {
124
+ const Database = require('better-sqlite3');
125
+ return new Database(dbPath, { fileMustExist: true });
126
+ } catch (err) {
127
+ if (err && err.code === 'MODULE_NOT_FOUND') {
128
+ log('[boot-reconciler] better-sqlite3 not available; skipping');
129
+ } else if (err && err.code === 'SQLITE_CANTOPEN') {
130
+ log(`[boot-reconciler] ${dbPath} not found; nothing to reconcile`);
131
+ } else {
132
+ log(`[boot-reconciler] failed to open ${dbPath}: ${err.message}`);
133
+ }
134
+ return null;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Run a single boot-time reconciliation pass. Pure and dep-injected
140
+ * for testability.
141
+ *
142
+ * @param {ReconcileDeps} deps
143
+ * @returns {Promise<ReconcileSummary>}
144
+ */
145
+ export async function reconcileWorldsWithDocker(deps) {
146
+ const log = deps.log ?? console.log;
147
+ const now = deps.now ?? (() => new Date().toISOString());
148
+ const openDb = deps.openDb ?? ((p) => defaultOpenDb(p, log));
149
+
150
+ const summary = {
151
+ inserted: 0,
152
+ orphaned: 0,
153
+ skipped: 0,
154
+ dockerUnreachable: false,
155
+ dbUnavailable: false,
156
+ };
157
+
158
+ const containerNames = await deps.listContainerNames();
159
+ if (containerNames === null) {
160
+ summary.dockerUnreachable = true;
161
+ return summary;
162
+ }
163
+
164
+ const liveWorldIds = new Set();
165
+ for (const name of containerNames) {
166
+ const worldId = extractWorldIdFromContainerName(name);
167
+ if (worldId) liveWorldIds.add(worldId);
168
+ }
169
+
170
+ const db = openDb(deps.dbPath);
171
+ if (!db) {
172
+ summary.dbUnavailable = true;
173
+ return summary;
174
+ }
175
+
176
+ try {
177
+ /** @type {Array<{ id: string, status: string }>} */
178
+ let rows;
179
+ try {
180
+ rows = db.prepare('SELECT id, status FROM worlds').all();
181
+ } catch (err) {
182
+ log(`[boot-reconciler] query failed: ${err.message}; skipping`);
183
+ summary.dbUnavailable = true;
184
+ return summary;
185
+ }
186
+
187
+ const dbWorlds = new Map(rows.map((r) => [r.id, r.status]));
188
+
189
+ // Pass 1: containers alive but missing from DB → insert.
190
+ const insertStmt = db.prepare(
191
+ `INSERT INTO worlds
192
+ (id, name, status, repos, branch, port_offset, workspace_path,
193
+ compute_provider, total_cost_usd, thought_count, created_at, updated_at)
194
+ VALUES (?, ?, 'reconciled', '[]', 'main', 0, ?, 'docker', 0, 0, ?, ?)`,
195
+ );
196
+ for (const worldId of liveWorldIds) {
197
+ if (dbWorlds.has(worldId)) {
198
+ summary.skipped += 1;
199
+ continue;
200
+ }
201
+ const ts = now();
202
+ const workspacePath = `~/.olam/worlds/${worldId}`;
203
+ try {
204
+ insertStmt.run(worldId, worldId, workspacePath, ts, ts);
205
+ summary.inserted += 1;
206
+ log(`[boot-reconciler] inserted reconciled row for ${worldId} (container alive, no DB row)`);
207
+ } catch (err) {
208
+ log(`[boot-reconciler] failed to insert ${worldId}: ${err.message}`);
209
+ }
210
+ }
211
+
212
+ // Pass 2: DB says alive but container missing → mark orphaned.
213
+ const orphanStmt = db.prepare(
214
+ `UPDATE worlds SET status = 'orphaned', updated_at = ? WHERE id = ?`,
215
+ );
216
+ const aliveStatuses = new Set(['running', 'active', 'creating']);
217
+ for (const [worldId, status] of dbWorlds) {
218
+ if (liveWorldIds.has(worldId)) continue;
219
+ if (!aliveStatuses.has(status)) continue;
220
+ try {
221
+ orphanStmt.run(now(), worldId);
222
+ summary.orphaned += 1;
223
+ log(`[boot-reconciler] marked ${worldId} as orphaned (was '${status}', container missing)`);
224
+ } catch (err) {
225
+ log(`[boot-reconciler] failed to mark ${worldId} orphaned: ${err.message}`);
226
+ }
227
+ }
228
+
229
+ log(
230
+ `[boot-reconciler] complete: inserted=${summary.inserted} orphaned=${summary.orphaned} ` +
231
+ `skipped=${summary.skipped} live-containers=${liveWorldIds.size}`,
232
+ );
233
+ } finally {
234
+ try { db.close?.(); } catch { /* ignore */ }
235
+ }
236
+
237
+ return summary;
238
+ }
@@ -10,6 +10,7 @@ import path from 'node:path';
10
10
 
11
11
  const DOCKER_HOST = process.env.DOCKER_HOST ?? 'docker-cli';
12
12
  const SOCAT_IMAGE = 'alpine/socat';
13
+ const SOCAT_IMAGE_TAGGED = 'alpine/socat:latest';
13
14
  const HOST_PORT_MIN = 25000;
14
15
  const HOST_PORT_MAX = 25999;
15
16
  const INFRA_PORTS = new Set([8080, 7681, 7682]);
@@ -83,11 +84,73 @@ async function dockerApiBase() {
83
84
  }
84
85
 
85
86
  /**
86
- * Create and start a socat bridge container. Returns containerId.
87
+ * Detect whether a docker error message indicates the image is missing
88
+ * (and therefore a `docker pull` retry would help). Docker uses a handful
89
+ * of phrasings across CLI + HTTP API surfaces.
90
+ */
91
+ function isImageMissingError(message) {
92
+ if (!message) return false;
93
+ return /Unable to find image|pull access denied|manifest unknown|No such image|not found in (the )?(repository|registry)/i.test(
94
+ message,
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Pull alpine/socat:latest via docker CLI. Used by the bare-node bridge
100
+ * create path's fallback retry. 60s budget — image is ~5MB; real pull
101
+ * is typically <2s.
102
+ *
103
+ * @returns {{ok: boolean, stderr: string}}
104
+ */
105
+ function pullSocatViaCli() {
106
+ const r = spawnSync('docker', ['pull', SOCAT_IMAGE_TAGGED], {
107
+ encoding: 'utf-8',
108
+ timeout: 60_000,
109
+ });
110
+ return {
111
+ ok: r.status === 0,
112
+ stderr: (r.stderr ?? '').trim() || (r.stdout ?? '').trim(),
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Pull alpine/socat:latest via Docker HTTP API. Used by the container-mode
118
+ * bridge create path's fallback retry. Streams the pull progress body so
119
+ * Docker actually performs the pull (it's a streaming endpoint).
120
+ *
121
+ * @param {string} apiBase — Docker HTTP API base URL
122
+ * @returns {Promise<{ok: boolean, stderr: string}>}
123
+ */
124
+ async function pullSocatViaHttpApi(apiBase) {
125
+ try {
126
+ const resp = await fetch(
127
+ `${apiBase}/images/create?fromImage=${encodeURIComponent(SOCAT_IMAGE)}&tag=latest`,
128
+ { method: 'POST', signal: AbortSignal.timeout(60_000) },
129
+ );
130
+ if (!resp.ok) {
131
+ const body = await resp.text().catch(() => '');
132
+ return { ok: false, stderr: `pull failed: ${resp.status} ${body}` };
133
+ }
134
+ // Drain the streaming progress body — Docker only completes the pull
135
+ // when the response is consumed.
136
+ await resp.text();
137
+ return { ok: true, stderr: '' };
138
+ } catch (err) {
139
+ return { ok: false, stderr: err?.message ?? String(err) };
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Create and start a socat bridge container.
145
+ *
146
+ * Returns `{ containerId, pulledImage }` — `pulledImage: true` indicates the
147
+ * function had to fall back to `docker pull alpine/socat:latest` (issue #964
148
+ * — preflight in `olam services up` should normally have already pulled it).
149
+ *
87
150
  * @param {string} worldId
88
151
  * @param {number} containerPort
89
152
  * @param {number} hostPort
90
- * @returns {Promise<string>} containerId
153
+ * @returns {Promise<{containerId: string, pulledImage: boolean}>}
91
154
  */
92
155
  async function createBridgeContainer(worldId, containerPort, hostPort) {
93
156
  const name = bridgeContainerName(worldId, containerPort);
@@ -111,11 +174,28 @@ async function createBridgeContainer(worldId, containerPort, hostPort) {
111
174
  'TCP-LISTEN:' + containerPort + ',fork,reuseaddr',
112
175
  'TCP:' + devboxName + ':' + containerPort,
113
176
  ];
114
- const result = spawnSync('docker', args, { encoding: 'utf-8', timeout: 10000 });
177
+ let result = spawnSync('docker', args, { encoding: 'utf-8', timeout: 10000 });
178
+ let pulledImage = false;
179
+
180
+ // Issue #964 fallback: if docker run failed because the image is missing,
181
+ // pull it and retry once. This covers hosts where `olam services up`
182
+ // didn't run the preflight (e.g. fresh Hazel install, docker restart
183
+ // pruned the image, etc.).
184
+ if (result.status !== 0 && isImageMissingError(result.stderr ?? '')) {
185
+ const pull = pullSocatViaCli();
186
+ if (!pull.ok) {
187
+ throw new Error(
188
+ `alpine/socat image missing and pull failed: ${pull.stderr || 'unknown error'}`,
189
+ );
190
+ }
191
+ pulledImage = true;
192
+ result = spawnSync('docker', args, { encoding: 'utf-8', timeout: 10000 });
193
+ }
194
+
115
195
  if (result.status !== 0) {
116
196
  throw new Error(result.stderr?.trim() || 'docker run failed');
117
197
  }
118
- return result.stdout.trim(); // container ID
198
+ return { containerId: result.stdout.trim(), pulledImage };
119
199
  }
120
200
 
121
201
  // container mode: Docker HTTP API
@@ -135,7 +215,7 @@ async function createBridgeContainer(worldId, containerPort, hostPort) {
135
215
  },
136
216
  };
137
217
 
138
- const createResp = await fetch(
218
+ const doCreate = () => fetch(
139
219
  `${apiBase}/containers/create?name=${encodeURIComponent(name)}`,
140
220
  {
141
221
  method: 'POST',
@@ -145,6 +225,28 @@ async function createBridgeContainer(worldId, containerPort, hostPort) {
145
225
  },
146
226
  );
147
227
 
228
+ let createResp = await doCreate();
229
+ let pulledImage = false;
230
+
231
+ // Issue #964 fallback for HTTP API path. Docker returns 404 with a body
232
+ // like {"message":"No such image: alpine/socat:latest"} when the image
233
+ // is missing.
234
+ if (!createResp.ok && createResp.status === 404) {
235
+ const body = await createResp.text().catch(() => '');
236
+ if (isImageMissingError(body)) {
237
+ const pull = await pullSocatViaHttpApi(apiBase);
238
+ if (!pull.ok) {
239
+ throw new Error(
240
+ `alpine/socat image missing and pull failed: ${pull.stderr || 'unknown error'}`,
241
+ );
242
+ }
243
+ pulledImage = true;
244
+ createResp = await doCreate();
245
+ } else {
246
+ throw new Error(`container create failed: 404 ${body}`);
247
+ }
248
+ }
249
+
148
250
  if (!createResp.ok) {
149
251
  const body = await createResp.text().catch(() => '');
150
252
  // If container already exists (409), try to get its ID
@@ -155,7 +257,7 @@ async function createBridgeContainer(worldId, containerPort, hostPort) {
155
257
  );
156
258
  if (inspectResp.ok) {
157
259
  const info = await inspectResp.json();
158
- return info.Id;
260
+ return { containerId: info.Id, pulledImage };
159
261
  }
160
262
  }
161
263
  throw new Error(`container create failed: ${createResp.status} ${body}`);
@@ -171,7 +273,7 @@ async function createBridgeContainer(worldId, containerPort, hostPort) {
171
273
  throw new Error(`container start failed: ${startResp.status}`);
172
274
  }
173
275
 
174
- return containerId;
276
+ return { containerId, pulledImage };
175
277
  }
176
278
 
177
279
  async function removeBridgeContainer(containerName, containerId) {
@@ -196,7 +298,7 @@ async function removeBridgeContainer(containerName, containerId) {
196
298
  *
197
299
  * @param {string} worldId
198
300
  * @param {number} containerPort
199
- * @returns {Promise<{hostPort: number, containerPort: number, url: string, containerId: string}>}
301
+ * @returns {Promise<{hostPort: number, containerPort: number, url: string, containerId: string, pulledImage?: boolean}>}
200
302
  */
201
303
  export async function exposePort(worldId, containerPort) {
202
304
  if (INFRA_PORTS.has(containerPort)) {
@@ -220,18 +322,22 @@ export async function exposePort(worldId, containerPort) {
220
322
  }
221
323
 
222
324
  const containerName = bridgeContainerName(worldId, containerPort);
223
- const containerId = await createBridgeContainer(worldId, containerPort, hostPort);
325
+ const { containerId, pulledImage } = await createBridgeContainer(worldId, containerPort, hostPort);
224
326
 
225
327
  const entry = { worldId, containerPort, hostPort, containerId, containerName };
226
328
  registry.set(key, entry);
227
329
  saveState();
228
330
 
229
- return {
331
+ const result = {
230
332
  hostPort,
231
333
  containerPort,
232
334
  url: `http://${HOST_IP}:${hostPort}`,
233
335
  containerId,
234
336
  };
337
+ // Only attach pulledImage when true so existing callers/tests don't see
338
+ // an unexpected key when the preflight succeeded.
339
+ if (pulledImage) result.pulledImage = true;
340
+ return result;
235
341
  }
236
342
 
237
343
  /**
@@ -76,6 +76,11 @@ import { readSecret as readPlanChatSecret, SECRET_PATH as PLAN_CHAT_SECRET_PATH
76
76
  import { createPrMergePoller } from './pr-merge-poller.mjs';
77
77
  import { parse as parseYaml } from 'yaml';
78
78
  import { startWorldsDbReconciler } from './worlds-db-source.mjs';
79
+ import {
80
+ reconcileWorldsWithDocker,
81
+ defaultListContainerNames,
82
+ } from './boot-reconciler.mjs';
83
+ import { startWorldActivityTracker } from './world-activity-tracker.mjs';
79
84
  import { authSecretHint } from './auth-secret-hint.mjs';
80
85
  import * as tunnelManager from './world-tunnel-manager.mjs';
81
86
  import * as bridgeManager from './port-bridge-manager.mjs';
@@ -3270,6 +3275,16 @@ startWorldsSnapshotLoop();
3270
3275
  startTunnelsSnapshotLoop();
3271
3276
  startListeningSnapshotLoop();
3272
3277
 
3278
+ // Closes #965: live thought_count + total_cost_usd updates from each
3279
+ // active world's Claude session JSONL. Periodic (60s default) so Rico's
3280
+ // scheduling loop can read fresh values from the `worlds` table and
3281
+ // SPAs can subscribe to the `world.activity.tick` event. Fail-soft per
3282
+ // world: missing/malformed JSONL never crashes the loop.
3283
+ const worldActivityTracker = startWorldActivityTracker({
3284
+ dbPath: WORLDS_DB_PATH,
3285
+ broadcaster: hostStream,
3286
+ });
3287
+
3273
3288
  // ── Phase 1a / B1 (PR3): engine-select + await-before-listen ─────
3274
3289
  //
3275
3290
  // Decision 15: the async KubernetesEngine factory MUST be fully awaited
@@ -3296,6 +3311,22 @@ const hostCpEngine = await (async () => {
3296
3311
  return createDockerEngine({ dockerHost: DOCKER_HOST });
3297
3312
  })();
3298
3313
 
3314
+ // ── Boot-time worlds.db ↔ docker reconciler (issue #963) ─────────────
3315
+ //
3316
+ // One-shot pass: if a container is alive but worlds.db has no row, insert
3317
+ // a status='reconciled' row so host-cp can see it. If worlds.db says a
3318
+ // world is running/active but the container is gone, mark it 'orphaned'.
3319
+ // Fail-soft: docker unreachable or DB unavailable → log + continue boot.
3320
+ // Runs BEFORE server.listen() so the first request sees reconciled state.
3321
+ try {
3322
+ await reconcileWorldsWithDocker({
3323
+ dbPath: WORLDS_DB_PATH,
3324
+ listContainerNames: () => defaultListContainerNames(DOCKER_API_BASE, console.log),
3325
+ });
3326
+ } catch (err) {
3327
+ console.error(`[boot-reconciler] unexpected error (continuing boot): ${err.message}`);
3328
+ }
3329
+
3299
3330
  server.listen(PORT, '0.0.0.0', () => {
3300
3331
  console.log(`olam-host-cp B3 listening on :${PORT}`);
3301
3332
  console.log(` DOCKER_HOST=${DOCKER_HOST}`);
@@ -3336,6 +3367,7 @@ for (const sig of ['SIGTERM', 'SIGINT']) {
3336
3367
  stopWorldsSnapshotLoop();
3337
3368
  stopTunnelsSnapshotLoop();
3338
3369
  stopListeningSnapshotLoop();
3370
+ worldActivityTracker.stop();
3339
3371
  if (serversSnapshotTimer) { clearTimeout(serversSnapshotTimer); serversSnapshotTimer = null; }
3340
3372
  hostStream.close();
3341
3373
  if (ndjsonSpanSink) ndjsonSpanSink.close().catch(() => {});