@harness-engineering/orchestrator 0.11.2 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -83,6 +83,7 @@ __export(index_exports, {
83
83
  defaultFetchModels: () => defaultFetchModels,
84
84
  deriveSeedPaths: () => deriveSeedPaths,
85
85
  detectScopeTier: () => detectScopeTier,
86
+ discoverCandidates: () => import_local_models6.discoverCandidates,
86
87
  discoverSkillCatalog: () => discoverSkillCatalog,
87
88
  discoverSkillCatalogNames: () => discoverSkillCatalogNames,
88
89
  emitProposalApproved: () => emitProposalApproved,
@@ -3882,8 +3883,19 @@ var AgentRunner = class {
3882
3883
 
3883
3884
  // src/agent/local-model-resolver.ts
3884
3885
  var import_local_models = require("@harness-engineering/local-models");
3886
+ function useCaseToProfile(useCase) {
3887
+ switch (useCase.kind) {
3888
+ case "tier":
3889
+ return useCase.tier === "diagnostic" ? "reasoning" : "coding";
3890
+ default:
3891
+ return "general";
3892
+ }
3893
+ }
3885
3894
  var DEFAULT_PROBE_INTERVAL_MS = 3e4;
3886
3895
  var MIN_PROBE_INTERVAL_MS = 1e3;
3896
+ var REFRESH_DEBOUNCE_MS = 250;
3897
+ var DEFAULT_BREAKER_THRESHOLD = 3;
3898
+ var DEFAULT_BREAKER_COOLDOWN_MS = 6e4;
3887
3899
  var DEFAULT_API_KEY = "lm-studio";
3888
3900
  var DEFAULT_FETCH_TIMEOUT_MS = 5e3;
3889
3901
  function normalizeLocalModel(input) {
@@ -3934,6 +3946,44 @@ async function defaultFetchModels(endpoint, apiKey, timeoutMs = DEFAULT_FETCH_TI
3934
3946
  }
3935
3947
  return ids;
3936
3948
  }
3949
+ async function defaultWarmModel(endpoint, ollamaName, apiKey, keepAlive = "10m", timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
3950
+ const base = endpoint.replace(/\/v1\/?$/, "").replace(/\/$/, "");
3951
+ const url = `${base}/api/generate`;
3952
+ try {
3953
+ await fetch(url, {
3954
+ method: "POST",
3955
+ headers: {
3956
+ "Content-Type": "application/json",
3957
+ Authorization: `Bearer ${apiKey ?? DEFAULT_API_KEY}`
3958
+ },
3959
+ // Empty prompt + keep_alive loads the model into VRAM without generating.
3960
+ body: JSON.stringify({ model: ollamaName, prompt: "", keep_alive: keepAlive }),
3961
+ signal: AbortSignal.timeout(timeoutMs)
3962
+ });
3963
+ } catch {
3964
+ }
3965
+ }
3966
+ async function defaultWarmModelViaCompletion(endpoint, model, apiKey, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
3967
+ const url = `${endpoint.replace(/\/$/, "")}/chat/completions`;
3968
+ try {
3969
+ await fetch(url, {
3970
+ method: "POST",
3971
+ headers: {
3972
+ "Content-Type": "application/json",
3973
+ Authorization: `Bearer ${apiKey ?? DEFAULT_API_KEY}`
3974
+ },
3975
+ // Minimal request: one token is enough to force the server to load the
3976
+ // model. The generated content is discarded — we only want the load.
3977
+ body: JSON.stringify({
3978
+ model,
3979
+ max_tokens: 1,
3980
+ messages: [{ role: "user", content: "warm" }]
3981
+ }),
3982
+ signal: AbortSignal.timeout(timeoutMs)
3983
+ });
3984
+ } catch {
3985
+ }
3986
+ }
3937
3987
  var LocalModelResolver = class {
3938
3988
  endpoint;
3939
3989
  apiKey;
@@ -3941,8 +3991,21 @@ var LocalModelResolver = class {
3941
3991
  poolState;
3942
3992
  probeIntervalMs;
3943
3993
  fetchModels;
3994
+ warmModel;
3944
3995
  logger;
3945
3996
  timer = null;
3997
+ /** Debounce handle for event-driven {@link refresh}; coalesces a burst into one probe. */
3998
+ refreshTimer = null;
3999
+ /** Test seam for the refresh debounce delay (0 in tests → next-tick probe). */
4000
+ refreshDebounceMs;
4001
+ /** Consumption Phase 3 (T10): circuit-breaker config + per-model failure/trip state. */
4002
+ breakerThreshold;
4003
+ breakerCooldownMs;
4004
+ now;
4005
+ /** Consecutive inference failures per model since its last success. */
4006
+ consecutiveFailures = /* @__PURE__ */ new Map();
4007
+ /** Epoch-ms at which a tripped model becomes eligible again (cooldown expiry). */
4008
+ trippedUntil = /* @__PURE__ */ new Map();
3946
4009
  listeners = /* @__PURE__ */ new Set();
3947
4010
  /**
3948
4011
  * Tracks an in-flight probe so concurrent invocations (interval tick while a
@@ -3972,12 +4035,28 @@ var LocalModelResolver = class {
3972
4035
  }
3973
4036
  const interval = opts.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
3974
4037
  this.probeIntervalMs = Math.max(MIN_PROBE_INTERVAL_MS, interval);
4038
+ this.refreshDebounceMs = Math.max(0, opts.refreshDebounceMs ?? REFRESH_DEBOUNCE_MS);
4039
+ this.breakerThreshold = Math.max(1, opts.breakerThreshold ?? DEFAULT_BREAKER_THRESHOLD);
4040
+ this.breakerCooldownMs = Math.max(0, opts.breakerCooldownMs ?? DEFAULT_BREAKER_COOLDOWN_MS);
4041
+ this.now = opts.now ?? (() => Date.now());
3975
4042
  const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
3976
4043
  this.fetchModels = opts.fetchModels ?? ((endpoint, apiKey) => defaultFetchModels(endpoint, apiKey, timeoutMs));
4044
+ if (opts.warmModel !== void 0) this.warmModel = opts.warmModel;
3977
4045
  this.logger = opts.logger ?? noopLogger;
3978
4046
  }
3979
- resolveModel() {
3980
- return this.resolved;
4047
+ /**
4048
+ * The model to dispatch to. With no `useCase`, returns the cached composite
4049
+ * resolution from the last probe (byte-identical to the pre-Phase-4 resolver).
4050
+ * With a `useCase`, orders the (pool-derived) candidates by that use-case's
4051
+ * task profile and picks the best loaded, breaker-healthy one — so a
4052
+ * coding-tagged dispatch prefers the coding specialist. A `general`-mapped
4053
+ * use-case returns the cached composite resolution unchanged.
4054
+ */
4055
+ resolveModel(useCase) {
4056
+ if (useCase === void 0) return this.resolved;
4057
+ const profile = useCaseToProfile(useCase);
4058
+ if (profile === "general") return this.resolved;
4059
+ return this.selectMatch(this.candidates(profile), this.detected);
3981
4060
  }
3982
4061
  getStatus() {
3983
4062
  return {
@@ -3995,8 +4074,8 @@ var LocalModelResolver = class {
3995
4074
  * from pool entries (currentScore desc → ollamaName); otherwise the static
3996
4075
  * `configured` list is returned unchanged (byte-identical to pre-Phase-4).
3997
4076
  */
3998
- candidates() {
3999
- return this.poolState ? (0, import_local_models.poolStateToCandidates)(this.poolState.snapshot()) : [...this.configured];
4077
+ candidates(profile) {
4078
+ return this.poolState ? (0, import_local_models.poolStateToCandidates)(this.poolState.snapshot(), profile) : [...this.configured];
4000
4079
  }
4001
4080
  onStatusChange(handler) {
4002
4081
  this.listeners.add(handler);
@@ -4016,13 +4095,14 @@ var LocalModelResolver = class {
4016
4095
  }
4017
4096
  async runProbe() {
4018
4097
  const before = this.snapshotForDiff();
4098
+ const prevResolved = this.resolved;
4019
4099
  try {
4020
4100
  const detected = await this.fetchModels(this.endpoint, this.apiKey);
4021
4101
  this.detected = [...detected];
4022
4102
  this.lastError = null;
4023
4103
  this.lastProbeAt = (/* @__PURE__ */ new Date()).toISOString();
4024
4104
  const candidates = this.candidates();
4025
- const match = candidates.find((id) => detected.includes(id)) ?? null;
4105
+ const match = this.selectMatch(candidates, detected);
4026
4106
  this.resolved = match;
4027
4107
  this.available = match !== null;
4028
4108
  this.warnings = match ? [] : [
@@ -4052,8 +4132,92 @@ var LocalModelResolver = class {
4052
4132
  }
4053
4133
  }
4054
4134
  }
4135
+ if (this.resolved !== null && this.resolved !== prevResolved) {
4136
+ this.warm(this.resolved);
4137
+ }
4055
4138
  return status;
4056
4139
  }
4140
+ /** Best-effort warm-hook invocation — a throwing warm never breaks a probe. */
4141
+ warm(ollamaName) {
4142
+ if (!this.warmModel) return;
4143
+ try {
4144
+ this.warmModel(ollamaName);
4145
+ } catch (err) {
4146
+ this.logger.warn("local-model-resolver warm hook threw", {
4147
+ error: err instanceof Error ? err.message : String(err)
4148
+ });
4149
+ }
4150
+ }
4151
+ /**
4152
+ * Consumption Phase 3 (T10): pick the resolved model from the candidates that
4153
+ * are actually loaded, preferring ones whose circuit breaker is not tripped.
4154
+ * Candidate order (score-desc from the pool) is preserved within each tier, so
4155
+ * a tripped top pick sinks below a healthy lower pick but still resolves as a
4156
+ * last resort when every loaded candidate is tripped (better a flaky model than
4157
+ * none). Returns null when no candidate is loaded.
4158
+ */
4159
+ selectMatch(candidates, detected) {
4160
+ const detectedSet = new Set(detected);
4161
+ const available = candidates.filter((id) => detectedSet.has(id));
4162
+ if (available.length === 0) return null;
4163
+ const healthy = available.filter((id) => !this.isTripped(id));
4164
+ return healthy[0] ?? available[0] ?? null;
4165
+ }
4166
+ /**
4167
+ * Whether `model`'s circuit breaker is currently tripped. Lazily clears the
4168
+ * trip once its cooldown has elapsed so the model becomes eligible again on the
4169
+ * next resolution without needing a separate timer.
4170
+ */
4171
+ isTripped(model) {
4172
+ const until = this.trippedUntil.get(model);
4173
+ if (until === void 0) return false;
4174
+ if (this.now() >= until) {
4175
+ this.trippedUntil.delete(model);
4176
+ this.consecutiveFailures.delete(model);
4177
+ return false;
4178
+ }
4179
+ return true;
4180
+ }
4181
+ /**
4182
+ * Record a successful inference on `model`: clears its failure count and any
4183
+ * active trip so a recovered model is immediately re-preferred.
4184
+ */
4185
+ recordSuccess(model) {
4186
+ if (!model) return;
4187
+ const hadState = this.consecutiveFailures.has(model) || this.trippedUntil.has(model);
4188
+ this.consecutiveFailures.delete(model);
4189
+ this.trippedUntil.delete(model);
4190
+ if (hadState) this.refresh();
4191
+ }
4192
+ /**
4193
+ * Record a failed inference on `model`. On the Nth consecutive failure the
4194
+ * breaker trips (deprioritizing the model for `breakerCooldownMs`) and a
4195
+ * re-probe is scheduled so the resolver rolls to a healthy alternative.
4196
+ */
4197
+ recordFailure(model) {
4198
+ if (!model) return;
4199
+ const next = (this.consecutiveFailures.get(model) ?? 0) + 1;
4200
+ this.consecutiveFailures.set(model, next);
4201
+ if (next >= this.breakerThreshold) {
4202
+ this.trippedUntil.set(model, this.now() + this.breakerCooldownMs);
4203
+ this.refresh();
4204
+ }
4205
+ }
4206
+ /**
4207
+ * Event-driven refresh: schedule a debounced re-probe. Called when a
4208
+ * `local-models:pool` mutation fires so a just-installed/swapped model becomes
4209
+ * usable within a debounce window instead of waiting up to `probeIntervalMs`.
4210
+ * A burst of frames coalesces into one probe (the timer is only armed once).
4211
+ */
4212
+ refresh() {
4213
+ if (this.refreshTimer !== null) return;
4214
+ this.refreshTimer = setTimeout(() => {
4215
+ this.refreshTimer = null;
4216
+ void this.probe();
4217
+ }, this.refreshDebounceMs);
4218
+ const handle = this.refreshTimer;
4219
+ handle.unref?.();
4220
+ }
4057
4221
  async start() {
4058
4222
  if (this.timer !== null) {
4059
4223
  return;
@@ -4070,6 +4234,10 @@ var LocalModelResolver = class {
4070
4234
  clearInterval(this.timer);
4071
4235
  this.timer = null;
4072
4236
  }
4237
+ if (this.refreshTimer !== null) {
4238
+ clearTimeout(this.refreshTimer);
4239
+ this.refreshTimer = null;
4240
+ }
4073
4241
  }
4074
4242
  snapshotForDiff() {
4075
4243
  return JSON.stringify({
@@ -4087,6 +4255,215 @@ var LocalModelResolver = class {
4087
4255
  var import_local_models5 = require("@harness-engineering/local-models");
4088
4256
  var import_core18 = require("@harness-engineering/core");
4089
4257
 
4258
+ // src/proposals/model-handlers.ts
4259
+ function isInUse(deps, ollamaName) {
4260
+ return deps.isModelInUse ? deps.isModelInUse(ollamaName) : false;
4261
+ }
4262
+ var MODEL_PROPOSAL_TOPIC = "local-models:proposal";
4263
+ var MODEL_POOL_TOPIC = "local-models:pool";
4264
+ var MODEL_INSTALL_TOPIC = "local-models:install";
4265
+ function makeInstallProgressForwarder(bus, base) {
4266
+ const emit4 = (phase, patch = {}) => {
4267
+ const frame = { ...base, ...patch, phase };
4268
+ bus.emit(MODEL_INSTALL_TOPIC, frame);
4269
+ };
4270
+ const onInstallEvent = (event) => {
4271
+ if (event.kind === "progress") {
4272
+ emit4("progress", {
4273
+ completedBytes: event.completedBytes,
4274
+ totalBytes: event.totalBytes,
4275
+ ...event.message !== void 0 ? { message: event.message } : {}
4276
+ });
4277
+ } else if (event.kind === "pulling") {
4278
+ emit4("progress", { message: event.message });
4279
+ }
4280
+ };
4281
+ return { emit: emit4, onInstallEvent };
4282
+ }
4283
+ function decisionOf(deps, action, reason) {
4284
+ return {
4285
+ decidedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
4286
+ decidedBy: deps.decidedBy ?? "orchestrator",
4287
+ action,
4288
+ ...reason !== void 0 ? { reason } : {}
4289
+ };
4290
+ }
4291
+ function emitApproved(deps, proposal) {
4292
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
4293
+ id: proposal.id,
4294
+ status: "approved",
4295
+ action: proposal.model.action,
4296
+ target: proposal.model.target.ollamaName
4297
+ });
4298
+ }
4299
+ async function deferEviction(deps, proposal, deferredName, event, evicted) {
4300
+ deps.pool.markPendingEviction(deferredName);
4301
+ const updated = await deps.updateProposal(proposal.id, {
4302
+ status: "approved",
4303
+ decision: decisionOf(deps, "approved")
4304
+ });
4305
+ deps.bus.emit(MODEL_POOL_TOPIC, event);
4306
+ emitApproved(deps, proposal);
4307
+ return { status: "approved", proposal: updated, evicted };
4308
+ }
4309
+ async function onApproveModelProposal(deps, proposal) {
4310
+ const { model } = proposal;
4311
+ if (model.action === "evict") {
4312
+ return applyEvictOnly(deps, proposal);
4313
+ }
4314
+ await deps.updateProposal(proposal.id, { status: "installing" });
4315
+ const installResult = await deps.pool.install({
4316
+ hfRepoId: model.target.hfRepoId,
4317
+ ollamaName: model.target.ollamaName,
4318
+ ...model.replaces !== void 0 ? { replaces: model.replaces.ollamaName } : {},
4319
+ ...model.diskImpactGb > 0 ? { sizeOnDiskGb: model.diskImpactGb } : {},
4320
+ ...model.targetScore !== void 0 ? { initialScore: model.targetScore } : {},
4321
+ ...deps.onInstallEvent ? { onEvent: deps.onInstallEvent } : {}
4322
+ });
4323
+ if (installResult.status === "error") {
4324
+ if (installResult.code === "failed_target_missing") {
4325
+ const updated2 = await deps.updateProposal(proposal.id, {
4326
+ status: "failed_target_missing"
4327
+ });
4328
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
4329
+ id: proposal.id,
4330
+ status: "failed_target_missing",
4331
+ action: model.action,
4332
+ target: model.target.ollamaName
4333
+ });
4334
+ return { status: "failed_target_missing", proposal: updated2 };
4335
+ }
4336
+ await deps.updateProposal(proposal.id, { status: "open" });
4337
+ return { status: "error", code: installResult.code, message: installResult.message };
4338
+ }
4339
+ const evicted = [...installResult.evicted];
4340
+ if (model.action === "swap" && model.replaces !== void 0) {
4341
+ const replacesName = model.replaces.ollamaName;
4342
+ if (isInUse(deps, replacesName)) {
4343
+ return deferEviction(
4344
+ deps,
4345
+ proposal,
4346
+ replacesName,
4347
+ {
4348
+ id: proposal.id,
4349
+ action: model.action,
4350
+ installed: model.target.ollamaName,
4351
+ phase: "evict_deferred",
4352
+ deferred: replacesName,
4353
+ ...installResult.evicted.length > 0 ? { evicted: installResult.evicted.map((e) => e.ollamaName) } : {}
4354
+ },
4355
+ evicted
4356
+ );
4357
+ }
4358
+ const evictResult = await deps.pool.evict({ ollamaName: replacesName });
4359
+ if (evictResult.status === "error") {
4360
+ deps.bus.emit(MODEL_POOL_TOPIC, {
4361
+ id: proposal.id,
4362
+ action: model.action,
4363
+ installed: model.target.ollamaName,
4364
+ phase: "swap_evict_failed"
4365
+ });
4366
+ await deps.updateProposal(proposal.id, { status: "open" });
4367
+ return { status: "error", code: evictResult.code, message: evictResult.message };
4368
+ }
4369
+ if (evictResult.removed !== null) {
4370
+ evicted.push(evictResult.removed);
4371
+ }
4372
+ }
4373
+ const updated = await deps.updateProposal(proposal.id, {
4374
+ status: "approved",
4375
+ decision: decisionOf(deps, "approved")
4376
+ });
4377
+ const evictedNames = [
4378
+ ...installResult.evicted.map((e) => e.ollamaName),
4379
+ ...model.replaces !== void 0 ? [model.replaces.ollamaName] : []
4380
+ ];
4381
+ deps.bus.emit(MODEL_POOL_TOPIC, {
4382
+ id: proposal.id,
4383
+ action: model.action,
4384
+ installed: model.target.ollamaName,
4385
+ ...evictedNames.length > 0 ? { evicted: evictedNames } : {}
4386
+ });
4387
+ emitApproved(deps, proposal);
4388
+ return { status: "approved", proposal: updated, evicted };
4389
+ }
4390
+ async function applyEvictOnly(deps, proposal) {
4391
+ const targetName = proposal.model.target.ollamaName;
4392
+ if (isInUse(deps, targetName)) {
4393
+ return deferEviction(
4394
+ deps,
4395
+ proposal,
4396
+ targetName,
4397
+ { id: proposal.id, action: "evict", phase: "evict_deferred", deferred: targetName },
4398
+ []
4399
+ );
4400
+ }
4401
+ const evictResult = await deps.pool.evict({ ollamaName: targetName });
4402
+ if (evictResult.status === "error") {
4403
+ return { status: "error", code: evictResult.code, message: evictResult.message };
4404
+ }
4405
+ const updated = await deps.updateProposal(proposal.id, {
4406
+ status: "approved",
4407
+ decision: decisionOf(deps, "approved")
4408
+ });
4409
+ deps.bus.emit(MODEL_POOL_TOPIC, {
4410
+ id: proposal.id,
4411
+ action: "evict",
4412
+ // XP-2: `evicted` is string[] at EVERY local-models:pool emit site (swap/add
4413
+ // list multiple removals) — the evict-only path wraps its single removal in
4414
+ // an array too, so consumers never branch on string-vs-array.
4415
+ evicted: [proposal.model.target.ollamaName]
4416
+ });
4417
+ emitApproved(deps, proposal);
4418
+ return {
4419
+ status: "approved",
4420
+ proposal: updated,
4421
+ evicted: evictResult.removed !== null && evictResult.removed !== void 0 ? [evictResult.removed] : []
4422
+ };
4423
+ }
4424
+ async function onRejectModelProposal(deps, proposal, reason) {
4425
+ const updated = await deps.updateProposal(proposal.id, {
4426
+ status: "rejected",
4427
+ decision: decisionOf(deps, "rejected", reason)
4428
+ });
4429
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
4430
+ id: proposal.id,
4431
+ status: "rejected",
4432
+ target: proposal.model.target.ollamaName,
4433
+ replaces: proposal.model.replaces?.ollamaName,
4434
+ reason
4435
+ });
4436
+ return updated;
4437
+ }
4438
+ async function redriveInstallingProposals(deps, proposals, opts = {}) {
4439
+ for (const proposal of proposals) {
4440
+ if (proposal.status !== "installing") continue;
4441
+ if (proposal.model.action === "evict") continue;
4442
+ const { emit: emit4, onInstallEvent } = makeInstallProgressForwarder(deps.bus, {
4443
+ proposalId: proposal.id,
4444
+ hfRepoId: proposal.model.target.hfRepoId,
4445
+ ollamaName: proposal.model.target.ollamaName
4446
+ });
4447
+ emit4("started");
4448
+ try {
4449
+ const outcome = await onApproveModelProposal({ ...deps, onInstallEvent }, proposal);
4450
+ if (outcome.status === "approved") {
4451
+ emit4("complete");
4452
+ } else if (outcome.status === "failed_target_missing") {
4453
+ emit4("error", {
4454
+ code: "failed_target_missing",
4455
+ message: `${proposal.model.target.hfRepoId} is no longer available on HuggingFace`
4456
+ });
4457
+ } else {
4458
+ emit4("error", { code: outcome.code, message: outcome.message });
4459
+ }
4460
+ } catch (err) {
4461
+ opts.onWarn?.("re-drive of interrupted install failed", err);
4462
+ emit4("error", { message: err instanceof Error ? err.message : String(err) });
4463
+ }
4464
+ }
4465
+ }
4466
+
4090
4467
  // src/agent/config-migration.ts
4091
4468
  var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
4092
4469
  var CASE1_ALWAYS_SUPPRESS = /* @__PURE__ */ new Set(["agent.backend"]);
@@ -5129,6 +5506,8 @@ var LocalBackend = class {
5129
5506
  name = "local";
5130
5507
  config;
5131
5508
  getModel;
5509
+ onModelUsed;
5510
+ onModelFailed;
5132
5511
  client;
5133
5512
  constructor(config = {}) {
5134
5513
  this.config = {
@@ -5138,6 +5517,8 @@ var LocalBackend = class {
5138
5517
  timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS
5139
5518
  };
5140
5519
  this.getModel = config.getModel;
5520
+ this.onModelUsed = config.onModelUsed;
5521
+ this.onModelFailed = config.onModelFailed;
5141
5522
  this.client = new import_openai2.default({
5142
5523
  apiKey: this.config.apiKey,
5143
5524
  baseURL: this.config.endpoint,
@@ -5204,6 +5585,7 @@ var LocalBackend = class {
5204
5585
  }
5205
5586
  } catch (err) {
5206
5587
  const errorMessage2 = err instanceof Error ? err.message : "Local backend request failed";
5588
+ this.notify(this.onModelFailed, localSession.resolvedModel);
5207
5589
  yield {
5208
5590
  type: "error",
5209
5591
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -5217,6 +5599,7 @@ var LocalBackend = class {
5217
5599
  error: errorMessage2
5218
5600
  };
5219
5601
  }
5602
+ this.notify(this.onModelUsed, localSession.resolvedModel);
5220
5603
  const usage = { inputTokens, outputTokens, totalTokens };
5221
5604
  yield {
5222
5605
  type: "usage",
@@ -5230,6 +5613,14 @@ var LocalBackend = class {
5230
5613
  usage
5231
5614
  };
5232
5615
  }
5616
+ /** Best-effort telemetry hook invocation — a throwing hook never breaks a turn. */
5617
+ notify(hook, model) {
5618
+ if (!hook) return;
5619
+ try {
5620
+ hook(model);
5621
+ } catch {
5622
+ }
5623
+ }
5233
5624
  async stopSession(_session) {
5234
5625
  return (0, import_types14.Ok)(void 0);
5235
5626
  }
@@ -5391,7 +5782,8 @@ var PiBackend = class {
5391
5782
  backendName: this.name,
5392
5783
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5393
5784
  piSession,
5394
- unsubscribe: null
5785
+ unsubscribe: null,
5786
+ ...resolvedModelName !== void 0 && { resolvedModel: resolvedModelName }
5395
5787
  };
5396
5788
  return (0, import_types15.Ok)(session);
5397
5789
  } catch (err) {
@@ -5483,7 +5875,9 @@ var PiBackend = class {
5483
5875
  }
5484
5876
  }
5485
5877
  const totalTokens = inputTokens + outputTokens;
5878
+ const resolvedModel = session.resolvedModel;
5486
5879
  if (promptErrorMsg) {
5880
+ if (resolvedModel !== void 0) this.notify(this.config.onModelFailed, resolvedModel);
5487
5881
  return {
5488
5882
  success: false,
5489
5883
  sessionId: session.sessionId,
@@ -5491,12 +5885,21 @@ var PiBackend = class {
5491
5885
  usage: { inputTokens, outputTokens, totalTokens }
5492
5886
  };
5493
5887
  }
5888
+ if (resolvedModel !== void 0) this.notify(this.config.onModelUsed, resolvedModel);
5494
5889
  return {
5495
5890
  success: true,
5496
5891
  sessionId: session.sessionId,
5497
5892
  usage: { inputTokens, outputTokens, totalTokens }
5498
5893
  };
5499
5894
  }
5895
+ /** Best-effort telemetry hook invocation — a throwing hook never breaks a turn. */
5896
+ notify(hook, model) {
5897
+ if (!hook) return;
5898
+ try {
5899
+ hook(model);
5900
+ } catch {
5901
+ }
5902
+ }
5500
5903
  /**
5501
5904
  * Consume events from the queue, yielding mapped AgentEvents until agent_end or prompt completion.
5502
5905
  */
@@ -6598,8 +7001,9 @@ var OrchestratorBackendFactory = class {
6598
7001
  let backend;
6599
7002
  const createOpts = this.opts.cacheMetrics ? { cacheMetrics: this.opts.cacheMetrics } : {};
6600
7003
  if ((def.type === "local" || def.type === "pi") && this.opts.getResolverModelFor) {
6601
- const getModel = this.opts.getResolverModelFor(name);
6602
- backend = getModel ? this.buildLocalLikeWithResolver(def, getModel) : createBackend(def, createOpts);
7004
+ const getModel = this.opts.getResolverModelFor(name, useCase);
7005
+ const usageHooks = this.opts.getModelUsageHooksFor?.(name);
7006
+ backend = getModel ? this.buildLocalLikeWithResolver(def, getModel, usageHooks) : createBackend(def, createOpts);
6603
7007
  } else {
6604
7008
  backend = createBackend(def, createOpts);
6605
7009
  }
@@ -6613,13 +7017,15 @@ var OrchestratorBackendFactory = class {
6613
7017
  * mirroring `createBackend`'s local/pi branches but substituting the
6614
7018
  * head-of-array placeholder with the orchestrator-owned resolver.
6615
7019
  */
6616
- buildLocalLikeWithResolver(def, getModel) {
7020
+ buildLocalLikeWithResolver(def, getModel, usageHooks) {
6617
7021
  if (def.type === "local") {
6618
7022
  return new LocalBackend({
6619
7023
  endpoint: def.endpoint,
6620
7024
  getModel,
6621
7025
  ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
6622
- ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
7026
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {},
7027
+ ...usageHooks?.onModelUsed !== void 0 ? { onModelUsed: usageHooks.onModelUsed } : {},
7028
+ ...usageHooks?.onModelFailed !== void 0 ? { onModelFailed: usageHooks.onModelFailed } : {}
6623
7029
  });
6624
7030
  }
6625
7031
  if (def.type === "pi") {
@@ -6627,7 +7033,9 @@ var OrchestratorBackendFactory = class {
6627
7033
  endpoint: def.endpoint,
6628
7034
  getModel,
6629
7035
  ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
6630
- ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
7036
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {},
7037
+ ...usageHooks?.onModelUsed !== void 0 ? { onModelUsed: usageHooks.onModelUsed } : {},
7038
+ ...usageHooks?.onModelFailed !== void 0 ? { onModelFailed: usageHooks.onModelFailed } : {}
6631
7039
  });
6632
7040
  }
6633
7041
  throw new Error(
@@ -6792,6 +7200,9 @@ function buildLocalLikeProvider(def, args, layerModel) {
6792
7200
  apiKey,
6793
7201
  baseUrl: def.endpoint,
6794
7202
  ...model !== void 0 && { defaultModel: model },
7203
+ ...layerModel === void 0 && {
7204
+ getModel: () => getResolverStatusSnapshot()?.resolved ?? void 0
7205
+ },
6795
7206
  ...intelligence?.requestTimeoutMs !== void 0 && {
6796
7207
  timeoutMs: intelligence.requestTimeoutMs
6797
7208
  },
@@ -8608,163 +9019,6 @@ function emitProposalRejected(bus, proposal) {
8608
9019
  emit3(bus, "proposal.rejected", data);
8609
9020
  }
8610
9021
 
8611
- // src/proposals/model-handlers.ts
8612
- function isInUse(deps, ollamaName) {
8613
- return deps.isModelInUse ? deps.isModelInUse(ollamaName) : false;
8614
- }
8615
- var MODEL_PROPOSAL_TOPIC = "local-models:proposal";
8616
- var MODEL_POOL_TOPIC = "local-models:pool";
8617
- function decisionOf(deps, action, reason) {
8618
- return {
8619
- decidedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
8620
- decidedBy: deps.decidedBy ?? "orchestrator",
8621
- action,
8622
- ...reason !== void 0 ? { reason } : {}
8623
- };
8624
- }
8625
- function emitApproved(deps, proposal) {
8626
- deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8627
- id: proposal.id,
8628
- status: "approved",
8629
- action: proposal.model.action,
8630
- target: proposal.model.target.ollamaName
8631
- });
8632
- }
8633
- async function deferEviction(deps, proposal, deferredName, event, evicted) {
8634
- deps.pool.markPendingEviction(deferredName);
8635
- const updated = await deps.updateProposal(proposal.id, {
8636
- status: "approved",
8637
- decision: decisionOf(deps, "approved")
8638
- });
8639
- deps.bus.emit(MODEL_POOL_TOPIC, event);
8640
- emitApproved(deps, proposal);
8641
- return { status: "approved", proposal: updated, evicted };
8642
- }
8643
- async function onApproveModelProposal(deps, proposal) {
8644
- const { model } = proposal;
8645
- if (model.action === "evict") {
8646
- return applyEvictOnly(deps, proposal);
8647
- }
8648
- const installResult = await deps.pool.install({
8649
- hfRepoId: model.target.hfRepoId,
8650
- ollamaName: model.target.ollamaName,
8651
- ...model.replaces !== void 0 ? { replaces: model.replaces.ollamaName } : {},
8652
- ...model.diskImpactGb > 0 ? { sizeOnDiskGb: model.diskImpactGb } : {}
8653
- });
8654
- if (installResult.status === "error") {
8655
- if (installResult.code === "failed_target_missing") {
8656
- const updated2 = await deps.updateProposal(proposal.id, {
8657
- status: "failed_target_missing"
8658
- });
8659
- deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8660
- id: proposal.id,
8661
- status: "failed_target_missing",
8662
- action: model.action,
8663
- target: model.target.ollamaName
8664
- });
8665
- return { status: "failed_target_missing", proposal: updated2 };
8666
- }
8667
- return { status: "error", code: installResult.code, message: installResult.message };
8668
- }
8669
- const evicted = [...installResult.evicted];
8670
- if (model.action === "swap" && model.replaces !== void 0) {
8671
- const replacesName = model.replaces.ollamaName;
8672
- if (isInUse(deps, replacesName)) {
8673
- return deferEviction(
8674
- deps,
8675
- proposal,
8676
- replacesName,
8677
- {
8678
- id: proposal.id,
8679
- action: model.action,
8680
- installed: model.target.ollamaName,
8681
- phase: "evict_deferred",
8682
- deferred: replacesName,
8683
- ...installResult.evicted.length > 0 ? { evicted: installResult.evicted.map((e) => e.ollamaName) } : {}
8684
- },
8685
- evicted
8686
- );
8687
- }
8688
- const evictResult = await deps.pool.evict({ ollamaName: replacesName });
8689
- if (evictResult.status === "error") {
8690
- deps.bus.emit(MODEL_POOL_TOPIC, {
8691
- id: proposal.id,
8692
- action: model.action,
8693
- installed: model.target.ollamaName,
8694
- phase: "swap_evict_failed"
8695
- });
8696
- return { status: "error", code: evictResult.code, message: evictResult.message };
8697
- }
8698
- if (evictResult.removed !== null) {
8699
- evicted.push(evictResult.removed);
8700
- }
8701
- }
8702
- const updated = await deps.updateProposal(proposal.id, {
8703
- status: "approved",
8704
- decision: decisionOf(deps, "approved")
8705
- });
8706
- const evictedNames = [
8707
- ...installResult.evicted.map((e) => e.ollamaName),
8708
- ...model.replaces !== void 0 ? [model.replaces.ollamaName] : []
8709
- ];
8710
- deps.bus.emit(MODEL_POOL_TOPIC, {
8711
- id: proposal.id,
8712
- action: model.action,
8713
- installed: model.target.ollamaName,
8714
- ...evictedNames.length > 0 ? { evicted: evictedNames } : {}
8715
- });
8716
- emitApproved(deps, proposal);
8717
- return { status: "approved", proposal: updated, evicted };
8718
- }
8719
- async function applyEvictOnly(deps, proposal) {
8720
- const targetName = proposal.model.target.ollamaName;
8721
- if (isInUse(deps, targetName)) {
8722
- return deferEviction(
8723
- deps,
8724
- proposal,
8725
- targetName,
8726
- { id: proposal.id, action: "evict", phase: "evict_deferred", deferred: targetName },
8727
- []
8728
- );
8729
- }
8730
- const evictResult = await deps.pool.evict({ ollamaName: targetName });
8731
- if (evictResult.status === "error") {
8732
- return { status: "error", code: evictResult.code, message: evictResult.message };
8733
- }
8734
- const updated = await deps.updateProposal(proposal.id, {
8735
- status: "approved",
8736
- decision: decisionOf(deps, "approved")
8737
- });
8738
- deps.bus.emit(MODEL_POOL_TOPIC, {
8739
- id: proposal.id,
8740
- action: "evict",
8741
- // XP-2: `evicted` is string[] at EVERY local-models:pool emit site (swap/add
8742
- // list multiple removals) — the evict-only path wraps its single removal in
8743
- // an array too, so consumers never branch on string-vs-array.
8744
- evicted: [proposal.model.target.ollamaName]
8745
- });
8746
- emitApproved(deps, proposal);
8747
- return {
8748
- status: "approved",
8749
- proposal: updated,
8750
- evicted: evictResult.removed !== null && evictResult.removed !== void 0 ? [evictResult.removed] : []
8751
- };
8752
- }
8753
- async function onRejectModelProposal(deps, proposal, reason) {
8754
- const updated = await deps.updateProposal(proposal.id, {
8755
- status: "rejected",
8756
- decision: decisionOf(deps, "rejected", reason)
8757
- });
8758
- deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8759
- id: proposal.id,
8760
- status: "rejected",
8761
- target: proposal.model.target.ollamaName,
8762
- replaces: proposal.model.replaces?.ollamaName,
8763
- reason
8764
- });
8765
- return updated;
8766
- }
8767
-
8768
9022
  // src/server/routes/v1/proposals.ts
8769
9023
  var LIST_RE = /^\/api\/v1\/proposals(?:\?.*)?$/;
8770
9024
  var SINGLE_RE = /^\/api\/v1\/proposals\/([^/?]+)(?:\?.*)?$/;
@@ -8858,12 +9112,38 @@ async function handleApprove(req, res, deps, id) {
8858
9112
  sendJSON8(res, 501, { error: "model proposal handlers not configured" });
8859
9113
  return;
8860
9114
  }
8861
- if (existing.status === "approved" || existing.status === "rejected" || existing.status === "failed_target_missing") {
9115
+ if (existing.status === "approved" || existing.status === "rejected" || existing.status === "failed_target_missing" || existing.status === "installing") {
8862
9116
  sendJSON8(res, 409, {
8863
9117
  error: `proposal already ${existing.status}; cannot approve`
8864
9118
  });
8865
9119
  return;
8866
9120
  }
9121
+ const action = existing.model.action;
9122
+ if (action === "add" || action === "swap") {
9123
+ const { emit: emit4, onInstallEvent } = makeInstallProgressForwarder(deps.bus, {
9124
+ proposalId: existing.id,
9125
+ hfRepoId: existing.model.target.hfRepoId,
9126
+ ollamaName: existing.model.target.ollamaName
9127
+ });
9128
+ const handler = { ...modelHandlerDeps(deps, req), onInstallEvent };
9129
+ emit4("started");
9130
+ void onApproveModelProposal(handler, existing).then((outcome2) => {
9131
+ if (outcome2.status === "approved") {
9132
+ emit4("complete");
9133
+ } else if (outcome2.status === "failed_target_missing") {
9134
+ emit4("error", {
9135
+ code: "failed_target_missing",
9136
+ message: `${existing.model.target.hfRepoId} is no longer available on HuggingFace`
9137
+ });
9138
+ } else {
9139
+ emit4("error", { code: outcome2.code, message: outcome2.message });
9140
+ }
9141
+ }).catch((err) => {
9142
+ emit4("error", { message: err instanceof Error ? err.message : String(err) });
9143
+ });
9144
+ sendJSON8(res, 202, { disposition: "installing", proposalId: existing.id });
9145
+ return;
9146
+ }
8867
9147
  const outcome = await onApproveModelProposal(modelHandlerDeps(deps, req), existing);
8868
9148
  sendJSON8(res, outcome.status === "error" ? 422 : 200, outcome);
8869
9149
  return;
@@ -9047,6 +9327,7 @@ function handleV1ProposalsRoute(req, res, deps) {
9047
9327
  // src/server/routes/v1/local-models.ts
9048
9328
  var import_local_models2 = require("@harness-engineering/local-models");
9049
9329
  var REFRESH_RE = /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/;
9330
+ var CANDIDATES_REFRESH_RE = /^\/api\/v1\/local-models\/candidates\/refresh(?:\?.*)?$/;
9050
9331
  var HARDWARE_RE = /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/;
9051
9332
  var POOL_RE = /^\/api\/v1\/local-models\/pool(?:\?.*)?$/;
9052
9333
  var RECS_RE = /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/;
@@ -9078,7 +9359,17 @@ function handleV1LocalModelsRoute(req, res, deps) {
9078
9359
  }
9079
9360
  return false;
9080
9361
  }
9081
- if (method !== "POST" || !REFRESH_RE.test(url)) return false;
9362
+ if (method !== "POST") return false;
9363
+ if (CANDIDATES_REFRESH_RE.test(url)) {
9364
+ const refresh = deps.getRefreshCandidates?.() ?? null;
9365
+ if (refresh === null) {
9366
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9367
+ return true;
9368
+ }
9369
+ void runCandidatesRefresh(res, refresh);
9370
+ return true;
9371
+ }
9372
+ if (!REFRESH_RE.test(url)) return false;
9082
9373
  const scheduler = deps.getRefreshScheduler();
9083
9374
  if (scheduler === null) {
9084
9375
  sendJSON9(res, 503, { error: "LMLM disabled" });
@@ -9087,6 +9378,16 @@ function handleV1LocalModelsRoute(req, res, deps) {
9087
9378
  void runForceRefresh(res, scheduler, deps);
9088
9379
  return true;
9089
9380
  }
9381
+ async function runCandidatesRefresh(res, refresh) {
9382
+ try {
9383
+ sendJSON9(res, 200, await refresh());
9384
+ } catch (err) {
9385
+ sendJSON9(res, 500, {
9386
+ error: "candidate refresh failed",
9387
+ detail: err instanceof Error ? err.message : "unknown"
9388
+ });
9389
+ }
9390
+ }
9090
9391
  async function runGet(res, handler) {
9091
9392
  try {
9092
9393
  await handler();
@@ -9247,6 +9548,11 @@ async function handleInstall(req, res, deps) {
9247
9548
  action: "add",
9248
9549
  target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
9249
9550
  scoreDelta: match.score,
9551
+ // Consumption Phase 2 (T7): carry the absolute ranked score so the new pool
9552
+ // entry seeds `currentScore` at its real rank instead of 0 — an
9553
+ // operator-initiated install is usable immediately, not buried until the
9554
+ // scheduler's next re-rank.
9555
+ targetScore: match.score,
9250
9556
  justification: {
9251
9557
  summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
9252
9558
  benchmarkBasis: [],
@@ -9269,27 +9575,37 @@ async function handleInstall(req, res, deps) {
9269
9575
  const record = await (0, import_core11.createModelProposal)(deps.projectPath, content, {
9270
9576
  proposedBy: decidedByOf(deps, req)
9271
9577
  });
9272
- const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9273
- if (outcome.status === "approved") {
9274
- const result = {
9275
- disposition: "installed",
9276
- proposalId: record.id,
9277
- evicted: outcome.evicted.map((e) => e.ollamaName)
9278
- };
9279
- return sendJSON10(res, 200, result);
9280
- }
9281
- if (outcome.status === "failed_target_missing") {
9282
- return sendJSON10(res, 404, {
9283
- error: `${hfRepoId} is no longer available on HuggingFace`,
9284
- proposalId: record.id
9285
- });
9286
- }
9287
- const status = outcome.code === "not_allowed" || outcome.code === "budget_exceeded" ? 409 : 502;
9288
- return sendJSON10(res, status, {
9289
- error: outcome.message,
9290
- code: outcome.code,
9291
- proposalId: record.id
9578
+ const ollamaName = match.ollamaName;
9579
+ const { emit: emit4, onInstallEvent } = makeInstallProgressForwarder(deps.bus, {
9580
+ proposalId: record.id,
9581
+ hfRepoId: match.hfRepoId,
9582
+ ollamaName
9583
+ });
9584
+ const handler = { ...handlerDeps(deps, req, pool), onInstallEvent };
9585
+ emit4("started");
9586
+ void onApproveModelProposal(handler, record).then((outcome) => {
9587
+ if (outcome.status === "approved") {
9588
+ emit4("complete", {});
9589
+ return;
9590
+ }
9591
+ if (outcome.status === "failed_target_missing") {
9592
+ emit4("error", {
9593
+ code: "failed_target_missing",
9594
+ message: `${hfRepoId} is no longer available on HuggingFace`
9595
+ });
9596
+ return;
9597
+ }
9598
+ emit4("error", { code: outcome.code, message: outcome.message });
9599
+ }).catch((err) => {
9600
+ emit4("error", { message: err instanceof Error ? err.message : String(err) });
9292
9601
  });
9602
+ const result = {
9603
+ disposition: "installing",
9604
+ proposalId: record.id,
9605
+ evicted: [],
9606
+ message: `installing ${ollamaName} \u2014 progress streams on the local-models:install channel`
9607
+ };
9608
+ return sendJSON10(res, 202, result);
9293
9609
  }
9294
9610
  async function handleRemove(req, res, deps) {
9295
9611
  const pool = deps.getModelPool();
@@ -10235,6 +10551,12 @@ var V1_BRIDGE_ROUTES = [
10235
10551
  scope: "manage-proposals",
10236
10552
  description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
10237
10553
  },
10554
+ {
10555
+ method: "POST",
10556
+ pattern: /^\/api\/v1\/local-models\/candidates\/refresh(?:\?.*)?$/,
10557
+ scope: "manage-proposals",
10558
+ description: "Re-discover candidates live from HuggingFace, re-seed the recommender, re-rank."
10559
+ },
10238
10560
  // ── LMLM dashboard pool mutation — operator-initiated install/remove ──
10239
10561
  // Modeled as auto-approved model proposals, so the same `manage-proposals`
10240
10562
  // write scope that governs approve/reject governs these too.
@@ -10425,6 +10747,7 @@ var OrchestratorServer = class {
10425
10747
  // LMLM Phase 6 — live model pool + refresh scheduler accessors.
10426
10748
  getModelPoolFn = null;
10427
10749
  getRefreshSchedulerFn = null;
10750
+ getRefreshCandidatesFn = null;
10428
10751
  // LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
10429
10752
  getHardwareProfileFn = null;
10430
10753
  getRecommendationsFn = null;
@@ -10435,6 +10758,8 @@ var OrchestratorServer = class {
10435
10758
  // LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
10436
10759
  modelProposalListener = null;
10437
10760
  modelPoolListener = null;
10761
+ // LMLM Phase 10 — bus→WS fan-out for byte-level install progress (D3 async install).
10762
+ modelInstallListener = null;
10438
10763
  recorder = null;
10439
10764
  planWatcher = null;
10440
10765
  tokenStore;
@@ -10481,6 +10806,7 @@ var OrchestratorServer = class {
10481
10806
  this.getBackendsFn = deps?.getBackends ?? null;
10482
10807
  this.getModelPoolFn = deps?.getModelPool ?? null;
10483
10808
  this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
10809
+ this.getRefreshCandidatesFn = deps?.getRefreshCandidates ?? null;
10484
10810
  this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
10485
10811
  this.getRecommendationsFn = deps?.getRecommendations ?? null;
10486
10812
  this.listModelProposalsFn = deps?.listModelProposals ?? null;
@@ -10503,8 +10829,10 @@ var OrchestratorServer = class {
10503
10829
  }
10504
10830
  this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
10505
10831
  this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
10832
+ this.modelInstallListener = (data) => this.broadcaster.broadcast(MODEL_INSTALL_TOPIC, data);
10506
10833
  this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
10507
10834
  this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
10835
+ this.orchestrator.on(MODEL_INSTALL_TOPIC, this.modelInstallListener);
10508
10836
  }
10509
10837
  /**
10510
10838
  * Broadcast a new interaction to all WebSocket clients.
@@ -10701,6 +11029,7 @@ var OrchestratorServer = class {
10701
11029
  // only when configured so absent ones surface as 503 (LMLM disabled).
10702
11030
  (req, res) => handleV1LocalModelsRoute(req, res, {
10703
11031
  getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
11032
+ ...this.getRefreshCandidatesFn ? { getRefreshCandidates: this.getRefreshCandidatesFn } : {},
10704
11033
  getModelPool: () => this.getModelPoolFn?.() ?? null,
10705
11034
  ...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
10706
11035
  ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
@@ -10816,6 +11145,10 @@ var OrchestratorServer = class {
10816
11145
  this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
10817
11146
  this.modelPoolListener = null;
10818
11147
  }
11148
+ if (this.modelInstallListener) {
11149
+ this.orchestrator.removeListener(MODEL_INSTALL_TOPIC, this.modelInstallListener);
11150
+ this.modelInstallListener = null;
11151
+ }
10819
11152
  if (this.planWatcher) {
10820
11153
  this.planWatcher.stop();
10821
11154
  this.planWatcher = null;
@@ -13908,6 +14241,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13908
14241
  * so this map is the single source of truth post-migration.
13909
14242
  */
13910
14243
  localResolvers = /* @__PURE__ */ new Map();
14244
+ /**
14245
+ * Consumption Phase 1 (T2): bus listener that debounce-refreshes every local
14246
+ * resolver when a `local-models:pool` mutation fires, so a just-installed or
14247
+ * swapped model becomes usable within the refresh window instead of waiting up
14248
+ * to `probeIntervalMs` for the next poll. Held for removal in {@link stop}.
14249
+ */
14250
+ poolRefreshListener = null;
13911
14251
  /** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
13912
14252
  poolStateProvider = null;
13913
14253
  poolStateStore = null;
@@ -13945,6 +14285,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13945
14285
  * see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
13946
14286
  */
13947
14287
  modelRecommender = null;
14288
+ /** Live HF candidate discovery (injectable for tests so startup makes no network calls). */
14289
+ discoverCandidatesFn;
14290
+ /** Snapshot of the last candidate seeding, surfaced to the refresh route. */
14291
+ candidateSourceState = {
14292
+ source: "frozen",
14293
+ count: 0
14294
+ };
13948
14295
  /** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
13949
14296
  schedulerTimerOverride;
13950
14297
  /**
@@ -14038,6 +14385,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14038
14385
  constructor(config, promptTemplate, overrides) {
14039
14386
  super();
14040
14387
  this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
14388
+ this.discoverCandidatesFn = overrides?.discoverCandidates ?? (async () => ({ candidates: [], warnings: [] }));
14041
14389
  this.setMaxListeners(50);
14042
14390
  this.config = config;
14043
14391
  this.promptTemplate = promptTemplate;
@@ -14106,6 +14454,17 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14106
14454
  if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
14107
14455
  if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
14108
14456
  if (this.poolStateProvider !== null) resolverOpts.poolState = this.poolStateProvider;
14457
+ const endpoint = def.endpoint;
14458
+ const apiKey = def.apiKey;
14459
+ if (def.type === "local") {
14460
+ resolverOpts.warmModel = (ollamaName) => {
14461
+ void defaultWarmModel(endpoint, ollamaName, apiKey);
14462
+ };
14463
+ } else {
14464
+ resolverOpts.warmModel = (model) => {
14465
+ void defaultWarmModelViaCompletion(endpoint, model, apiKey);
14466
+ };
14467
+ }
14109
14468
  this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
14110
14469
  }
14111
14470
  }
@@ -14128,9 +14487,27 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14128
14487
  ...this.config.agent.secrets !== void 0 ? { secrets: this.config.agent.secrets } : {},
14129
14488
  cacheMetrics: this.cacheMetrics,
14130
14489
  decisionBus: this.routingDecisionBus,
14131
- getResolverModelFor: (name) => {
14490
+ getResolverModelFor: (name, useCase) => {
14491
+ const resolver = this.localResolvers.get(name);
14492
+ return resolver ? () => resolver.resolveModel(useCase) : void 0;
14493
+ },
14494
+ // Consumption Phase 3 (T11): bind per-backend runtime feedback. A
14495
+ // successful turn stamps `lastUsedAt` (LRU) via the pool and clears the
14496
+ // resolver's circuit breaker; a failed turn feeds the breaker so a
14497
+ // repeatedly-failing model is deprioritized. `modelPool` is read lazily
14498
+ // (per dispatch) because it loads in start(), after this constructor.
14499
+ getModelUsageHooksFor: (name) => {
14132
14500
  const resolver = this.localResolvers.get(name);
14133
- return resolver ? () => resolver.resolveModel() : void 0;
14501
+ if (!resolver) return void 0;
14502
+ return {
14503
+ onModelUsed: (model) => {
14504
+ resolver.recordSuccess(model);
14505
+ void this.modelPool?.markUsed(model);
14506
+ },
14507
+ onModelFailed: (model) => {
14508
+ resolver.recordFailure(model);
14509
+ }
14510
+ };
14134
14511
  }
14135
14512
  });
14136
14513
  } else {
@@ -14227,6 +14604,9 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14227
14604
  isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
14228
14605
  // LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
14229
14606
  getRefreshScheduler: () => this.refreshScheduler,
14607
+ // Live candidate refresh for POST /local-models/candidates/refresh (the
14608
+ // "Refresh" button). Null when LMLM is disabled → route 503s.
14609
+ getRefreshCandidates: () => this.modelPool ? () => this.refreshCandidatesLive() : null,
14230
14610
  // LMLM Phase 7 read surface — hardware / recommendations / model proposals.
14231
14611
  // Each returns null/[] when LMLM is disabled so the route renders 503/[].
14232
14612
  getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
@@ -15294,10 +15674,51 @@ ${messages}`);
15294
15674
  const installerCfg = this.config.localModels?.installer;
15295
15675
  this.modelInstaller = new import_local_models5.OllamaInstallAdapter({
15296
15676
  baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
15297
- onWarn
15677
+ onWarn,
15678
+ // Survive transient `/api/pull` drops (most often the host sleeping mid
15679
+ // multi-GB download): ollama resumes from cached blobs, and any forward
15680
+ // progress resets the budget, so an install nibbled through across several
15681
+ // sleep cycles still completes instead of dead-ending in an error.
15682
+ maxPullRetries: 5
15298
15683
  });
15299
15684
  this.modelPool = new import_local_models5.PoolManager({ store, installer: this.modelInstaller, onWarn });
15300
15685
  }
15686
+ /**
15687
+ * Resume installs interrupted by a restart. A proposal left `installing` had
15688
+ * its background `ollama pull` cut short when the orchestrator went down; the
15689
+ * pull is idempotent (ollama resumes from cached blobs), so we re-drive it.
15690
+ * Fire-and-forget with its own error isolation — a resumed multi-GB download
15691
+ * must not block startup, and a re-drive failure only logs.
15692
+ */
15693
+ redriveInterruptedInstalls() {
15694
+ const pool = this.modelPool;
15695
+ if (pool === null) return;
15696
+ void (async () => {
15697
+ try {
15698
+ const modelProposals = await (0, import_core18.listProposals)(this.projectRoot, {
15699
+ kind: "model"
15700
+ });
15701
+ const installing = modelProposals.filter((p) => p.status === "installing");
15702
+ if (installing.length === 0) return;
15703
+ this.logger.info(`Resuming ${installing.length} model install(s) interrupted by a restart`);
15704
+ await redriveInstallingProposals(
15705
+ {
15706
+ pool,
15707
+ bus: this,
15708
+ updateProposal: (id, patch) => (0, import_core18.updateProposal)(this.projectRoot, id, patch),
15709
+ decidedBy: "orchestrator",
15710
+ isModelInUse: (name) => this.isLocalModelInUse(name)
15711
+ },
15712
+ installing,
15713
+ {
15714
+ onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
15715
+ }
15716
+ );
15717
+ } catch (err) {
15718
+ this.logger.warn("interrupted-install re-drive failed", { cause: err });
15719
+ }
15720
+ })();
15721
+ }
15301
15722
  /**
15302
15723
  * LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
15303
15724
  * budget + org/family allowlist) from `localModels.pool` to the live pool.
@@ -15343,8 +15764,8 @@ ${messages}`);
15343
15764
  allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
15344
15765
  });
15345
15766
  }
15346
- const recommend = (0, import_local_models5.createNativeRecommender)({ candidates });
15347
- this.modelRecommender = recommend;
15767
+ this.seedRecommender(candidates, "frozen");
15768
+ const recommend = (hardware) => this.modelRecommender(hardware);
15348
15769
  this.refreshScheduler = new import_local_models5.RefreshScheduler({
15349
15770
  runTick: () => (0, import_local_models5.runRefreshTick)({
15350
15771
  detectHardware: () => this.detectLmlmHardware(),
@@ -15374,6 +15795,55 @@ ${messages}`);
15374
15795
  });
15375
15796
  this.refreshScheduler.start();
15376
15797
  }
15798
+ /** (Re)build the recommender over `candidates` and record the seeding source. */
15799
+ seedRecommender(candidates, source) {
15800
+ this.modelRecommender = (0, import_local_models5.createNativeRecommender)({ candidates });
15801
+ this.candidateSourceState = { source, count: candidates.length };
15802
+ }
15803
+ /**
15804
+ * Refresh ranking candidates live from HuggingFace, merge the curated
15805
+ * `ollamaName`/`family` tags from the frozen snapshot (so results stay
15806
+ * installable — decision A), and re-seed the recommender. Fail-closed: on any
15807
+ * error or an empty installable result, the current candidates stand. Runs a
15808
+ * `forceRefresh` tick so recommendations + proposals reflect the fresh set.
15809
+ * Used by both the startup background refresh and the operator "Refresh" button.
15810
+ */
15811
+ async refreshCandidatesLive(signal) {
15812
+ const poolCfg = this.config.localModels?.pool;
15813
+ const orgs = poolCfg?.allowedOrgs ?? [];
15814
+ if (orgs.length === 0) return this.candidateSourceState;
15815
+ const curation = (0, import_local_models5.curationFromCandidates)((0, import_local_models5.loadFrozenCandidates)().candidates);
15816
+ let result;
15817
+ try {
15818
+ result = await this.discoverCandidatesFn({
15819
+ orgs,
15820
+ curation,
15821
+ ...signal ? { signal } : {},
15822
+ onWarn: (m, cause) => this.logger.warn(m, cause !== void 0 ? { cause } : void 0)
15823
+ });
15824
+ } catch (err) {
15825
+ this.logger.warn("LMLM live candidate discovery failed; keeping current candidates", {
15826
+ cause: err
15827
+ });
15828
+ return this.candidateSourceState;
15829
+ }
15830
+ const selected = (0, import_local_models5.selectCandidates)(result.candidates, poolCfg);
15831
+ if (selected.length === 0) {
15832
+ this.logger.warn("LMLM live discovery yielded no installable candidates; keeping current", {
15833
+ warnings: result.warnings
15834
+ });
15835
+ return this.candidateSourceState;
15836
+ }
15837
+ this.seedRecommender(selected, "live");
15838
+ this.logger.info("LMLM candidates refreshed from HuggingFace", { count: selected.length });
15839
+ await this.refreshScheduler?.forceRefresh();
15840
+ this.emit("local-models:pool", {
15841
+ phase: "candidates_refreshed",
15842
+ source: "live",
15843
+ count: selected.length
15844
+ });
15845
+ return this.candidateSourceState;
15846
+ }
15377
15847
  /** Resolve the hardware profile for a refresh tick (operator override wins). */
15378
15848
  async detectLmlmHardware() {
15379
15849
  const override = this.config.localModels?.hardware?.override;
@@ -15421,12 +15891,27 @@ ${messages}`);
15421
15891
  if (this.poolStateStore !== null) {
15422
15892
  await this.poolStateStore.load();
15423
15893
  await this.applyConfiguredPoolBounds();
15894
+ this.redriveInterruptedInstalls();
15424
15895
  }
15425
15896
  for (const resolver of this.localResolvers.values()) {
15426
15897
  await resolver.start();
15427
15898
  }
15899
+ if (this.poolRefreshListener === null && this.localResolvers.size > 0) {
15900
+ const listener = () => {
15901
+ for (const resolver of this.localResolvers.values()) {
15902
+ resolver.refresh();
15903
+ }
15904
+ };
15905
+ this.poolRefreshListener = listener;
15906
+ this.on("local-models:pool", listener);
15907
+ }
15428
15908
  }
15429
15909
  this.startRefreshScheduler();
15910
+ if (this.modelPool !== null) {
15911
+ void this.refreshCandidatesLive().catch(
15912
+ (err) => this.logger.warn("LMLM startup candidate refresh failed", { cause: err })
15913
+ );
15914
+ }
15430
15915
  this.pipeline = this.createIntelligencePipeline();
15431
15916
  this.server?.setPipeline(this.pipeline);
15432
15917
  }
@@ -15499,6 +15984,10 @@ ${messages}`);
15499
15984
  this.localModelStatusUnsubscribes = [];
15500
15985
  this.routingDecisionBus?.clearListeners();
15501
15986
  this.routingDecisionBus = null;
15987
+ if (this.poolRefreshListener !== null) {
15988
+ this.removeListener("local-models:pool", this.poolRefreshListener);
15989
+ this.poolRefreshListener = null;
15990
+ }
15502
15991
  for (const resolver of this.localResolvers.values()) {
15503
15992
  resolver.stop();
15504
15993
  }
@@ -16491,6 +16980,9 @@ function buildArchiveHooks(opts) {
16491
16980
  }
16492
16981
  };
16493
16982
  }
16983
+
16984
+ // src/index.ts
16985
+ var import_local_models6 = require("@harness-engineering/local-models");
16494
16986
  // Annotate the CommonJS export names for ESM import in node:
16495
16987
  0 && (module.exports = {
16496
16988
  AnalysisArchive,
@@ -16546,6 +17038,7 @@ function buildArchiveHooks(opts) {
16546
17038
  defaultFetchModels,
16547
17039
  deriveSeedPaths,
16548
17040
  detectScopeTier,
17041
+ discoverCandidates,
16549
17042
  discoverSkillCatalog,
16550
17043
  discoverSkillCatalogNames,
16551
17044
  emitProposalApproved,