@norskvideo/norsk-auto-manager 0.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.
@@ -0,0 +1,2746 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.AutoManager = exports.NODE_SERVICE_ROLE = exports.MIGRATION_TARGET_ROLE = void 0;
37
+ exports.getJobIdForNode = getJobIdForNode;
38
+ exports.currentNodeIdForJob = currentNodeIdForJob;
39
+ const util = __importStar(require("util"));
40
+ const uuid = __importStar(require("uuid"));
41
+ const clock_1 = require("./clock");
42
+ const types_1 = require("./types");
43
+ const validation_1 = require("./validation");
44
+ const settingsValidation_1 = require("./settingsValidation");
45
+ const conversions_1 = require("./conversions");
46
+ const placement_1 = require("./placement");
47
+ const norsk_manager_sdk_1 = require("@norskvideo/norsk-manager-sdk");
48
+ /** Fixed role for the migration-target instance. No concurrent migrations per Job. @public */
49
+ exports.MIGRATION_TARGET_ROLE = "migrationTarget";
50
+ /**
51
+ * Fixed role assigned to runtime jobs that exist to back a
52
+ * NodeService. Worker reads `WorkerJob.role === NODE_SERVICE_ROLE`
53
+ * to recognise the instance as a service rather than a regular
54
+ * workload, and adds the service's `provides[]` capabilities to its
55
+ * inventory once the job reaches `ready`.
56
+ *
57
+ * @public
58
+ */
59
+ exports.NODE_SERVICE_ROLE = "nodeService";
60
+ const DEFAULT_REJECTION_BACKOFF_MS = 5000;
61
+ const DEFAULT_REJECTION_ESCALATION_COUNT = 3;
62
+ const DEFAULT_FAILURE_BACKOFF_MS = 30000;
63
+ const DEFAULT_MAX_RESTARTS_PER_HOUR = 6;
64
+ const RESTART_WINDOW_MS = 60 * 60 * 1000;
65
+ // Minimum spacing between consecutive recovery placement attempts for
66
+ // the same JobId. Stops the "provisionJob → JobFailed → provisionJob"
67
+ // tight loop that JobStore state-transition events naturally drive
68
+ // when worker-side launches keep failing fast. 5s is short enough that
69
+ // transient failures still recover quickly but long enough that even a
70
+ // pathological failure pattern doesn't burn through `maxRestartsPerHour`
71
+ // in seconds. Operator-tunable later via AutoSettings.
72
+ const MIN_RECOVERY_INTERVAL_MS = 5000;
73
+ // Minimum spacing between any `placeAndProvision` entries for the same
74
+ // JobId. Tighter than MIN_RECOVERY_INTERVAL_MS — the window we need is
75
+ // just "time for daemon's provisionJob to land + JobStarted to round-
76
+ // trip back into AutoManager's nodeMap". A second is comfortably more
77
+ // than typical local-host dispatch latency.
78
+ const MIN_PLACEMENT_INTERVAL_MS = 1000;
79
+ const DEFAULT_JOB_STARTUP_GRACE_MS = 120000;
80
+ const DEFAULT_HOT_SPARE_RECONCILE_INTERVAL_MS = 60000;
81
+ const DEFAULT_NODE_SERVICE_RECONCILE_INTERVAL_MS = 60000;
82
+ // Phase E phase-deadline defaults. Tuned to the design's expectation
83
+ // that target launch + workflow init typically completes well under
84
+ // two minutes, source-stop is workflow-driven and quick, and apply
85
+ // is a handful of wire hops.
86
+ const DEFAULT_MIGRATION_LAUNCHING_DEADLINE_MS = 120000;
87
+ const DEFAULT_MIGRATION_TARGET_READY_DEADLINE_MS = 120000;
88
+ const DEFAULT_MIGRATION_SOURCE_STOP_DEADLINE_MS = 30000;
89
+ const DEFAULT_MIGRATION_APPLYING_DEADLINE_MS = 10000;
90
+ class AutoManager {
91
+ /** @public */
92
+ static async run(settings) {
93
+ // Phase D1 — fail fast on configuration mistakes.
94
+ const validationErrors = (0, settingsValidation_1.validateAutoSettings)(settings);
95
+ if (validationErrors.length > 0) {
96
+ throw new settingsValidation_1.AutoSettingsValidationError(validationErrors);
97
+ }
98
+ debuglog("Trying to connect", { url: settings.url });
99
+ const fire = (state) => settings.onConnectionStateChange?.(state);
100
+ try {
101
+ const norsk = await norsk_manager_sdk_1.NorskManager.connect({
102
+ url: settings.url,
103
+ signal: settings.signal,
104
+ onAttemptingToConnect: () => fire("idle"),
105
+ onConnecting: () => fire("connecting"),
106
+ onReady: () => fire("ready"),
107
+ onFailedToConnect: () => fire("transientFailure"),
108
+ onShutdown: () => fire("shutdown"),
109
+ });
110
+ const autoManager = new AutoManager(norsk, settings);
111
+ const promise = autoManager.runInternal();
112
+ return [autoManager, promise];
113
+ }
114
+ catch (err) {
115
+ debuglog("Exception in connection");
116
+ throw err;
117
+ }
118
+ }
119
+ /** @public */
120
+ async terminateNode(nodeId) {
121
+ await this.norsk.terminateNode(nodeId);
122
+ }
123
+ /**
124
+ * Phase C8 — cordon a node. AutoManager projects this onto every
125
+ * placement decision: cordoned nodes are filtered out, so no new
126
+ * placements land on them. Existing jobs continue running.
127
+ * Idempotent.
128
+ * @public
129
+ */
130
+ cordonNode(nodeId) {
131
+ if (this.cordonedNodes.has(nodeId))
132
+ return;
133
+ this.cordonedNodes.add(nodeId);
134
+ debuglog("Node cordoned", { nodeId });
135
+ // Reconcile in case a cordoned node was holding a spare slot —
136
+ // its un-spare-ness now opens the underflow path. Same for any
137
+ // NodeServices placed on the now-cordoned node.
138
+ this.reconcileHotSpares();
139
+ this.reconcileNodeServices();
140
+ }
141
+ /**
142
+ * Phase C8 — un-cordon a node, restoring it to the placement pool.
143
+ * Idempotent.
144
+ * @public
145
+ */
146
+ uncordonNode(nodeId) {
147
+ if (!this.cordonedNodes.delete(nodeId))
148
+ return;
149
+ debuglog("Node uncordoned", { nodeId });
150
+ this.reconcileHotSpares();
151
+ this.reconcileNodeServices();
152
+ }
153
+ /**
154
+ * Phase C8 — whether a node is currently cordoned by AutoManager.
155
+ * @public
156
+ */
157
+ isCordoned(nodeId) {
158
+ return this.cordonedNodes.has(nodeId);
159
+ }
160
+ /**
161
+ * Phase C8 — drain a node. Cordons it (so no new placements land
162
+ * here) and terminates the node — Manager emits `nodeStopping`
163
+ * for every job hosted there, and the existing band-aware recovery
164
+ * path re-places them onto other nodes (excluding this cordoned
165
+ * one). Bronze jobs are dropped; gold prefers hot spares.
166
+ *
167
+ * v1 takes the immediate-terminate path; the design's "graceful
168
+ * shuffle without recovery gap" needs Phase E job migration to
169
+ * land first.
170
+ * @public
171
+ */
172
+ async drainNode(nodeId) {
173
+ this.cordonNode(nodeId);
174
+ await this.norsk.terminateNode(nodeId);
175
+ }
176
+ /**
177
+ * Shut down this AutoManager. Cancels periodic intervals and closes
178
+ * the underlying NorskManager — which causes the event-loop iterator
179
+ * to terminate, resolving the `Promise<void>` returned from `run()`.
180
+ * Idempotent.
181
+ * @public
182
+ */
183
+ close() {
184
+ if (this.closed)
185
+ return;
186
+ this.closed = true;
187
+ this.cleanupInterval?.cancel();
188
+ this.cleanupInterval = undefined;
189
+ this.hotSpareReconcileInterval?.cancel();
190
+ this.hotSpareReconcileInterval = undefined;
191
+ this.nodeServiceReconcileInterval?.cancel();
192
+ this.nodeServiceReconcileInterval = undefined;
193
+ for (const t of this.startupGraceTimers.values())
194
+ t.cancel();
195
+ this.startupGraceTimers.clear();
196
+ for (const t of this.migrationPhaseTimers.values())
197
+ t.cancel();
198
+ this.migrationPhaseTimers.clear();
199
+ for (const t of this.pendingPlacementRetries.values())
200
+ clearTimeout(t);
201
+ this.pendingPlacementRetries.clear();
202
+ this.lastPlacementAttemptAt.clear();
203
+ this.norsk.close();
204
+ }
205
+ /**
206
+ * Alias for `close()`. Provided for symmetry with the
207
+ * `using`-style disposal conventions emerging in TypeScript.
208
+ * @public
209
+ */
210
+ dispose() {
211
+ this.close();
212
+ }
213
+ /**
214
+ * AutoManager v1 — submit a new bundle. The bundle is validated by
215
+ * Manager (per §4.3) and expanded into N replica jobs server-side;
216
+ * AutoManager mirrors the resulting state via the bundleUpdated +
217
+ * jobUpdated events. The returned bundleId matches the one Manager
218
+ * assigns (typically the same as bundle.bundleId, but Manager has
219
+ * the final word).
220
+ *
221
+ * @public
222
+ */
223
+ async createBundle(bundle) {
224
+ return this.norsk.createBundle((0, conversions_1.toPbBundle)(bundle));
225
+ }
226
+ /**
227
+ * AutoManager v1 — update an existing bundle.
228
+ * @public
229
+ */
230
+ async updateBundle(bundle) {
231
+ await this.norsk.updateBundle((0, conversions_1.toPbBundle)(bundle));
232
+ }
233
+ /**
234
+ * AutoManager v1 — delete a bundle and all its replica jobs.
235
+ * @public
236
+ */
237
+ async deleteBundle(bundleId) {
238
+ await this.norsk.deleteBundle(bundleId);
239
+ }
240
+ /**
241
+ * AutoManager v1 — activate a dormant (start_mode = "manual") bundle
242
+ * job. Manager removes the dormant tag and re-emits jobUpdated;
243
+ * AutoManager's placement path picks the job up via the standard
244
+ * flow. See §10.2 of the design.
245
+ * @public
246
+ */
247
+ async startBundleJob(bundleId, replicaIndex, jobName) {
248
+ await this.norsk.startBundleJob(bundleId, replicaIndex, jobName);
249
+ }
250
+ /**
251
+ * AutoManager v1 — deactivate a bundle job (tag-flip).
252
+ *
253
+ * Runtime termination of an in-flight workload and empty-node
254
+ * release with linger are pre-go-live work tracked as Phase E.5
255
+ * (§10.2.1). Until they land, calling stopBundleJob on an active
256
+ * job marks it dormant for future placement but does not stop the
257
+ * currently-running worker.
258
+ * @public
259
+ */
260
+ async stopBundleJob(bundleId, replicaIndex, jobName) {
261
+ await this.norsk.stopBundleJob(bundleId, replicaIndex, jobName);
262
+ }
263
+ /**
264
+ * AutoManager v1 — typed view of the jobs Manager has told us about.
265
+ * `JobWithHistory` carries the state-transition log for each job so
266
+ * consumers can render lifecycle timelines without a separate
267
+ * round-trip.
268
+ * @public
269
+ */
270
+ jobs() {
271
+ return Array.from(this.jobMap.values());
272
+ }
273
+ nodes() {
274
+ debuglog("Getting nodes", this.nodeMap);
275
+ return Array.from(this.nodeMap.values());
276
+ }
277
+ /**
278
+ * AutoManager v1 — typed view of the bundle definitions Manager has
279
+ * told us about. Mirror only; AutoManager owns no durable state.
280
+ * @public
281
+ */
282
+ bundles() {
283
+ return Array.from(this.bundleMap.values());
284
+ }
285
+ /**
286
+ * Phase D4 — snapshot of AutoManager's operational metrics.
287
+ * Cumulative counters since `startedAt`; per-pool gauges computed
288
+ * at call time. Cheap to call repeatedly — consumers poll on their
289
+ * own cadence and compute deltas if they want rates.
290
+ * @public
291
+ */
292
+ metrics() {
293
+ const now = this.clock.now();
294
+ const uptimeMs = this.startedAt
295
+ ? now.getTime() - this.startedAt.getTime()
296
+ : 0;
297
+ const poolNames = new Set();
298
+ for (const p of this.settings.placementPools ?? [])
299
+ poolNames.add(p.name);
300
+ // Include any pool name we've seen on a node, in case nodes are
301
+ // tagged with a pool that isn't in placementPools (rare but worth
302
+ // surfacing — operator's likely misconfigured).
303
+ for (const node of this.nodeMap.values()) {
304
+ const pn = node.nodeMetadata?.tags?.["pool"];
305
+ if (pn)
306
+ poolNames.add(pn);
307
+ }
308
+ const pools = [];
309
+ for (const poolName of poolNames) {
310
+ pools.push(this.computePoolMetrics(poolName));
311
+ }
312
+ return {
313
+ startedAt: this.startedAt,
314
+ uptimeMs,
315
+ ...this.metricsCounters,
316
+ pools,
317
+ brokenBundleIds: [...this.brokenBundles],
318
+ };
319
+ }
320
+ computePoolMetrics(poolName) {
321
+ let nodeCount = 0;
322
+ let liveSpares = 0;
323
+ let utilSum = 0;
324
+ let utilN = 0;
325
+ for (const node of this.nodeMap.values()) {
326
+ if (this.poolNameForNode(node.nodeId) !== poolName)
327
+ continue;
328
+ nodeCount++;
329
+ if (this.isHotSpareNode(node.nodeId) &&
330
+ node.nodeState === "running" &&
331
+ (this.inventoryMap.get(node.nodeId)?.assignedJobs.length ?? 0) === 0) {
332
+ liveSpares++;
333
+ }
334
+ const inv = this.inventoryMap.get(node.nodeId);
335
+ if (inv && inv.totalCapacity > 0) {
336
+ utilSum += inv.reservedCapacity / inv.totalCapacity;
337
+ utilN++;
338
+ }
339
+ }
340
+ let spareTarget = 0;
341
+ for (const cfg of this.settings.hotSpares ?? []) {
342
+ if (cfg.pool === poolName)
343
+ spareTarget += cfg.targetCount;
344
+ }
345
+ return {
346
+ poolName,
347
+ nodeCount,
348
+ liveSpares,
349
+ spareTarget,
350
+ usedCapacityFraction: utilN > 0 ? utilSum / utilN : 0,
351
+ };
352
+ }
353
+ /**
354
+ * Look up a single bundle by id. Convenience over `bundles().find(...)`.
355
+ * @public
356
+ */
357
+ bundleById(bundleId) {
358
+ return this.bundleMap.get(bundleId);
359
+ }
360
+ /**
361
+ * Every job (with history) that belongs to a given bundle. Derived
362
+ * from `tags.bundleId` on the job entries in jobMap. Returns an empty
363
+ * array if the bundle is unknown or has no jobs.
364
+ * @public
365
+ */
366
+ jobsForBundle(bundleId) {
367
+ const out = [];
368
+ for (const entry of this.jobMap.values()) {
369
+ if ((entry.job.tags ?? {})["bundleId"] === bundleId) {
370
+ out.push(entry);
371
+ }
372
+ }
373
+ return out;
374
+ }
375
+ /**
376
+ * The node the given job is currently placed on, or undefined if
377
+ * the job hasn't been placed (or has been deleted). Useful for
378
+ * operator UIs that render "job X is on node Y" and for proxy
379
+ * routers that need to know which worker hosts each job.
380
+ *
381
+ * O(1) lookup against the `jobToNode` index — set at placement
382
+ * time, deleted on jobDeleted, rebuilt from job history on R7
383
+ * reconnect. Works regardless of whether the node hosts one job
384
+ * (cloud, today) or many (cluster, or any future cloud N:1)
385
+ * because the assignment is recorded against the JOB rather than
386
+ * scanned out of NODE tags.
387
+ * @public
388
+ */
389
+ nodeForJob(jobId) {
390
+ const nodeId = this.jobToNode.get(jobId);
391
+ return nodeId !== undefined ? this.nodeMap.get(nodeId) : undefined;
392
+ }
393
+ /**
394
+ * Current placement of every job AutoManager has bound to a node,
395
+ * newest-first by decision timestamp. Mirrors `nodeForJob` but
396
+ * returns the full `PlacementDecisionInfo` (so consumers get the
397
+ * pool, trace, replicaIndex etc., not just the node) — and exposes
398
+ * it as queryable live state rather than only via the
399
+ * `onPlacementDecision` event stream.
400
+ *
401
+ * Hydrated jobs (placed in a previous AutoManager generation, picked
402
+ * up on R7 reconnect) get an entry with an empty `trace.attempts`
403
+ * and a decision inferred from job history. Consumers that *need*
404
+ * a real trace should subscribe to `onPlacementDecision` for live
405
+ * decisions; this getter is for "where is everything right now?"
406
+ * operator views.
407
+ *
408
+ * @public
409
+ */
410
+ placements() {
411
+ return [...this.placementsByJob.values()].sort((a, b) => b.at.getTime() - a.at.getTime());
412
+ }
413
+ /**
414
+ * O(1) lookup for the most recent placement decision binding `jobId`
415
+ * to a node. Returns undefined for jobs that have never been placed
416
+ * (or whose placement attempts have only failed). @public
417
+ */
418
+ placementForJob(jobId) {
419
+ return this.placementsByJob.get(jobId);
420
+ }
421
+ /**
422
+ * Run the placement engine against AutoManager's current inventory
423
+ * snapshot and configured pools, without actually placing or
424
+ * provisioning anything. Powers preview affordances in operator
425
+ * UIs ("if I submitted this bundle now, where would each replica
426
+ * land?").
427
+ *
428
+ * `jobName` is optional; if omitted the *first* job in the bundle
429
+ * is used. For multi-job bundles, supply the name to get a decision
430
+ * specific to that job.
431
+ *
432
+ * Returns undefined if the bundle's named job is missing or the
433
+ * replica index is out of range.
434
+ * @public
435
+ */
436
+ placeDryRun(bundle, replicaIndex, jobName) {
437
+ if (replicaIndex < 0 || replicaIndex >= (0, types_1.replicaCount)(bundle))
438
+ return undefined;
439
+ const job = jobName
440
+ ? bundle.jobs.find((j) => j.jobName === jobName)
441
+ : bundle.jobs[0];
442
+ if (!job)
443
+ return undefined;
444
+ const pools = new Map((this.settings.placementPools ?? []).map((p) => [p.name, p]));
445
+ return (0, placement_1.placeWithTrace)({
446
+ bundle,
447
+ job,
448
+ replicaIndex,
449
+ inventory: this.inventoriesAsNodeViews(),
450
+ pools,
451
+ });
452
+ }
453
+ /**
454
+ * Static validation of a bundle against AutoManager's currently
455
+ * configured pools. Equivalent to calling the module-level
456
+ * `validateBundle` with `PoolDescriptor[]` derived from
457
+ * `AutoSettings.placementPools` — elastic pools project their
458
+ * `candidateInstanceTypes` into node profiles; cluster pools project
459
+ * their currently-known live inventory.
460
+ * @public
461
+ */
462
+ validateBundle(bundle) {
463
+ return (0, validation_1.validateBundle)(bundle, this.poolDescriptorsForValidation());
464
+ }
465
+ /**
466
+ * Phase C4 — packaged admission check. Combines `validateBundle`
467
+ * (static spec sanity) with `placeDryRun` for every (replica,
468
+ * jobName) pair. Returns `ok` only when both pass for every
469
+ * member; otherwise reports the structured failure. Use as a
470
+ * pre-submit guard in operator-facing tooling.
471
+ *
472
+ * Per the design's §3.4 band table, all bands reject at admission
473
+ * on "no capacity right now" — there's no band differentiation
474
+ * here. (Gold/silver get *queued* internally on capacity-after-
475
+ * submit failures via the rejection re-place path, but that's a
476
+ * runtime concern, not admission.)
477
+ *
478
+ * @public
479
+ */
480
+ validateBundleAdmission(bundle) {
481
+ const validation = this.validateBundle(bundle);
482
+ if (validation.errors.length > 0) {
483
+ return { kind: "validationErrors", errors: validation.errors };
484
+ }
485
+ const unplaceable = [];
486
+ for (let replicaIndex = 0; replicaIndex < (0, types_1.replicaCount)(bundle); replicaIndex++) {
487
+ for (const job of bundle.jobs) {
488
+ const dry = this.placeDryRun(bundle, replicaIndex, job.jobName);
489
+ if (!dry) {
490
+ // placeDryRun returns undefined only for malformed input
491
+ // (out-of-range replica, unknown jobName) — validateBundle
492
+ // should have caught those already. Treat as unplaceable.
493
+ unplaceable.push({
494
+ replicaIndex,
495
+ jobName: job.jobName,
496
+ reason: { code: "noCapacityInAnyPool", triedPools: [] },
497
+ });
498
+ continue;
499
+ }
500
+ if (dry.result.kind === "failure") {
501
+ unplaceable.push({
502
+ replicaIndex,
503
+ jobName: job.jobName,
504
+ reason: dry.result.reason,
505
+ });
506
+ }
507
+ }
508
+ }
509
+ if (unplaceable.length > 0) {
510
+ return { kind: "noCapacity", unplaceable };
511
+ }
512
+ return { kind: "ok" };
513
+ }
514
+ poolDescriptorsForValidation() {
515
+ const out = [];
516
+ for (const pool of this.settings.placementPools ?? []) {
517
+ // Union the feasibility profiles across the pool's tiers: elastic
518
+ // tiers contribute the shapes they can provision; fixed (cluster)
519
+ // tiers contribute the live inventory projected for them.
520
+ const profiles = [];
521
+ for (const tier of pool.tiers) {
522
+ if (tier.scaleOut === "elastic") {
523
+ for (const opt of tier.candidateInstanceTypes) {
524
+ profiles.push({
525
+ totalCapacity: opt.totalCapacity,
526
+ totalCores: opt.totalCores,
527
+ capabilities: opt.capabilities,
528
+ });
529
+ }
530
+ }
531
+ else {
532
+ for (const view of this.inventoriesAsNodeViews()) {
533
+ if (!(0, placement_1.nodeMatchesTier)(view, pool, tier))
534
+ continue;
535
+ profiles.push({
536
+ totalCapacity: view.inventory.totalCapacity,
537
+ totalCores: view.inventory.totalCores,
538
+ capabilities: view.inventory.capabilities,
539
+ });
540
+ }
541
+ }
542
+ }
543
+ // A pool that can provision is judged on what it can provision (a
544
+ // miss is a hard error); a purely-fixed pool's misses are warnings —
545
+ // a worker may join after submission (see validation.ts).
546
+ const kind = pool.tiers.some((t) => t.scaleOut === "elastic")
547
+ ? "elastic"
548
+ : "cluster";
549
+ const clouds = [...new Set(pool.tiers.map((t) => t.kind))];
550
+ out.push({ poolName: pool.name, kind, nodeProfiles: profiles, clouds });
551
+ }
552
+ return out;
553
+ }
554
+ /**
555
+ * AutoManager v1 — typed inventory snapshot for a single node, or
556
+ * undefined if the worker hasn't (yet) advertised one.
557
+ * @public
558
+ */
559
+ inventory(nodeId) {
560
+ return this.inventoryMap.get(nodeId);
561
+ }
562
+ /**
563
+ * AutoManager v1 — typed inventory snapshots for every node we know
564
+ * about. Iteration order is insertion order.
565
+ * @public
566
+ */
567
+ inventories() {
568
+ return Array.from(this.inventoryMap.entries()).map(([nodeId, inventory]) => ({
569
+ nodeId,
570
+ inventory,
571
+ }));
572
+ }
573
+ // ---- AutoManager v1 event handlers ----
574
+ handleNodeInventoryUpdated(raw) {
575
+ if (!raw.nodeId || !raw.inventory)
576
+ return;
577
+ const nodeId = raw.nodeId.nodeId;
578
+ this.inventoryMap.set(nodeId, (0, conversions_1.fromPbNodeInventory)(raw.inventory));
579
+ // A clean inventory update clears any "unhealthy" / recently-failed
580
+ // mark — the worker has reported back successfully, treat as recovered.
581
+ this.unhealthyNodes.delete(nodeId);
582
+ this.rejectionsPerNode.delete(nodeId);
583
+ this.recentFailures.delete(nodeId);
584
+ // Phase C6: inventory change can flip a spare to "consumed" (jobs
585
+ // assigned > 0). Reconcile so a replacement is provisioned
586
+ // promptly rather than waiting for the periodic tick.
587
+ this.reconcileHotSpares();
588
+ // Phase F: inventory change might newly satisfy / no-longer satisfy
589
+ // a NodeService's `consumes` — reconcile placements.
590
+ this.reconcileNodeServices();
591
+ // Fresh inventory means capacity may have appeared (a node's first
592
+ // report after Hello — the startup race — a freed reservation, or an
593
+ // un-cordon). Re-attempt any job that couldn't be placed before.
594
+ this.reconcilePendingPlacements();
595
+ }
596
+ /**
597
+ * A running job has had its config updated at runtime (operator action,
598
+ * job-driven, or other side channel). Refresh our in-memory copy so a
599
+ * subsequent `redeploy` (placeAndProvision triggered by node failure
600
+ * etc.) hands the new config rather than the launch one.
601
+ *
602
+ * Manager is responsible for persisting the new value; AutoManager
603
+ * just keeps its mirror up to date.
604
+ */
605
+ handleJobConfigUpdated(raw) {
606
+ if (!raw.jobId)
607
+ return;
608
+ const jobId = raw.jobId.jobId;
609
+ const existing = this.jobMap.get(jobId);
610
+ if (!existing)
611
+ return;
612
+ this.jobMap.set(jobId, {
613
+ ...existing,
614
+ job: { ...existing.job, managerConfiguration: raw.config },
615
+ });
616
+ this.settings.onJobConfigUpdated?.(jobId, raw.config);
617
+ }
618
+ handleJobRejected(raw) {
619
+ if (!raw.nodeId || !raw.jobId)
620
+ return;
621
+ const nodeId = raw.nodeId.nodeId;
622
+ const jobId = raw.jobId.jobId;
623
+ const typedInventory = raw.inventory
624
+ ? (0, conversions_1.fromPbNodeInventory)(raw.inventory)
625
+ : undefined;
626
+ if (typedInventory) {
627
+ // Carries the worker's latest view, but does NOT clear unhealthy —
628
+ // a rejection is the opposite of "I'm fine". Update the cache only.
629
+ this.inventoryMap.set(nodeId, typedInventory);
630
+ }
631
+ this.metricsCounters.jobsRejected++;
632
+ this.settings.onJobRejected?.({
633
+ jobId,
634
+ nodeId,
635
+ reason: raw.reason,
636
+ inventory: typedInventory,
637
+ at: this.clock.now(),
638
+ });
639
+ const escalated = this.recordRejection(nodeId);
640
+ if (escalated) {
641
+ debuglog("Node escalated to unhealthy", { nodeId });
642
+ this.settings.onNodeUnhealthy?.(nodeId);
643
+ }
644
+ const existing = this.jobMap.get(jobId);
645
+ const existingReq = existing?.job.autoPlacement?.requirements;
646
+ if (existing && existingReq) {
647
+ // Re-place the job, excluding the rejecting node (and any others
648
+ // currently considered unfit).
649
+ const exclude = this.currentExcludeSet();
650
+ exclude.add(nodeId);
651
+ void this.placeAndProvision(jobId, existingReq, existing.job.tags ?? {}, { excludeNodes: exclude }).catch((err) => this.settings.onError?.(err));
652
+ }
653
+ }
654
+ recordRejection(nodeId) {
655
+ const now = this.clock.now();
656
+ const window = this.cfgRejectionBackoffMs();
657
+ const previous = this.rejectionsPerNode.get(nodeId) ?? [];
658
+ const recent = previous.filter((d) => now.getTime() - d.getTime() < window);
659
+ recent.push(now);
660
+ this.rejectionsPerNode.set(nodeId, recent);
661
+ const threshold = this.cfgRejectionEscalationCount();
662
+ if (recent.length >= threshold && !this.unhealthyNodes.has(nodeId)) {
663
+ this.unhealthyNodes.add(nodeId);
664
+ return true;
665
+ }
666
+ return false;
667
+ }
668
+ currentExcludeSet() {
669
+ const exclude = new Set(this.unhealthyNodes);
670
+ const now = this.clock.now().getTime();
671
+ for (const [nodeId, deadline] of this.recentFailures) {
672
+ if (deadline.getTime() < now) {
673
+ this.recentFailures.delete(nodeId);
674
+ }
675
+ else {
676
+ exclude.add(nodeId);
677
+ }
678
+ }
679
+ return exclude;
680
+ }
681
+ /**
682
+ * Phase C2 — restart-rate check. Records the current attempt; if the
683
+ * trailing-hour count crosses the band's `maxRestartsPerHour`, marks
684
+ * the bundle broken, fires `onBundleBroken`, and returns true
685
+ * (caller must short-circuit). Returns false when within budget.
686
+ */
687
+ shouldBreakOnRestart(jobId, bundleId, band) {
688
+ const now = this.clock.now();
689
+ const previous = this.restartHistory.get(jobId) ?? [];
690
+ const recent = previous.filter((d) => now.getTime() - d.getTime() < RESTART_WINDOW_MS);
691
+ recent.push(now);
692
+ this.restartHistory.set(jobId, recent);
693
+ const limit = this.cfgMaxRestartsPerHour(band);
694
+ if (recent.length > limit) {
695
+ if (!this.brokenBundles.has(bundleId)) {
696
+ this.brokenBundles.add(bundleId);
697
+ debuglog("Bundle marked broken — restart rate exceeded", {
698
+ bundleId,
699
+ jobId,
700
+ attempts: recent.length,
701
+ limit,
702
+ });
703
+ this.metricsCounters.bundlesBroken++;
704
+ const reason = `Restart rate ${recent.length}/h exceeds ceiling ${limit}/h for replica ${jobId}`;
705
+ this.settings.onBundleBroken?.(bundleId, reason);
706
+ // Persist the verdict back to the daemon so the poison job is not
707
+ // re-placed after a restart (the in-process `brokenBundles` flag is
708
+ // rebuilt-from-scratch on reconnect). Fire-and-forget; the live flag
709
+ // above already suppresses further placement this session.
710
+ void this.norsk
711
+ .markJobFailed(jobId, reason)
712
+ .catch((err) => this.settings.onError?.(err));
713
+ }
714
+ return true;
715
+ }
716
+ return false;
717
+ }
718
+ /**
719
+ * Whether the named bundle has tripped the restart ceiling and is
720
+ * being held by AutoManager. Cleared by `bundleUpdated` (operator
721
+ * pushed a fresh spec) or `bundleDeleted`.
722
+ * @public
723
+ */
724
+ isBundleBroken(bundleId) {
725
+ return this.brokenBundles.has(bundleId);
726
+ }
727
+ // ---- Phase E — migration state machine ---------------------------
728
+ /**
729
+ * Initiate a migration of a running job to a target node. AutoManager
730
+ * provisions a second instance of the same Job on `targetNodeId` with
731
+ * role="migrationTarget" and drives the cutover handshake. Returns
732
+ * a stable `migrationId` for tracking.
733
+ *
734
+ * Throws if a migration is already in flight for `sourceJobId` (no
735
+ * concurrent migrations per Job).
736
+ *
737
+ * `sourceRole` defaults to whichever role the first node hosting
738
+ * `sourceJobId` has — adequate for single-instance Jobs. Specify
739
+ * explicitly for multi-replica bundles.
740
+ *
741
+ * @public
742
+ */
743
+ async migrateJob(sourceJobId, targetNodeId, sourceRole) {
744
+ if (this.migrationByJobId.has(sourceJobId)) {
745
+ throw new Error(`migrateJob: a migration is already in flight for jobId ${sourceJobId}`);
746
+ }
747
+ const resolvedSourceRole = sourceRole ?? this.inferSourceRole(sourceJobId);
748
+ if (!resolvedSourceRole) {
749
+ throw new Error(`migrateJob: cannot locate a running instance for jobId ${sourceJobId} — pass sourceRole explicitly`);
750
+ }
751
+ const migrationId = uuid.v4();
752
+ const now = this.clock.now();
753
+ const state = {
754
+ migrationId,
755
+ sourceJobId,
756
+ sourceRole: resolvedSourceRole,
757
+ targetNodeId,
758
+ startedAt: now,
759
+ phase: "launching",
760
+ phaseHistory: [{ phase: "launching", at: now }],
761
+ };
762
+ this.migrationStates.set(migrationId, state);
763
+ this.migrationByJobId.set(sourceJobId, migrationId);
764
+ this.settings.onMigrationPhase?.(state);
765
+ this.armMigrationPhaseDeadline(state);
766
+ debuglog("Migration started", { migrationId, sourceJobId, targetNodeId });
767
+ try {
768
+ await this.norsk.migrateJob(sourceJobId, targetNodeId, migrationId);
769
+ }
770
+ catch (err) {
771
+ this.failMigration(migrationId, `Manager.migrateJob failed: ${String(err)}`);
772
+ throw err;
773
+ }
774
+ return migrationId;
775
+ }
776
+ /**
777
+ * Abort an in-flight migration. AutoManager sends MigrationAbort to
778
+ * any live source/target instances and marks the migration aborted.
779
+ * No-op (with warning log) for an unknown migration id.
780
+ *
781
+ * @public
782
+ */
783
+ async abortMigration(migrationId, reason) {
784
+ const state = this.migrationStates.get(migrationId);
785
+ if (!state) {
786
+ debuglog("abortMigration: unknown migrationId", { migrationId });
787
+ return;
788
+ }
789
+ if (state.phase === "done" || state.phase === "aborted")
790
+ return;
791
+ await this.dispatchMigrationAborts(state, reason).catch((err) => this.settings.onError?.(err));
792
+ this.markMigrationAborted(state, reason);
793
+ }
794
+ /** All known migrations (active + done + aborted). @public */
795
+ migrations() {
796
+ return Array.from(this.migrationStates.values());
797
+ }
798
+ /** Look up a single migration by id. @public */
799
+ migrationById(migrationId) {
800
+ return this.migrationStates.get(migrationId);
801
+ }
802
+ /** Migration active for the given source jobId, if any. @public */
803
+ migrationForJob(jobId) {
804
+ const id = this.migrationByJobId.get(jobId);
805
+ return id ? this.migrationStates.get(id) : undefined;
806
+ }
807
+ /**
808
+ * Find the role of the (first) node currently running this Job.
809
+ * Returns undefined if no node hosts the job.
810
+ */
811
+ inferSourceRole(jobId) {
812
+ for (const node of this.nodeMap.values()) {
813
+ if (getJobIdForNode(node.nodeMetadata) === jobId) {
814
+ const role = node.nodeMetadata?.tags?.["role"] ?? node.provisional?.role;
815
+ if (role)
816
+ return role;
817
+ }
818
+ }
819
+ return undefined;
820
+ }
821
+ advanceMigration(state, phase) {
822
+ if (state.phase === phase)
823
+ return;
824
+ const now = this.clock.now();
825
+ state.phase = phase;
826
+ state.phaseHistory.push({ phase, at: now });
827
+ this.settings.onMigrationPhase?.(state);
828
+ debuglog("Migration phase", { migrationId: state.migrationId, phase });
829
+ this.armMigrationPhaseDeadline(state);
830
+ }
831
+ /** Phase E deadlines — re-arm the per-phase timer for the migration. */
832
+ armMigrationPhaseDeadline(state) {
833
+ // Always cancel any prior timer; terminal phases don't re-arm.
834
+ this.migrationPhaseTimers.get(state.migrationId)?.cancel();
835
+ this.migrationPhaseTimers.delete(state.migrationId);
836
+ if (state.phase === "done" || state.phase === "aborted")
837
+ return;
838
+ const deadlineMs = this.cfgMigrationPhaseDeadline(state.phase);
839
+ if (deadlineMs === undefined)
840
+ return;
841
+ const id = state.migrationId;
842
+ const phase = state.phase;
843
+ const timer = this.clock.setTimeout(() => {
844
+ this.migrationPhaseTimers.delete(id);
845
+ const current = this.migrationStates.get(id);
846
+ // Only fire if the migration is still in the phase we armed for.
847
+ if (!current || current.phase !== phase)
848
+ return;
849
+ debuglog("Migration phase deadline fired — aborting", {
850
+ migrationId: id,
851
+ phase,
852
+ deadlineMs,
853
+ });
854
+ const reason = `phase timeout: ${phase}`;
855
+ void this.dispatchMigrationAborts(current, reason).catch((err) => this.settings.onError?.(err));
856
+ this.markMigrationAborted(current, reason);
857
+ }, deadlineMs);
858
+ this.migrationPhaseTimers.set(state.migrationId, timer);
859
+ }
860
+ cfgMigrationPhaseDeadline(phase) {
861
+ const m = this.settings.migration;
862
+ switch (phase) {
863
+ case "launching":
864
+ return m?.launchingDeadlineMs ?? DEFAULT_MIGRATION_LAUNCHING_DEADLINE_MS;
865
+ case "awaitingTargetReady":
866
+ return m?.awaitingTargetReadyDeadlineMs ?? DEFAULT_MIGRATION_TARGET_READY_DEADLINE_MS;
867
+ case "awaitingSourceStop":
868
+ return m?.awaitingSourceStopDeadlineMs ?? DEFAULT_MIGRATION_SOURCE_STOP_DEADLINE_MS;
869
+ case "applying":
870
+ return m?.applyingDeadlineMs ?? DEFAULT_MIGRATION_APPLYING_DEADLINE_MS;
871
+ case "done":
872
+ case "aborted":
873
+ return undefined;
874
+ }
875
+ }
876
+ markMigrationDone(state) {
877
+ this.advanceMigration(state, "done");
878
+ this.migrationByJobId.delete(state.sourceJobId);
879
+ this.settings.onMigrationCompleted?.(state.migrationId);
880
+ }
881
+ markMigrationAborted(state, reason) {
882
+ state.abortReason = reason;
883
+ this.advanceMigration(state, "aborted");
884
+ this.migrationByJobId.delete(state.sourceJobId);
885
+ this.settings.onMigrationAborted?.(state.migrationId, reason);
886
+ }
887
+ failMigration(migrationId, reason) {
888
+ const state = this.migrationStates.get(migrationId);
889
+ if (!state)
890
+ return;
891
+ if (state.phase === "done" || state.phase === "aborted")
892
+ return;
893
+ this.markMigrationAborted(state, reason);
894
+ }
895
+ async dispatchMigrationAborts(state, reason) {
896
+ // Manager's abortMigration takes both keys and fans out to source
897
+ // + target workers; AutoManager doesn't need to send anything
898
+ // else.
899
+ await this.norsk
900
+ .abortMigration(state.sourceJobId, state.sourceRole, exports.MIGRATION_TARGET_ROLE, reason)
901
+ .catch((err) => this.settings.onError?.(err));
902
+ }
903
+ // ---- Phase E — event handlers ------------------------------------
904
+ async handleMigrationTargetReady(raw) {
905
+ const jobId = raw.jobKey?.jobId?.jobId;
906
+ if (!jobId)
907
+ return;
908
+ const state = this.migrationForJob(jobId);
909
+ if (!state) {
910
+ debuglog("MigrationTargetReady for unknown migration", { jobId });
911
+ return;
912
+ }
913
+ if (state.phase !== "launching" && state.phase !== "awaitingTargetReady") {
914
+ debuglog("MigrationTargetReady in unexpected phase", {
915
+ migrationId: state.migrationId,
916
+ phase: state.phase,
917
+ });
918
+ return;
919
+ }
920
+ this.advanceMigration(state, "awaitingSourceStop");
921
+ try {
922
+ await this.norsk.notifyMigrationCutoverReady({
923
+ jobId: state.sourceJobId,
924
+ role: state.sourceRole,
925
+ });
926
+ }
927
+ catch (err) {
928
+ this.settings.onError?.(err);
929
+ this.failMigration(state.migrationId, `notifyMigrationCutoverReady failed: ${String(err)}`);
930
+ }
931
+ }
932
+ async handleMigrationSourceStoppedOutput(raw) {
933
+ const jobId = raw.jobKey?.jobId?.jobId;
934
+ if (!jobId)
935
+ return;
936
+ const state = this.migrationForJob(jobId);
937
+ if (!state)
938
+ return;
939
+ if (state.phase !== "awaitingSourceStop") {
940
+ debuglog("MigrationSourceStoppedOutput in unexpected phase", {
941
+ migrationId: state.migrationId,
942
+ phase: state.phase,
943
+ });
944
+ return;
945
+ }
946
+ const ts = Number(raw.lastOutputTimestampNs);
947
+ state.cutoverTimestampNs = ts;
948
+ this.advanceMigration(state, "applying");
949
+ try {
950
+ await this.norsk.notifyMigrationCutoverApply({ jobId: state.sourceJobId, role: exports.MIGRATION_TARGET_ROLE }, raw.lastOutputTimestampNs);
951
+ }
952
+ catch (err) {
953
+ this.settings.onError?.(err);
954
+ this.failMigration(state.migrationId, `notifyMigrationCutoverApply failed: ${String(err)}`);
955
+ return;
956
+ }
957
+ // Source workflow has already stopped output; safe to terminate the
958
+ // source instance. norsk-mgr-equivalents would do this via the
959
+ // existing stopJob path, but since AutoManager owns the protocol
960
+ // we drive the cleanup here.
961
+ // R10: don't dispatch stopJob with nodeId="" if we can't locate
962
+ // the source — that just sends a malformed RPC. If the source is
963
+ // already gone, treat as success; surface other mismatches.
964
+ const sourceNodeId = this.findNodeForJobKey(state.sourceJobId, state.sourceRole);
965
+ if (sourceNodeId === undefined) {
966
+ debuglog("Migration: source instance not located in nodeMap — skipping stopJob", {
967
+ migrationId: state.migrationId,
968
+ jobId: state.sourceJobId,
969
+ role: state.sourceRole,
970
+ });
971
+ }
972
+ else {
973
+ try {
974
+ await this.norsk.stopJob(state.sourceJobId, state.sourceRole, sourceNodeId);
975
+ }
976
+ catch (err) {
977
+ debuglog("Migration: source stopJob failed (continuing)", {
978
+ migrationId: state.migrationId,
979
+ err: String(err),
980
+ });
981
+ this.settings.onError?.(err);
982
+ }
983
+ }
984
+ this.markMigrationDone(state);
985
+ }
986
+ handleMigrationAborted(raw) {
987
+ const jobId = raw.jobKey?.jobId?.jobId;
988
+ if (!jobId)
989
+ return;
990
+ const state = this.migrationForJob(jobId);
991
+ if (!state)
992
+ return;
993
+ debuglog("Migration aborted by worker", {
994
+ migrationId: state.migrationId,
995
+ reason: raw.reason,
996
+ });
997
+ // Dispatch an abort to the other side so it tears down its
998
+ // migration state too. Don't await — fire-and-forget.
999
+ void this.dispatchMigrationAborts(state, raw.reason);
1000
+ this.markMigrationAborted(state, raw.reason);
1001
+ }
1002
+ findNodeForJobKey(jobId, role) {
1003
+ for (const node of this.nodeMap.values()) {
1004
+ const tags = node.nodeMetadata?.tags ?? {};
1005
+ if (tags["jobId"] === jobId && tags["role"] === role) {
1006
+ return node.nodeId;
1007
+ }
1008
+ const prov = node.provisional;
1009
+ if (prov && prov.jobId === jobId && prov.role === role) {
1010
+ return node.nodeId;
1011
+ }
1012
+ }
1013
+ return undefined;
1014
+ }
1015
+ // ---- Phase C4 — config resolution ---------------------------------
1016
+ // Precedence for every tunable: settings.placement.X > settings.X
1017
+ // > compiled-in default. Per-band tunables use settings.bands[band]
1018
+ // first, with the same flat/default fallback.
1019
+ cfgRejectionBackoffMs() {
1020
+ return (this.settings.placement?.rejectionBackoffMs ??
1021
+ this.settings.rejectionBackoffMs ??
1022
+ DEFAULT_REJECTION_BACKOFF_MS);
1023
+ }
1024
+ cfgRejectionEscalationCount() {
1025
+ return (this.settings.placement?.rejectionEscalationCount ??
1026
+ this.settings.rejectionEscalationCount ??
1027
+ DEFAULT_REJECTION_ESCALATION_COUNT);
1028
+ }
1029
+ cfgFailureBackoffMs() {
1030
+ return (this.settings.placement?.failureBackoffMs ??
1031
+ this.settings.failureBackoffMs ??
1032
+ DEFAULT_FAILURE_BACKOFF_MS);
1033
+ }
1034
+ cfgJobStartupGraceMs() {
1035
+ return (this.settings.placement?.jobStartupGraceMs ??
1036
+ this.settings.jobStartupGraceMs ??
1037
+ DEFAULT_JOB_STARTUP_GRACE_MS);
1038
+ }
1039
+ cfgMaxRestartsPerHour(band) {
1040
+ // Bronze drops on failure — restart ceiling is moot, but return a
1041
+ // large number so the rate-check path can be called uniformly
1042
+ // without crashing if anyone reaches it.
1043
+ if (band === "bronze")
1044
+ return Number.POSITIVE_INFINITY;
1045
+ const perBand = this.settings.bands?.[band]?.maxRestartsPerHour;
1046
+ return (perBand ??
1047
+ this.settings.maxRestartsPerHour ??
1048
+ DEFAULT_MAX_RESTARTS_PER_HOUR);
1049
+ }
1050
+ // ---- AutoManager v1 placement-driven path ----
1051
+ //
1052
+ // A "modern" job — one with `requirements` set — is a member of a
1053
+ // bundle that Manager has expanded into N replica jobs. The job
1054
+ // carries bundleId / replicaIndex / jobName tags so AutoManager can
1055
+ // recover its bundle context and run the placement engine. The
1056
+ // legacy createJob path (no requirements) is unaffected.
1057
+ async placeAndProvision(jobId, pbRequirements, tags, recovery) {
1058
+ const bundleId = tags["bundleId"];
1059
+ const replicaIndexStr = tags["replicaIndex"];
1060
+ const jobName = tags["jobName"];
1061
+ if (!bundleId || replicaIndexStr === undefined || !jobName) {
1062
+ debuglog("Job has requirements but missing bundle context tags", tags);
1063
+ return;
1064
+ }
1065
+ const replicaIndex = parseInt(replicaIndexStr, 10);
1066
+ // In-flight-placement gate (initial placements only). handleJobChanged
1067
+ // can re-fire `placeAndProvision` for the same jobId while the
1068
+ // daemon's provisionJob is mid-dispatch — `nodesByJob().size` is
1069
+ // still 0 because JobStarted hasn't landed yet. Each re-fire ends
1070
+ // in `INVALID_ARGUMENT: Job already running on node` from the
1071
+ // daemon and a `placementsDispatchFailed++`. Suppress. Recovery
1072
+ // placements bypass this gate; they're driven by failure events
1073
+ // (JobRejected, NodeStopped) and have their own MIN_RECOVERY_INTERVAL_MS
1074
+ // backoff further down — and the bronze-drop / band-aware-recovery
1075
+ // branch needs to run even when an initial placement just dispatched.
1076
+ const now = this.clock.now();
1077
+ if (!recovery) {
1078
+ const lastAttempt = this.lastPlacementAttemptAt.get(jobId);
1079
+ if (lastAttempt && now.getTime() - lastAttempt.getTime() < MIN_PLACEMENT_INTERVAL_MS) {
1080
+ debuglog("Placement attempt suppressed (in-flight)", { jobId, bundleId });
1081
+ return;
1082
+ }
1083
+ this.lastPlacementAttemptAt.set(jobId, now);
1084
+ }
1085
+ const bundle = this.bundleMap.get(bundleId);
1086
+ if (!bundle) {
1087
+ debuglog("Job's bundle not in map yet", { jobId, bundleId });
1088
+ return;
1089
+ }
1090
+ const bundleJobSpec = bundle.jobs.find((j) => j.jobName === jobName);
1091
+ if (!bundleJobSpec) {
1092
+ debuglog("Bundle has no job named", { bundleId, jobName });
1093
+ return;
1094
+ }
1095
+ // Use the bundle's typed requirements (richer / canonical) rather
1096
+ // than re-converting from PB. Keeps placement decisions consistent
1097
+ // with the bundle the user submitted.
1098
+ void pbRequirements;
1099
+ // Band-aware recovery (Phase C1). On a recovery path:
1100
+ // - bronze: drop without re-placing; fire onJobDropped.
1101
+ // - gold: re-place, preferring hot spares.
1102
+ // - silver: re-place, no spare preference.
1103
+ // Initial placements bypass this entirely.
1104
+ const band = bundleJobSpec.requirements.priorityBand;
1105
+ if (recovery && band === "bronze") {
1106
+ debuglog("Bronze job dropped (no restart on failure)", { jobId, bundleId });
1107
+ this.metricsCounters.jobsDropped++;
1108
+ this.settings.onJobDropped?.(jobId);
1109
+ // Bronze never restarts — this is terminal. Persist the verdict so the
1110
+ // job isn't re-placed after a restart.
1111
+ void this.norsk
1112
+ .markJobFailed(jobId, "bronze job dropped after failure")
1113
+ .catch((err) => this.settings.onError?.(err));
1114
+ return;
1115
+ }
1116
+ // Phase C2 — restart-rate ceiling + broken-bundle short-circuit.
1117
+ // Always run the rate check on every recovery trigger — the
1118
+ // ceiling tracks failure rate, which is real-time, even if we
1119
+ // defer the actual placement below. Without this, the
1120
+ // minimum-interval backoff would coalesce N rapid failures into
1121
+ // a single recorded attempt and the rate-broken trip would never
1122
+ // fire.
1123
+ if (recovery) {
1124
+ if (this.brokenBundles.has(bundleId)) {
1125
+ debuglog("Skipping recovery — bundle is marked broken", { jobId, bundleId });
1126
+ return;
1127
+ }
1128
+ if (this.shouldBreakOnRestart(jobId, bundleId, band)) {
1129
+ return;
1130
+ }
1131
+ }
1132
+ // Minimum-interval backoff between consecutive recovery placement
1133
+ // dispatches for the same job. The failure rate has already been
1134
+ // counted above; this gate only delays the next actual placement
1135
+ // attempt so we don't hammer the daemon/worker at 12 attempts/sec.
1136
+ // Coalescing: if a deferred retry is already pending, further
1137
+ // triggers in the window become no-ops (the timer will see the
1138
+ // latest state when it fires).
1139
+ if (recovery) {
1140
+ const history = this.restartHistory.get(jobId) ?? [];
1141
+ // The just-pushed entry is the last one; we want the entry
1142
+ // before it to determine spacing.
1143
+ const previousAttempt = history[history.length - 2];
1144
+ const now = this.clock.now();
1145
+ if (previousAttempt && now.getTime() - previousAttempt.getTime() < MIN_RECOVERY_INTERVAL_MS) {
1146
+ if (this.pendingPlacementRetries.has(jobId)) {
1147
+ debuglog("Recovery placement already pending (coalescing)", { jobId, bundleId });
1148
+ return;
1149
+ }
1150
+ const delay = MIN_RECOVERY_INTERVAL_MS - (now.getTime() - previousAttempt.getTime());
1151
+ debuglog("Recovery placement deferred (backoff)", { jobId, bundleId, delayMs: delay });
1152
+ const timer = setTimeout(() => {
1153
+ this.pendingPlacementRetries.delete(jobId);
1154
+ void this.placeAndProvision(jobId, pbRequirements, tags, recovery).catch((err) => this.settings.onError?.(err));
1155
+ }, delay);
1156
+ this.pendingPlacementRetries.set(jobId, timer);
1157
+ return;
1158
+ }
1159
+ }
1160
+ const inventory = this.inventoriesAsNodeViews();
1161
+ const pools = new Map((this.settings.placementPools ?? []).map((p) => [p.name, p]));
1162
+ if (pools.size === 0) {
1163
+ debuglog("placementPools not configured — cannot place", { jobId });
1164
+ this.settings.onError?.(new Error(`placementPools not configured; can't place job ${jobId}. ` +
1165
+ `Add to AutoSettings or this job will never start.`));
1166
+ return;
1167
+ }
1168
+ const { result, trace } = (0, placement_1.placeWithTrace)({
1169
+ bundle,
1170
+ job: bundleJobSpec,
1171
+ replicaIndex,
1172
+ inventory,
1173
+ pools,
1174
+ recoveryContext: recovery
1175
+ ? { excludeNodes: recovery.excludeNodes, preferHotSpares: band === "gold" }
1176
+ : undefined,
1177
+ });
1178
+ debuglog("Placement decision", {
1179
+ jobId,
1180
+ bundleId,
1181
+ replicaIndex,
1182
+ decision: result,
1183
+ trace: summariseTrace(trace),
1184
+ });
1185
+ const at = this.clock.now();
1186
+ this.metricsCounters.placementsAttempted++;
1187
+ if (result.kind === "failure") {
1188
+ this.metricsCounters.placementsFailed++;
1189
+ }
1190
+ // R12: placementsSucceeded counted post-dispatch; see below.
1191
+ const info = { jobId, bundleId, replicaIndex, jobName, result, trace, at };
1192
+ // Mirror into live-state map so consumers can query the current
1193
+ // placement without subscribing from process start. Failures don't
1194
+ // bind a placement, so they don't go in the map — `placementForJob`
1195
+ // returning undefined for "we tried and failed" is the correct
1196
+ // signal; failures still flow through `onPlacementFailed` for
1197
+ // consumers that care.
1198
+ if (result.kind !== "failure") {
1199
+ this.placementsByJob.set(jobId, info);
1200
+ }
1201
+ this.settings.onPlacementDecision?.(info);
1202
+ if (result.kind === "failure") {
1203
+ // A failed placement dispatched nothing, so the in-flight gate
1204
+ // (lastPlacementAttemptAt + MIN_PLACEMENT_INTERVAL_MS) must not
1205
+ // hold this job back: a capacity-increasing event can arrive
1206
+ // moments later and `reconcilePendingPlacements` needs to be free
1207
+ // to retry. The gate exists to suppress duplicate dispatch while a
1208
+ // real placement is in flight — a failure is not in flight.
1209
+ this.lastPlacementAttemptAt.delete(jobId);
1210
+ this.settings.onPlacementFailed?.({
1211
+ jobId,
1212
+ bundleId,
1213
+ replicaIndex,
1214
+ jobName,
1215
+ reason: result.reason,
1216
+ triedPools: result.reason.triedPools,
1217
+ at,
1218
+ });
1219
+ this.settings.onError?.(new Error(`Placement failed for ${jobId}: ${JSON.stringify(result.reason)}`));
1220
+ return;
1221
+ }
1222
+ const role = roleFromReplicaIndex(replicaIndex);
1223
+ // A degraded backup still gets placed/provisioned (best-effort) — surface
1224
+ // the reduced DR posture so an operator can act / re-place later.
1225
+ if (result.degraded) {
1226
+ this.settings.onResilienceDegraded?.({
1227
+ jobId,
1228
+ bundleId,
1229
+ replicaIndex,
1230
+ jobName,
1231
+ violated: result.degraded.violated,
1232
+ ...(result.degraded.interruptiblePrimary ? { interruptiblePrimary: true } : {}),
1233
+ at,
1234
+ });
1235
+ }
1236
+ if (result.kind === "place") {
1237
+ // Existing node already running on the chosen target. Manager's
1238
+ // StartJob RPC binds the job to that node.
1239
+ try {
1240
+ await this.norsk.startJob(jobId, role, result.nodeId);
1241
+ this.jobToNode.set(jobId, result.nodeId);
1242
+ this.metricsCounters.placementsSucceeded++;
1243
+ }
1244
+ catch (err) {
1245
+ this.metricsCounters.placementsDispatchFailed++;
1246
+ this.settings.onError?.(err);
1247
+ }
1248
+ return;
1249
+ }
1250
+ // Provision in the chosen pool's chosen tier.
1251
+ const pool = pools.get(result.pool);
1252
+ if (!pool)
1253
+ return;
1254
+ const tier = pool.tiers.find((t) => t.name === result.tier);
1255
+ if (!tier)
1256
+ return;
1257
+ // `pool` + `tier` tags are load-bearing: poolNameForNode / tierNameForNode
1258
+ // read them back via nodeMetadata.tags to identify which pool+tier an
1259
+ // existing node belongs to. We must set them here, or every placement
1260
+ // after the node starts will filter it out as wrong-pool/wrong-tier.
1261
+ const provisionTags = {
1262
+ "auto-manager": "true",
1263
+ jobId,
1264
+ role,
1265
+ bundleId,
1266
+ replicaIndex: replicaIndexStr,
1267
+ jobName,
1268
+ pool: pool.name,
1269
+ tier: tier.name,
1270
+ // Failure-domain metadata for the resilience engine + observability.
1271
+ reliability: (0, placement_1.tierReliability)(tier),
1272
+ };
1273
+ const nodeId = uuid.v4() + "." + jobId + "." + role;
1274
+ this.updateNodeMap(nodeId, "starting", undefined, undefined, { jobId, role });
1275
+ this.jobToNode.set(jobId, nodeId);
1276
+ this.settings.onNodeStarting?.(nodeId);
1277
+ if (tier.kind === "aws") {
1278
+ try {
1279
+ await this.norsk.createAwsNode({
1280
+ nodeId,
1281
+ region: tier.region ?? "",
1282
+ instanceType: result.instanceType,
1283
+ tags: provisionTags,
1284
+ writeFiles: [],
1285
+ launchMode: result.launchMode,
1286
+ });
1287
+ this.metricsCounters.placementsSucceeded++;
1288
+ }
1289
+ catch (err) {
1290
+ this.metricsCounters.placementsDispatchFailed++;
1291
+ this.settings.onError?.(err);
1292
+ }
1293
+ }
1294
+ else if (tier.kind === "oci") {
1295
+ try {
1296
+ await this.norsk.createOciNode({
1297
+ nodeId,
1298
+ availabilityDomain: tier.region ?? "",
1299
+ architecture: "x86_64",
1300
+ shape: result.instanceType,
1301
+ subnet: "",
1302
+ tags: provisionTags,
1303
+ writeFiles: [],
1304
+ });
1305
+ this.metricsCounters.placementsSucceeded++;
1306
+ }
1307
+ catch (err) {
1308
+ this.metricsCounters.placementsDispatchFailed++;
1309
+ this.settings.onError?.(err);
1310
+ }
1311
+ }
1312
+ else {
1313
+ // Cluster tiers never auto-scale; place-on-existing only.
1314
+ this.settings.onError?.(new Error(`Cluster tier '${pool.name}/${tier.name}' returned a 'provision' ` +
1315
+ `placement; cluster tiers should only place onto existing nodes.`));
1316
+ }
1317
+ }
1318
+ inventoriesAsNodeViews() {
1319
+ // Reservation (reservedCapacity / cores / capabilities, assignedJobs) is
1320
+ // not populated upstream: the worker advertises raw hardware and the
1321
+ // daemon forwards it verbatim, so the wire values arrive as 0 / []. Left
1322
+ // as-is, every node would look fully free regardless of what's running on
1323
+ // it — placement would over-subscribe, and the capacity-driven reconcile
1324
+ // could never observe a node fill up and later free up. AutoManager is the
1325
+ // one component that knows both which jobs it placed where (`jobToNode`)
1326
+ // and each job's requirement (the bundle's typed spec), so it derives the
1327
+ // reservation here. A non-zero wire reservation, if a producer ever sends
1328
+ // one, is treated as authoritative and the derivation defers to it.
1329
+ const placedJobsByNode = new Map();
1330
+ for (const [jobId, nodeId] of this.jobToNode) {
1331
+ const list = placedJobsByNode.get(nodeId);
1332
+ if (list)
1333
+ list.push(jobId);
1334
+ else
1335
+ placedJobsByNode.set(nodeId, [jobId]);
1336
+ }
1337
+ const out = [];
1338
+ for (const [nodeId, inv] of this.inventoryMap.entries()) {
1339
+ const placedHere = placedJobsByNode.get(nodeId) ?? [];
1340
+ // Prefer the wire's assigned jobs when present; otherwise list the
1341
+ // jobs AutoManager itself placed on this node. Either feeds the
1342
+ // affinity / spread constraints in the placement engine.
1343
+ const runningJobs = inv.assignedJobs.length > 0
1344
+ ? inv.assignedJobs.flatMap((a) => this.runningJobFor(a.jobId))
1345
+ : placedHere.flatMap((jobId) => this.runningJobFor(jobId));
1346
+ // Phase C8: merge the AutoManager-local cordon into the wire-reported
1347
+ // cordoned flag. Either side can mark a node off-limits.
1348
+ const cordoned = this.cordonedNodes.has(nodeId)
1349
+ ? { ...inv, cordoned: true }
1350
+ : inv;
1351
+ // Overlay derived reservation, deferring to a non-zero wire value.
1352
+ const reservation = this.derivedReservation(placedHere);
1353
+ const effectiveInv = {
1354
+ ...cordoned,
1355
+ reservedCapacity: cordoned.reservedCapacity > 0
1356
+ ? cordoned.reservedCapacity
1357
+ : reservation.capacity,
1358
+ reservedCores: cordoned.reservedCores > 0 ? cordoned.reservedCores : reservation.cores,
1359
+ reservedCapabilities: cordoned.reservedCapabilities.length > 0
1360
+ ? cordoned.reservedCapabilities
1361
+ : reservation.capabilities,
1362
+ };
1363
+ // poolName / tierName are read from the node's NodeMetadata (nodeMap);
1364
+ // nodes we haven't seen there yet resolve to "" / undefined and never
1365
+ // match a pool filter.
1366
+ out.push({
1367
+ nodeId,
1368
+ poolName: this.poolNameForNode(nodeId),
1369
+ tierName: this.tierNameForNode(nodeId),
1370
+ cloud: this.cloudForNode(nodeId),
1371
+ inventory: effectiveInv,
1372
+ runningJobs,
1373
+ az: this.azForNode(nodeId),
1374
+ // Phase C5: spare-ness is advertised via NodeMetadata.tags["spare"].
1375
+ isHotSpare: this.isHotSpareNode(nodeId),
1376
+ });
1377
+ }
1378
+ return out;
1379
+ }
1380
+ /** Map a placed jobId to the RunningJob view the placement engine uses
1381
+ * for affinity / spread, via the job's bundle-context tags. Empty when
1382
+ * the job lacks bundle tags. */
1383
+ runningJobFor(jobId) {
1384
+ const tags = this.tagsForJob(jobId);
1385
+ if (!tags)
1386
+ return [];
1387
+ const bundleId = tags["bundleId"];
1388
+ const replicaIndexStr = tags["replicaIndex"];
1389
+ const jobName = tags["jobName"];
1390
+ if (!bundleId || replicaIndexStr === undefined || !jobName)
1391
+ return [];
1392
+ return [{ bundleId, replicaIndex: parseInt(replicaIndexStr, 10), jobName }];
1393
+ }
1394
+ /** Sum the resource requirements of the given placed jobs into a node
1395
+ * reservation. Capacity / cores / capabilities come from each job's
1396
+ * bundle spec — the canonical typed requirements the placement engine
1397
+ * scores against, looked up via the job's bundleId / jobName tags. Jobs
1398
+ * whose bundle or spec we don't have yet contribute nothing. */
1399
+ derivedReservation(jobIds) {
1400
+ let capacity = 0;
1401
+ let cores = 0;
1402
+ const caps = new Map();
1403
+ for (const jobId of jobIds) {
1404
+ const req = this.requirementsForPlacedJob(jobId);
1405
+ if (!req)
1406
+ continue;
1407
+ capacity += req.requiredCapacity;
1408
+ cores += req.requiredCores ?? 0;
1409
+ for (const c of req.requiredCapabilities) {
1410
+ caps.set(c.name, (caps.get(c.name) ?? 0) + c.count);
1411
+ }
1412
+ }
1413
+ const capabilities = [...caps.entries()].map(([name, count]) => ({ name, count }));
1414
+ return { capacity, cores, capabilities };
1415
+ }
1416
+ /** The canonical typed requirements for a placed job, from its bundle
1417
+ * spec (the same source `placeAndProvision` scores against). */
1418
+ requirementsForPlacedJob(jobId) {
1419
+ const tags = this.tagsForJob(jobId);
1420
+ if (!tags)
1421
+ return undefined;
1422
+ const bundleId = tags["bundleId"];
1423
+ const jobName = tags["jobName"];
1424
+ if (!bundleId || !jobName)
1425
+ return undefined;
1426
+ return this.bundleMap
1427
+ .get(bundleId)
1428
+ ?.jobs.find((j) => j.jobName === jobName)?.requirements;
1429
+ }
1430
+ /**
1431
+ * Phase C5 — is the named node currently tagged as a hot spare?
1432
+ * Spares are warm-running nodes provisioned (by the C6 reconciler)
1433
+ * with `NodeMetadata.tags["spare"] = "true"`. The optional
1434
+ * `tags["spareBand"]` ("gold" | "silver") dedicates a spare to a
1435
+ * band; if absent, the spare is band-agnostic.
1436
+ * @public
1437
+ */
1438
+ isHotSpareNode(nodeId) {
1439
+ return this.nodeMap.get(nodeId)?.nodeMetadata?.tags?.["spare"] === "true";
1440
+ }
1441
+ /**
1442
+ * Phase C5 — band a spare is dedicated to, if any. `undefined` for a
1443
+ * non-spare or for a band-agnostic spare.
1444
+ * @public
1445
+ */
1446
+ spareBandForNode(nodeId) {
1447
+ if (!this.isHotSpareNode(nodeId))
1448
+ return undefined;
1449
+ const v = this.nodeMap.get(nodeId)?.nodeMetadata?.tags?.["spareBand"];
1450
+ return v === "gold" || v === "silver" || v === "bronze" ? v : undefined;
1451
+ }
1452
+ // ---- Phase C6 — hot-spare reconciliation -------------------------
1453
+ /**
1454
+ * Drive every configured `hotSpares` entry toward its `targetCount`.
1455
+ * Counts live + pending spares matching (pool, band); provisions
1456
+ * short, terminates excess (oldest live first). Cluster pools are
1457
+ * non-elastic — under-target just fires `onSpareUnderflow` and
1458
+ * leaves provisioning to the operator. Idempotent and cheap; safe
1459
+ * to call on every lifecycle event plus the periodic tick.
1460
+ */
1461
+ reconcileHotSpares() {
1462
+ const entries = this.settings.hotSpares ?? [];
1463
+ if (entries.length === 0)
1464
+ return;
1465
+ for (const cfg of entries) {
1466
+ const pool = (this.settings.placementPools ?? []).find((p) => p.name === cfg.pool);
1467
+ if (!pool) {
1468
+ debuglog("hotSpares entry references unknown pool", { entry: cfg });
1469
+ continue;
1470
+ }
1471
+ const matching = this.spareNodesMatching(cfg);
1472
+ const pending = this.pendingSparesMatching(cfg);
1473
+ const effective = matching.length + pending;
1474
+ const target = cfg.targetCount;
1475
+ if (effective < target) {
1476
+ const short = target - effective;
1477
+ this.settings.onSpareUnderflow?.(cfg.pool, effective, target);
1478
+ // Warm the cheapest provisionable tier (first elastic tier in
1479
+ // cost order). A pool with no elastic tier can't grow spares.
1480
+ const provisionTier = pool.tiers.find((t) => t.scaleOut === "elastic");
1481
+ if (!provisionTier) {
1482
+ debuglog("Pool has no elastic tier — under spare target, no provision", {
1483
+ cfg,
1484
+ effective,
1485
+ target,
1486
+ });
1487
+ continue;
1488
+ }
1489
+ for (let i = 0; i < short; i++) {
1490
+ void this.provisionHotSpare(pool, provisionTier, cfg);
1491
+ }
1492
+ }
1493
+ else if (matching.length > target) {
1494
+ // Only terminate from the truly-live (non-pending) set. Sort
1495
+ // by lastActivity ascending so the oldest goes first — matches
1496
+ // the design's "support rotation for security patching."
1497
+ const excess = matching.length - target;
1498
+ const sorted = [...matching].sort((a, b) => a.lastActivity.getTime() - b.lastActivity.getTime());
1499
+ for (let i = 0; i < excess; i++) {
1500
+ const victim = sorted[i];
1501
+ debuglog("Hot-spare excess — terminating oldest", {
1502
+ nodeId: victim.nodeId,
1503
+ cfg,
1504
+ });
1505
+ void this.norsk
1506
+ .terminateNode(victim.nodeId)
1507
+ .catch((err) => this.settings.onError?.(err));
1508
+ }
1509
+ }
1510
+ }
1511
+ }
1512
+ spareNodesMatching(cfg) {
1513
+ const out = [];
1514
+ for (const node of this.nodeMap.values()) {
1515
+ if (!this.isHotSpareNode(node.nodeId))
1516
+ continue;
1517
+ if (node.nodeState !== "running")
1518
+ continue;
1519
+ if (this.poolNameForNode(node.nodeId) !== cfg.pool)
1520
+ continue;
1521
+ if (cfg.forBand && this.spareBandForNode(node.nodeId) !== cfg.forBand)
1522
+ continue;
1523
+ // A spare with jobs assigned is consumed — exclude from the count
1524
+ // so the underflow fires and a replacement provisions.
1525
+ const inv = this.inventoryMap.get(node.nodeId);
1526
+ if (inv && inv.assignedJobs.length > 0)
1527
+ continue;
1528
+ out.push(node);
1529
+ }
1530
+ return out;
1531
+ }
1532
+ pendingSparesMatching(cfg) {
1533
+ let n = 0;
1534
+ for (const p of this.pendingSpareProvisions.values()) {
1535
+ if (p.pool !== cfg.pool)
1536
+ continue;
1537
+ if (cfg.forBand && p.band !== cfg.forBand)
1538
+ continue;
1539
+ n++;
1540
+ }
1541
+ return n;
1542
+ }
1543
+ async provisionHotSpare(pool, tier, cfg) {
1544
+ const instanceType = cfg.spec.instanceType;
1545
+ if (!instanceType) {
1546
+ this.settings.onError?.(new Error(`hotSpares entry for elastic pool '${cfg.pool}' needs spec.instanceType`));
1547
+ return;
1548
+ }
1549
+ const nodeId = uuid.v4() + ".spare." + cfg.pool;
1550
+ const tags = {
1551
+ "auto-manager": "true",
1552
+ spare: "true",
1553
+ pool: cfg.pool,
1554
+ tier: tier.name,
1555
+ };
1556
+ if (cfg.forBand)
1557
+ tags["spareBand"] = cfg.forBand;
1558
+ this.pendingSpareProvisions.set(nodeId, { pool: cfg.pool, band: cfg.forBand });
1559
+ this.updateNodeMap(nodeId, "starting", undefined, undefined);
1560
+ this.settings.onNodeStarting?.(nodeId);
1561
+ try {
1562
+ if (tier.kind === "aws") {
1563
+ await this.norsk.createAwsNode({
1564
+ nodeId,
1565
+ region: cfg.spec.region ?? tier.region ?? "",
1566
+ instanceType,
1567
+ tags,
1568
+ writeFiles: [],
1569
+ });
1570
+ }
1571
+ else if (tier.kind === "oci") {
1572
+ await this.norsk.createOciNode({
1573
+ nodeId,
1574
+ availabilityDomain: cfg.spec.az ?? cfg.spec.region ?? tier.region ?? "",
1575
+ architecture: "x86_64",
1576
+ shape: instanceType,
1577
+ subnet: "",
1578
+ tags,
1579
+ writeFiles: [],
1580
+ });
1581
+ }
1582
+ // Cluster path falls through — non-elastic, no auto-provision.
1583
+ }
1584
+ catch (err) {
1585
+ // Failed to even submit. Drop the pending marker so the next
1586
+ // reconcile can try again instead of waiting forever.
1587
+ this.pendingSpareProvisions.delete(nodeId);
1588
+ this.settings.onError?.(err);
1589
+ }
1590
+ }
1591
+ // ---- Phase F — NodeService reconciliation ------------------------
1592
+ /**
1593
+ * @public
1594
+ * Current placements for each configured NodeService. Returns a
1595
+ * snapshot map of `serviceId → (nodeId → runtimeJobId)`.
1596
+ */
1597
+ nodeServices() {
1598
+ return this.settings.nodeServices ?? [];
1599
+ }
1600
+ /**
1601
+ * @public
1602
+ * Nodes on which the named NodeService is currently placed,
1603
+ * keyed by the runtime job's jobId.
1604
+ */
1605
+ nodeServicePlacementsFor(serviceId) {
1606
+ return new Map(this.nodeServicePlacements.get(serviceId) ?? []);
1607
+ }
1608
+ /**
1609
+ * Drive every configured NodeService toward its lifecycle target.
1610
+ * v1 only handles `eager` — places on every eligible node.
1611
+ * `lazy` placements + capability-loss-driven consumer recovery (F3)
1612
+ * are deferred.
1613
+ */
1614
+ reconcileNodeServices() {
1615
+ const services = this.settings.nodeServices ?? [];
1616
+ if (services.length === 0)
1617
+ return;
1618
+ for (const svc of services) {
1619
+ if (svc.lifecycle !== "eager") {
1620
+ // Lazy lifecycle not implemented in v1; skip.
1621
+ continue;
1622
+ }
1623
+ this.reconcileEagerNodeService(svc);
1624
+ }
1625
+ }
1626
+ reconcileEagerNodeService(svc) {
1627
+ const eligible = this.eligibleNodesForService(svc);
1628
+ const placed = this.nodeServicePlacements.get(svc.serviceId) ?? new Map();
1629
+ // Place on every eligible node that doesn't already host the service.
1630
+ for (const nodeId of eligible) {
1631
+ if (placed.has(nodeId))
1632
+ continue;
1633
+ void this.placeNodeService(svc, nodeId).catch((err) => this.settings.onError?.(err));
1634
+ }
1635
+ // Tear down any placement that's no longer eligible.
1636
+ for (const [nodeId, jobId] of placed) {
1637
+ if (eligible.has(nodeId))
1638
+ continue;
1639
+ void this.teardownNodeService(svc.serviceId, nodeId, jobId).catch((err) => this.settings.onError?.(err));
1640
+ }
1641
+ }
1642
+ /**
1643
+ * Identify nodes in the service's named pools whose current inventory
1644
+ * satisfies its `consumes` requirements. Used both for placement and
1645
+ * for tear-down decisions.
1646
+ */
1647
+ eligibleNodesForService(svc) {
1648
+ const eligible = new Set();
1649
+ const poolSet = new Set(svc.pools);
1650
+ for (const node of this.nodeMap.values()) {
1651
+ if (node.nodeState !== "running")
1652
+ continue;
1653
+ if (!poolSet.has(this.poolNameForNode(node.nodeId)))
1654
+ continue;
1655
+ const inv = this.inventoryMap.get(node.nodeId);
1656
+ if (!inv || !inv.reachable || inv.cordoned)
1657
+ continue;
1658
+ if (this.cordonedNodes.has(node.nodeId))
1659
+ continue;
1660
+ if (this.nodeSatisfiesConsumes(inv, svc.consumes)) {
1661
+ eligible.add(node.nodeId);
1662
+ }
1663
+ }
1664
+ return eligible;
1665
+ }
1666
+ nodeSatisfiesConsumes(inv, consumes) {
1667
+ for (const req of consumes) {
1668
+ const cap = inv.capabilities.find((c) => c.name === req.name);
1669
+ if (!cap)
1670
+ return false;
1671
+ const reserved = inv.reservedCapabilities.find((c) => c.name === req.name)?.count ?? 0;
1672
+ if (cap.count - reserved < req.count)
1673
+ return false;
1674
+ if (req.attributeMatches) {
1675
+ for (const [k, v] of Object.entries(req.attributeMatches)) {
1676
+ if (cap.attributes?.[k] !== v)
1677
+ return false;
1678
+ }
1679
+ }
1680
+ }
1681
+ return true;
1682
+ }
1683
+ async placeNodeService(svc, nodeId) {
1684
+ const jobId = `${svc.serviceId}.${uuid.v4().slice(0, 8)}`;
1685
+ debuglog("Placing NodeService", { serviceId: svc.serviceId, nodeId, jobId });
1686
+ // Optimistically record the placement so a re-entrant reconcile
1687
+ // doesn't double-place.
1688
+ let placements = this.nodeServicePlacements.get(svc.serviceId);
1689
+ if (!placements) {
1690
+ placements = new Map();
1691
+ this.nodeServicePlacements.set(svc.serviceId, placements);
1692
+ }
1693
+ placements.set(nodeId, jobId);
1694
+ // Build the runtime Job: take the configured template, override
1695
+ // the jobId, and tag it as a NodeService instance.
1696
+ const job = {
1697
+ ...svc.jobSpec,
1698
+ jobId,
1699
+ tags: {
1700
+ ...(svc.jobSpec.tags ?? {}),
1701
+ "auto-manager": "true",
1702
+ nodeService: svc.serviceId,
1703
+ },
1704
+ };
1705
+ let createdJob = false;
1706
+ try {
1707
+ await this.norsk.createJob(job);
1708
+ createdJob = true;
1709
+ await this.norsk.startJob(jobId, exports.NODE_SERVICE_ROLE, nodeId);
1710
+ this.jobToNode.set(jobId, nodeId);
1711
+ }
1712
+ catch (err) {
1713
+ // Roll back the optimistic placement on failure.
1714
+ placements.delete(nodeId);
1715
+ // R9: if createJob succeeded but startJob failed, the Manager
1716
+ // has a dangling Job row that the next reconcile would orphan
1717
+ // by allocating a fresh jobId. Best-effort delete.
1718
+ if (createdJob) {
1719
+ try {
1720
+ await this.norsk.deleteJob(jobId);
1721
+ }
1722
+ catch (cleanupErr) {
1723
+ debuglog("placeNodeService: cleanup deleteJob failed", {
1724
+ jobId,
1725
+ err: String(cleanupErr),
1726
+ });
1727
+ }
1728
+ }
1729
+ this.settings.onError?.(err);
1730
+ }
1731
+ }
1732
+ async teardownNodeService(serviceId, nodeId, jobId) {
1733
+ debuglog("Tearing down NodeService instance", { serviceId, nodeId, jobId });
1734
+ const placements = this.nodeServicePlacements.get(serviceId);
1735
+ placements?.delete(nodeId);
1736
+ try {
1737
+ await this.norsk.stopJob(jobId, exports.NODE_SERVICE_ROLE, nodeId);
1738
+ }
1739
+ catch (err) {
1740
+ // Job might be gone already; tolerate.
1741
+ debuglog("NodeService stopJob failed (continuing)", { serviceId, err: String(err) });
1742
+ }
1743
+ }
1744
+ tagsForJob(jobId) {
1745
+ return this.jobMap.get(jobId)?.job.tags;
1746
+ }
1747
+ poolNameForNode(nodeId) {
1748
+ const node = this.nodeMap.get(nodeId);
1749
+ return node?.nodeMetadata?.tags?.["pool"] ?? "";
1750
+ }
1751
+ // Tier within the pool, from the node's `tier` tag. Undefined for
1752
+ // pre-registered cluster nodes (no tier tag) — nodeMatchesTier then
1753
+ // treats them as the pool's fixed tier.
1754
+ tierNameForNode(nodeId) {
1755
+ const node = this.nodeMap.get(nodeId);
1756
+ return node?.nodeMetadata?.tags?.["tier"];
1757
+ }
1758
+ azForNode(nodeId) {
1759
+ const node = this.nodeMap.get(nodeId);
1760
+ return node?.nodeMetadata?.tags?.["az"];
1761
+ }
1762
+ // Cloud / failure-domain key for the resilience sameCloud rule: the node's
1763
+ // tier kind (aws / oci / local-servers), resolved via its pool+tier tags. Matches
1764
+ // how provisionDegradation and validation derive a pool's clouds.
1765
+ cloudForNode(nodeId) {
1766
+ const poolName = this.poolNameForNode(nodeId);
1767
+ const tierName = this.tierNameForNode(nodeId);
1768
+ const pool = (this.settings.placementPools ?? []).find((p) => p.name === poolName);
1769
+ if (!pool)
1770
+ return undefined;
1771
+ const tier = tierName !== undefined
1772
+ ? pool.tiers.find((t) => t.name === tierName)
1773
+ : pool.tiers.find((t) => t.scaleOut === "fixed");
1774
+ return tier?.kind;
1775
+ }
1776
+ handleBundleUpdated(raw) {
1777
+ if (!raw.bundle)
1778
+ return;
1779
+ const b = (0, conversions_1.fromPbBundle)(raw.bundle);
1780
+ this.bundleMap.set(b.bundleId, b);
1781
+ // Phase C2: operator pushed a fresh spec — clear any prior broken
1782
+ // state for this bundle and reset its replicas' restart counters
1783
+ // so the new spec gets a clean start.
1784
+ if (this.brokenBundles.delete(b.bundleId)) {
1785
+ debuglog("Cleared broken flag on bundleUpdated", { bundleId: b.bundleId });
1786
+ }
1787
+ this.clearRestartHistoryForBundle(b.bundleId);
1788
+ this.settings.onBundleUpdated?.(b);
1789
+ }
1790
+ handleBundleDeleted(raw) {
1791
+ if (!raw.bundleId)
1792
+ return;
1793
+ const id = raw.bundleId.bundleId;
1794
+ this.bundleMap.delete(id);
1795
+ this.brokenBundles.delete(id);
1796
+ this.clearRestartHistoryForBundle(id);
1797
+ const stop = this.pendingStopTimers.get(id);
1798
+ if (stop) {
1799
+ stop.timer.cancel();
1800
+ this.pendingStopTimers.delete(id);
1801
+ }
1802
+ this.settings.onBundleDeleted?.(id);
1803
+ }
1804
+ clearRestartHistoryForBundle(bundleId) {
1805
+ for (const jobId of this.jobMap.keys()) {
1806
+ const tags = this.jobMap.get(jobId)?.job.tags ?? {};
1807
+ if (tags["bundleId"] === bundleId)
1808
+ this.restartHistory.delete(jobId);
1809
+ }
1810
+ }
1811
+ nodesByJob(jobId) {
1812
+ // Uses the jobToNode index so the "is this job placed yet?" check
1813
+ // works for multi-job nodes too — the older `tags["jobId"]` scan
1814
+ // always returned empty for cluster workers (the tag is per-node
1815
+ // and can only carry one value), which caused AutoManager to
1816
+ // repeatedly re-attempt placement and the daemon to reject with
1817
+ // `INVALID_ARGUMENT: Job already running on node`.
1818
+ const nodes = new Set();
1819
+ const nodeId = this.jobToNode.get(jobId);
1820
+ if (nodeId !== undefined) {
1821
+ const node = this.nodeMap.get(nodeId);
1822
+ if (node)
1823
+ nodes.add(node);
1824
+ }
1825
+ return nodes;
1826
+ }
1827
+ /** @internal */
1828
+ constructor(norsk, settings) {
1829
+ // All AutoManager state is a mirror of what the event stream most
1830
+ // recently said — AutoManager owns no durable state. Maps are cleared
1831
+ // and re-hydrated on (re)connect from the event-stream initial state.
1832
+ this.jobMap = new Map();
1833
+ this.nodeMap = new Map();
1834
+ this.bundleMap = new Map();
1835
+ this.inventoryMap = new Map();
1836
+ // Per-node rejection history (timestamps). Pruned to the configured
1837
+ // backoff window on read; entries trigger escalation to `unhealthyNodes`
1838
+ // once the threshold is crossed.
1839
+ this.rejectionsPerNode = new Map();
1840
+ // Nodes excluded from placement entirely until the next clean
1841
+ // `nodeInventoryUpdated` clears the entry.
1842
+ this.unhealthyNodes = new Set();
1843
+ // Recently-failed nodes — excluded from placement for `failureBackoffMs`
1844
+ // after we observe their `nodeStopping`. Map value is the deadline.
1845
+ this.recentFailures = new Map();
1846
+ // Phase C2: per-replica restart history. Each entry is the time a
1847
+ // recovery placement was attempted; older-than-1h entries are
1848
+ // pruned on read. Used to trip the maxRestartsPerHour ceiling.
1849
+ this.restartHistory = new Map();
1850
+ // Canonical jobId → nodeId index. Set at placement time (either
1851
+ // when AutoManager binds the job to an existing node via startJob,
1852
+ // or when it provisions a new node and records the provisional
1853
+ // marker); deleted on jobDeleted. Underpins `nodeForJob` as an
1854
+ // O(1) lookup so consumers (proxy routers, operator UIs) don't pay
1855
+ // a fleet-wide scan on every read — important when a single host
1856
+ // can carry many jobs (cluster, or any future cloud N:1) and the
1857
+ // `tags["jobId"]` shortcut on the node stops being expressive.
1858
+ // Rebuilt on R7 reconnect from each job's history via
1859
+ // `currentNodeIdForJob`. Entries for jobs whose node has since
1860
+ // terminated remain in the map but resolve to undefined through
1861
+ // nodeMap — harmless, and cleaned up the next time the job is
1862
+ // re-placed or deleted.
1863
+ this.jobToNode = new Map();
1864
+ // Per-job placement record. Set at the same callsite that fires
1865
+ // `onPlacementDecision` (live decisions during this AutoManager
1866
+ // generation); rebuilt from job history on R7 reconnect — for
1867
+ // hydrated jobs we have no live trace, so we synthesise one with
1868
+ // empty `trace.attempts` and the decision inferred from the
1869
+ // history event ("place" = re-binding to an existing node, "provision"
1870
+ // = node started for the job). Consumers query this via
1871
+ // `placements()` / `placementForJob()` so an operator UI shows the
1872
+ // current placement immediately on (re)connect rather than waiting
1873
+ // for the next live decision — same mirror-of-state pattern jobMap
1874
+ // and nodeMap use. Cleared on jobDeleted; entries are kept across
1875
+ // node restarts since the placement-of-record doesn't change just
1876
+ // because a node bounced.
1877
+ this.placementsByJob = new Map();
1878
+ // Pending placement timers — one per scheduled job. Set when
1879
+ // `handleJobChanged` sees a pre/active job whose `startDateTime`
1880
+ // (minus the pool's `placementLeadMs`) is still in the future;
1881
+ // fires placeAndProvision when the lead-time window opens.
1882
+ // Cancelled on jobDeleted, on R7 reconnect (re-evaluated from
1883
+ // hydrated state), and replaced on subsequent job-updates that
1884
+ // change the schedule. Tracks `scheduledFor` so `pendingPlacements()`
1885
+ // can render upcoming work in operator UIs.
1886
+ this.pendingPlacementTimers = new Map();
1887
+ // Pending stop timers — one per bundleId. Set when any job in the
1888
+ // bundle carries the `__stopDateTime` tag and the instant is still
1889
+ // in the future; on fire, calls `deleteBundle(bundleId)` and
1890
+ // removes the entry. Daemon has no first-class concept of stop
1891
+ // time so this state is rebuilt from job tags on R7 reconnect.
1892
+ // Public surface: `setBundleStopTime(bundleId, newStopTime?)` for
1893
+ // the future Edit-bundle UI; `bundleStopTimes()` for read.
1894
+ this.pendingStopTimers = new Map();
1895
+ // Outstanding deferred recovery placements. Each entry caps to one
1896
+ // setTimeout-driven retry per JobId; subsequent triggers while a
1897
+ // retry is in flight are no-ops (the scheduled fire will see the
1898
+ // latest state). Prevents the tight-loop "provisionJob → JobFailed →
1899
+ // provisionJob" pattern we observed when worker-side launch errors
1900
+ // were instantly retryable. See 04-e2e-todo's B-daemon-retry-storm.
1901
+ this.pendingPlacementRetries = new Map();
1902
+ // Timestamp of the most recent `placeAndProvision` entry per JobId.
1903
+ // Used by both initial and recovery branches to skip duplicate
1904
+ // attempts while a placement is still in flight (the daemon has
1905
+ // dispatched provisionJob but JobStarted hasn't landed yet, so
1906
+ // `nodesByJob().size` is still 0 and handleJobChanged would
1907
+ // otherwise re-fire). See 04-e2e-todo's
1908
+ // B-placement-overretry-dispatch-fail.
1909
+ this.lastPlacementAttemptAt = new Map();
1910
+ // Phase C2: bundles that have tripped the restart ceiling. While in
1911
+ // this set, AutoManager refuses recovery placements for any of the
1912
+ // bundle's replicas. Cleared on bundleUpdated (operator pushed a
1913
+ // fresh spec) or bundleDeleted.
1914
+ this.brokenBundles = new Set();
1915
+ // Phase C3: pending nodeStarting → nodeStarted grace timers. If a
1916
+ // timer fires before the node reports running, AutoManager
1917
+ // terminates the node and the existing nodeStopping path runs
1918
+ // recovery per band rules.
1919
+ this.startupGraceTimers = new Map();
1920
+ // Phase C6: spares we have asked the provider to create but which
1921
+ // haven't yet appeared in nodeMap with the spare tag. Counted
1922
+ // alongside live spares so the reconciler doesn't fire duplicate
1923
+ // provisions while a previous one is in flight.
1924
+ this.pendingSpareProvisions = new Map();
1925
+ // Phase C8: nodes the operator has cordoned client-side. Merged
1926
+ // into NodeView.inventory.cordoned at placement time so existing
1927
+ // placement filter excludes them. (When Manager grows a cordon
1928
+ // API and emits `cordoned: true` on inventory updates, this local
1929
+ // set becomes redundant — until then it's the API.)
1930
+ this.cordonedNodes = new Set();
1931
+ // Phase D4 — cumulative metrics counters since startedAt. Read via
1932
+ // `metrics()`. Counters never reset; consumers compute deltas.
1933
+ this.metricsCounters = {
1934
+ placementsAttempted: 0,
1935
+ placementsSucceeded: 0,
1936
+ placementsFailed: 0,
1937
+ placementsDispatchFailed: 0,
1938
+ jobsRejected: 0,
1939
+ bundlesBroken: 0,
1940
+ jobsDropped: 0,
1941
+ };
1942
+ // Phase E — active migrations. "No concurrent migrations per Job"
1943
+ // means migrationByJobId is a 1-to-1 index of migrations.
1944
+ this.migrationStates = new Map();
1945
+ this.migrationByJobId = new Map();
1946
+ // Phase E deadlines — a per-migration timer fires if the current
1947
+ // phase hasn't transitioned by the configured window. Re-armed
1948
+ // on every phase transition; cleared on done/aborted/close().
1949
+ this.migrationPhaseTimers = new Map();
1950
+ // Phase F — NodeService runtime tracking. Per service, the set of
1951
+ // (nodeId → runtime jobId) AutoManager has placed it on. The
1952
+ // reconciler keeps this in sync with the configured eligible set.
1953
+ this.nodeServicePlacements = new Map();
1954
+ this.closed = false;
1955
+ this.norsk = norsk;
1956
+ this.settings = settings;
1957
+ this.clock = settings.clock ?? new clock_1.RealClock();
1958
+ if (!this.settings.onError) {
1959
+ this.settings.onError = (err) => {
1960
+ debuglog("Unhandled error: %o", err);
1961
+ };
1962
+ }
1963
+ }
1964
+ /** @internal */
1965
+ async runInternal() {
1966
+ this.startedAt = this.clock.now();
1967
+ const [initialState, iter] = await (0, norsk_manager_sdk_1.eventStream)(this.norsk, {
1968
+ pendingWindow: this.settings.pendingWindow,
1969
+ });
1970
+ // R7: Hydrate state from initial-state. AutoManager owns nothing
1971
+ // durable; clear-then-populate so a reconnect drops anything the
1972
+ // server has since forgotten about. Includes every transient map
1973
+ // we maintain in-process — without this, a reconnect would carry
1974
+ // forward unhealthy/restart/migration/spare state against a fleet
1975
+ // view that may no longer match.
1976
+ this.jobMap.clear();
1977
+ this.nodeMap.clear();
1978
+ this.bundleMap.clear();
1979
+ this.inventoryMap.clear();
1980
+ this.unhealthyNodes.clear();
1981
+ this.recentFailures.clear();
1982
+ this.rejectionsPerNode.clear();
1983
+ this.restartHistory.clear();
1984
+ this.brokenBundles.clear();
1985
+ this.pendingSpareProvisions.clear();
1986
+ this.jobToNode.clear();
1987
+ this.placementsByJob.clear();
1988
+ // Pending placement timers are session-local; the recovery loop
1989
+ // below re-evaluates each hydrated pre/active job and schedules
1990
+ // fresh timers (or places immediately) as appropriate.
1991
+ for (const { timer } of this.pendingPlacementTimers.values())
1992
+ timer.cancel();
1993
+ this.pendingPlacementTimers.clear();
1994
+ // Same for stop timers — the recovery loop calls reconcileStopTimer
1995
+ // for each hydrated job so the daemon-persisted __stopDateTime tag
1996
+ // re-arms the timer.
1997
+ for (const { timer } of this.pendingStopTimers.values())
1998
+ timer.cancel();
1999
+ this.pendingStopTimers.clear();
2000
+ for (const t of this.startupGraceTimers.values())
2001
+ t.cancel();
2002
+ this.startupGraceTimers.clear();
2003
+ // Migrations are in-flight client-side state; on reconnect the
2004
+ // worker-side actors are gone with the old connection, so any
2005
+ // pending migration is effectively dead. Cancel deadlines first
2006
+ // (they refer to timers we're about to cancel anyway), then drop
2007
+ // the state maps.
2008
+ for (const d of this.migrationPhaseTimers.values())
2009
+ d.cancel();
2010
+ this.migrationPhaseTimers.clear();
2011
+ this.migrationStates.clear();
2012
+ this.migrationByJobId.clear();
2013
+ this.nodeServicePlacements.clear();
2014
+ for (const jobWithHistory of initialState.activeJobs) {
2015
+ this.jobMap.set(jobWithHistory.job.jobId, jobWithHistory);
2016
+ // Rebuild jobToNode from history — on reconnect we don't get a
2017
+ // fresh placement-decision stream for jobs that were already
2018
+ // placed; the history is the source of truth.
2019
+ const nodeId = currentNodeIdForJob(jobWithHistory);
2020
+ if (nodeId !== undefined) {
2021
+ this.jobToNode.set(jobWithHistory.job.jobId, nodeId);
2022
+ // Same reasoning for the placement map: a job that was placed
2023
+ // in a prior generation has no live `onPlacementDecision` event
2024
+ // we can replay, so we synthesise a record from history. The
2025
+ // trace is empty (we never had it) but the result + at +
2026
+ // job-identity fields are all derivable.
2027
+ const synth = synthesisePlacementFromHistory(jobWithHistory, nodeId);
2028
+ if (synth)
2029
+ this.placementsByJob.set(jobWithHistory.job.jobId, synth);
2030
+ }
2031
+ // Fire onJobUpdated for each hydrated job so consumers can
2032
+ // treat hydration identically to a stream of fresh
2033
+ // job-updated events. Without this, a consumer subscribed
2034
+ // through onJobUpdated wouldn't see existing jobs until the
2035
+ // next genuine update — proxies that build routing tables
2036
+ // from the event stream would stay empty until the operator
2037
+ // touched something.
2038
+ this.settings.onJobUpdated?.(jobWithHistory);
2039
+ }
2040
+ for (const runningNode of initialState.nodesRunning) {
2041
+ const nodeId = runningNode.nodeMetadata.nodeId;
2042
+ this.updateNodeMap(nodeId, "running", runningNode.nodeMetadata, runningNode.runningNodeMetadata);
2043
+ // Surface hydrated nodes to consumers as started — symmetric with
2044
+ // the job-hydration loop above replaying each job via onJobUpdated.
2045
+ // A worker already up before AutoManager (re)connected is delivered
2046
+ // as a `NodeRunning` snapshot, never a live `NodeStarted`; without
2047
+ // this, downstream listeners (e.g. the norsk-mgr Cluster page, which
2048
+ // only refetches on a node-started signal) never learn it joined.
2049
+ const node = this.nodeMap.get(nodeId);
2050
+ if (node)
2051
+ this.settings.onNodeStarted?.(node);
2052
+ }
2053
+ for (const ba of initialState.bundles) {
2054
+ if (ba.bundle) {
2055
+ const b = (0, conversions_1.fromPbBundle)(ba.bundle);
2056
+ this.bundleMap.set(b.bundleId, b);
2057
+ }
2058
+ }
2059
+ for (const snap of initialState.inventories) {
2060
+ if (snap.inventory && snap.nodeId) {
2061
+ this.inventoryMap.set(snap.nodeId.nodeId, (0, conversions_1.fromPbNodeInventory)(snap.inventory));
2062
+ }
2063
+ }
2064
+ // hotSpares + nodeServices reserved (Phase F).
2065
+ // Recover any active jobs that we don't have a node for — these
2066
+ // exist when AutoManager has just (re)connected and the server
2067
+ // tells us about them via initial-state. Route through
2068
+ // `placeOrDefer` so scheduled jobs whose `startDateTime - lead`
2069
+ // is still in the future get a fresh timer; ones whose window
2070
+ // has already arrived (or was missed while mgr was down) place
2071
+ // immediately.
2072
+ for (const entry of this.jobMap.values()) {
2073
+ // Rebuild stop timers from the daemon-persisted __stopDateTime
2074
+ // tag on every hydrated job. Dedupes per bundleId — first job
2075
+ // wins, subsequent jobs in the same bundle are no-ops.
2076
+ this.reconcileStopTimer(entry);
2077
+ if (this.wantsPlacementNow(entry)) {
2078
+ await this.placeOrDefer(entry);
2079
+ }
2080
+ }
2081
+ // Periodic cleanup: terminate stopped nodes older than the threshold.
2082
+ // R19: cadence derived from removeStoppedNodesAfter (1/4 of the
2083
+ // threshold, capped to [10s, 5min]) so the sweep is proportionate
2084
+ // to the retention window — tests that lower the threshold to
2085
+ // seconds see the sweep fire quickly without rewiring the cadence.
2086
+ const cleanupCadenceMs = Math.max(10000, Math.min(300000, Math.floor((this.settings.removeStoppedNodesAfter * 1000) / 4)));
2087
+ this.cleanupInterval = this.clock.setInterval(async () => {
2088
+ if (this.closed)
2089
+ return; // R14: don't run cleanup after close()
2090
+ // R13: snapshot entries up front so concurrent mutations during
2091
+ // the await terminateNode don't reshuffle traversal.
2092
+ const snapshot = Array.from(this.nodeMap.entries());
2093
+ for (const [nodeId, node] of snapshot) {
2094
+ if (this.closed)
2095
+ return;
2096
+ if (node.nodeState === "stopped" &&
2097
+ this.clock.now().getTime() - node.lastActivity.getTime() >
2098
+ this.settings.removeStoppedNodesAfter * 1000) {
2099
+ await this.norsk.terminateNode(nodeId).catch((err) => {
2100
+ this.settings.onError?.(err);
2101
+ });
2102
+ this.nodeMap.delete(nodeId);
2103
+ this.settings.onNodeTerminated?.(nodeId);
2104
+ }
2105
+ }
2106
+ }, cleanupCadenceMs);
2107
+ // Phase C6: periodic hot-spare reconciliation tick. Event-driven
2108
+ // reconciles handle most cases; this is the safety net for
2109
+ // missed signals (e.g. a provider that quietly terminated a
2110
+ // spare without Manager emitting nodeStopping).
2111
+ const reconcileInterval = this.settings.hotSpareReconcileIntervalMs ??
2112
+ DEFAULT_HOT_SPARE_RECONCILE_INTERVAL_MS;
2113
+ this.hotSpareReconcileInterval = this.clock.setInterval(() => this.reconcileHotSpares(), reconcileInterval);
2114
+ // Kick once at startup so a fresh AutoManager with hotSpares
2115
+ // configured begins provisioning immediately rather than waiting
2116
+ // for the first event or interval.
2117
+ this.reconcileHotSpares();
2118
+ // Phase F: same shape as hot-spare reconcile — periodic tick
2119
+ // for NodeServices, with a startup kick.
2120
+ const nsReconcileInterval = this.settings.nodeServiceReconcileIntervalMs ??
2121
+ DEFAULT_NODE_SERVICE_RECONCILE_INTERVAL_MS;
2122
+ this.nodeServiceReconcileInterval = this.clock.setInterval(() => this.reconcileNodeServices(), nsReconcileInterval);
2123
+ this.reconcileNodeServices();
2124
+ for await (const event of iter) {
2125
+ try {
2126
+ const eventType = event.event;
2127
+ switch (eventType) {
2128
+ case "jobUpdated":
2129
+ case "jobPending":
2130
+ await this.handleJobChanged(event.jobWithHistory);
2131
+ break;
2132
+ case "jobDeleted":
2133
+ this.handleJobDeleted(event.jobId);
2134
+ break;
2135
+ case "jobOutOfWindow":
2136
+ this.handleJobDeleted(event.jobWithHistory.job.jobId);
2137
+ break;
2138
+ case "jobInfo":
2139
+ this.settings.onJobInfo?.(event);
2140
+ break;
2141
+ case "nodeStarting":
2142
+ this.handleNodeStarting(event.nodeMetadata);
2143
+ break;
2144
+ case "nodeStarted":
2145
+ this.handleNodeStarted(event.nodeMetadata, event.runningNodeMetadata);
2146
+ break;
2147
+ case "nodeStopping":
2148
+ await this.handleNodeStopping(event.nodeId, event.reason);
2149
+ break;
2150
+ case "nodeStopped":
2151
+ this.handleNodeStopped(event.nodeId);
2152
+ break;
2153
+ case "physicalNodeConnected":
2154
+ // Reserved.
2155
+ break;
2156
+ case "providerHealthChange":
2157
+ debuglog("Provider health change %o", event.health);
2158
+ break;
2159
+ case "eventStreamClosed":
2160
+ break;
2161
+ case "nodeInventoryUpdated":
2162
+ this.handleNodeInventoryUpdated(event.raw);
2163
+ break;
2164
+ case "jobRejected":
2165
+ this.handleJobRejected(event.raw);
2166
+ break;
2167
+ case "jobConfigUpdated":
2168
+ this.handleJobConfigUpdated(event.raw);
2169
+ break;
2170
+ case "bundleUpdated":
2171
+ this.handleBundleUpdated(event.raw);
2172
+ break;
2173
+ case "bundleDeleted":
2174
+ this.handleBundleDeleted(event.raw);
2175
+ break;
2176
+ case "nodeServiceUpdated":
2177
+ case "nodeServiceDeleted":
2178
+ // Phase F (reserved).
2179
+ break;
2180
+ case "migrationTargetReady":
2181
+ await this.handleMigrationTargetReady(event.raw);
2182
+ break;
2183
+ case "migrationSourceStoppedOutput":
2184
+ await this.handleMigrationSourceStoppedOutput(event.raw);
2185
+ break;
2186
+ case "migrationAborted":
2187
+ this.handleMigrationAborted(event.raw);
2188
+ break;
2189
+ case "auditAppended":
2190
+ this.settings.onAuditAppended?.(event.entry);
2191
+ break;
2192
+ default: {
2193
+ const exhaustiveCheck = eventType;
2194
+ throw new Error(`Unhandled case: ${exhaustiveCheck}`);
2195
+ }
2196
+ }
2197
+ }
2198
+ catch (err) {
2199
+ this.settings.onError?.(err);
2200
+ }
2201
+ }
2202
+ }
2203
+ // ---- job lifecycle ----
2204
+ async handleJobChanged(jobWithHistory) {
2205
+ const job = jobWithHistory.job;
2206
+ this.jobMap.set(job.jobId, jobWithHistory);
2207
+ this.settings.onJobUpdated?.(jobWithHistory);
2208
+ // Reconcile the per-bundle stop timer from the well-known tag on
2209
+ // this job. Done before the placement decisions so an already-past
2210
+ // stop time triggers deleteBundle without waiting for placement.
2211
+ this.reconcileStopTimer(jobWithHistory);
2212
+ if (this.wantsPlacementNow(jobWithHistory)) {
2213
+ await this.placeOrDefer(jobWithHistory);
2214
+ }
2215
+ }
2216
+ /** Read the `__stopDateTime` tag off the job, and (re)schedule the
2217
+ * bundle's stop timer accordingly. Tag is set per-job but applies
2218
+ * to the whole bundle — first-job-wins for read; all jobs in a
2219
+ * bundle share the same value at submit. */
2220
+ reconcileStopTimer(jh) {
2221
+ const tags = jh.job.tags ?? {};
2222
+ const bundleId = tags["bundleId"];
2223
+ if (!bundleId)
2224
+ return;
2225
+ const stopStr = tags[conversions_1.STOP_DATE_TIME_TAG];
2226
+ const stopAt = stopStr ? new Date(stopStr) : undefined;
2227
+ const prev = this.pendingStopTimers.get(bundleId);
2228
+ // If the tag is gone OR unchanged, just keep / drop the existing
2229
+ // timer. Equality on millis is fine — daemon round-trips ISO
2230
+ // strings, no float fuzz.
2231
+ if (!stopAt || Number.isNaN(stopAt.getTime())) {
2232
+ if (prev) {
2233
+ prev.timer.cancel();
2234
+ this.pendingStopTimers.delete(bundleId);
2235
+ }
2236
+ return;
2237
+ }
2238
+ if (prev && prev.stopAt.getTime() === stopAt.getTime())
2239
+ return;
2240
+ if (prev)
2241
+ prev.timer.cancel();
2242
+ this.scheduleStopTimer(bundleId, stopAt);
2243
+ }
2244
+ scheduleStopTimer(bundleId, stopAt) {
2245
+ const delayMs = stopAt.getTime() - this.clock.now().getTime();
2246
+ if (delayMs <= 0) {
2247
+ // Already past — fire immediately and don't keep a timer entry.
2248
+ debuglog("Stop time already past; deleting bundle now", { bundleId, stopAt: stopAt.toISOString() });
2249
+ void this.deleteBundle(bundleId).catch((e) => this.settings.onError?.(e instanceof Error ? e : new Error(String(e))));
2250
+ this.pendingStopTimers.delete(bundleId);
2251
+ return;
2252
+ }
2253
+ debuglog("Scheduling bundle stop", { bundleId, stopAt: stopAt.toISOString(), inMs: delayMs });
2254
+ const timer = this.clock.setTimeout(() => {
2255
+ this.pendingStopTimers.delete(bundleId);
2256
+ void this.deleteBundle(bundleId).catch((e) => this.settings.onError?.(e instanceof Error ? e : new Error(String(e))));
2257
+ }, delayMs);
2258
+ this.pendingStopTimers.set(bundleId, { timer, stopAt });
2259
+ }
2260
+ /**
2261
+ * Update (or clear) a bundle's auto-stop time. Calls `updateBundle`
2262
+ * so the new value persists via the daemon's tag store and any other
2263
+ * clients see it through their event streams. Pass `undefined` to
2264
+ * cancel a previously-set stop time.
2265
+ *
2266
+ * Throws if the bundle isn't known to this AutoManager (no live
2267
+ * bundleMap entry); operators wanting to set stop on a not-yet-
2268
+ * streamed bundle should retry after JobPending fires.
2269
+ * @public
2270
+ */
2271
+ async setBundleStopTime(bundleId, stopDateTime) {
2272
+ const bundle = this.bundleById(bundleId);
2273
+ if (!bundle) {
2274
+ throw new Error(`No bundle ${bundleId} in AutoManager state`);
2275
+ }
2276
+ const updated = {
2277
+ ...bundle,
2278
+ jobs: bundle.jobs.map((j) => j.launch?.kind === "productTemplate"
2279
+ ? { ...j, launch: { ...j.launch, stopDateTime } }
2280
+ : j),
2281
+ };
2282
+ await this.updateBundle(updated);
2283
+ }
2284
+ /**
2285
+ * Snapshot of all currently-scheduled bundle stop timers. UIs render
2286
+ * this alongside `pendingPlacements()` for an "upcoming events"
2287
+ * view.
2288
+ * @public
2289
+ */
2290
+ bundleStopTimes() {
2291
+ return [...this.pendingStopTimers.entries()]
2292
+ .map(([bundleId, { stopAt }]) => ({ bundleId, stopAt }))
2293
+ .sort((a, b) => a.stopAt.getTime() - b.stopAt.getTime());
2294
+ }
2295
+ /** A job that should be running but currently has no node bound to
2296
+ * it: it carries placement `requirements`, isn't manual-start, is in
2297
+ * a placeable state (`pre` / `active`), and `nodesByJob` is empty.
2298
+ * This is the single source of truth for "this job wants a placement
2299
+ * and doesn't have one" — shared by initial hydration, the per-job
2300
+ * `handleJobChanged` path, and the capacity-driven reconcile sweep,
2301
+ * so the decision lives in exactly one place. */
2302
+ wantsPlacementNow(jh) {
2303
+ const job = jh.job;
2304
+ if (!job.autoPlacement?.requirements)
2305
+ return false;
2306
+ if ((job.tags ?? {})["startMode"] === "manual")
2307
+ return false;
2308
+ // Durable terminal verdict (poison / bronze drop) persisted by a prior
2309
+ // session: never re-place it. This is what makes "fully failed" survive a
2310
+ // daemon restart — on reconnect the job hydrates with terminalState set.
2311
+ if (job.terminalState)
2312
+ return false;
2313
+ if (job.state !== "pre" && job.state !== "active")
2314
+ return false;
2315
+ return this.nodesByJob(job.jobId).size === 0;
2316
+ }
2317
+ /**
2318
+ * Re-run placement for every job that wants a node but hasn't got
2319
+ * one. Called whenever a *capacity-increasing* event arrives — a node
2320
+ * comes online (`nodeStarted`), a worker reports fresh inventory
2321
+ * (`nodeInventoryUpdated`, which also covers an un-cordon or a node
2322
+ * that has just freed reserved capacity), or a job finishes and
2323
+ * releases its node (`jobDeleted`). Those are the only transitions
2324
+ * that can turn a previously-unplaceable job placeable, so they are
2325
+ * exactly when re-running the engine is worthwhile — there is no
2326
+ * polling retry, the trigger is always a concrete event.
2327
+ *
2328
+ * Placement stays implemented once: each candidate routes through
2329
+ * `placeOrDefer` (the same entry point hydration and `handleJobChanged`
2330
+ * use), inheriting its scheduling, the in-flight gate, and the
2331
+ * failure path. Jobs already waiting on a schedule timer or a recovery
2332
+ * backoff are skipped — their own timers own them — so a capacity
2333
+ * event never disturbs a future-scheduled or backing-off job.
2334
+ *
2335
+ * Cheap and idempotent (mirrors `reconcileHotSpares` /
2336
+ * `reconcileNodeServices`); safe to call on every relevant event.
2337
+ */
2338
+ reconcilePendingPlacements() {
2339
+ for (const jh of this.jobMap.values()) {
2340
+ if (!this.wantsPlacementNow(jh))
2341
+ continue;
2342
+ const jobId = jh.job.jobId;
2343
+ if (this.pendingPlacementTimers.has(jobId))
2344
+ continue;
2345
+ if (this.pendingPlacementRetries.has(jobId))
2346
+ continue;
2347
+ void this.placeOrDefer(jh).catch((err) => this.settings.onError?.(err));
2348
+ }
2349
+ }
2350
+ /** Decide whether to place immediately or defer until the job's
2351
+ * scheduled `startDateTime` (minus pool lead time) arrives.
2352
+ * Re-entry from job-update events: cancels and re-evaluates so a
2353
+ * schedule change moves the timer. Hydrated jobs with `startTime -
2354
+ * lead` in the past place immediately (operator intent was to run;
2355
+ * schedule was a "when", not a "drop if missed"). */
2356
+ async placeOrDefer(jh) {
2357
+ const job = jh.job;
2358
+ const requirements = job.autoPlacement?.requirements;
2359
+ if (!requirements)
2360
+ return;
2361
+ const tags = job.tags ?? {};
2362
+ // Any previous pending timer for this jobId is now stale — either
2363
+ // we'll re-schedule below or we'll place immediately. Either way,
2364
+ // drop the prior timer.
2365
+ const prev = this.pendingPlacementTimers.get(job.jobId);
2366
+ if (prev) {
2367
+ prev.timer.cancel();
2368
+ this.pendingPlacementTimers.delete(job.jobId);
2369
+ }
2370
+ const startDateTime = job.startDateTime;
2371
+ if (!startDateTime) {
2372
+ // No schedule — fire now, current behaviour.
2373
+ await this.placeAndProvision(job.jobId, requirements, tags);
2374
+ return;
2375
+ }
2376
+ const leadMs = this.placementLeadMsForJob(jh);
2377
+ const fireAt = startDateTime.getTime() - leadMs;
2378
+ const delayMs = fireAt - this.clock.now().getTime();
2379
+ if (delayMs <= 0) {
2380
+ // Schedule has arrived (or passed — hydration after mgr was
2381
+ // down through the launch window). Operator's intent was for
2382
+ // the job to be running; honour it.
2383
+ await this.placeAndProvision(job.jobId, requirements, tags);
2384
+ return;
2385
+ }
2386
+ debuglog("Deferring placement until scheduled startDateTime", {
2387
+ jobId: job.jobId,
2388
+ startDateTime: startDateTime.toISOString(),
2389
+ leadMs,
2390
+ fireInMs: delayMs,
2391
+ });
2392
+ const timer = this.clock.setTimeout(() => {
2393
+ this.pendingPlacementTimers.delete(job.jobId);
2394
+ // Re-check that we still want to place — the job may have been
2395
+ // deleted or another instance placed it during the wait. Cheap
2396
+ // re-test using nodesByJob (now jobToNode-backed and O(1)).
2397
+ const live = this.jobMap.get(job.jobId);
2398
+ if (!live)
2399
+ return;
2400
+ if (this.nodesByJob(job.jobId).size > 0)
2401
+ return;
2402
+ void this.placeAndProvision(job.jobId, requirements, tags);
2403
+ }, delayMs);
2404
+ this.pendingPlacementTimers.set(job.jobId, { timer, scheduledFor: new Date(fireAt) });
2405
+ }
2406
+ /** Maximum `placementLeadMs` across the bundle pool's tiers (worst case
2407
+ * — guarantees the job is ready by `startDateTime` regardless of which
2408
+ * tier the placement engine ends up picking). Falls back to the per-kind
2409
+ * default when a tier doesn't specify one. Returns 0 if the bundle or
2410
+ * pool isn't found — we'd rather race the schedule than indefinitely
2411
+ * delay placement. */
2412
+ placementLeadMsForJob(jh) {
2413
+ const bundleId = (jh.job.tags ?? {})["bundleId"];
2414
+ if (!bundleId)
2415
+ return 0;
2416
+ const bundle = this.bundleMap.get(bundleId);
2417
+ if (!bundle)
2418
+ return 0;
2419
+ const pools = this.settings.placementPools ?? [];
2420
+ const pool = pools.find((p) => p.name === bundle.pool);
2421
+ if (!pool)
2422
+ return 0;
2423
+ let max = 0;
2424
+ for (const tier of pool.tiers) {
2425
+ const lead = tier.placementLeadMs ?? (0, placement_1.defaultPlacementLeadMs)(tier.kind);
2426
+ if (lead > max)
2427
+ max = lead;
2428
+ }
2429
+ return max;
2430
+ }
2431
+ /**
2432
+ * Currently-scheduled placements (jobs AutoManager has deferred
2433
+ * because their `startDateTime - leadTime` is still in the future).
2434
+ * Returns one entry per pending timer; the UI can render an
2435
+ * "upcoming jobs" list and show when each will go live.
2436
+ * @public
2437
+ */
2438
+ pendingPlacements() {
2439
+ return [...this.pendingPlacementTimers.entries()]
2440
+ .map(([jobId, { scheduledFor }]) => ({ jobId, scheduledFor }))
2441
+ .sort((a, b) => a.scheduledFor.getTime() - b.scheduledFor.getTime());
2442
+ }
2443
+ handleJobDeleted(jobId) {
2444
+ // Pure local-state cleanup. The daemon's deleteBundle saga is now
2445
+ // responsible for stopping the worker-side compose stacks before
2446
+ // emitting JobDeleted; node-level lifecycle (terminate / drain) is
2447
+ // AutoManager policy (drainNode, hot-spare reconciliation) and
2448
+ // intentionally not coupled to job deletion. Prior versions called
2449
+ // `terminateNode` here, which was provider-incorrect for cluster
2450
+ // workers (terminate is a no-op on long-lived operator-attached
2451
+ // nodes; the worker's compose stack would leak as a result).
2452
+ const existed = this.jobMap.delete(jobId);
2453
+ this.restartHistory.delete(jobId);
2454
+ this.lastPlacementAttemptAt.delete(jobId);
2455
+ this.jobToNode.delete(jobId);
2456
+ this.placementsByJob.delete(jobId);
2457
+ const pending = this.pendingPlacementTimers.get(jobId);
2458
+ if (pending) {
2459
+ pending.timer.cancel();
2460
+ this.pendingPlacementTimers.delete(jobId);
2461
+ }
2462
+ const pendingRetry = this.pendingPlacementRetries.get(jobId);
2463
+ if (pendingRetry) {
2464
+ clearTimeout(pendingRetry);
2465
+ this.pendingPlacementRetries.delete(jobId);
2466
+ }
2467
+ if (existed)
2468
+ this.settings.onJobDeleted?.(jobId);
2469
+ // A job leaving frees the capacity it reserved, which may let a
2470
+ // previously-unplaceable job land. The freed capacity becomes
2471
+ // visible to the placement engine once the worker re-reports
2472
+ // inventory (`nodeInventoryUpdated` will reconcile again then); this
2473
+ // trigger covers the case where AutoManager's cached inventory
2474
+ // already reflects the release.
2475
+ this.reconcilePendingPlacements();
2476
+ }
2477
+ // ---- node lifecycle ----
2478
+ handleNodeStarting(nodeMetadata) {
2479
+ this.updateNodeMap(nodeMetadata.nodeId, "starting", nodeMetadata, undefined);
2480
+ this.settings.onNodeStarting?.(nodeMetadata.nodeId);
2481
+ this.armStartupGrace(nodeMetadata.nodeId);
2482
+ }
2483
+ /** Phase C3. Schedule the grace timer; replace any prior timer. */
2484
+ armStartupGrace(nodeId) {
2485
+ this.startupGraceTimers.get(nodeId)?.cancel();
2486
+ const grace = this.cfgJobStartupGraceMs();
2487
+ const timer = this.clock.setTimeout(() => {
2488
+ this.startupGraceTimers.delete(nodeId);
2489
+ debuglog("Node startup grace exceeded — terminating", { nodeId, grace });
2490
+ void this.norsk
2491
+ .terminateNode(nodeId)
2492
+ .catch((err) => this.settings.onError?.(err));
2493
+ }, grace);
2494
+ this.startupGraceTimers.set(nodeId, timer);
2495
+ }
2496
+ cancelStartupGrace(nodeId) {
2497
+ this.startupGraceTimers.get(nodeId)?.cancel();
2498
+ this.startupGraceTimers.delete(nodeId);
2499
+ }
2500
+ handleNodeStarted(nodeMetadata, runningNodeMetadata) {
2501
+ this.updateNodeMap(nodeMetadata.nodeId, "running", nodeMetadata, runningNodeMetadata);
2502
+ this.cancelStartupGrace(nodeMetadata.nodeId);
2503
+ // Phase C2: a successful start clears restart history for this
2504
+ // job — the "fundamentally broken" diagnosis only fires when no
2505
+ // amount of replacement is helping, so a job that has ever started
2506
+ // is by definition not fundamentally broken.
2507
+ const jobId = getJobIdForNode(nodeMetadata);
2508
+ if (jobId)
2509
+ this.restartHistory.delete(jobId);
2510
+ // Phase C6: a pending spare just landed.
2511
+ this.pendingSpareProvisions.delete(nodeMetadata.nodeId);
2512
+ const node = this.nodeMap.get(nodeMetadata.nodeId);
2513
+ if (node)
2514
+ this.settings.onNodeStarted?.(node);
2515
+ this.reconcileHotSpares();
2516
+ this.reconcileNodeServices();
2517
+ // A node coming online is new capacity — a cluster worker joining or
2518
+ // a hot spare landing can host a job that previously had nowhere to
2519
+ // go. Re-attempt pending placements.
2520
+ this.reconcilePendingPlacements();
2521
+ // R6: a migration target node has come up — advance from
2522
+ // `launching` to `awaitingTargetReady`. The workflow may still
2523
+ // need time to initialise; the awaitingTargetReady deadline now
2524
+ // bounds that step independently from launching.
2525
+ this.advanceMigrationsOnNodeStarted(nodeMetadata.nodeId);
2526
+ }
2527
+ advanceMigrationsOnNodeStarted(nodeId) {
2528
+ for (const state of this.migrationStates.values()) {
2529
+ if (state.targetNodeId !== nodeId)
2530
+ continue;
2531
+ if (state.phase !== "launching")
2532
+ continue;
2533
+ this.advanceMigration(state, "awaitingTargetReady");
2534
+ }
2535
+ }
2536
+ async handleNodeStopping(nodeId, _reason) {
2537
+ this.updateNodeMap(nodeId, "stopping", undefined, undefined);
2538
+ this.cancelStartupGrace(nodeId);
2539
+ // Surface to consumers so cluster-membership views (mgr's Cluster
2540
+ // page) refresh promptly. Without this callback, a Ctrl-C'd
2541
+ // cluster worker only surfaces in mgr UI when the eventual
2542
+ // NodeStopped/NodeTerminated arrives — which can be many seconds
2543
+ // later via the daemon's keepalive timeout + cleanup loop.
2544
+ this.settings.onNodeStopping?.(nodeId);
2545
+ // Mark the node as recently-failed so subsequent placements skip it
2546
+ // for `failureBackoffMs`. Cleared when the node next reports a
2547
+ // healthy `nodeInventoryUpdated`.
2548
+ const window = this.cfgFailureBackoffMs();
2549
+ this.recentFailures.set(nodeId, new Date(this.clock.now().getTime() + window));
2550
+ // TODO future slice: thread the nodeStopping reason into
2551
+ // recoveryContext so band-specific behaviour applies (gold→prefer
2552
+ // hot spare, bronze drop, etc.).
2553
+ const node = this.nodeMap.get(nodeId);
2554
+ const jobId = node?.provisional?.jobId ?? getJobIdForNode(node?.nodeMetadata);
2555
+ if (jobId) {
2556
+ // R8: if the job is mid-migration, don't run recovery placement —
2557
+ // the migration target is already a second live instance for the
2558
+ // same jobId, and a fresh placeAndProvision would create a third.
2559
+ // Abort the migration instead; operator can re-attempt once the
2560
+ // source is back. The target on the new node is allowed to keep
2561
+ // running (it's stoppable through the normal jobMap path).
2562
+ const migration = this.migrationForJob(jobId);
2563
+ if (migration && (migration.phase !== "done" && migration.phase !== "aborted")) {
2564
+ this.failMigration(migration.migrationId, `source nodeStopping during phase ${migration.phase}`);
2565
+ }
2566
+ else {
2567
+ const entry = this.jobMap.get(jobId);
2568
+ const job = entry?.job;
2569
+ const req = job?.autoPlacement?.requirements;
2570
+ if (job && req && (job.state === "pre" || job.state === "active")) {
2571
+ await this.placeAndProvision(job.jobId, req, job.tags ?? {}, { excludeNodes: this.currentExcludeSet() });
2572
+ }
2573
+ }
2574
+ }
2575
+ // Phase C6: if the stopping node was a spare, reconcile to
2576
+ // provision a replacement promptly (don't wait for the tick).
2577
+ this.reconcileHotSpares();
2578
+ // Phase F: lost capacity → reconcile NodeService placements.
2579
+ // Also drop any tracked placement on this node (the runtime job
2580
+ // is gone with the node).
2581
+ this.dropNodeServicePlacementsOnNode(nodeId);
2582
+ this.reconcileNodeServices();
2583
+ }
2584
+ handleNodeStopped(nodeId) {
2585
+ this.updateNodeMap(nodeId, "stopped", undefined, undefined);
2586
+ // Phase C6: a spare going away should trigger a replacement.
2587
+ this.pendingSpareProvisions.delete(nodeId);
2588
+ this.settings.onNodeStopped?.(nodeId);
2589
+ this.reconcileHotSpares();
2590
+ // Phase F: clear NodeService placements on stopped nodes.
2591
+ this.dropNodeServicePlacementsOnNode(nodeId);
2592
+ this.reconcileNodeServices();
2593
+ }
2594
+ dropNodeServicePlacementsOnNode(nodeId) {
2595
+ for (const placements of this.nodeServicePlacements.values()) {
2596
+ placements.delete(nodeId);
2597
+ }
2598
+ }
2599
+ // ---- maps ----
2600
+ updateNodeMap(nodeId, nodeState, nodeMetadata, runningNodeMetadata, provisional) {
2601
+ const existing = this.nodeMap.get(nodeId);
2602
+ this.nodeMap.set(nodeId, {
2603
+ nodeId,
2604
+ nodeState,
2605
+ lastActivity: this.clock.now(),
2606
+ nodeMetadata: nodeMetadata ?? existing?.nodeMetadata,
2607
+ runningNodeMetadata: runningNodeMetadata ?? existing?.runningNodeMetadata,
2608
+ provisional: provisional ?? existing?.provisional,
2609
+ });
2610
+ }
2611
+ }
2612
+ exports.AutoManager = AutoManager;
2613
+ function summariseTrace(trace) {
2614
+ // One-line per attempted pool, listing how nodes were filtered
2615
+ // (grouped by reason) and what happened. Compact enough that it
2616
+ // doesn't drown a debuglog stream when many placements happen in a
2617
+ // burst, but informative enough that "why did my job land there?"
2618
+ // is immediately answerable.
2619
+ const lines = trace.attempts.map((a) => {
2620
+ const groups = {};
2621
+ for (const f of a.filtered)
2622
+ groups[f.reason] = (groups[f.reason] ?? 0) + 1;
2623
+ const filterSummary = Object.entries(groups)
2624
+ .map(([reason, count]) => `${reason}=${count}`)
2625
+ .join(", ");
2626
+ const winner = a.outcome.kind === "placed"
2627
+ ? ` → placed on ${a.outcome.nodeId}${a.outcome.gpuIndex !== undefined ? ` (gpu ${a.outcome.gpuIndex})` : ""}`
2628
+ : a.outcome.kind === "provisioned"
2629
+ ? ` → provisioned ${a.outcome.instanceType}`
2630
+ : a.outcome.kind === "exhausted"
2631
+ ? " → fixed-pool exhausted, fall through"
2632
+ : a.outcome.kind === "no-instance-type"
2633
+ ? " → no allowed instance type fits, fall through"
2634
+ : " → pool not configured";
2635
+ const matchSummary = `${a.scored.length}/${a.initialCandidates} matched`;
2636
+ const filterPart = filterSummary ? `, filtered: ${filterSummary}` : "";
2637
+ return `[${a.pool}] ${matchSummary}${filterPart}${winner}`;
2638
+ });
2639
+ return `${trace.decision}: ${lines.join(" || ")}`;
2640
+ }
2641
+ function roleFromReplicaIndex(replicaIndex) {
2642
+ // Preserve the existing primary/backup convention for replicas 0 and 1
2643
+ // so consumers that grep on role keep working. Higher replica counts
2644
+ // get a numeric form.
2645
+ if (replicaIndex === 0)
2646
+ return "primary";
2647
+ if (replicaIndex === 1)
2648
+ return "backup";
2649
+ return `replica-${replicaIndex}`;
2650
+ }
2651
+ function debuglog(msg, ...param) {
2652
+ util.debuglog("norsk-automanager")(util.formatWithOptions({ maxArrayLength: null, depth: null, colors: true }, "[" + new Date().toISOString() + "] " + msg, ...param));
2653
+ }
2654
+ function getJobIdForNode(nodeMetadata) {
2655
+ return nodeMetadata?.tags["jobId"];
2656
+ }
2657
+ /**
2658
+ * Walk a job's history backward and return the nodeId where it is
2659
+ * currently placed (or was most recently placed). Prefers `running`
2660
+ * entries; falls back to `provisioned` for jobs still booting their
2661
+ * node. Returns undefined for jobs that have never been placed or
2662
+ * whose history is empty (defensive — runtime data always has at
2663
+ * least a `created` entry, which carries no nodeMetadata).
2664
+ *
2665
+ * This is the canonical "where does this job live" lookup and
2666
+ * underpins `AutoManager.nodeForJob`. It works regardless of whether
2667
+ * a node hosts one job (cloud, today) or many (cluster, or future
2668
+ * cloud N:1) because the nodeId is recorded against the JOB rather
2669
+ * than scanned out of NODE tags.
2670
+ */
2671
+ function currentNodeIdForJob(jh) {
2672
+ if (!jh)
2673
+ return undefined;
2674
+ // Two-pass: prefer the most recent `running` over the most recent
2675
+ // `provisioned`. A job that has been provisioned, run, stopped,
2676
+ // re-provisioned, but not yet run again should report the newest
2677
+ // provisioned node — but if it's running we always want that.
2678
+ let mostRecentProvisioned;
2679
+ for (let i = jh.history.length - 1; i >= 0; i--) {
2680
+ const e = jh.history[i];
2681
+ if (e.event === "running")
2682
+ return e.nodeMetadata.nodeId;
2683
+ if (e.event === "provisioned" && mostRecentProvisioned === undefined) {
2684
+ mostRecentProvisioned = e.nodeMetadata.nodeId;
2685
+ }
2686
+ }
2687
+ return mostRecentProvisioned;
2688
+ }
2689
+ /**
2690
+ * Build a `PlacementDecisionInfo` from a hydrated job's history. Used
2691
+ * on R7 reconnect to seed the placementsByJob map for jobs that were
2692
+ * placed in a previous generation — we have no live trace, so this
2693
+ * synthesises a minimal record (empty `trace.attempts`) that's still
2694
+ * shaped like a real decision so consumers can render it uniformly.
2695
+ *
2696
+ * `nodeId` is passed in by the caller (already resolved via
2697
+ * `currentNodeIdForJob`), to avoid walking the history twice.
2698
+ *
2699
+ * Returns undefined if the job's tags are too sparse to derive
2700
+ * `bundleId` / `replicaIndex` / `jobName` (un-managed jobs the
2701
+ * AutoManager-driven UI couldn't usefully display anyway).
2702
+ */
2703
+ function synthesisePlacementFromHistory(jh, nodeId) {
2704
+ const tags = jh.job.tags ?? {};
2705
+ const bundleId = tags["bundleId"];
2706
+ const replicaIndexStr = tags["replicaIndex"];
2707
+ const jobName = tags["jobName"];
2708
+ if (!bundleId || replicaIndexStr === undefined || !jobName)
2709
+ return undefined;
2710
+ const replicaIndex = Number(replicaIndexStr);
2711
+ if (!Number.isFinite(replicaIndex))
2712
+ return undefined;
2713
+ // Find the most recent placement event for `at` + decision kind.
2714
+ // running > provisioned (same precedence as currentNodeIdForJob).
2715
+ // If the only event we found is `provisioned`, decision="provision"
2716
+ // (the node was started for this job); `running` could be either —
2717
+ // we treat it as "place" because by the time we hydrate the node is
2718
+ // already running, so functionally the job was bound to an existing
2719
+ // running node. The trace stays empty either way.
2720
+ let at;
2721
+ let decision = "place";
2722
+ for (let i = jh.history.length - 1; i >= 0; i--) {
2723
+ const e = jh.history[i];
2724
+ if (e.event === "running") {
2725
+ at = e.timestamp instanceof Date ? e.timestamp : new Date(e.timestamp);
2726
+ decision = "place";
2727
+ break;
2728
+ }
2729
+ if (e.event === "provisioned" && at === undefined) {
2730
+ at = e.timestamp instanceof Date ? e.timestamp : new Date(e.timestamp);
2731
+ decision = "provision";
2732
+ }
2733
+ }
2734
+ if (!at)
2735
+ return undefined;
2736
+ // `pool`, `tier` and `instanceType` aren't on the history entries —
2737
+ // leave them as best-guess placeholders. Consumers that care about
2738
+ // accurate pool/tier attribution should walk the node's tags via
2739
+ // nodeMap.get(nodeId).nodeMetadata.tags.{pool,tier}.
2740
+ const result = decision === "place"
2741
+ ? { kind: "place", nodeId, pool: "", tier: "" }
2742
+ : { kind: "provision", pool: "", tier: "", instanceType: "", launchMode: "on-demand" };
2743
+ const trace = { attempts: [], decision };
2744
+ return { jobId: jh.job.jobId, bundleId, replicaIndex, jobName, result, trace, at };
2745
+ }
2746
+ //# sourceMappingURL=automanager.js.map