@kici-dev/orchestrator 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. package/dist/__test-helpers__/fake-scaler-state-store.d.ts +24 -0
  2. package/dist/__test-helpers__/mock-db-query.d.ts +130 -0
  3. package/dist/__test-helpers__/mock-db.d.ts +23 -0
  4. package/dist/agent/dispatcher.d.ts +81 -13
  5. package/dist/agent/host-roster.d.ts +37 -5
  6. package/dist/agent/registry.d.ts +2 -0
  7. package/dist/app.d.ts +33 -6
  8. package/dist/approvals/apply-decision.d.ts +67 -19
  9. package/dist/approvals/held-run-release-wiring.d.ts +45 -0
  10. package/dist/approvals/independent-wiring.d.ts +55 -0
  11. package/dist/approvals/resume-router.d.ts +9 -0
  12. package/dist/approvals/triggerer-subject.d.ts +37 -0
  13. package/dist/cache/dep-cache.d.ts +54 -10
  14. package/dist/cache/global-eval-round-cache.d.ts +7 -0
  15. package/dist/cli/api-client.d.ts +18 -0
  16. package/dist/cli/commands/cluster-settings.d.ts +11 -0
  17. package/dist/cli/commands/diagnose.d.ts +6 -0
  18. package/dist/cli/commands/held-run.d.ts +58 -0
  19. package/dist/cli/commands/trust-policy.d.ts +117 -12
  20. package/dist/cli/kici-admin.d.ts +10 -0
  21. package/dist/cli.js +2272 -1025
  22. package/dist/cluster/cluster-identity.d.ts +3 -3
  23. package/dist/cluster/cluster-settings-reader.d.ts +7 -1
  24. package/dist/cluster/coordinator.d.ts +44 -0
  25. package/dist/cluster/index.d.ts +4 -0
  26. package/dist/cluster/peer-handler.d.ts +19 -0
  27. package/dist/cluster/peer-registry.d.ts +17 -0
  28. package/dist/cluster/plan-headroom-store.d.ts +26 -0
  29. package/dist/cluster/worker-eviction.d.ts +69 -0
  30. package/dist/cold-store/load-event-log-range.d.ts +10 -3
  31. package/dist/cold-store/load-secret-audit-log-range.d.ts +4 -3
  32. package/dist/cold-store/tables/secret-audit-log.d.ts +4 -3
  33. package/dist/config.d.ts +21 -21
  34. package/dist/contexts/held-runs.d.ts +123 -16
  35. package/dist/contexts/protection/aggregate.d.ts +3 -2
  36. package/dist/contexts/protection/branch-gate.d.ts +15 -0
  37. package/dist/contexts/protection/concurrency-gate.d.ts +8 -2
  38. package/dist/contexts/protection/pipeline.d.ts +18 -0
  39. package/dist/contexts/release-queued-holds.d.ts +35 -0
  40. package/dist/dashboard/attestation-filters.d.ts +8 -0
  41. package/dist/dashboard/handler.d.ts +6 -24
  42. package/dist/db/migrations/116_org_plan_headroom.d.ts +4 -0
  43. package/dist/db/migrations/117_invoke_gate_proxy_jobs.d.ts +4 -0
  44. package/dist/db/migrations/118_invoke_gate_scheduling.d.ts +4 -0
  45. package/dist/db/migrations/119_scaler_ha_ownership.d.ts +4 -0
  46. package/dist/db/migrations/120_cluster_settings_scaler_reaper.d.ts +4 -0
  47. package/dist/db/migrations/121_org_settings_allow_untrusted_dockerfile_builds.d.ts +19 -0
  48. package/dist/db/migrations/122_host_roster_scaler_managed.d.ts +25 -0
  49. package/dist/db/migrations/123_workflow_registrations_default_branch.d.ts +24 -0
  50. package/dist/db/migrations/124_cluster_settings_provision_backoff.d.ts +4 -0
  51. package/dist/db/migrations/125_org_trust_directory.d.ts +30 -0
  52. package/dist/db/migrations/126_held_runs_posted_pending_check.d.ts +28 -0
  53. package/dist/db/migrations/127_org_trust_policy_approval_expiry_seconds.d.ts +26 -0
  54. package/dist/db/migrations/128_execution_runs_global_eval_round.d.ts +23 -0
  55. package/dist/db/migrations/129_execution_runs_dispatch_routing_key.d.ts +24 -0
  56. package/dist/db/migrations/130_scaler_provision_outcomes.d.ts +32 -0
  57. package/dist/db/types.d.ts +368 -10
  58. package/dist/entry-helpers.d.ts +28 -0
  59. package/dist/events/event-emitter.d.ts +22 -0
  60. package/dist/events/event-router.d.ts +87 -1
  61. package/dist/events/types.d.ts +2 -23
  62. package/dist/git/credential-broker.d.ts +94 -0
  63. package/dist/git/inherited-secret.d.ts +24 -0
  64. package/dist/git/installation-token.d.ts +38 -0
  65. package/dist/git/job-context.d.ts +13 -0
  66. package/dist/index.js +324 -84
  67. package/dist/metrics/agent-metrics-aggregator.d.ts +2 -2
  68. package/dist/metrics/prometheus.d.ts +111 -3
  69. package/dist/oidc/oidc-mint-registration.d.ts +7 -3
  70. package/dist/orchestrator-core.d.ts +79 -23
  71. package/dist/pipeline/direct-ingress-deps.d.ts +100 -0
  72. package/dist/pipeline/dispatch-matched-workflow.d.ts +302 -31
  73. package/dist/pipeline/global-eval-round.d.ts +14 -0
  74. package/dist/pipeline/internal-event-pipeline.d.ts +140 -0
  75. package/dist/pipeline/invoke-gate.d.ts +164 -0
  76. package/dist/pipeline/needs-scheduler.d.ts +13 -0
  77. package/dist/pipeline/process-webhook.d.ts +119 -31
  78. package/dist/pipeline/processor.d.ts +226 -21
  79. package/dist/pipeline/rerun.d.ts +21 -0
  80. package/dist/pipeline/resume-workflow.d.ts +40 -7
  81. package/dist/pipeline/route-or-dispatch-jobs.d.ts +9 -0
  82. package/dist/pipeline/security-hold-check.d.ts +217 -0
  83. package/dist/pipeline/undispatched-hold-checks.d.ts +23 -0
  84. package/dist/policy/dashboard-write-policy-listener.d.ts +62 -0
  85. package/dist/policy/dashboard-write-policy.d.ts +14 -0
  86. package/dist/provider-registry.d.ts +10 -2
  87. package/dist/providers/github/check-status-poster.d.ts +11 -0
  88. package/dist/providers/github/index.d.ts +0 -2
  89. package/dist/providers/github/normalizer.d.ts +3 -2
  90. package/dist/providers/local/normalizer.d.ts +3 -5
  91. package/dist/providers/universal-git/index.d.ts +11 -2
  92. package/dist/queue/job-queue.d.ts +43 -1
  93. package/dist/registration/extractor.d.ts +3 -3
  94. package/dist/registration/registration-index.d.ts +7 -0
  95. package/dist/registration/registration-store.d.ts +22 -0
  96. package/dist/reporting/check-run-reporter.d.ts +94 -1
  97. package/dist/reporting/check-run-summary.d.ts +25 -1
  98. package/dist/reporting/execution-tracker.d.ts +207 -2
  99. package/dist/reporting/run-aggregator.d.ts +4 -14
  100. package/dist/reporting/step-display-order.d.ts +43 -0
  101. package/dist/routes/admin-held-runs.d.ts +142 -0
  102. package/dist/routes/admin-trust-policy.d.ts +34 -6
  103. package/dist/routes/admin.d.ts +9 -0
  104. package/dist/scaler/backend-factory.d.ts +55 -0
  105. package/dist/scaler/bare-metal-backend.d.ts +43 -5
  106. package/dist/scaler/claim-store.d.ts +119 -0
  107. package/dist/scaler/config.d.ts +4 -0
  108. package/dist/scaler/container-backend.d.ts +18 -4
  109. package/dist/scaler/container-routing.d.ts +23 -0
  110. package/dist/scaler/container-spawn.d.ts +28 -0
  111. package/dist/scaler/event-backend.d.ts +124 -0
  112. package/dist/scaler/event-provision-reaper.d.ts +350 -0
  113. package/dist/scaler/failure-tracker.d.ts +1 -1
  114. package/dist/scaler/firecracker-backend.d.ts +33 -6
  115. package/dist/scaler/index.d.ts +11 -1
  116. package/dist/scaler/label-matcher.d.ts +4 -3
  117. package/dist/scaler/manager.d.ts +899 -72
  118. package/dist/scaler/resolve-container-auth.d.ts +45 -0
  119. package/dist/scaler/scaler-events.d.ts +11 -0
  120. package/dist/scaler/scaler-state-store.d.ts +294 -10
  121. package/dist/scaler/types.d.ts +131 -13
  122. package/dist/scaler/warm-pool.d.ts +132 -29
  123. package/dist/security/comment-handler.d.ts +50 -11
  124. package/dist/security/identity-link.d.ts +60 -0
  125. package/dist/security/lock-source.d.ts +11 -13
  126. package/dist/security/reduced-privilege-note.d.ts +59 -0
  127. package/dist/security/trust-directory-store.d.ts +233 -0
  128. package/dist/security/trust-policy-gate.d.ts +94 -59
  129. package/dist/security/trust-policy-store.d.ts +3 -0
  130. package/dist/security/trust-resolver.d.ts +14 -98
  131. package/dist/server.d.ts +11 -1
  132. package/dist/server.js +17684 -7351
  133. package/dist/stale-detector/gate-deadline-detector.d.ts +52 -0
  134. package/dist/stale-detector/stale-run-detector.d.ts +61 -3
  135. package/dist/standalone.js +24238 -13822
  136. package/dist/worker/in-memory-job-queue.d.ts +1 -1
  137. package/dist/ws/agent-handler.d.ts +28 -4
  138. package/dist/ws/dashboard-context-handler.d.ts +28 -4
  139. package/dist/ws/dashboard-global-workflows-handler.d.ts +6 -0
  140. package/dist/ws/failure-messages.d.ts +2 -0
  141. package/dist/ws/git-credential-relay.d.ts +39 -0
  142. package/dist/ws/oidc-token-relay.d.ts +6 -11
  143. package/dist/ws/platform-client.d.ts +54 -9
  144. package/installer-image-digests.json +3 -3
  145. package/package.json +24 -19
  146. package/sbom.spdx.json +864 -822
  147. package/dist/approvals/team-membership-lookup.d.ts +0 -13
  148. package/dist/providers/github/contributor-resolver.d.ts +0 -30
  149. package/dist/security/contributor-cache.d.ts +0 -83
