@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.mjs CHANGED
@@ -3767,8 +3767,19 @@ var AgentRunner = class {
3767
3767
 
3768
3768
  // src/agent/local-model-resolver.ts
3769
3769
  import { poolStateToCandidates } from "@harness-engineering/local-models";
3770
+ function useCaseToProfile(useCase) {
3771
+ switch (useCase.kind) {
3772
+ case "tier":
3773
+ return useCase.tier === "diagnostic" ? "reasoning" : "coding";
3774
+ default:
3775
+ return "general";
3776
+ }
3777
+ }
3770
3778
  var DEFAULT_PROBE_INTERVAL_MS = 3e4;
3771
3779
  var MIN_PROBE_INTERVAL_MS = 1e3;
3780
+ var REFRESH_DEBOUNCE_MS = 250;
3781
+ var DEFAULT_BREAKER_THRESHOLD = 3;
3782
+ var DEFAULT_BREAKER_COOLDOWN_MS = 6e4;
3772
3783
  var DEFAULT_API_KEY = "lm-studio";
3773
3784
  var DEFAULT_FETCH_TIMEOUT_MS = 5e3;
3774
3785
  function normalizeLocalModel(input) {
@@ -3819,6 +3830,44 @@ async function defaultFetchModels(endpoint, apiKey, timeoutMs = DEFAULT_FETCH_TI
3819
3830
  }
3820
3831
  return ids;
3821
3832
  }
3833
+ async function defaultWarmModel(endpoint, ollamaName, apiKey, keepAlive = "10m", timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
3834
+ const base = endpoint.replace(/\/v1\/?$/, "").replace(/\/$/, "");
3835
+ const url = `${base}/api/generate`;
3836
+ try {
3837
+ await fetch(url, {
3838
+ method: "POST",
3839
+ headers: {
3840
+ "Content-Type": "application/json",
3841
+ Authorization: `Bearer ${apiKey ?? DEFAULT_API_KEY}`
3842
+ },
3843
+ // Empty prompt + keep_alive loads the model into VRAM without generating.
3844
+ body: JSON.stringify({ model: ollamaName, prompt: "", keep_alive: keepAlive }),
3845
+ signal: AbortSignal.timeout(timeoutMs)
3846
+ });
3847
+ } catch {
3848
+ }
3849
+ }
3850
+ async function defaultWarmModelViaCompletion(endpoint, model, apiKey, timeoutMs = DEFAULT_FETCH_TIMEOUT_MS) {
3851
+ const url = `${endpoint.replace(/\/$/, "")}/chat/completions`;
3852
+ try {
3853
+ await fetch(url, {
3854
+ method: "POST",
3855
+ headers: {
3856
+ "Content-Type": "application/json",
3857
+ Authorization: `Bearer ${apiKey ?? DEFAULT_API_KEY}`
3858
+ },
3859
+ // Minimal request: one token is enough to force the server to load the
3860
+ // model. The generated content is discarded — we only want the load.
3861
+ body: JSON.stringify({
3862
+ model,
3863
+ max_tokens: 1,
3864
+ messages: [{ role: "user", content: "warm" }]
3865
+ }),
3866
+ signal: AbortSignal.timeout(timeoutMs)
3867
+ });
3868
+ } catch {
3869
+ }
3870
+ }
3822
3871
  var LocalModelResolver = class {
3823
3872
  endpoint;
3824
3873
  apiKey;
@@ -3826,8 +3875,21 @@ var LocalModelResolver = class {
3826
3875
  poolState;
3827
3876
  probeIntervalMs;
3828
3877
  fetchModels;
3878
+ warmModel;
3829
3879
  logger;
3830
3880
  timer = null;
3881
+ /** Debounce handle for event-driven {@link refresh}; coalesces a burst into one probe. */
3882
+ refreshTimer = null;
3883
+ /** Test seam for the refresh debounce delay (0 in tests → next-tick probe). */
3884
+ refreshDebounceMs;
3885
+ /** Consumption Phase 3 (T10): circuit-breaker config + per-model failure/trip state. */
3886
+ breakerThreshold;
3887
+ breakerCooldownMs;
3888
+ now;
3889
+ /** Consecutive inference failures per model since its last success. */
3890
+ consecutiveFailures = /* @__PURE__ */ new Map();
3891
+ /** Epoch-ms at which a tripped model becomes eligible again (cooldown expiry). */
3892
+ trippedUntil = /* @__PURE__ */ new Map();
3831
3893
  listeners = /* @__PURE__ */ new Set();
3832
3894
  /**
3833
3895
  * Tracks an in-flight probe so concurrent invocations (interval tick while a
@@ -3857,12 +3919,28 @@ var LocalModelResolver = class {
3857
3919
  }
3858
3920
  const interval = opts.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
3859
3921
  this.probeIntervalMs = Math.max(MIN_PROBE_INTERVAL_MS, interval);
3922
+ this.refreshDebounceMs = Math.max(0, opts.refreshDebounceMs ?? REFRESH_DEBOUNCE_MS);
3923
+ this.breakerThreshold = Math.max(1, opts.breakerThreshold ?? DEFAULT_BREAKER_THRESHOLD);
3924
+ this.breakerCooldownMs = Math.max(0, opts.breakerCooldownMs ?? DEFAULT_BREAKER_COOLDOWN_MS);
3925
+ this.now = opts.now ?? (() => Date.now());
3860
3926
  const timeoutMs = opts.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
3861
3927
  this.fetchModels = opts.fetchModels ?? ((endpoint, apiKey) => defaultFetchModels(endpoint, apiKey, timeoutMs));
3928
+ if (opts.warmModel !== void 0) this.warmModel = opts.warmModel;
3862
3929
  this.logger = opts.logger ?? noopLogger;
3863
3930
  }
3864
- resolveModel() {
3865
- return this.resolved;
3931
+ /**
3932
+ * The model to dispatch to. With no `useCase`, returns the cached composite
3933
+ * resolution from the last probe (byte-identical to the pre-Phase-4 resolver).
3934
+ * With a `useCase`, orders the (pool-derived) candidates by that use-case's
3935
+ * task profile and picks the best loaded, breaker-healthy one — so a
3936
+ * coding-tagged dispatch prefers the coding specialist. A `general`-mapped
3937
+ * use-case returns the cached composite resolution unchanged.
3938
+ */
3939
+ resolveModel(useCase) {
3940
+ if (useCase === void 0) return this.resolved;
3941
+ const profile = useCaseToProfile(useCase);
3942
+ if (profile === "general") return this.resolved;
3943
+ return this.selectMatch(this.candidates(profile), this.detected);
3866
3944
  }
3867
3945
  getStatus() {
3868
3946
  return {
@@ -3880,8 +3958,8 @@ var LocalModelResolver = class {
3880
3958
  * from pool entries (currentScore desc → ollamaName); otherwise the static
3881
3959
  * `configured` list is returned unchanged (byte-identical to pre-Phase-4).
3882
3960
  */
3883
- candidates() {
3884
- return this.poolState ? poolStateToCandidates(this.poolState.snapshot()) : [...this.configured];
3961
+ candidates(profile) {
3962
+ return this.poolState ? poolStateToCandidates(this.poolState.snapshot(), profile) : [...this.configured];
3885
3963
  }
3886
3964
  onStatusChange(handler) {
3887
3965
  this.listeners.add(handler);
@@ -3901,13 +3979,14 @@ var LocalModelResolver = class {
3901
3979
  }
3902
3980
  async runProbe() {
3903
3981
  const before = this.snapshotForDiff();
3982
+ const prevResolved = this.resolved;
3904
3983
  try {
3905
3984
  const detected = await this.fetchModels(this.endpoint, this.apiKey);
3906
3985
  this.detected = [...detected];
3907
3986
  this.lastError = null;
3908
3987
  this.lastProbeAt = (/* @__PURE__ */ new Date()).toISOString();
3909
3988
  const candidates = this.candidates();
3910
- const match = candidates.find((id) => detected.includes(id)) ?? null;
3989
+ const match = this.selectMatch(candidates, detected);
3911
3990
  this.resolved = match;
3912
3991
  this.available = match !== null;
3913
3992
  this.warnings = match ? [] : [
@@ -3937,8 +4016,92 @@ var LocalModelResolver = class {
3937
4016
  }
3938
4017
  }
3939
4018
  }
4019
+ if (this.resolved !== null && this.resolved !== prevResolved) {
4020
+ this.warm(this.resolved);
4021
+ }
3940
4022
  return status;
3941
4023
  }
4024
+ /** Best-effort warm-hook invocation — a throwing warm never breaks a probe. */
4025
+ warm(ollamaName) {
4026
+ if (!this.warmModel) return;
4027
+ try {
4028
+ this.warmModel(ollamaName);
4029
+ } catch (err) {
4030
+ this.logger.warn("local-model-resolver warm hook threw", {
4031
+ error: err instanceof Error ? err.message : String(err)
4032
+ });
4033
+ }
4034
+ }
4035
+ /**
4036
+ * Consumption Phase 3 (T10): pick the resolved model from the candidates that
4037
+ * are actually loaded, preferring ones whose circuit breaker is not tripped.
4038
+ * Candidate order (score-desc from the pool) is preserved within each tier, so
4039
+ * a tripped top pick sinks below a healthy lower pick but still resolves as a
4040
+ * last resort when every loaded candidate is tripped (better a flaky model than
4041
+ * none). Returns null when no candidate is loaded.
4042
+ */
4043
+ selectMatch(candidates, detected) {
4044
+ const detectedSet = new Set(detected);
4045
+ const available = candidates.filter((id) => detectedSet.has(id));
4046
+ if (available.length === 0) return null;
4047
+ const healthy = available.filter((id) => !this.isTripped(id));
4048
+ return healthy[0] ?? available[0] ?? null;
4049
+ }
4050
+ /**
4051
+ * Whether `model`'s circuit breaker is currently tripped. Lazily clears the
4052
+ * trip once its cooldown has elapsed so the model becomes eligible again on the
4053
+ * next resolution without needing a separate timer.
4054
+ */
4055
+ isTripped(model) {
4056
+ const until = this.trippedUntil.get(model);
4057
+ if (until === void 0) return false;
4058
+ if (this.now() >= until) {
4059
+ this.trippedUntil.delete(model);
4060
+ this.consecutiveFailures.delete(model);
4061
+ return false;
4062
+ }
4063
+ return true;
4064
+ }
4065
+ /**
4066
+ * Record a successful inference on `model`: clears its failure count and any
4067
+ * active trip so a recovered model is immediately re-preferred.
4068
+ */
4069
+ recordSuccess(model) {
4070
+ if (!model) return;
4071
+ const hadState = this.consecutiveFailures.has(model) || this.trippedUntil.has(model);
4072
+ this.consecutiveFailures.delete(model);
4073
+ this.trippedUntil.delete(model);
4074
+ if (hadState) this.refresh();
4075
+ }
4076
+ /**
4077
+ * Record a failed inference on `model`. On the Nth consecutive failure the
4078
+ * breaker trips (deprioritizing the model for `breakerCooldownMs`) and a
4079
+ * re-probe is scheduled so the resolver rolls to a healthy alternative.
4080
+ */
4081
+ recordFailure(model) {
4082
+ if (!model) return;
4083
+ const next = (this.consecutiveFailures.get(model) ?? 0) + 1;
4084
+ this.consecutiveFailures.set(model, next);
4085
+ if (next >= this.breakerThreshold) {
4086
+ this.trippedUntil.set(model, this.now() + this.breakerCooldownMs);
4087
+ this.refresh();
4088
+ }
4089
+ }
4090
+ /**
4091
+ * Event-driven refresh: schedule a debounced re-probe. Called when a
4092
+ * `local-models:pool` mutation fires so a just-installed/swapped model becomes
4093
+ * usable within a debounce window instead of waiting up to `probeIntervalMs`.
4094
+ * A burst of frames coalesces into one probe (the timer is only armed once).
4095
+ */
4096
+ refresh() {
4097
+ if (this.refreshTimer !== null) return;
4098
+ this.refreshTimer = setTimeout(() => {
4099
+ this.refreshTimer = null;
4100
+ void this.probe();
4101
+ }, this.refreshDebounceMs);
4102
+ const handle = this.refreshTimer;
4103
+ handle.unref?.();
4104
+ }
3942
4105
  async start() {
3943
4106
  if (this.timer !== null) {
3944
4107
  return;
@@ -3955,6 +4118,10 @@ var LocalModelResolver = class {
3955
4118
  clearInterval(this.timer);
3956
4119
  this.timer = null;
3957
4120
  }
4121
+ if (this.refreshTimer !== null) {
4122
+ clearTimeout(this.refreshTimer);
4123
+ this.refreshTimer = null;
4124
+ }
3958
4125
  }
3959
4126
  snapshotForDiff() {
3960
4127
  return JSON.stringify({
@@ -3978,9 +4145,219 @@ import {
3978
4145
  runRefreshTick,
3979
4146
  createNativeRecommender,
3980
4147
  loadFrozenCandidates,
3981
- selectCandidates as selectCandidates2
4148
+ selectCandidates as selectCandidates2,
4149
+ curationFromCandidates
3982
4150
  } from "@harness-engineering/local-models";
3983
- import { createModelProposal as createModelProposal2, listProposals as listProposals2 } from "@harness-engineering/core";
4151
+ import { createModelProposal as createModelProposal2, listProposals as listProposals2, updateProposal as updateProposal5 } from "@harness-engineering/core";
4152
+
4153
+ // src/proposals/model-handlers.ts
4154
+ function isInUse(deps, ollamaName) {
4155
+ return deps.isModelInUse ? deps.isModelInUse(ollamaName) : false;
4156
+ }
4157
+ var MODEL_PROPOSAL_TOPIC = "local-models:proposal";
4158
+ var MODEL_POOL_TOPIC = "local-models:pool";
4159
+ var MODEL_INSTALL_TOPIC = "local-models:install";
4160
+ function makeInstallProgressForwarder(bus, base) {
4161
+ const emit4 = (phase, patch = {}) => {
4162
+ const frame = { ...base, ...patch, phase };
4163
+ bus.emit(MODEL_INSTALL_TOPIC, frame);
4164
+ };
4165
+ const onInstallEvent = (event) => {
4166
+ if (event.kind === "progress") {
4167
+ emit4("progress", {
4168
+ completedBytes: event.completedBytes,
4169
+ totalBytes: event.totalBytes,
4170
+ ...event.message !== void 0 ? { message: event.message } : {}
4171
+ });
4172
+ } else if (event.kind === "pulling") {
4173
+ emit4("progress", { message: event.message });
4174
+ }
4175
+ };
4176
+ return { emit: emit4, onInstallEvent };
4177
+ }
4178
+ function decisionOf(deps, action, reason) {
4179
+ return {
4180
+ decidedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
4181
+ decidedBy: deps.decidedBy ?? "orchestrator",
4182
+ action,
4183
+ ...reason !== void 0 ? { reason } : {}
4184
+ };
4185
+ }
4186
+ function emitApproved(deps, proposal) {
4187
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
4188
+ id: proposal.id,
4189
+ status: "approved",
4190
+ action: proposal.model.action,
4191
+ target: proposal.model.target.ollamaName
4192
+ });
4193
+ }
4194
+ async function deferEviction(deps, proposal, deferredName, event, evicted) {
4195
+ deps.pool.markPendingEviction(deferredName);
4196
+ const updated = await deps.updateProposal(proposal.id, {
4197
+ status: "approved",
4198
+ decision: decisionOf(deps, "approved")
4199
+ });
4200
+ deps.bus.emit(MODEL_POOL_TOPIC, event);
4201
+ emitApproved(deps, proposal);
4202
+ return { status: "approved", proposal: updated, evicted };
4203
+ }
4204
+ async function onApproveModelProposal(deps, proposal) {
4205
+ const { model } = proposal;
4206
+ if (model.action === "evict") {
4207
+ return applyEvictOnly(deps, proposal);
4208
+ }
4209
+ await deps.updateProposal(proposal.id, { status: "installing" });
4210
+ const installResult = await deps.pool.install({
4211
+ hfRepoId: model.target.hfRepoId,
4212
+ ollamaName: model.target.ollamaName,
4213
+ ...model.replaces !== void 0 ? { replaces: model.replaces.ollamaName } : {},
4214
+ ...model.diskImpactGb > 0 ? { sizeOnDiskGb: model.diskImpactGb } : {},
4215
+ ...model.targetScore !== void 0 ? { initialScore: model.targetScore } : {},
4216
+ ...deps.onInstallEvent ? { onEvent: deps.onInstallEvent } : {}
4217
+ });
4218
+ if (installResult.status === "error") {
4219
+ if (installResult.code === "failed_target_missing") {
4220
+ const updated2 = await deps.updateProposal(proposal.id, {
4221
+ status: "failed_target_missing"
4222
+ });
4223
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
4224
+ id: proposal.id,
4225
+ status: "failed_target_missing",
4226
+ action: model.action,
4227
+ target: model.target.ollamaName
4228
+ });
4229
+ return { status: "failed_target_missing", proposal: updated2 };
4230
+ }
4231
+ await deps.updateProposal(proposal.id, { status: "open" });
4232
+ return { status: "error", code: installResult.code, message: installResult.message };
4233
+ }
4234
+ const evicted = [...installResult.evicted];
4235
+ if (model.action === "swap" && model.replaces !== void 0) {
4236
+ const replacesName = model.replaces.ollamaName;
4237
+ if (isInUse(deps, replacesName)) {
4238
+ return deferEviction(
4239
+ deps,
4240
+ proposal,
4241
+ replacesName,
4242
+ {
4243
+ id: proposal.id,
4244
+ action: model.action,
4245
+ installed: model.target.ollamaName,
4246
+ phase: "evict_deferred",
4247
+ deferred: replacesName,
4248
+ ...installResult.evicted.length > 0 ? { evicted: installResult.evicted.map((e) => e.ollamaName) } : {}
4249
+ },
4250
+ evicted
4251
+ );
4252
+ }
4253
+ const evictResult = await deps.pool.evict({ ollamaName: replacesName });
4254
+ if (evictResult.status === "error") {
4255
+ deps.bus.emit(MODEL_POOL_TOPIC, {
4256
+ id: proposal.id,
4257
+ action: model.action,
4258
+ installed: model.target.ollamaName,
4259
+ phase: "swap_evict_failed"
4260
+ });
4261
+ await deps.updateProposal(proposal.id, { status: "open" });
4262
+ return { status: "error", code: evictResult.code, message: evictResult.message };
4263
+ }
4264
+ if (evictResult.removed !== null) {
4265
+ evicted.push(evictResult.removed);
4266
+ }
4267
+ }
4268
+ const updated = await deps.updateProposal(proposal.id, {
4269
+ status: "approved",
4270
+ decision: decisionOf(deps, "approved")
4271
+ });
4272
+ const evictedNames = [
4273
+ ...installResult.evicted.map((e) => e.ollamaName),
4274
+ ...model.replaces !== void 0 ? [model.replaces.ollamaName] : []
4275
+ ];
4276
+ deps.bus.emit(MODEL_POOL_TOPIC, {
4277
+ id: proposal.id,
4278
+ action: model.action,
4279
+ installed: model.target.ollamaName,
4280
+ ...evictedNames.length > 0 ? { evicted: evictedNames } : {}
4281
+ });
4282
+ emitApproved(deps, proposal);
4283
+ return { status: "approved", proposal: updated, evicted };
4284
+ }
4285
+ async function applyEvictOnly(deps, proposal) {
4286
+ const targetName = proposal.model.target.ollamaName;
4287
+ if (isInUse(deps, targetName)) {
4288
+ return deferEviction(
4289
+ deps,
4290
+ proposal,
4291
+ targetName,
4292
+ { id: proposal.id, action: "evict", phase: "evict_deferred", deferred: targetName },
4293
+ []
4294
+ );
4295
+ }
4296
+ const evictResult = await deps.pool.evict({ ollamaName: targetName });
4297
+ if (evictResult.status === "error") {
4298
+ return { status: "error", code: evictResult.code, message: evictResult.message };
4299
+ }
4300
+ const updated = await deps.updateProposal(proposal.id, {
4301
+ status: "approved",
4302
+ decision: decisionOf(deps, "approved")
4303
+ });
4304
+ deps.bus.emit(MODEL_POOL_TOPIC, {
4305
+ id: proposal.id,
4306
+ action: "evict",
4307
+ // XP-2: `evicted` is string[] at EVERY local-models:pool emit site (swap/add
4308
+ // list multiple removals) — the evict-only path wraps its single removal in
4309
+ // an array too, so consumers never branch on string-vs-array.
4310
+ evicted: [proposal.model.target.ollamaName]
4311
+ });
4312
+ emitApproved(deps, proposal);
4313
+ return {
4314
+ status: "approved",
4315
+ proposal: updated,
4316
+ evicted: evictResult.removed !== null && evictResult.removed !== void 0 ? [evictResult.removed] : []
4317
+ };
4318
+ }
4319
+ async function onRejectModelProposal(deps, proposal, reason) {
4320
+ const updated = await deps.updateProposal(proposal.id, {
4321
+ status: "rejected",
4322
+ decision: decisionOf(deps, "rejected", reason)
4323
+ });
4324
+ deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
4325
+ id: proposal.id,
4326
+ status: "rejected",
4327
+ target: proposal.model.target.ollamaName,
4328
+ replaces: proposal.model.replaces?.ollamaName,
4329
+ reason
4330
+ });
4331
+ return updated;
4332
+ }
4333
+ async function redriveInstallingProposals(deps, proposals, opts = {}) {
4334
+ for (const proposal of proposals) {
4335
+ if (proposal.status !== "installing") continue;
4336
+ if (proposal.model.action === "evict") continue;
4337
+ const { emit: emit4, onInstallEvent } = makeInstallProgressForwarder(deps.bus, {
4338
+ proposalId: proposal.id,
4339
+ hfRepoId: proposal.model.target.hfRepoId,
4340
+ ollamaName: proposal.model.target.ollamaName
4341
+ });
4342
+ emit4("started");
4343
+ try {
4344
+ const outcome = await onApproveModelProposal({ ...deps, onInstallEvent }, proposal);
4345
+ if (outcome.status === "approved") {
4346
+ emit4("complete");
4347
+ } else if (outcome.status === "failed_target_missing") {
4348
+ emit4("error", {
4349
+ code: "failed_target_missing",
4350
+ message: `${proposal.model.target.hfRepoId} is no longer available on HuggingFace`
4351
+ });
4352
+ } else {
4353
+ emit4("error", { code: outcome.code, message: outcome.message });
4354
+ }
4355
+ } catch (err) {
4356
+ opts.onWarn?.("re-drive of interrupted install failed", err);
4357
+ emit4("error", { message: err instanceof Error ? err.message : String(err) });
4358
+ }
4359
+ }
4360
+ }
3984
4361
 
3985
4362
  // src/agent/config-migration.ts
3986
4363
  var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
@@ -5039,6 +5416,8 @@ var LocalBackend = class {
5039
5416
  name = "local";
5040
5417
  config;
5041
5418
  getModel;
5419
+ onModelUsed;
5420
+ onModelFailed;
5042
5421
  client;
5043
5422
  constructor(config = {}) {
5044
5423
  this.config = {
@@ -5048,6 +5427,8 @@ var LocalBackend = class {
5048
5427
  timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS
5049
5428
  };
5050
5429
  this.getModel = config.getModel;
5430
+ this.onModelUsed = config.onModelUsed;
5431
+ this.onModelFailed = config.onModelFailed;
5051
5432
  this.client = new OpenAI2({
5052
5433
  apiKey: this.config.apiKey,
5053
5434
  baseURL: this.config.endpoint,
@@ -5114,6 +5495,7 @@ var LocalBackend = class {
5114
5495
  }
5115
5496
  } catch (err) {
5116
5497
  const errorMessage2 = err instanceof Error ? err.message : "Local backend request failed";
5498
+ this.notify(this.onModelFailed, localSession.resolvedModel);
5117
5499
  yield {
5118
5500
  type: "error",
5119
5501
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -5127,6 +5509,7 @@ var LocalBackend = class {
5127
5509
  error: errorMessage2
5128
5510
  };
5129
5511
  }
5512
+ this.notify(this.onModelUsed, localSession.resolvedModel);
5130
5513
  const usage = { inputTokens, outputTokens, totalTokens };
5131
5514
  yield {
5132
5515
  type: "usage",
@@ -5140,6 +5523,14 @@ var LocalBackend = class {
5140
5523
  usage
5141
5524
  };
5142
5525
  }
5526
+ /** Best-effort telemetry hook invocation — a throwing hook never breaks a turn. */
5527
+ notify(hook, model) {
5528
+ if (!hook) return;
5529
+ try {
5530
+ hook(model);
5531
+ } catch {
5532
+ }
5533
+ }
5143
5534
  async stopSession(_session) {
5144
5535
  return Ok14(void 0);
5145
5536
  }
@@ -5304,7 +5695,8 @@ var PiBackend = class {
5304
5695
  backendName: this.name,
5305
5696
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
5306
5697
  piSession,
5307
- unsubscribe: null
5698
+ unsubscribe: null,
5699
+ ...resolvedModelName !== void 0 && { resolvedModel: resolvedModelName }
5308
5700
  };
5309
5701
  return Ok15(session);
5310
5702
  } catch (err) {
@@ -5396,7 +5788,9 @@ var PiBackend = class {
5396
5788
  }
5397
5789
  }
5398
5790
  const totalTokens = inputTokens + outputTokens;
5791
+ const resolvedModel = session.resolvedModel;
5399
5792
  if (promptErrorMsg) {
5793
+ if (resolvedModel !== void 0) this.notify(this.config.onModelFailed, resolvedModel);
5400
5794
  return {
5401
5795
  success: false,
5402
5796
  sessionId: session.sessionId,
@@ -5404,12 +5798,21 @@ var PiBackend = class {
5404
5798
  usage: { inputTokens, outputTokens, totalTokens }
5405
5799
  };
5406
5800
  }
5801
+ if (resolvedModel !== void 0) this.notify(this.config.onModelUsed, resolvedModel);
5407
5802
  return {
5408
5803
  success: true,
5409
5804
  sessionId: session.sessionId,
5410
5805
  usage: { inputTokens, outputTokens, totalTokens }
5411
5806
  };
5412
5807
  }
5808
+ /** Best-effort telemetry hook invocation — a throwing hook never breaks a turn. */
5809
+ notify(hook, model) {
5810
+ if (!hook) return;
5811
+ try {
5812
+ hook(model);
5813
+ } catch {
5814
+ }
5815
+ }
5413
5816
  /**
5414
5817
  * Consume events from the queue, yielding mapped AgentEvents until agent_end or prompt completion.
5415
5818
  */
@@ -6519,8 +6922,9 @@ var OrchestratorBackendFactory = class {
6519
6922
  let backend;
6520
6923
  const createOpts = this.opts.cacheMetrics ? { cacheMetrics: this.opts.cacheMetrics } : {};
6521
6924
  if ((def.type === "local" || def.type === "pi") && this.opts.getResolverModelFor) {
6522
- const getModel = this.opts.getResolverModelFor(name);
6523
- backend = getModel ? this.buildLocalLikeWithResolver(def, getModel) : createBackend(def, createOpts);
6925
+ const getModel = this.opts.getResolverModelFor(name, useCase);
6926
+ const usageHooks = this.opts.getModelUsageHooksFor?.(name);
6927
+ backend = getModel ? this.buildLocalLikeWithResolver(def, getModel, usageHooks) : createBackend(def, createOpts);
6524
6928
  } else {
6525
6929
  backend = createBackend(def, createOpts);
6526
6930
  }
@@ -6534,13 +6938,15 @@ var OrchestratorBackendFactory = class {
6534
6938
  * mirroring `createBackend`'s local/pi branches but substituting the
6535
6939
  * head-of-array placeholder with the orchestrator-owned resolver.
6536
6940
  */
6537
- buildLocalLikeWithResolver(def, getModel) {
6941
+ buildLocalLikeWithResolver(def, getModel, usageHooks) {
6538
6942
  if (def.type === "local") {
6539
6943
  return new LocalBackend({
6540
6944
  endpoint: def.endpoint,
6541
6945
  getModel,
6542
6946
  ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
6543
- ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
6947
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {},
6948
+ ...usageHooks?.onModelUsed !== void 0 ? { onModelUsed: usageHooks.onModelUsed } : {},
6949
+ ...usageHooks?.onModelFailed !== void 0 ? { onModelFailed: usageHooks.onModelFailed } : {}
6544
6950
  });
6545
6951
  }
6546
6952
  if (def.type === "pi") {
@@ -6548,7 +6954,9 @@ var OrchestratorBackendFactory = class {
6548
6954
  endpoint: def.endpoint,
6549
6955
  getModel,
6550
6956
  ...def.apiKey !== void 0 ? { apiKey: def.apiKey } : {},
6551
- ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {}
6957
+ ...def.timeoutMs !== void 0 ? { timeoutMs: def.timeoutMs } : {},
6958
+ ...usageHooks?.onModelUsed !== void 0 ? { onModelUsed: usageHooks.onModelUsed } : {},
6959
+ ...usageHooks?.onModelFailed !== void 0 ? { onModelFailed: usageHooks.onModelFailed } : {}
6552
6960
  });
6553
6961
  }
6554
6962
  throw new Error(
@@ -6722,6 +7130,9 @@ function buildLocalLikeProvider(def, args, layerModel) {
6722
7130
  apiKey,
6723
7131
  baseUrl: def.endpoint,
6724
7132
  ...model !== void 0 && { defaultModel: model },
7133
+ ...layerModel === void 0 && {
7134
+ getModel: () => getResolverStatusSnapshot()?.resolved ?? void 0
7135
+ },
6725
7136
  ...intelligence?.requestTimeoutMs !== void 0 && {
6726
7137
  timeoutMs: intelligence.requestTimeoutMs
6727
7138
  },
@@ -8561,163 +8972,6 @@ function emitProposalRejected(bus, proposal) {
8561
8972
  emit3(bus, "proposal.rejected", data);
8562
8973
  }
8563
8974
 
8564
- // src/proposals/model-handlers.ts
8565
- function isInUse(deps, ollamaName) {
8566
- return deps.isModelInUse ? deps.isModelInUse(ollamaName) : false;
8567
- }
8568
- var MODEL_PROPOSAL_TOPIC = "local-models:proposal";
8569
- var MODEL_POOL_TOPIC = "local-models:pool";
8570
- function decisionOf(deps, action, reason) {
8571
- return {
8572
- decidedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
8573
- decidedBy: deps.decidedBy ?? "orchestrator",
8574
- action,
8575
- ...reason !== void 0 ? { reason } : {}
8576
- };
8577
- }
8578
- function emitApproved(deps, proposal) {
8579
- deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8580
- id: proposal.id,
8581
- status: "approved",
8582
- action: proposal.model.action,
8583
- target: proposal.model.target.ollamaName
8584
- });
8585
- }
8586
- async function deferEviction(deps, proposal, deferredName, event, evicted) {
8587
- deps.pool.markPendingEviction(deferredName);
8588
- const updated = await deps.updateProposal(proposal.id, {
8589
- status: "approved",
8590
- decision: decisionOf(deps, "approved")
8591
- });
8592
- deps.bus.emit(MODEL_POOL_TOPIC, event);
8593
- emitApproved(deps, proposal);
8594
- return { status: "approved", proposal: updated, evicted };
8595
- }
8596
- async function onApproveModelProposal(deps, proposal) {
8597
- const { model } = proposal;
8598
- if (model.action === "evict") {
8599
- return applyEvictOnly(deps, proposal);
8600
- }
8601
- const installResult = await deps.pool.install({
8602
- hfRepoId: model.target.hfRepoId,
8603
- ollamaName: model.target.ollamaName,
8604
- ...model.replaces !== void 0 ? { replaces: model.replaces.ollamaName } : {},
8605
- ...model.diskImpactGb > 0 ? { sizeOnDiskGb: model.diskImpactGb } : {}
8606
- });
8607
- if (installResult.status === "error") {
8608
- if (installResult.code === "failed_target_missing") {
8609
- const updated2 = await deps.updateProposal(proposal.id, {
8610
- status: "failed_target_missing"
8611
- });
8612
- deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8613
- id: proposal.id,
8614
- status: "failed_target_missing",
8615
- action: model.action,
8616
- target: model.target.ollamaName
8617
- });
8618
- return { status: "failed_target_missing", proposal: updated2 };
8619
- }
8620
- return { status: "error", code: installResult.code, message: installResult.message };
8621
- }
8622
- const evicted = [...installResult.evicted];
8623
- if (model.action === "swap" && model.replaces !== void 0) {
8624
- const replacesName = model.replaces.ollamaName;
8625
- if (isInUse(deps, replacesName)) {
8626
- return deferEviction(
8627
- deps,
8628
- proposal,
8629
- replacesName,
8630
- {
8631
- id: proposal.id,
8632
- action: model.action,
8633
- installed: model.target.ollamaName,
8634
- phase: "evict_deferred",
8635
- deferred: replacesName,
8636
- ...installResult.evicted.length > 0 ? { evicted: installResult.evicted.map((e) => e.ollamaName) } : {}
8637
- },
8638
- evicted
8639
- );
8640
- }
8641
- const evictResult = await deps.pool.evict({ ollamaName: replacesName });
8642
- if (evictResult.status === "error") {
8643
- deps.bus.emit(MODEL_POOL_TOPIC, {
8644
- id: proposal.id,
8645
- action: model.action,
8646
- installed: model.target.ollamaName,
8647
- phase: "swap_evict_failed"
8648
- });
8649
- return { status: "error", code: evictResult.code, message: evictResult.message };
8650
- }
8651
- if (evictResult.removed !== null) {
8652
- evicted.push(evictResult.removed);
8653
- }
8654
- }
8655
- const updated = await deps.updateProposal(proposal.id, {
8656
- status: "approved",
8657
- decision: decisionOf(deps, "approved")
8658
- });
8659
- const evictedNames = [
8660
- ...installResult.evicted.map((e) => e.ollamaName),
8661
- ...model.replaces !== void 0 ? [model.replaces.ollamaName] : []
8662
- ];
8663
- deps.bus.emit(MODEL_POOL_TOPIC, {
8664
- id: proposal.id,
8665
- action: model.action,
8666
- installed: model.target.ollamaName,
8667
- ...evictedNames.length > 0 ? { evicted: evictedNames } : {}
8668
- });
8669
- emitApproved(deps, proposal);
8670
- return { status: "approved", proposal: updated, evicted };
8671
- }
8672
- async function applyEvictOnly(deps, proposal) {
8673
- const targetName = proposal.model.target.ollamaName;
8674
- if (isInUse(deps, targetName)) {
8675
- return deferEviction(
8676
- deps,
8677
- proposal,
8678
- targetName,
8679
- { id: proposal.id, action: "evict", phase: "evict_deferred", deferred: targetName },
8680
- []
8681
- );
8682
- }
8683
- const evictResult = await deps.pool.evict({ ollamaName: targetName });
8684
- if (evictResult.status === "error") {
8685
- return { status: "error", code: evictResult.code, message: evictResult.message };
8686
- }
8687
- const updated = await deps.updateProposal(proposal.id, {
8688
- status: "approved",
8689
- decision: decisionOf(deps, "approved")
8690
- });
8691
- deps.bus.emit(MODEL_POOL_TOPIC, {
8692
- id: proposal.id,
8693
- action: "evict",
8694
- // XP-2: `evicted` is string[] at EVERY local-models:pool emit site (swap/add
8695
- // list multiple removals) — the evict-only path wraps its single removal in
8696
- // an array too, so consumers never branch on string-vs-array.
8697
- evicted: [proposal.model.target.ollamaName]
8698
- });
8699
- emitApproved(deps, proposal);
8700
- return {
8701
- status: "approved",
8702
- proposal: updated,
8703
- evicted: evictResult.removed !== null && evictResult.removed !== void 0 ? [evictResult.removed] : []
8704
- };
8705
- }
8706
- async function onRejectModelProposal(deps, proposal, reason) {
8707
- const updated = await deps.updateProposal(proposal.id, {
8708
- status: "rejected",
8709
- decision: decisionOf(deps, "rejected", reason)
8710
- });
8711
- deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
8712
- id: proposal.id,
8713
- status: "rejected",
8714
- target: proposal.model.target.ollamaName,
8715
- replaces: proposal.model.replaces?.ollamaName,
8716
- reason
8717
- });
8718
- return updated;
8719
- }
8720
-
8721
8975
  // src/server/routes/v1/proposals.ts
8722
8976
  var LIST_RE = /^\/api\/v1\/proposals(?:\?.*)?$/;
8723
8977
  var SINGLE_RE = /^\/api\/v1\/proposals\/([^/?]+)(?:\?.*)?$/;
@@ -8811,12 +9065,38 @@ async function handleApprove(req, res, deps, id) {
8811
9065
  sendJSON8(res, 501, { error: "model proposal handlers not configured" });
8812
9066
  return;
8813
9067
  }
8814
- if (existing.status === "approved" || existing.status === "rejected" || existing.status === "failed_target_missing") {
9068
+ if (existing.status === "approved" || existing.status === "rejected" || existing.status === "failed_target_missing" || existing.status === "installing") {
8815
9069
  sendJSON8(res, 409, {
8816
9070
  error: `proposal already ${existing.status}; cannot approve`
8817
9071
  });
8818
9072
  return;
8819
9073
  }
9074
+ const action = existing.model.action;
9075
+ if (action === "add" || action === "swap") {
9076
+ const { emit: emit4, onInstallEvent } = makeInstallProgressForwarder(deps.bus, {
9077
+ proposalId: existing.id,
9078
+ hfRepoId: existing.model.target.hfRepoId,
9079
+ ollamaName: existing.model.target.ollamaName
9080
+ });
9081
+ const handler = { ...modelHandlerDeps(deps, req), onInstallEvent };
9082
+ emit4("started");
9083
+ void onApproveModelProposal(handler, existing).then((outcome2) => {
9084
+ if (outcome2.status === "approved") {
9085
+ emit4("complete");
9086
+ } else if (outcome2.status === "failed_target_missing") {
9087
+ emit4("error", {
9088
+ code: "failed_target_missing",
9089
+ message: `${existing.model.target.hfRepoId} is no longer available on HuggingFace`
9090
+ });
9091
+ } else {
9092
+ emit4("error", { code: outcome2.code, message: outcome2.message });
9093
+ }
9094
+ }).catch((err) => {
9095
+ emit4("error", { message: err instanceof Error ? err.message : String(err) });
9096
+ });
9097
+ sendJSON8(res, 202, { disposition: "installing", proposalId: existing.id });
9098
+ return;
9099
+ }
8820
9100
  const outcome = await onApproveModelProposal(modelHandlerDeps(deps, req), existing);
8821
9101
  sendJSON8(res, outcome.status === "error" ? 422 : 200, outcome);
8822
9102
  return;
@@ -9002,6 +9282,7 @@ import {
9002
9282
  isTickHardFailure
9003
9283
  } from "@harness-engineering/local-models";
9004
9284
  var REFRESH_RE = /^\/api\/v1\/local-models\/refresh(?:\?.*)?$/;
9285
+ var CANDIDATES_REFRESH_RE = /^\/api\/v1\/local-models\/candidates\/refresh(?:\?.*)?$/;
9005
9286
  var HARDWARE_RE = /^\/api\/v1\/local-models\/hardware(?:\?.*)?$/;
9006
9287
  var POOL_RE = /^\/api\/v1\/local-models\/pool(?:\?.*)?$/;
9007
9288
  var RECS_RE = /^\/api\/v1\/local-models\/recommendations(?:\?.*)?$/;
@@ -9033,7 +9314,17 @@ function handleV1LocalModelsRoute(req, res, deps) {
9033
9314
  }
9034
9315
  return false;
9035
9316
  }
9036
- if (method !== "POST" || !REFRESH_RE.test(url)) return false;
9317
+ if (method !== "POST") return false;
9318
+ if (CANDIDATES_REFRESH_RE.test(url)) {
9319
+ const refresh = deps.getRefreshCandidates?.() ?? null;
9320
+ if (refresh === null) {
9321
+ sendJSON9(res, 503, { error: "LMLM disabled" });
9322
+ return true;
9323
+ }
9324
+ void runCandidatesRefresh(res, refresh);
9325
+ return true;
9326
+ }
9327
+ if (!REFRESH_RE.test(url)) return false;
9037
9328
  const scheduler = deps.getRefreshScheduler();
9038
9329
  if (scheduler === null) {
9039
9330
  sendJSON9(res, 503, { error: "LMLM disabled" });
@@ -9042,6 +9333,16 @@ function handleV1LocalModelsRoute(req, res, deps) {
9042
9333
  void runForceRefresh(res, scheduler, deps);
9043
9334
  return true;
9044
9335
  }
9336
+ async function runCandidatesRefresh(res, refresh) {
9337
+ try {
9338
+ sendJSON9(res, 200, await refresh());
9339
+ } catch (err) {
9340
+ sendJSON9(res, 500, {
9341
+ error: "candidate refresh failed",
9342
+ detail: err instanceof Error ? err.message : "unknown"
9343
+ });
9344
+ }
9345
+ }
9045
9346
  async function runGet(res, handler) {
9046
9347
  try {
9047
9348
  await handler();
@@ -9202,6 +9503,11 @@ async function handleInstall(req, res, deps) {
9202
9503
  action: "add",
9203
9504
  target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
9204
9505
  scoreDelta: match.score,
9506
+ // Consumption Phase 2 (T7): carry the absolute ranked score so the new pool
9507
+ // entry seeds `currentScore` at its real rank instead of 0 — an
9508
+ // operator-initiated install is usable immediately, not buried until the
9509
+ // scheduler's next re-rank.
9510
+ targetScore: match.score,
9205
9511
  justification: {
9206
9512
  summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
9207
9513
  benchmarkBasis: [],
@@ -9224,27 +9530,37 @@ async function handleInstall(req, res, deps) {
9224
9530
  const record = await createModelProposal(deps.projectPath, content, {
9225
9531
  proposedBy: decidedByOf(deps, req)
9226
9532
  });
9227
- const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9228
- if (outcome.status === "approved") {
9229
- const result = {
9230
- disposition: "installed",
9231
- proposalId: record.id,
9232
- evicted: outcome.evicted.map((e) => e.ollamaName)
9233
- };
9234
- return sendJSON10(res, 200, result);
9235
- }
9236
- if (outcome.status === "failed_target_missing") {
9237
- return sendJSON10(res, 404, {
9238
- error: `${hfRepoId} is no longer available on HuggingFace`,
9239
- proposalId: record.id
9240
- });
9241
- }
9242
- const status = outcome.code === "not_allowed" || outcome.code === "budget_exceeded" ? 409 : 502;
9243
- return sendJSON10(res, status, {
9244
- error: outcome.message,
9245
- code: outcome.code,
9246
- proposalId: record.id
9533
+ const ollamaName = match.ollamaName;
9534
+ const { emit: emit4, onInstallEvent } = makeInstallProgressForwarder(deps.bus, {
9535
+ proposalId: record.id,
9536
+ hfRepoId: match.hfRepoId,
9537
+ ollamaName
9247
9538
  });
9539
+ const handler = { ...handlerDeps(deps, req, pool), onInstallEvent };
9540
+ emit4("started");
9541
+ void onApproveModelProposal(handler, record).then((outcome) => {
9542
+ if (outcome.status === "approved") {
9543
+ emit4("complete", {});
9544
+ return;
9545
+ }
9546
+ if (outcome.status === "failed_target_missing") {
9547
+ emit4("error", {
9548
+ code: "failed_target_missing",
9549
+ message: `${hfRepoId} is no longer available on HuggingFace`
9550
+ });
9551
+ return;
9552
+ }
9553
+ emit4("error", { code: outcome.code, message: outcome.message });
9554
+ }).catch((err) => {
9555
+ emit4("error", { message: err instanceof Error ? err.message : String(err) });
9556
+ });
9557
+ const result = {
9558
+ disposition: "installing",
9559
+ proposalId: record.id,
9560
+ evicted: [],
9561
+ message: `installing ${ollamaName} \u2014 progress streams on the local-models:install channel`
9562
+ };
9563
+ return sendJSON10(res, 202, result);
9248
9564
  }
9249
9565
  async function handleRemove(req, res, deps) {
9250
9566
  const pool = deps.getModelPool();
@@ -10197,6 +10513,12 @@ var V1_BRIDGE_ROUTES = [
10197
10513
  scope: "manage-proposals",
10198
10514
  description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
10199
10515
  },
10516
+ {
10517
+ method: "POST",
10518
+ pattern: /^\/api\/v1\/local-models\/candidates\/refresh(?:\?.*)?$/,
10519
+ scope: "manage-proposals",
10520
+ description: "Re-discover candidates live from HuggingFace, re-seed the recommender, re-rank."
10521
+ },
10200
10522
  // ── LMLM dashboard pool mutation — operator-initiated install/remove ──
10201
10523
  // Modeled as auto-approved model proposals, so the same `manage-proposals`
10202
10524
  // write scope that governs approve/reject governs these too.
@@ -10387,6 +10709,7 @@ var OrchestratorServer = class {
10387
10709
  // LMLM Phase 6 — live model pool + refresh scheduler accessors.
10388
10710
  getModelPoolFn = null;
10389
10711
  getRefreshSchedulerFn = null;
10712
+ getRefreshCandidatesFn = null;
10390
10713
  // LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
10391
10714
  getHardwareProfileFn = null;
10392
10715
  getRecommendationsFn = null;
@@ -10397,6 +10720,8 @@ var OrchestratorServer = class {
10397
10720
  // LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
10398
10721
  modelProposalListener = null;
10399
10722
  modelPoolListener = null;
10723
+ // LMLM Phase 10 — bus→WS fan-out for byte-level install progress (D3 async install).
10724
+ modelInstallListener = null;
10400
10725
  recorder = null;
10401
10726
  planWatcher = null;
10402
10727
  tokenStore;
@@ -10443,6 +10768,7 @@ var OrchestratorServer = class {
10443
10768
  this.getBackendsFn = deps?.getBackends ?? null;
10444
10769
  this.getModelPoolFn = deps?.getModelPool ?? null;
10445
10770
  this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
10771
+ this.getRefreshCandidatesFn = deps?.getRefreshCandidates ?? null;
10446
10772
  this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
10447
10773
  this.getRecommendationsFn = deps?.getRecommendations ?? null;
10448
10774
  this.listModelProposalsFn = deps?.listModelProposals ?? null;
@@ -10465,8 +10791,10 @@ var OrchestratorServer = class {
10465
10791
  }
10466
10792
  this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
10467
10793
  this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
10794
+ this.modelInstallListener = (data) => this.broadcaster.broadcast(MODEL_INSTALL_TOPIC, data);
10468
10795
  this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
10469
10796
  this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
10797
+ this.orchestrator.on(MODEL_INSTALL_TOPIC, this.modelInstallListener);
10470
10798
  }
10471
10799
  /**
10472
10800
  * Broadcast a new interaction to all WebSocket clients.
@@ -10663,6 +10991,7 @@ var OrchestratorServer = class {
10663
10991
  // only when configured so absent ones surface as 503 (LMLM disabled).
10664
10992
  (req, res) => handleV1LocalModelsRoute(req, res, {
10665
10993
  getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
10994
+ ...this.getRefreshCandidatesFn ? { getRefreshCandidates: this.getRefreshCandidatesFn } : {},
10666
10995
  getModelPool: () => this.getModelPoolFn?.() ?? null,
10667
10996
  ...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
10668
10997
  ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
@@ -10778,6 +11107,10 @@ var OrchestratorServer = class {
10778
11107
  this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
10779
11108
  this.modelPoolListener = null;
10780
11109
  }
11110
+ if (this.modelInstallListener) {
11111
+ this.orchestrator.removeListener(MODEL_INSTALL_TOPIC, this.modelInstallListener);
11112
+ this.modelInstallListener = null;
11113
+ }
10781
11114
  if (this.planWatcher) {
10782
11115
  this.planWatcher.stop();
10783
11116
  this.planWatcher = null;
@@ -13878,6 +14211,13 @@ var Orchestrator = class extends EventEmitter {
13878
14211
  * so this map is the single source of truth post-migration.
13879
14212
  */
13880
14213
  localResolvers = /* @__PURE__ */ new Map();
14214
+ /**
14215
+ * Consumption Phase 1 (T2): bus listener that debounce-refreshes every local
14216
+ * resolver when a `local-models:pool` mutation fires, so a just-installed or
14217
+ * swapped model becomes usable within the refresh window instead of waiting up
14218
+ * to `probeIntervalMs` for the next poll. Held for removal in {@link stop}.
14219
+ */
14220
+ poolRefreshListener = null;
13881
14221
  /** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
13882
14222
  poolStateProvider = null;
13883
14223
  poolStateStore = null;
@@ -13915,6 +14255,13 @@ var Orchestrator = class extends EventEmitter {
13915
14255
  * see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
13916
14256
  */
13917
14257
  modelRecommender = null;
14258
+ /** Live HF candidate discovery (injectable for tests so startup makes no network calls). */
14259
+ discoverCandidatesFn;
14260
+ /** Snapshot of the last candidate seeding, surfaced to the refresh route. */
14261
+ candidateSourceState = {
14262
+ source: "frozen",
14263
+ count: 0
14264
+ };
13918
14265
  /** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
13919
14266
  schedulerTimerOverride;
13920
14267
  /**
@@ -14008,6 +14355,7 @@ var Orchestrator = class extends EventEmitter {
14008
14355
  constructor(config, promptTemplate, overrides) {
14009
14356
  super();
14010
14357
  this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
14358
+ this.discoverCandidatesFn = overrides?.discoverCandidates ?? (async () => ({ candidates: [], warnings: [] }));
14011
14359
  this.setMaxListeners(50);
14012
14360
  this.config = config;
14013
14361
  this.promptTemplate = promptTemplate;
@@ -14076,6 +14424,17 @@ var Orchestrator = class extends EventEmitter {
14076
14424
  if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
14077
14425
  if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
14078
14426
  if (this.poolStateProvider !== null) resolverOpts.poolState = this.poolStateProvider;
14427
+ const endpoint = def.endpoint;
14428
+ const apiKey = def.apiKey;
14429
+ if (def.type === "local") {
14430
+ resolverOpts.warmModel = (ollamaName) => {
14431
+ void defaultWarmModel(endpoint, ollamaName, apiKey);
14432
+ };
14433
+ } else {
14434
+ resolverOpts.warmModel = (model) => {
14435
+ void defaultWarmModelViaCompletion(endpoint, model, apiKey);
14436
+ };
14437
+ }
14079
14438
  this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
14080
14439
  }
14081
14440
  }
@@ -14098,9 +14457,27 @@ var Orchestrator = class extends EventEmitter {
14098
14457
  ...this.config.agent.secrets !== void 0 ? { secrets: this.config.agent.secrets } : {},
14099
14458
  cacheMetrics: this.cacheMetrics,
14100
14459
  decisionBus: this.routingDecisionBus,
14101
- getResolverModelFor: (name) => {
14460
+ getResolverModelFor: (name, useCase) => {
14461
+ const resolver = this.localResolvers.get(name);
14462
+ return resolver ? () => resolver.resolveModel(useCase) : void 0;
14463
+ },
14464
+ // Consumption Phase 3 (T11): bind per-backend runtime feedback. A
14465
+ // successful turn stamps `lastUsedAt` (LRU) via the pool and clears the
14466
+ // resolver's circuit breaker; a failed turn feeds the breaker so a
14467
+ // repeatedly-failing model is deprioritized. `modelPool` is read lazily
14468
+ // (per dispatch) because it loads in start(), after this constructor.
14469
+ getModelUsageHooksFor: (name) => {
14102
14470
  const resolver = this.localResolvers.get(name);
14103
- return resolver ? () => resolver.resolveModel() : void 0;
14471
+ if (!resolver) return void 0;
14472
+ return {
14473
+ onModelUsed: (model) => {
14474
+ resolver.recordSuccess(model);
14475
+ void this.modelPool?.markUsed(model);
14476
+ },
14477
+ onModelFailed: (model) => {
14478
+ resolver.recordFailure(model);
14479
+ }
14480
+ };
14104
14481
  }
14105
14482
  });
14106
14483
  } else {
@@ -14197,6 +14574,9 @@ var Orchestrator = class extends EventEmitter {
14197
14574
  isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
14198
14575
  // LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
14199
14576
  getRefreshScheduler: () => this.refreshScheduler,
14577
+ // Live candidate refresh for POST /local-models/candidates/refresh (the
14578
+ // "Refresh" button). Null when LMLM is disabled → route 503s.
14579
+ getRefreshCandidates: () => this.modelPool ? () => this.refreshCandidatesLive() : null,
14200
14580
  // LMLM Phase 7 read surface — hardware / recommendations / model proposals.
14201
14581
  // Each returns null/[] when LMLM is disabled so the route renders 503/[].
14202
14582
  getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
@@ -15264,10 +15644,51 @@ ${messages}`);
15264
15644
  const installerCfg = this.config.localModels?.installer;
15265
15645
  this.modelInstaller = new OllamaInstallAdapter({
15266
15646
  baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
15267
- onWarn
15647
+ onWarn,
15648
+ // Survive transient `/api/pull` drops (most often the host sleeping mid
15649
+ // multi-GB download): ollama resumes from cached blobs, and any forward
15650
+ // progress resets the budget, so an install nibbled through across several
15651
+ // sleep cycles still completes instead of dead-ending in an error.
15652
+ maxPullRetries: 5
15268
15653
  });
15269
15654
  this.modelPool = new PoolManager({ store, installer: this.modelInstaller, onWarn });
15270
15655
  }
15656
+ /**
15657
+ * Resume installs interrupted by a restart. A proposal left `installing` had
15658
+ * its background `ollama pull` cut short when the orchestrator went down; the
15659
+ * pull is idempotent (ollama resumes from cached blobs), so we re-drive it.
15660
+ * Fire-and-forget with its own error isolation — a resumed multi-GB download
15661
+ * must not block startup, and a re-drive failure only logs.
15662
+ */
15663
+ redriveInterruptedInstalls() {
15664
+ const pool = this.modelPool;
15665
+ if (pool === null) return;
15666
+ void (async () => {
15667
+ try {
15668
+ const modelProposals = await listProposals2(this.projectRoot, {
15669
+ kind: "model"
15670
+ });
15671
+ const installing = modelProposals.filter((p) => p.status === "installing");
15672
+ if (installing.length === 0) return;
15673
+ this.logger.info(`Resuming ${installing.length} model install(s) interrupted by a restart`);
15674
+ await redriveInstallingProposals(
15675
+ {
15676
+ pool,
15677
+ bus: this,
15678
+ updateProposal: (id, patch) => updateProposal5(this.projectRoot, id, patch),
15679
+ decidedBy: "orchestrator",
15680
+ isModelInUse: (name) => this.isLocalModelInUse(name)
15681
+ },
15682
+ installing,
15683
+ {
15684
+ onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
15685
+ }
15686
+ );
15687
+ } catch (err) {
15688
+ this.logger.warn("interrupted-install re-drive failed", { cause: err });
15689
+ }
15690
+ })();
15691
+ }
15271
15692
  /**
15272
15693
  * LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
15273
15694
  * budget + org/family allowlist) from `localModels.pool` to the live pool.
@@ -15313,8 +15734,8 @@ ${messages}`);
15313
15734
  allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
15314
15735
  });
15315
15736
  }
15316
- const recommend = createNativeRecommender({ candidates });
15317
- this.modelRecommender = recommend;
15737
+ this.seedRecommender(candidates, "frozen");
15738
+ const recommend = (hardware) => this.modelRecommender(hardware);
15318
15739
  this.refreshScheduler = new RefreshScheduler({
15319
15740
  runTick: () => runRefreshTick({
15320
15741
  detectHardware: () => this.detectLmlmHardware(),
@@ -15344,6 +15765,55 @@ ${messages}`);
15344
15765
  });
15345
15766
  this.refreshScheduler.start();
15346
15767
  }
15768
+ /** (Re)build the recommender over `candidates` and record the seeding source. */
15769
+ seedRecommender(candidates, source) {
15770
+ this.modelRecommender = createNativeRecommender({ candidates });
15771
+ this.candidateSourceState = { source, count: candidates.length };
15772
+ }
15773
+ /**
15774
+ * Refresh ranking candidates live from HuggingFace, merge the curated
15775
+ * `ollamaName`/`family` tags from the frozen snapshot (so results stay
15776
+ * installable — decision A), and re-seed the recommender. Fail-closed: on any
15777
+ * error or an empty installable result, the current candidates stand. Runs a
15778
+ * `forceRefresh` tick so recommendations + proposals reflect the fresh set.
15779
+ * Used by both the startup background refresh and the operator "Refresh" button.
15780
+ */
15781
+ async refreshCandidatesLive(signal) {
15782
+ const poolCfg = this.config.localModels?.pool;
15783
+ const orgs = poolCfg?.allowedOrgs ?? [];
15784
+ if (orgs.length === 0) return this.candidateSourceState;
15785
+ const curation = curationFromCandidates(loadFrozenCandidates().candidates);
15786
+ let result;
15787
+ try {
15788
+ result = await this.discoverCandidatesFn({
15789
+ orgs,
15790
+ curation,
15791
+ ...signal ? { signal } : {},
15792
+ onWarn: (m, cause) => this.logger.warn(m, cause !== void 0 ? { cause } : void 0)
15793
+ });
15794
+ } catch (err) {
15795
+ this.logger.warn("LMLM live candidate discovery failed; keeping current candidates", {
15796
+ cause: err
15797
+ });
15798
+ return this.candidateSourceState;
15799
+ }
15800
+ const selected = selectCandidates2(result.candidates, poolCfg);
15801
+ if (selected.length === 0) {
15802
+ this.logger.warn("LMLM live discovery yielded no installable candidates; keeping current", {
15803
+ warnings: result.warnings
15804
+ });
15805
+ return this.candidateSourceState;
15806
+ }
15807
+ this.seedRecommender(selected, "live");
15808
+ this.logger.info("LMLM candidates refreshed from HuggingFace", { count: selected.length });
15809
+ await this.refreshScheduler?.forceRefresh();
15810
+ this.emit("local-models:pool", {
15811
+ phase: "candidates_refreshed",
15812
+ source: "live",
15813
+ count: selected.length
15814
+ });
15815
+ return this.candidateSourceState;
15816
+ }
15347
15817
  /** Resolve the hardware profile for a refresh tick (operator override wins). */
15348
15818
  async detectLmlmHardware() {
15349
15819
  const override = this.config.localModels?.hardware?.override;
@@ -15391,12 +15861,27 @@ ${messages}`);
15391
15861
  if (this.poolStateStore !== null) {
15392
15862
  await this.poolStateStore.load();
15393
15863
  await this.applyConfiguredPoolBounds();
15864
+ this.redriveInterruptedInstalls();
15394
15865
  }
15395
15866
  for (const resolver of this.localResolvers.values()) {
15396
15867
  await resolver.start();
15397
15868
  }
15869
+ if (this.poolRefreshListener === null && this.localResolvers.size > 0) {
15870
+ const listener = () => {
15871
+ for (const resolver of this.localResolvers.values()) {
15872
+ resolver.refresh();
15873
+ }
15874
+ };
15875
+ this.poolRefreshListener = listener;
15876
+ this.on("local-models:pool", listener);
15877
+ }
15398
15878
  }
15399
15879
  this.startRefreshScheduler();
15880
+ if (this.modelPool !== null) {
15881
+ void this.refreshCandidatesLive().catch(
15882
+ (err) => this.logger.warn("LMLM startup candidate refresh failed", { cause: err })
15883
+ );
15884
+ }
15400
15885
  this.pipeline = this.createIntelligencePipeline();
15401
15886
  this.server?.setPipeline(this.pipeline);
15402
15887
  }
@@ -15469,6 +15954,10 @@ ${messages}`);
15469
15954
  this.localModelStatusUnsubscribes = [];
15470
15955
  this.routingDecisionBus?.clearListeners();
15471
15956
  this.routingDecisionBus = null;
15957
+ if (this.poolRefreshListener !== null) {
15958
+ this.removeListener("local-models:pool", this.poolRefreshListener);
15959
+ this.poolRefreshListener = null;
15960
+ }
15472
15961
  for (const resolver of this.localResolvers.values()) {
15473
15962
  resolver.stop();
15474
15963
  }
@@ -16463,6 +16952,9 @@ function buildArchiveHooks(opts) {
16463
16952
  }
16464
16953
  };
16465
16954
  }
16955
+
16956
+ // src/index.ts
16957
+ import { discoverCandidates } from "@harness-engineering/local-models";
16466
16958
  export {
16467
16959
  AnalysisArchive,
16468
16960
  BUILT_IN_TASKS,
@@ -16517,6 +17009,7 @@ export {
16517
17009
  defaultFetchModels,
16518
17010
  deriveSeedPaths,
16519
17011
  detectScopeTier,
17012
+ discoverCandidates,
16520
17013
  discoverSkillCatalog,
16521
17014
  discoverSkillCatalogNames,
16522
17015
  emitProposalApproved,