@@ -6,10 +6,25 @@
6
6
  * enforces global limits, tracks spawning agents to prevent over-provisioning,
7
7
  * and manages the agent lifecycle from spawn to destroy.
8
8
  */
9
- import type { ResourceRequest } from '@kici-dev/engine';
9
+ import type { ResourceRequest, ScalerCapacitySummary } from '@kici-dev/engine';
10
+ import { ScaleDownReason } from './scaler-events.js';
11
+ import type { ScalerEventEmitterLike } from './event-backend.js';
12
+ import type { ClaimStore, ClaimedCredentials } from './claim-store.js';
10
13
  import type { BackendFailureSummary } from './failure-tracker.js';
11
- import type { ScalerBackend, ScalerConfig, ScaleResult, ScalerEvent, ResourceCap, ValidationResult } from './types.js';
12
- import type { ScalerStateStore, ScalerStateRecovery } from './scaler-state-store.js';
14
+ import type { ScalerBackend, ScalerConfig, ScalerEntry, ScaleResult, ScalerEvent, ResourceCap, ValidationResult, ResolvedContainerSpawn } from './types.js';
15
+ import type { ScalerStateStore, ScalerStateRecovery, ReapCandidate } from './scaler-state-store.js';
16
+ /**
17
+ * Raised when a registering agent presents a scaler-minted id that the
18
+ * orchestrator cannot back with a spawn record — either because the lookup
19
+ * could not be completed (`cause` set) or because no record exists at all
20
+ * (`cause` omitted). The WS handler answers it by refusing the registration, so
21
+ * the agent reconnects rather than registering with no `mandatoryLabels` gate.
22
+ */
23
+ export declare class ScalerAdoptionLookupError extends Error {
24
+ readonly agentId: string;
25
+ readonly cause?: unknown;
26
+ constructor(agentId: string, cause?: unknown);
27
+ }
13
28
  /**
14
29
  * Counting semaphore that throttles the number of concurrent async operations.
15
30
  *
@@ -111,18 +126,184 @@ export interface ScalerStatus {
111
126
  /** Machine-pool reference, if any. */
112
127
  machinePool?: string;
113
128
  /**
114
- * Labels a job MUST declare in `runsOn` to be allowed on this backend.
115
- * Empty array = no gate. Surfaced in heartbeat-side scaler capacity
116
- * summaries so cross-peer routing applies the same gate.
129
+ * The union of every entry in {@link labelSetMandatoryLabels}. Surfaced in
130
+ * heartbeat-side scaler capacity summaries for a peer that predates the
131
+ * per-label-set gate.
132
+ *
133
+ * @deprecated Use {@link labelSetMandatoryLabels}. On a scaler whose label
134
+ * sets declare different platforms this union names a taint no single set
135
+ * can satisfy, which is what made a mixed-platform scaler unroutable.
117
136
  */
118
137
  mandatoryLabels: string[];
138
+ /**
139
+ * Labels a job MUST declare in `runsOn` to be allowed on each label set,
140
+ * index-aligned with `labelSets`. An empty entry means that set has no
141
+ * gate.
142
+ */
143
+ labelSetMandatoryLabels: string[][];
144
+ /**
145
+ * True when this scaler was removed from the config and is draining: it
146
+ * accepts no new work, and disappears once its last agent goes away.
147
+ */
148
+ retiring: boolean;
149
+ }>;
150
+ }
151
+ /**
152
+ * What the coordinator holding an event agent needs to tear its provision
153
+ * down: enough to emit `kici.scaler.scale-down` with no local backend and no
154
+ * config entry.
155
+ *
156
+ * Recorded on both registration paths — the agent adopted from a peer and the
157
+ * one this coordinator spawned itself — so the teardown addresses the targets
158
+ * recorded at spawn either way.
159
+ */
160
+ export interface AdoptedSpec {
161
+ scalerName: string;
162
+ provisioningTargets: string[];
163
+ }
164
+ /**
165
+ * The three knobs bounding how often a repeatedly failing external scaler is
166
+ * asked to provision again.
167
+ */
168
+ export interface ProvisionBackoffSettings {
169
+ /** First deferral after one consecutive failure; doubles per further failure. */
170
+ baseMs: number;
171
+ /** Ceiling on the doubling. */
172
+ maxMs: number;
173
+ /** Consecutive failures past which a refusal names repeated failure. */
174
+ maxConsecutiveFailures: number;
175
+ }
176
+ /** Everything the {@link ScalerManager} is constructed from. */
177
+ export interface ScalerManagerDeps {
178
+ config: ScalerConfig;
179
+ backends: Array<{
180
+ name: string;
181
+ backend: ScalerBackend;
119
182
  }>;
183
+ /**
184
+ * Read-only view of the agent registry, for the warm pool's deficit
185
+ * calculation. Structural on purpose — the scaler must not depend on the
186
+ * agent layer's concrete class.
187
+ *
188
+ * Both hosts pass one, and a host that skips it does not get an inert warm
189
+ * pool: the readiness count falls back to 0, so a configured pool never sees
190
+ * the agents it started and keeps filling until `warmCapacityRemaining`
191
+ * clamps it at `maxAgents` — starving the job-bound spawns that share the
192
+ * cap. Optional only because the unit tests drive the spawn path without a
193
+ * registry.
194
+ */
195
+ agentRegistry?: {
196
+ findAvailable(requiredLabels: string[]): Array<{
197
+ agentId: string;
198
+ activeJobs: number;
199
+ registeredAt: number;
200
+ }>;
201
+ };
202
+ /**
203
+ * Id of the orchestrator instance this manager runs on. Rows this
204
+ * coordinator writes to the shared scaler tables are attributed to it, so
205
+ * every peer can tell which instance owns a given spawn.
206
+ */
207
+ instanceId: string;
208
+ /** Callback for relaying scaler events with runId/jobId context. */
209
+ onScalerEvent?: (runId: string, jobId: string, event: ScalerEvent) => void;
210
+ /**
211
+ * Optional DB-backed state store. Tests omit it; production wires it
212
+ * up via the orchestrator-core bootstrap.
213
+ */
214
+ stateStore?: ScalerStateStore;
215
+ /**
216
+ * Late-bound reserved-event emitter, resolved per call because the event
217
+ * router is built after the manager. Omitted where there is no event plane
218
+ * (the worker, and unit tests that never emit).
219
+ */
220
+ eventEmitter?: () => ScalerEventEmitterLike;
221
+ /**
222
+ * Redemption-only claim store. Wired wherever the shared claim table is
223
+ * reachable, so a provisioning code minted by any coordinator can be
224
+ * redeemed here. Omitted where there is no database.
225
+ */
226
+ claimStore?: ClaimStore;
227
+ /**
228
+ * Optional machine-ledger options. When `machinePools` are configured,
229
+ * the manager initializes a `MachineLedger` keyed off this directory and
230
+ * the orchestrator's instance id; reservations sum across orchestrators
231
+ * on the same host.
232
+ */
233
+ machineLedger?: {
234
+ /** Override the on-disk ledger directory. Falls back to `KICI_MACHINE_LEDGER_DIR`. */
235
+ dir?: string;
236
+ /** Orchestrator instance id (used in ledger rows for ownership). */
237
+ instanceId: string;
238
+ };
239
+ /** Coordinator drain predicate. When true, requestScale() declines to spawn
240
+ * fresh capacity for the held Pending backlog. Defaults to never-draining. */
241
+ isDraining?: () => boolean;
242
+ /**
243
+ * Cluster-wide default deadline (ms) for a single `backend.spawn`. Always
244
+ * supplied (from config.scalerSpawnTimeoutMs); the per-org resolver, when
245
+ * present, overrides it per tenant.
246
+ */
247
+ spawnTimeoutMs: number;
248
+ /**
249
+ * Optional per-org resolver for the spawn deadline: given the job's org
250
+ * (jobConfig.cacheOrgId, undefined for warm-pool spawns), returns the
251
+ * effective timeout, falling back to the cluster default internally. Wired
252
+ * in orchestrator-core (has DB); omitted by the worker + unit tests → the
253
+ * cluster default spawnTimeoutMs is used.
254
+ */
255
+ resolveSpawnTimeoutMs?: (orgId: string | undefined) => Promise<number>;
256
+ /**
257
+ * Live resolver for the external-provision backoff knobs, read once per spawn
258
+ * request so an operator retunes a running cluster. Wired in
259
+ * orchestrator-core from `ClusterSettingsReader` + the config defaults;
260
+ * omitted by the worker and unit tests, which then get
261
+ * {@link DEFAULT_PROVISION_BACKOFF}.
262
+ */
263
+ resolveProvisionBackoff?: () => Promise<ProvisionBackoffSettings>;
264
+ /**
265
+ * Constructs a backend for a scaler added by a config reload. Supplied by
266
+ * both hosts from the shared backend factory, so a reload can only build
267
+ * what startup already exercises. When omitted (unit tests), a reload that
268
+ * adds a scaler is rejected rather than silently ignored.
269
+ *
270
+ * `config` is the config being reloaded, not the boot-time one. The factory
271
+ * reads `defaults.resources` and the whole `firecracker` network block off
272
+ * it, so an added scaler built from a stale config would allocate IPs from
273
+ * the old CIDR and attach TAPs to the old bridge — silently unreachable.
274
+ */
275
+ createBackend?: (entry: ScalerEntry, config: ScalerConfig) => Promise<ScalerBackend | null>;
120
276
  }
121
277
  export declare class ScalerManager {
122
278
  private readonly backends;
123
279
  private readonly backendRoles;
280
+ /**
281
+ * Scalers deleted from the config that still have agents running. They stay
282
+ * in `backends` — and so stay counted by `getGlobalActiveCount` and reachable
283
+ * for destroy / log forwarding — but are filtered out of routing, and are
284
+ * torn down once their last agent goes away.
285
+ */
286
+ private readonly retiring;
287
+ /** When each retiring scaler was marked, for the drain-duration log. */
288
+ private readonly retiredAt;
289
+ /** Periodic sweep that tears down drained retiring backends. */
290
+ private retirementSweep;
124
291
  /** Recent scaler spawn failures, surfaced by `kici-admin diagnose`. */
125
292
  private readonly failureTracker;
293
+ /**
294
+ * Consecutive external-provision failures per scaler NAME, and the deferral
295
+ * they earned.
296
+ *
297
+ * Keyed by name rather than by backend type so one failing scaler never
298
+ * defers spawns for an unrelated one — two event scalers routinely drive two
299
+ * different providers, and an outage at one says nothing about the other.
300
+ *
301
+ * In-memory and leader-local, like `ScalerFailureTracker`: the state resets on
302
+ * restart, which is the conservative direction (a fresh coordinator retries
303
+ * once immediately rather than inheriting a deferral it never observed).
304
+ */
305
+ private readonly provisionFailures;
306
+ private readonly resolveProvisionBackoff;
126
307
  private globalMaxAgents;
127
308
  /** Per-scaler resource caps (`{ maxCpu, maxMemoryBytes }`), keyed by scaler name. */
128
309
  private readonly resourceCaps;
@@ -151,12 +332,31 @@ export declare class ScalerManager {
151
332
  private reservationLock;
152
333
  /**
153
334
  * File-backed cross-process ledger for named machine pools (optional).
154
- * Lazily initialized in the constructor when at least one scaler entry
155
- * references a pool.
335
+ * Initialized when at least one scaler entry references a pool — at
336
+ * construction, or on the reload that first declares one.
337
+ */
338
+ private machineLedger;
339
+ /** Ledger options, retained so a reload that first declares a pool can build one. */
340
+ private readonly machineLedgerOptions;
341
+ /** True between `start()` and `shutdownAll()`; a ledger built later must be started too. */
342
+ private started;
343
+ /** Builds a backend for a scaler a reload added. Absent in unit tests. */
344
+ private readonly createBackend?;
345
+ /**
346
+ * Read-only agent-registry view backing the warm pool's deficit pass.
347
+ * Absent in unit tests and in any host that has no registry.
156
348
  */
157
- private readonly machineLedger;
349
+ private readonly agentRegistry?;
158
350
  /** Per-scaler URL overrides from config, keyed by scaler name */
159
351
  private readonly scalerUrls;
352
+ /**
353
+ * Per-scaler `provisioningTargets` from config, keyed by scaler name.
354
+ *
355
+ * Copied onto every spawn row, because it is the only thing that lets a
356
+ * coordinator with no config entry for the scaler address the customer's
357
+ * teardown workflow when the agent it adopted disconnects.
358
+ */
359
+ private readonly scalerProvisioningTargets;
160
360
  /**
161
361
  * Tracks agents being spawned but not yet registered via WS.
162
362
  * Keyed by pre-generated agentId.
@@ -166,6 +366,32 @@ export declare class ScalerManager {
166
366
  * Maps registered agentId to backendName for lifecycle events.
167
367
  */
168
368
  private readonly managedAgentIndex;
369
+ /**
370
+ * Agents this instance pre-spawned to wait for work, rather than for a
371
+ * specific queued job. The agent registry tracks readiness (`activeJobs`,
372
+ * `registeredAt`) and has never tracked provenance — it cannot derive this,
373
+ * and the spawning entry that carries it is deleted at registration.
374
+ *
375
+ * This is not a second copy of the pool's readiness. It is provenance, which
376
+ * nothing else knows, and only the reaper reads it: it may destroy an agent
377
+ * the pool itself created, never one that registered for a bound job whose
378
+ * dispatch has not yet arrived.
379
+ */
380
+ private readonly warmAgents;
381
+ /**
382
+ * Warm agents whose destroy is issued but has not settled yet.
383
+ *
384
+ * An agent leaves `warmAgents` only once its destroy resolves, so until then
385
+ * `listIdle` keeps returning it and the next reap pass — 30 seconds later,
386
+ * or a reload that lowers the target — selects the very same agent again. A
387
+ * backend whose teardown outlives a tick would be handed a second destroy for
388
+ * an agent it is already destroying, and every extra pass would count another
389
+ * `kici_orch_scaler_warm_pool_reaped_total`. One entry per agent, cleared
390
+ * when the destroy settles either way.
391
+ */
392
+ private readonly warmDestroying;
393
+ /** Agents this instance adopted from another instance's spawn record. */
394
+ private readonly adoptedAgents;
169
395
  /**
170
396
  * Active log forwarders for scaler-managed agents (container/bare-metal).
171
397
  * Keyed by agentId. Each forwarder consumes a LogCapture stream.
@@ -203,6 +429,11 @@ export declare class ScalerManager {
203
429
  /** Trailing debounce window for {@link notifyCapacityFreed}. */
204
430
  private static readonly CAPACITY_FREED_DEBOUNCE_MS;
205
431
  private readonly warmPool;
432
+ /**
433
+ * The callback set handed to `warmPool`, kept so the manager's own view of
434
+ * pool capacity and reapability is reachable without a live agent registry.
435
+ */
436
+ private readonly warmPoolCallbacks;
206
437
  /**
207
438
  * Optional DB-backed state store. When wired (production path), every
208
439
  * mutation to `spawningAgents` / `agentJobCorrelation` / `reservations`
@@ -211,53 +442,174 @@ export declare class ScalerManager {
211
442
  * Unit tests can omit the store and operate from in-memory Maps only.
212
443
  */
213
444
  private readonly stateStore?;
445
+ /** Id of the orchestrator instance this manager runs on. */
446
+ private readonly instanceId;
447
+ /**
448
+ * Late-bound reserved-event emitter. Undefined where there is no event
449
+ * plane (the worker, and unit tests that never emit).
450
+ */
451
+ private readonly eventEmitter?;
452
+ /**
453
+ * Redemption-only claim store, reading the shared `scaler_pending_claims`
454
+ * table. Undefined where there is no database.
455
+ */
456
+ private readonly claimStore?;
214
457
  /** Cluster-wide default deadline (ms) for a single `backend.spawn`. */
215
458
  private readonly spawnTimeoutMs;
216
459
  /** Optional per-org spawn-deadline resolver (production path; DB-backed). */
217
460
  private readonly resolveSpawnTimeoutMs?;
218
- constructor(deps: {
219
- config: ScalerConfig;
220
- backends: Array<{
221
- name: string;
222
- backend: ScalerBackend;
223
- }>;
224
- /** Callback for relaying scaler events with runId/jobId context. */
225
- onScalerEvent?: (runId: string, jobId: string, event: ScalerEvent) => void;
226
- /**
227
- * Optional DB-backed state store. Tests omit it; production wires it
228
- * up via the orchestrator-core bootstrap.
229
- */
230
- stateStore?: ScalerStateStore;
231
- /**
232
- * Optional machine-ledger options. When `machinePools` are configured,
233
- * the manager initializes a `MachineLedger` keyed off this directory and
234
- * the orchestrator's instance id; reservations sum across orchestrators
235
- * on the same host.
236
- */
237
- machineLedger?: {
238
- /** Override the on-disk ledger directory. Falls back to `KICI_MACHINE_LEDGER_DIR`. */
239
- dir?: string;
240
- /** Orchestrator instance id (used in ledger rows for ownership). */
241
- instanceId: string;
242
- };
243
- /** Coordinator drain predicate. When true, requestScale() declines to spawn
244
- * fresh capacity for the held Pending backlog. Defaults to never-draining. */
245
- isDraining?: () => boolean;
246
- /**
247
- * Cluster-wide default deadline (ms) for a single `backend.spawn`. Always
248
- * supplied (from config.scalerSpawnTimeoutMs); the per-org resolver, when
249
- * present, overrides it per tenant.
250
- */
251
- spawnTimeoutMs: number;
252
- /**
253
- * Optional per-org resolver for the spawn deadline: given the job's org
254
- * (jobConfig.cacheOrgId, undefined for warm-pool spawns), returns the
255
- * effective timeout, falling back to the cluster default internally. Wired
256
- * in orchestrator-core (has DB); omitted by the worker + unit tests → the
257
- * cluster default spawnTimeoutMs is used.
258
- */
259
- resolveSpawnTimeoutMs?: (orgId: string | undefined) => Promise<number>;
260
- });
461
+ constructor(deps: ScalerManagerDeps);
462
+ /**
463
+ * Pre-spawn one warm agent for a pool's label set.
464
+ *
465
+ * The pool's counterpart of `requestScale`, with the job term of every
466
+ * resolution absent: no bound job, no run, and resources resolved
467
+ * from the label set (then the scaler defaults) alone. It takes the same
468
+ * reservations through the same {@link reserveForSpawn} helper — a warm
469
+ * agent occupies the same cpu and memory as a job-bound one, and on an event
470
+ * backend it claims a cluster slot too, so a pool can no longer carry a
471
+ * scaler past `maxAgents` fleet-wide.
472
+ *
473
+ * A refused reservation is reported back to the pool as a failed spawn, so
474
+ * the in-flight slot it holds is released and the deficit is retried on the
475
+ * next tick rather than the pool stalling below target.
476
+ */
477
+ private spawnWarmAgent;
478
+ /**
479
+ * Whether a warm pool for this label set can count its own agents.
480
+ *
481
+ * The pool measures readiness with `AgentRegistry.findAvailable(labels)`, and
482
+ * that query applies the agent's `mandatoryLabels` gate: an agent is dropped
483
+ * unless every label in its gate also appears in the query. A scaler-spawned
484
+ * agent's gate is `labelSetMandatoryLabels()` for the set it was spawned for,
485
+ * which unions the configured gate with the pool's platform taints.
486
+ * `warmPoolQueryLabels` puts the structured taints into the query, so that
487
+ * part of the gate is satisfied by construction, and the legacy shim derives
488
+ * from this label set's own labels, so it is satisfied by construction too.
489
+ *
490
+ * That leaves one residual case: a configured `mandatoryLabels` entry absent
491
+ * from the label set, which `scaler/config.ts` validation already rejects.
492
+ * The check stays as the fail-safe — without it a pool in that state would
493
+ * spawn to `maxAgents` with `ready` stuck at 0, starving the on-demand spawns
494
+ * that share the cap. A refused pool is inert, exactly as it is before it
495
+ * fills at all — never partially filled.
496
+ *
497
+ * The gate derives from `declaredLabels` and is compared against
498
+ * `queryLabels`. Deriving it from `queryLabels` would fold a structured taint
499
+ * back in as a legacy one; comparing it against `declaredLabels` alone would
500
+ * refuse every structurally-tainted pool.
501
+ */
502
+ private warmPoolLabelSetFillable;
503
+ /**
504
+ * Whether this scaler pre-spawned (warm-filled) the agent, i.e. whether
505
+ * {@link canPrespawnedAgentServe} can ever answer false for it. A caller that
506
+ * picks one agent at a time uses this to decide whether the suitability check
507
+ * is worth carrying at all.
508
+ */
509
+ isPrespawnedAgent(agentId: string): boolean;
510
+ /**
511
+ * Whether a pre-spawned (warm) agent can serve this job.
512
+ *
513
+ * A warm agent is generic by construction: it was started before the job
514
+ * existed, at the pool's declared shape and running the pool's agent image.
515
+ * Both are applied when the agent starts and cannot be changed afterwards —
516
+ * nothing in the agent reads a job's `resources`, and an already-running
517
+ * container cannot become a different image. So a job that needs something
518
+ * else must get its own agent instead of silently running with the pool's.
519
+ *
520
+ * Returns true for any agent this scaler did not pre-spawn: a job-bound or
521
+ * static agent is not this predicate's business. {@link isPrespawnedAgent}
522
+ * answers that half on its own.
523
+ *
524
+ * Only the fields the job actually declares are compared. `resolveEffective`
525
+ * layers a job's declaration over the label set and the scaler defaults field
526
+ * by field, and the warm agent's own reservation is what those two layers
527
+ * resolve to — so a field the job leaves out already matches whatever the
528
+ * agent was started at, and comparing it would refuse a job that fits.
529
+ *
530
+ * A field the job DOES declare must match exactly, in both directions. A
531
+ * bigger ready agent is not a superset: limits are what the kernel enforces
532
+ * on the running agent, so a job asking to be capped at 2 cpus, served by an
533
+ * agent started at 4, runs uncapped. That is the same wrong execution
534
+ * environment as the under-provisioned case, in the other direction, so `>=`
535
+ * is not a safe relaxation.
536
+ *
537
+ * Both sides of the shape are compared, not just `requests`. The two answer
538
+ * different questions — `requests` is what the agent bills against the caps,
539
+ * `limits` is what the kernel enforces — and a declaration may legally set
540
+ * them apart, so a job matched on `requests` alone can still land on an agent
541
+ * capped somewhere else entirely.
542
+ */
543
+ canPrespawnedAgentServe(agentId: string, job: {
544
+ resources?: ResourceRequest;
545
+ hasOwnContainerImage: boolean;
546
+ }): boolean;
547
+ /**
548
+ * Whether one side (`requests` or `limits`) of a job's declared shape leaves
549
+ * the agent's resolved side unchanged.
550
+ *
551
+ * True when the job declares nothing on this side, and true when `resolved`
552
+ * is absent — both mean "this cannot be told", and the predicate's rule is to
553
+ * fail open rather than strand a job.
554
+ */
555
+ private shapeSideMatches;
556
+ /**
557
+ * Whether a warm pool for this label set has a declared agent shape.
558
+ *
559
+ * A warm agent starts before any job exists, so its cpu and memory come from
560
+ * the label set (or the scaler defaults) and from nowhere else. Both are
561
+ * applied when the agent starts and cannot be changed afterwards, so an
562
+ * undeclared shape means the pool reserves a guess and no job can be tested
563
+ * for fit against it. Refusing is the fail-safe reading, and it is what makes
564
+ * a pool predictable — on a cloud backend it is what lets an operator reason
565
+ * about instance size and cost at all.
566
+ *
567
+ * Limits alone count as a declaration: `resolveEffective` mirrors limits into
568
+ * requests, so a label set naming only `limits` still resolves to a concrete
569
+ * shape.
570
+ *
571
+ * A refused pool is inert, exactly as it was before it filled at all — never
572
+ * partially filled.
573
+ *
574
+ * A declaration this cannot resolve is refused the same way, and must never
575
+ * throw: the config schema types `resources.*.memory` as a plain string, so
576
+ * an unparseable value such as `4Gi` reaches `resolveEffective` and
577
+ * `parseMemoryString` rejects it. This runs inside the constructor and inside
578
+ * the commit block of {@link reload}, where an exception would abort
579
+ * orchestrator startup or leave a half-applied config behind.
580
+ */
581
+ private warmPoolShapeDeclared;
582
+ /**
583
+ * Agents this backend may still start before it reaches the scaler's
584
+ * `maxAgents` or the orchestrator's `globalMaxAgents`, whichever binds first.
585
+ *
586
+ * The warm pool clamps its deficit to this, so a pool that has reached a
587
+ * count cap asks for nothing at all rather than `size` spawns on every tick.
588
+ * Only the count caps are visible here: a pool with count headroom but no
589
+ * cpu/memory headroom still asks, and {@link reserveForSpawn} refuses each
590
+ * request under the reservation lock. Those refusals cost a log line and a
591
+ * `spawn_refusals` increment per tick, never a spawned agent.
592
+ *
593
+ * It is a best-effort read, not a reservation: the authority is
594
+ * {@link reserveForSpawn}, which every warm pre-spawn goes through and which
595
+ * re-checks both count caps under the reservation lock. The two backend
596
+ * families read differently here. A local backend (container, bare-metal,
597
+ * Firecracker) pins its compute to this host, so `getActiveCount()` is the
598
+ * whole population and the room it reports is exact. An **event** backend is
599
+ * capped cluster-wide — the reserve sequence claims a spawn row under
600
+ * `withScalerCapLock` — while `getActiveCount()` sees only the provisions
601
+ * this coordinator tracks, so the room reported here counts slots a peer may
602
+ * already hold. The cluster-wide cap is still enforced, by that claim rather
603
+ * than by this clamp.
604
+ */
605
+ private warmCapacityRemaining;
606
+ /**
607
+ * Publish the warm-pool gauges from the pool's current fill state.
608
+ *
609
+ * Called after each pool tick and after a config reload, so an operator sees
610
+ * a newly configured target immediately rather than at the next tick.
611
+ */
612
+ private publishWarmPoolGauges;
261
613
  /** Per-scaler mandatoryLabels (taint-style opt-in gate). */
262
614
  private readonly scalerMandatoryLabels;
263
615
  /** Declared structured platform per scaler (undefined = host-derive / linux default). */
@@ -280,12 +632,63 @@ export declare class ScalerManager {
280
632
  */
281
633
  private resolveScalerPlatform;
282
634
  /**
283
- * Effective taint gate for a scaler: its configured mandatoryLabels plus the
284
- * platform-taint labels derived from the pool's resolved structured platform,
285
- * unioned with the legacy plain-label denylist shim. A pool declaring a
286
- * non-default platform (`windows`, `macos`, `arm64`, …) only accepts jobs that
287
- * request that platform. Container / firecracker pools resolve to linux and
288
- * carry no taint; calling it uniformly is safe.
635
+ * The plain platform-taint tokens (`windows`, `macos`, `arm64`) for a scaler,
636
+ * derived from the same resolved platform `labelSetMandatoryLabels` gates on.
637
+ *
638
+ * This is the single source for the taint: it feeds the matcher's view of the
639
+ * label sets, the taint gate, the labels the spawned agent registers with, and
640
+ * the warm-pool readiness query. Deriving it anywhere else — in a backend, say
641
+ * — is what let the gate and the agent's labels disagree, so a tainted pool
642
+ * spawned agents `AgentRegistry.findAvailable` could never return.
643
+ *
644
+ * `backendType` is passed rather than looked up because the constructor
645
+ * configures warm pools from config entries whose backend may not be indexed.
646
+ */
647
+ private platformTaintsFor;
648
+ /**
649
+ * The labels a warm pool measures its readiness with: the declared label set
650
+ * plus the pool's platform taints.
651
+ *
652
+ * `AgentRegistry.findAvailable` drops an agent unless its whole
653
+ * `mandatoryLabels` gate appears in the query, and a tainted pool's gate
654
+ * carries the taints — so a query built from the declared labels alone counts
655
+ * none of the pool's own agents and the pool fills to `maxAgents` with `ready`
656
+ * stuck at 0. Both construction sites (the constructor and the reload path)
657
+ * call this so the two can never diverge.
658
+ *
659
+ * These labels are for the QUERY only. The pool is keyed by, and spawns with,
660
+ * the DECLARED label set: a backend matches the requested set against its own
661
+ * `labelSets` to resolve the image / binary / VM config and throws on a set it
662
+ * does not have, and every in-flight release path normalizes the spawn labels
663
+ * back to the map key.
664
+ */
665
+ private warmPoolQueryLabels;
666
+ /**
667
+ * Taint gate for ONE of a scaler's label sets: the configured
668
+ * `mandatoryLabels` plus the platform taints derived from the pool's resolved
669
+ * structured platform, unioned with the legacy plain-label denylist shim read
670
+ * from THIS label set alone.
671
+ *
672
+ * This is the gate every routing and stamping decision uses. Deriving the
673
+ * legacy shim per label set is what keeps a mixed-platform scaler routable: a
674
+ * pool declaring `[linux, gpu]` alongside `[macos, xcode]` gates only the
675
+ * second set on `macos`, where the union would gate both and leave the linux
676
+ * set unreachable by any sensible job.
677
+ *
678
+ * `backend` carries only `type` because the label set is passed in — a caller
679
+ * that holds one label set never has to reach for the whole backend.
680
+ */
681
+ private labelSetMandatoryLabels;
682
+ /**
683
+ * The scaler-wide taint gate: {@link labelSetMandatoryLabels} unioned across
684
+ * every label set the backend declares.
685
+ *
686
+ * Its only remaining consumers are the deprecated scaler-wide fields — the
687
+ * `mandatoryLabels` entry on `ScalerStatus['backends']` and the peer
688
+ * scaler-capacity summary — which stay populated for a peer that predates the
689
+ * per-label-set gate. Do NOT gate routing or stamp an agent with this: on a
690
+ * scaler whose label sets declare different platforms the union names a taint
691
+ * no single set can satisfy.
289
692
  */
290
693
  private effectiveMandatoryLabels;
291
694
  /**
@@ -298,8 +701,32 @@ export declare class ScalerManager {
298
701
  * (declared field wins, else host-derive for bare-metal, else linux), and the
299
702
  * mandatory labels combine the per-scaler config gate with the platform taint,
300
703
  * so the label matcher applies the gate alongside subset matching.
704
+ *
705
+ * Retiring scalers are skipped: they were removed from the config and must
706
+ * take no new work while their remaining agents finish.
301
707
  */
302
708
  private getEnrichedScalerEntries;
709
+ /**
710
+ * Mark a scaler retiring: routing stops immediately, but job-bound agents are
711
+ * left to finish their work. The backend is torn down by
712
+ * `sweepRetiredBackends` once its active count reaches zero.
713
+ */
714
+ private retireBackend;
715
+ /**
716
+ * Tear down every retiring backend that has drained. Runs on an interval and
717
+ * after each agent teardown, so a wind-down finishes promptly without a
718
+ * dedicated per-backend watcher.
719
+ */
720
+ private sweepRetiredBackends;
721
+ /**
722
+ * True while any spawn this manager started for `name` has neither registered
723
+ * nor failed — including one still queued behind the spawn semaphore, which
724
+ * has not reached `backend.spawn` at all. Such a spawn counts toward the
725
+ * scaler's drain even though the backend reports no active agent.
726
+ */
727
+ private hasSpawnInFlight;
728
+ /** Drop every trace of a scaler: the backend and all its per-scaler metadata. */
729
+ private forgetScaler;
303
730
  /**
304
731
  * Main entry point: called by the Dispatcher when no agent is available.
305
732
  *
@@ -317,7 +744,40 @@ export declare class ScalerManager {
317
744
  * surface as an unhandled rejection.
318
745
  */
319
746
  private runSpawnWithTimeout;
320
- requestScale(labels: string[], jobId: string, runId: string, excludeLabels?: string[], resources?: ResourceRequest, orgId?: string): Promise<ScaleResult>;
747
+ requestScale(labels: string[], jobId: string, runId: string, excludeLabels?: string[], resources?: ResourceRequest, orgId?: string,
748
+ /**
749
+ * The job's resolved container image + registry credentials, when it
750
+ * declared one. Threaded to the backend so it can spawn the job's own
751
+ * image instead of the pool's fixed agent image.
752
+ */
753
+ containerSpawn?: ResolvedContainerSpawn): Promise<ScaleResult>;
754
+ /**
755
+ * Take every reservation one spawn needs, or take none at all.
756
+ *
757
+ * The single reserve sequence both spawn paths share — the job-bound
758
+ * `requestScale` and the warm pool's pre-spawn. The only difference between
759
+ * them is `boundJobId`, which is `undefined` for a pre-spawn; a warm agent
760
+ * costs the same host resources as a job-bound one, so it takes the same
761
+ * reservations.
762
+ *
763
+ * Ordering, and why:
764
+ * - Count caps and the in-memory resource reservation run under the
765
+ * in-process reservation lock, so the check and the reserve are atomic
766
+ * against a concurrent caller. A local backend counts its own host's
767
+ * agents in process, which is what its cap means: the compute is pinned to
768
+ * this machine. An event backend provisions cloud instances any
769
+ * coordinator can start, so its cap is counted — and its slot claimed —
770
+ * cluster-wide, inside one advisory-locked transaction. The in-process
771
+ * lock stays the outer guard so the two never contend.
772
+ * - The cross-process machine-pool ledger is taken outside that lock,
773
+ * because the ledger has its own cross-process mutex; holding both at once
774
+ * buys nothing.
775
+ *
776
+ * Every refusal rolls back exactly what it already took, so a `false` return
777
+ * leaves no reservation behind. On success the caller owns the release,
778
+ * which is always `releaseAll(agentId)`.
779
+ */
780
+ private reserveForSpawn;
321
781
  /**
322
782
  * Run a critical section under the reservation lock. Serializes concurrent
323
783
  * `requestScale()` calls so the check+reserve sequence is atomic.
@@ -410,30 +870,245 @@ export declare class ScalerManager {
410
870
  * (`dispatchBoundJob` → `dequeueById`) both reject queued jobs whose
411
871
  * `runsOn` does not include every gate label.
412
872
  *
413
- * Returns `null` for static agents and warm-pool replenishment spawns
414
- * (no spawning entry exists).
873
+ * Returns `null` for a static agent — no spawning entry exists, and no other
874
+ * instance's spawn record claims the agent either. A warm-pool spawn is not
875
+ * that case: its spawning entry (and durable row) survives until it
876
+ * registers, so it resolves with `mandatoryLabels` and no `boundJobId`, which
877
+ * is exactly what marks the agent warm on `register.ack`.
878
+ *
879
+ * A local hit (this instance spawned the agent) is answered from memory. A
880
+ * miss falls back to the shared spawn record: on an HA cluster behind one
881
+ * endpoint the agent routinely reaches an instance that did not spawn it,
882
+ * and adopting is the normal path, not an exception. Adoption is a
883
+ * conditional UPDATE, so exactly one instance can win.
415
884
  */
416
- onAgentRegistered(agentId: string, labels: string[]): {
885
+ onAgentRegistered(agentId: string, labels: string[]): Promise<{
417
886
  boundJobId?: string;
418
887
  mandatoryLabels: string[];
419
- } | null;
888
+ } | null>;
889
+ /**
890
+ * Reclaim the host-local compute of a scaler-spawned agent this coordinator
891
+ * refused for want of a spawn record.
892
+ *
893
+ * Refusing closes the WS before registration completes, so `onAgentDisconnected`
894
+ * never runs for that agent, and `managedAgentIndex` never held it in the first
895
+ * place — the two writes that populate it (`completeLocalRegistration` and the
896
+ * adoption branch) both come after this point. So even the disconnect hook is a
897
+ * no-op here: it reads that index, finds nothing, and takes the static-agent
898
+ * exit. The agent keeps its VM or host process, reconnects, is refused again,
899
+ * and loops until an operator stops it by hand.
900
+ *
901
+ * **This force-destroys a live instance, so read what makes it safe.**
902
+ *
903
+ * - **Local backends only.** An event agent's compute is a customer cloud
904
+ * instance, reachable from any coordinator, and its missing adoption is
905
+ * ambiguous: `adoptSpawningAgent` resolves `null` both when no row exists AND
906
+ * when a peer coordinator already holds the row, so an event agent that is
907
+ * alive and legitimately owned elsewhere reaches this exact branch. Tearing
908
+ * it down there would destroy a peer's healthy provision. A local backend's
909
+ * compute cannot be reached that way: it lives on the host that spawned it.
910
+ * - **Host-local evidence decides, not this verdict.** The verdict alone is NOT
911
+ * evidence the agent is this host's: `adoptSpawningAgent` adopts `event` rows
912
+ * only, so a `container` / `bare-metal` / `firecracker` agent that reconnects
913
+ * to a peer behind a shared endpoint is refused here even though its spawn
914
+ * row is intact and its compute is alive on the peer's host. What keeps that
915
+ * harmless is that each backend reclaims only what its own tracking or its
916
+ * own on-host artifacts name — never a shared database row, which on an HA
917
+ * pair names an identically-configured scaler on both hosts. A wrongly-routed
918
+ * agent id finds nothing here and nothing is touched.
919
+ * - **Never on a transient failure.** The caller must have positive evidence
920
+ * the agent is unowned. A store read that merely *failed* is not evidence —
921
+ * the record may exist and be perfectly healthy.
922
+ *
923
+ * Fire-and-forget, matching `onAgentDisconnected`'s own teardown: the caller
924
+ * refuses the registration immediately rather than holding the agent's socket
925
+ * open for a VM shutdown.
926
+ */
927
+ private reclaimUnownedScalerAgent;
928
+ /**
929
+ * Finish registration for an agent this instance spawned itself, from the
930
+ * in-memory spawning entry.
931
+ */
932
+ private completeLocalRegistration;
420
933
  /**
421
934
  * Called from agent-handler.ts when an agent disconnects.
422
935
  */
423
936
  onAgentDisconnected(agentId: string): void;
424
937
  /**
425
- * Look up the scaler backend TYPE (`container`, `firecracker`,
426
- * `bare-metal`) for a registered agent. Returns null if the agent is
427
- * not scaler-managed (a static / stateful agent).
938
+ * Tear down a stranded provision the `EventProvisionReaper` found.
939
+ *
940
+ * Public because the reaper is leader-gated and lives outside the manager,
941
+ * while the emit and the row delete must stay in one place. The reason comes
942
+ * from the reaper's verdict — a provision whose agent never registered is a
943
+ * `spawn-timeout`, one whose agent went away is a `heartbeat-timeout` — so
944
+ * unlike the disconnect path this does not default to `shutdown`.
945
+ *
946
+ * `kici_orch_scaler_external_provision_timeout_total` counts the spawn-timeout
947
+ * arm. Nothing else increments it in production: `runSpawnWithTimeout` bounds
948
+ * only the `spawn()` call, and an event backend's `spawn()` returns the moment
949
+ * the scale-up event is emitted.
950
+ *
951
+ * The spawn-timeout arm is also where an external provisioning failure enters
952
+ * the ordinary failure machinery. The local backends report a dead spawn
953
+ * through the `scaler.failed` event their `spawn()` emits; an event scaler's
954
+ * `spawn()` succeeds by definition (it only emits a scale-up), so the failure
955
+ * is not observable until the provision it asked for never registers — which
956
+ * is exactly this verdict. Synthesizing the event here gives the fleet
957
+ * counter, the operator's `diagnose scaler` view, and `last_provisioning_error`
958
+ * the same input every other backend already supplies, so a job whose external
959
+ * provisioning failed settles with the provisioning cause instead of a
960
+ * label-mismatch complaint that sends its operator to fix a correct `runsOn`.
961
+ */
962
+ emitOrphanScaleDown(candidate: ReapCandidate, reason: ScaleDownReason): Promise<void>;
963
+ /**
964
+ * Refuse a spawn request while the scaler is inside its backoff window.
965
+ *
966
+ * Returns the refusal to hand back, or null to proceed. The message names how
967
+ * many consecutive failures produced the deferral and when it lifts. It
968
+ * reaches the operator through this method's own log line: the dispatcher
969
+ * branches on `action` alone and discards `reason`, so a job deferred here
970
+ * carries no record of the deferral on its queue row. The job whose provision
971
+ * actually failed is the one that gets `last_provisioning_error`, written by
972
+ * the `scaler.failed` this backoff's own trigger emits.
973
+ *
974
+ * The knobs are read here per request rather than per process, so an operator
975
+ * lowering the failure limit mid-outage sees the repeated-failure wording on
976
+ * the next refusal. The window itself was armed by `recordProvisionFailure`
977
+ * from the base and ceiling live at that moment, so a retuned window applies
978
+ * from the next recorded failure.
979
+ */
980
+ private provisionBackoffRefusal;
981
+ /**
982
+ * Record a consecutive provisioning failure for a scaler and arm its next
983
+ * deferral.
984
+ *
985
+ * The delay doubles per consecutive failure and is capped, so a provider
986
+ * outage settles into a steady retry cadence rather than either hammering the
987
+ * provider or growing without bound. `2 ** (n - 1)` is bounded by the cap
988
+ * before it is used, so a long outage cannot overflow the shift.
989
+ */
990
+ private recordProvisionFailure;
991
+ /**
992
+ * Claim a dead provision as counted, or report that someone already did.
993
+ *
994
+ * The same provision is seen twice on the leader — once by its own
995
+ * stale-spawn prune, once by the leader-gated reaper — and every consequence
996
+ * of a failure is counted per provision, not per observer. The claim is
997
+ * synchronous and happens before any await, so two observers arriving in the
998
+ * same tick cannot both pass it.
999
+ */
1000
+ private claimFailedProvision;
1001
+ /**
1002
+ * Report one dead external provision, wherever this coordinator noticed it.
1003
+ *
1004
+ * Everything a failed provision owes an operator happens here: the fleet
1005
+ * spawn-failure counter, the structured warn, the `ScalerFailureTracker`
1006
+ * record `kici-admin diagnose scaler` reads, the `last_provisioning_error`
1007
+ * that tells the waiting job why it never ran, and the backoff.
1008
+ *
1009
+ * It is deliberately reachable from BOTH observers rather than from the
1010
+ * reaper alone. `ScalerFailureTracker` and `provisionFailures` are
1011
+ * per-process, and the reaper is leader-gated — so a reaper-only report left
1012
+ * every non-leader with an empty `diagnose scaler` for a scaler failing
1013
+ * fleet-wide, and dispatching at the un-deferred cadence while only the
1014
+ * leader backed off. The local prune is the observation every coordinator has
1015
+ * of its own spawns.
1016
+ *
1017
+ * Synchronous up to the backoff arm, so a caller that must clean up after the
1018
+ * event (the prune drops the agent's event buffer) sees the effect
1019
+ * immediately.
1020
+ */
1021
+ private reportProvisionFailure;
1022
+ /**
1023
+ * Report a pruned event spawn as a dead provision — unless a peer adopted it.
1024
+ *
1025
+ * The prune is NOT proof of failure on an HA cluster. Nothing tells the
1026
+ * spawning coordinator that its agent registered on a peer, so the entry
1027
+ * survives adoption and lands in the prune the stale window later next to a
1028
+ * perfectly live agent — the same asymmetry `deleteUnadoptedSpawningAgent`
1029
+ * exists for. Reporting that as a dead provision writes a `scaler.failed` and
1030
+ * a `last_provisioning_error` onto a job that is running, and backs a healthy
1031
+ * scaler off, which is the misattribution this whole report exists to remove.
1032
+ * The reaper already applies the same test: it returns `spawn-timeout` only
1033
+ * for a candidate with no `adoptedBy`, so without this the two observers of
1034
+ * one provision disagree.
1035
+ *
1036
+ * The question is "was this provision ever adopted?", and the spawn row alone
1037
+ * cannot answer it: the row is deleted on teardown, so its absence is BOTH
1038
+ * "never adopted" and "adopted, then torn down". `provisionAdopter` reads the
1039
+ * live row first and the durable provision-outcome record second, which
1040
+ * survives the delete — so an adopted provision the reaper has since
1041
+ * condemned no longer reads as a dead one.
1042
+ *
1043
+ * The lookup is fire-and-forget so the prune stays off the request path, and
1044
+ * it fails open. Both "the store errored" and "there is no verdict" leave the
1045
+ * failure reported, deliberately: a provision with no positive evidence of
1046
+ * adoption is what a single coordinator sees on a real provisioning outage,
1047
+ * and suppressing there would disarm the per-coordinator backoff on every
1048
+ * follower — an invisible missing report in place of a visible false one. The
1049
+ * only rows with no verdict are those adopted before the outcome table
1050
+ * existed, and they age out within one stale-prune window of the deploy.
1051
+ */
1052
+ private reportPrunedProvisionFailure;
1053
+ /**
1054
+ * Clear a scaler's consecutive-failure state after a successful provision.
1055
+ *
1056
+ * Called from `onAgentRegistered`, which is the single entry point for BOTH a
1057
+ * locally-spawned agent and one this coordinator adopted from a peer. An
1058
+ * adopted agent is a successful provision — the instance that spawned it
1059
+ * is not the one it reached — so counting only local registrations
1060
+ * would leave a healthy scaler deferred on an HA cluster.
1061
+ */
1062
+ private clearProvisionFailures;
1063
+ /**
1064
+ * Emit `kici.scaler.scale-down` straight from a persisted spawn spec, then
1065
+ * drop the durable row.
1066
+ *
1067
+ * Used where the instance holding the agent has no local backend for its
1068
+ * scaler — the ordinary shape on an HA cluster behind one shared endpoint,
1069
+ * where the coordinator the agent reached may not configure the scaler that
1070
+ * spawned it. The row's own targets are therefore primary, with local config
1071
+ * as the fallback for a row that recorded none.
1072
+ *
1073
+ * A failed emit leaves the row in place: it is the only durable pointer at
1074
+ * the customer's running instance, so the reaper needs it to retry from.
1075
+ *
1076
+ * Returns whether the teardown was actually emitted, so a caller that counts
1077
+ * teardowns counts deliveries rather than attempts.
1078
+ */
1079
+ private emitScaleDownForSpec;
1080
+ /**
1081
+ * Look up the scaler backend TYPE (a `ScalerBackendType` — `container`,
1082
+ * `firecracker`, `bare-metal`, `event`, …) for a registered agent.
1083
+ * Returns null if the agent is not scaler-managed (a static / stateful
1084
+ * agent).
428
1085
  *
429
1086
  * Used by AgentMetricsAggregator to stamp the `scaler` label on each
430
1087
  * kici_agent_* series. The label MUST be the backend type, not the
431
1088
  * operator-chosen scaler name: the Platform catalog filter constrains
432
- * it to AGENT_SCALER_VALUES (the four types), so a free-form name is
1089
+ * it to the ScalerBackendType set (plus `stateful`), so a free-form name is
433
1090
  * dropped as bad_label_value. `managedAgentIndex` stores the scaler
434
1091
  * name, so resolve it through `backends` to the type.
435
1092
  */
436
1093
  getBackendType(agentId: string): string | null;
1094
+ /**
1095
+ * Exchange a provisioning claim code for freshly minted ephemeral credentials.
1096
+ *
1097
+ * Reads the shared claim table directly rather than routing through the
1098
+ * backend that minted the code: the mint needs only the agent-token store,
1099
+ * which is already DB-backed, so redemption succeeds on an instance that has
1100
+ * never heard of the emitting scaler. That is what lets an HA cluster front
1101
+ * its coordinators with one shared endpoint — the provisioned agent reaches
1102
+ * whichever coordinator the load balancer picks.
1103
+ *
1104
+ * Returns `{ error }` when this instance has no claim store (no database), or
1105
+ * when the redeem is rejected (unknown / consumed / expired / minting error).
1106
+ * Called from the `scaler.claim-credentials` WS handler.
1107
+ */
1108
+ claimScalerCredentials(code: string): Promise<{
1109
+ credentials?: ClaimedCredentials;
1110
+ error?: string;
1111
+ }>;
437
1112
  /**
438
1113
  * Called from agent-handler.ts when an agent sends config.ack.
439
1114
  * For Firecracker agents, clears MMDS data (belt-and-suspenders with in-VM iptables).
@@ -458,7 +1133,60 @@ export declare class ScalerManager {
458
1133
  */
459
1134
  shutdownAll(): Promise<void>;
460
1135
  /**
461
- * Validate and reload configuration.
1136
+ * Classify every configured scaler against the currently loaded backends.
1137
+ * Pure — it reads state and writes none.
1138
+ */
1139
+ private planReload;
1140
+ /**
1141
+ * Validate the new config: label-set overlaps, backend type changes, and
1142
+ * each kept backend's own view of its label sets.
1143
+ *
1144
+ * `backend.reload` validates and applies in one call, so a backend rejected
1145
+ * late in the loop would otherwise leave the ones before it holding the new
1146
+ * label sets and `maxAgents` while the commit never runs. Each successful
1147
+ * call's previous state is therefore snapshotted and restored when any
1148
+ * backend rejects — a rejected reload leaves every backend as it was.
1149
+ *
1150
+ * The snapshots are returned as well as used here: a later stage can still
1151
+ * fail (an added scaler whose backend does not construct), and the same
1152
+ * rollback is what keeps that rejection from half-applying too.
1153
+ */
1154
+ private validateReload;
1155
+ /** Put every backend a rejected reload had already mutated back as it was. */
1156
+ private restoreReloadedBackends;
1157
+ /**
1158
+ * Construct every added backend into a staging list. On any failure the
1159
+ * partially built set is torn down and the caller keeps the old config.
1160
+ */
1161
+ private buildAddedBackends;
1162
+ /**
1163
+ * Host prep for backends added by a reload: bridge provisioning, a reap of
1164
+ * leftovers from a previous incarnation of the same scaler, and the periodic
1165
+ * TAP sweep a long-running Firecracker backend needs. Every failure degrades
1166
+ * that one scaler; none of them fails the reload.
1167
+ */
1168
+ private prepareAddedBackends;
1169
+ /**
1170
+ * Refresh per-scaler metadata from the new config. Keys are deleted only for
1171
+ * scalers that are neither configured nor retiring — a retiring scaler keeps
1172
+ * its cap, URL, roles and defaults until its last agent is gone.
1173
+ */
1174
+ private applyScalerMetadata;
1175
+ /**
1176
+ * Register every configured machine pool with the ledger, building the
1177
+ * ledger when this reload is the first config to declare a pool. `cap` is
1178
+ * re-registered on every reload, so a changed cap applies without a restart.
1179
+ */
1180
+ private applyMachinePools;
1181
+ /** Rebuild the warm-pool config map from the new config (see WarmPoolManager.reload). */
1182
+ private applyWarmPoolConfig;
1183
+ /**
1184
+ * Validate and reload configuration: plan → validate → build → commit.
1185
+ *
1186
+ * Either the whole new config applies, or nothing does and the previous one
1187
+ * keeps running. A scaler the new config no longer names is retired, not
1188
+ * dropped: it stops taking work at once, its idle warm agents are destroyed,
1189
+ * its job-bound agents finish, and its backend is torn down when it drains.
462
1190
  */
463
1191
  reload(newConfig: ScalerConfig): Promise<ValidationResult>;
464
1192
  /**
@@ -470,6 +1198,15 @@ export declare class ScalerManager {
470
1198
  */
471
1199
  recentSpawnFailures(windowMs: number, nowMs: number): Map<string, BackendFailureSummary>;
472
1200
  getStatus(): ScalerStatus;
1201
+ /**
1202
+ * Scaler capacity advertised to peers in the cluster heartbeat.
1203
+ *
1204
+ * Retiring backends are excluded: they accept no new work locally, so a peer
1205
+ * that saw them would keep selecting this host for jobs it can no longer
1206
+ * serve. Agents still draining on a retiring backend stay visible through the
1207
+ * separate per-agent inventory, so in-flight work is unaffected.
1208
+ */
1209
+ getRoutableCapacity(): ScalerCapacitySummary[];
473
1210
  /**
474
1211
  * Get the backend name managing a specific agent.
475
1212
  * Returns null if the agent is not scaler-managed (standalone).
@@ -511,7 +1248,8 @@ export declare class ScalerManager {
511
1248
  */
512
1249
  getScalerContextForAgent(agentId: string): Record<string, unknown> | undefined;
513
1250
  /**
514
- * Start the warm pool idle check interval and the machine-pool ledger reaper.
1251
+ * Start the warm pool idle check interval, the machine-pool ledger reaper,
1252
+ * and the retirement sweep that tears down drained retiring backends.
515
1253
  */
516
1254
  start(): void;
517
1255
  /**
@@ -523,9 +1261,21 @@ export declare class ScalerManager {
523
1261
  */
524
1262
  ensureHostsReady(): Promise<void>;
525
1263
  /**
526
- * Remove spawning entries whose `backend.spawn` started more than 5 minutes
527
- * ago but never registered via WS (e.g., process/container crashed on
528
- * startup). Without cleanup, these entries would leak in spawningAgents
1264
+ * The window a spawning entry may sit unregistered before it is pruned.
1265
+ *
1266
+ * An entry cannot be stale before its own spawn deadline has passed, so the
1267
+ * window is floored at the configured deadline: an operator raising
1268
+ * `KICI_SCALER_SPAWN_TIMEOUT_MS` for a slow cloud must not have this prune
1269
+ * fire first and drop the correlation a provision booting inside the raised
1270
+ * deadline still needs. The floor only ever extends the window — a lowered
1271
+ * deadline keeps the default five minutes, because a local backend's agent
1272
+ * boots and registers well after `spawn()` itself returns.
1273
+ */
1274
+ private stalePruneWindowMs;
1275
+ /**
1276
+ * Remove spawning entries whose `backend.spawn` started longer ago than the
1277
+ * stale window but never registered via WS (e.g., process/container crashed
1278
+ * on startup). Without cleanup, these entries would leak in spawningAgents
529
1279
  * forever.
530
1280
  *
531
1281
  * The window is measured from `spawnStartedAt` (when the throttled spawn
@@ -534,10 +1284,65 @@ export declare class ScalerManager {
534
1284
  * reaped mid-queue during a large burst. Recovered orphan entries carry
535
1285
  * `spawnStartedAt` set to their persisted enqueue time, so they are subject to
536
1286
  * the normal window.
1287
+ *
1288
+ * What happens to the durable row depends on the backend type the spawn
1289
+ * recorded, which the entry carries so a scaler since removed from this
1290
+ * coordinator's config still resolves. A local backend's
1291
+ * compute is pinned to a host this process manages, so a pruned entry means
1292
+ * the spawn is dead and the row goes. An event backend's row is the only
1293
+ * durable pointer at a customer cloud instance, and it is precisely the
1294
+ * reaper's `spawn-timeout` candidate — so the row STAYS, and the reaper
1295
+ * decides. Deleting it here would beat the reaper by the flap grace on any
1296
+ * coordinator serving jobs, so the teardown the row exists to trigger would
1297
+ * never be emitted.
537
1298
  */
538
1299
  private pruneStaleSpawningEntries;
1300
+ /**
1301
+ * The spawn row for one agent, as every writer of it must build it.
1302
+ *
1303
+ * Ownership and self-describing columns are assembled only here.
1304
+ * `backend_type` is what `adoptSpawningAgent`'s conditional UPDATE matches
1305
+ * on, so a row without it can never be adopted by any instance — including
1306
+ * the one that spawned it. `owner_instance_id` scopes recovery to that
1307
+ * instance, and `mandatory_labels` carries the platform taint to a
1308
+ * coordinator that has no config entry for this scaler and can therefore
1309
+ * derive it from nothing else. `provisioning_targets` is the same story for
1310
+ * teardown: it is the only address such a coordinator has for the customer's
1311
+ * teardown workflow, and an emit to an empty target list reaches nobody, so
1312
+ * the provision would bill forever. `roles` travels with it so the row stays
1313
+ * a complete description of the spawn.
1314
+ */
1315
+ private spawningAgentSnapshot;
1316
+ /**
1317
+ * Claim one slot against an event scaler's cluster-wide `maxAgents`.
1318
+ *
1319
+ * The count and the claim share a single advisory-locked transaction, so the
1320
+ * row this writes is already visible to the next coordinator that takes the
1321
+ * lock. A store failure is never an admission: returning false costs a job a
1322
+ * queue wait, while admitting on a count nobody recorded provisions a cloud
1323
+ * instance the cap cannot see. Failing closed is not silent — a refusal
1324
+ * increments the spawn-refusal counter and a store failure its own counter,
1325
+ * labelled so a database outage, a contended lock, and a full cluster are
1326
+ * three distinguishable things rather than one.
1327
+ *
1328
+ * The failure arm bounds the admission, not the row: a transaction that
1329
+ * commits and then loses its acknowledgement leaves `slotClaimed` false, so
1330
+ * neither rollback path fires and the row counts against the cap for an
1331
+ * agent that never spawned. Closing that window needs two-phase commit; the
1332
+ * event-provision reaper is the backstop, clearing the row once the spawn
1333
+ * deadline passes with nothing adopted.
1334
+ */
1335
+ private claimClusterSlot;
539
1336
  private persistSpawningAgent;
540
1337
  private deleteSpawningAgentFromStore;
1338
+ /**
1339
+ * Stamp `adopted_by = this instance` on the spawning row of an agent that just
1340
+ * registered here. Reuses the cross-instance conditional UPDATE, so a row some
1341
+ * other instance already claimed is left exactly as it is.
1342
+ */
1343
+ private markSpawningAgentAdopted;
1344
+ /** Store-side half of the stale prune: drops the row only if nobody adopted it. */
1345
+ private deleteUnadoptedSpawningAgentFromStore;
541
1346
  private persistReservation;
542
1347
  private deleteReservationFromStore;
543
1348
  private persistAgentJob;
@@ -557,9 +1362,24 @@ export declare class ScalerManager {
557
1362
  * emitted by the previous coord before correlation are lost (see
558
1363
  * wishlist for the rationale).
559
1364
  *
1365
+ * Both reads are scoped to this instance's own rows. An unscoped read
1366
+ * hydrates a peer's in-flight spawns and reservations as our own, so our
1367
+ * spawn-timeout reaper destroys agents that peer is still legitimately
1368
+ * waiting on and our caps double-count its reservations. Rows owned by a
1369
+ * dead instance are the leader-gated reaper's business, not ours.
1370
+ *
560
1371
  * No-op when no store is wired (unit-test path).
561
1372
  */
562
1373
  recoverState(): Promise<ScalerStateRecovery>;
1374
+ /**
1375
+ * Mint a scaler-managed agent id.
1376
+ *
1377
+ * Built from {@link SCALER_AGENT_ID_PREFIX} rather than a second copy of the
1378
+ * literal: the registration guard in `onAgentRegistered` recognises a
1379
+ * scaler-managed agent by that prefix alone when the spawn-record lookup
1380
+ * fails, so a minting prefix that drifts from the constant leaves the guard
1381
+ * matching nothing and failing open.
1382
+ */
563
1383
  private generateAgentId;
564
1384
  /**
565
1385
  * Start log forwarding for a scaler-managed agent if its backend supports LogCapture.
@@ -589,6 +1409,13 @@ export declare class ScalerManager {
589
1409
  * The backend label is resolved from the spawning entry first, then the
590
1410
  * managed-agent index — so a late failure after the spawning entry is gone
591
1411
  * still buckets under the real backend instead of "unknown".
1412
+ *
1413
+ * `attribution` is the last resort for a failure this coordinator did not
1414
+ * spawn and never registered: the reaper is leader-gated, so the instance that
1415
+ * condemns a stranded external provision routinely has neither a spawning
1416
+ * entry nor a managed-agent index row for it, and every in-memory resolver
1417
+ * above comes back empty. It is read only where the maps are silent, so a live
1418
+ * local entry always wins over a durable row that may be a sweep behind.
592
1419
  */
593
1420
  private handleScalerEvent;
594
1421
  }