@harness-engineering/orchestrator 0.11.1 → 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({
@@ -4084,9 +4252,218 @@ var LocalModelResolver = class {
4084
4252
  };
4085
4253
 
4086
4254
  // src/orchestrator.ts
4087
- var import_local_models4 = require("@harness-engineering/local-models");
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();
@@ -9188,6 +9489,7 @@ async function runForceRefresh(res, scheduler, deps) {
9188
9489
 
9189
9490
  // src/server/routes/v1/local-models-pool-mutation.ts
9190
9491
  var import_core11 = require("@harness-engineering/core");
9492
+ var import_local_models3 = require("@harness-engineering/local-models");
9191
9493
  var INSTALL_RE = /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/;
9192
9494
  var REMOVE_RE = /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/;
9193
9495
  var RESOLVE_TOP = 50;
@@ -9246,6 +9548,11 @@ async function handleInstall(req, res, deps) {
9246
9548
  action: "add",
9247
9549
  target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
9248
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,
9249
9556
  justification: {
9250
9557
  summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
9251
9558
  benchmarkBasis: [],
@@ -9253,34 +9560,52 @@ async function handleInstall(req, res, deps) {
9253
9560
  evidence: match.evidence,
9254
9561
  freshness: `snapshot ${match.benchmarkSnapshot}`
9255
9562
  },
9256
- // 0 the installer resolves the true on-disk size via `inspect` before the
9257
- // budget check, rather than trusting an estimate.
9258
- diskImpactGb: 0
9563
+ // Estimate the on-disk size from the candidate's params + quant (the same
9564
+ // `estimateDiskGb` the automated proposal engine uses). The pool needs a
9565
+ // size for its pre-commit budget check *before* the pull; it cannot inspect
9566
+ // the target first, because ollama `/api/show` 404s for a model that is not
9567
+ // yet pulled locally — which would surface as a spurious "no longer
9568
+ // available on HuggingFace" 404 on every operator install.
9569
+ diskImpactGb: (0, import_local_models3.estimateDiskGb)({
9570
+ sizeB: match.sizeB,
9571
+ quant: match.quant,
9572
+ ...match.activeB !== void 0 ? { activeB: match.activeB } : {}
9573
+ })
9259
9574
  };
9260
9575
  const record = await (0, import_core11.createModelProposal)(deps.projectPath, content, {
9261
9576
  proposedBy: decidedByOf(deps, req)
9262
9577
  });
9263
- const outcome = await onApproveModelProposal(handlerDeps(deps, req, pool), record);
9264
- if (outcome.status === "approved") {
9265
- const result = {
9266
- disposition: "installed",
9267
- proposalId: record.id,
9268
- evicted: outcome.evicted.map((e) => e.ollamaName)
9269
- };
9270
- return sendJSON10(res, 200, result);
9271
- }
9272
- if (outcome.status === "failed_target_missing") {
9273
- return sendJSON10(res, 404, {
9274
- error: `${hfRepoId} is no longer available on HuggingFace`,
9275
- proposalId: record.id
9276
- });
9277
- }
9278
- const status = outcome.code === "not_allowed" || outcome.code === "budget_exceeded" ? 409 : 502;
9279
- return sendJSON10(res, status, {
9280
- error: outcome.message,
9281
- code: outcome.code,
9282
- 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) });
9283
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);
9284
9609
  }
9285
9610
  async function handleRemove(req, res, deps) {
9286
9611
  const pool = deps.getModelPool();
@@ -10226,6 +10551,12 @@ var V1_BRIDGE_ROUTES = [
10226
10551
  scope: "manage-proposals",
10227
10552
  description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
10228
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
+ },
10229
10560
  // ── LMLM dashboard pool mutation — operator-initiated install/remove ──
10230
10561
  // Modeled as auto-approved model proposals, so the same `manage-proposals`
10231
10562
  // write scope that governs approve/reject governs these too.
@@ -10416,6 +10747,7 @@ var OrchestratorServer = class {
10416
10747
  // LMLM Phase 6 — live model pool + refresh scheduler accessors.
10417
10748
  getModelPoolFn = null;
10418
10749
  getRefreshSchedulerFn = null;
10750
+ getRefreshCandidatesFn = null;
10419
10751
  // LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
10420
10752
  getHardwareProfileFn = null;
10421
10753
  getRecommendationsFn = null;
@@ -10426,6 +10758,8 @@ var OrchestratorServer = class {
10426
10758
  // LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
10427
10759
  modelProposalListener = null;
10428
10760
  modelPoolListener = null;
10761
+ // LMLM Phase 10 — bus→WS fan-out for byte-level install progress (D3 async install).
10762
+ modelInstallListener = null;
10429
10763
  recorder = null;
10430
10764
  planWatcher = null;
10431
10765
  tokenStore;
@@ -10472,6 +10806,7 @@ var OrchestratorServer = class {
10472
10806
  this.getBackendsFn = deps?.getBackends ?? null;
10473
10807
  this.getModelPoolFn = deps?.getModelPool ?? null;
10474
10808
  this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
10809
+ this.getRefreshCandidatesFn = deps?.getRefreshCandidates ?? null;
10475
10810
  this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
10476
10811
  this.getRecommendationsFn = deps?.getRecommendations ?? null;
10477
10812
  this.listModelProposalsFn = deps?.listModelProposals ?? null;
@@ -10494,8 +10829,10 @@ var OrchestratorServer = class {
10494
10829
  }
10495
10830
  this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
10496
10831
  this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
10832
+ this.modelInstallListener = (data) => this.broadcaster.broadcast(MODEL_INSTALL_TOPIC, data);
10497
10833
  this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
10498
10834
  this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
10835
+ this.orchestrator.on(MODEL_INSTALL_TOPIC, this.modelInstallListener);
10499
10836
  }
10500
10837
  /**
10501
10838
  * Broadcast a new interaction to all WebSocket clients.
@@ -10692,6 +11029,7 @@ var OrchestratorServer = class {
10692
11029
  // only when configured so absent ones surface as 503 (LMLM disabled).
10693
11030
  (req, res) => handleV1LocalModelsRoute(req, res, {
10694
11031
  getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
11032
+ ...this.getRefreshCandidatesFn ? { getRefreshCandidates: this.getRefreshCandidatesFn } : {},
10695
11033
  getModelPool: () => this.getModelPoolFn?.() ?? null,
10696
11034
  ...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
10697
11035
  ...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
@@ -10807,6 +11145,10 @@ var OrchestratorServer = class {
10807
11145
  this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
10808
11146
  this.modelPoolListener = null;
10809
11147
  }
11148
+ if (this.modelInstallListener) {
11149
+ this.orchestrator.removeListener(MODEL_INSTALL_TOPIC, this.modelInstallListener);
11150
+ this.modelInstallListener = null;
11151
+ }
10810
11152
  if (this.planWatcher) {
10811
11153
  this.planWatcher.stop();
10812
11154
  this.planWatcher = null;
@@ -13899,6 +14241,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13899
14241
  * so this map is the single source of truth post-migration.
13900
14242
  */
13901
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;
13902
14251
  /** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
13903
14252
  poolStateProvider = null;
13904
14253
  poolStateStore = null;
@@ -13936,6 +14285,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
13936
14285
  * see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
13937
14286
  */
13938
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
+ };
13939
14295
  /** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
13940
14296
  schedulerTimerOverride;
13941
14297
  /**
@@ -14029,6 +14385,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14029
14385
  constructor(config, promptTemplate, overrides) {
14030
14386
  super();
14031
14387
  this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
14388
+ this.discoverCandidatesFn = overrides?.discoverCandidates ?? (async () => ({ candidates: [], warnings: [] }));
14032
14389
  this.setMaxListeners(50);
14033
14390
  this.config = config;
14034
14391
  this.promptTemplate = promptTemplate;
@@ -14080,7 +14437,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14080
14437
  if (overrides?.poolState) {
14081
14438
  this.poolStateProvider = overrides.poolState;
14082
14439
  } else if (localModelsEnabled) {
14083
- this.poolStateStore = new import_local_models4.PoolStateStore({
14440
+ this.poolStateStore = new import_local_models5.PoolStateStore({
14084
14441
  onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
14085
14442
  });
14086
14443
  this.poolStateProvider = this.poolStateStore;
@@ -14097,6 +14454,17 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14097
14454
  if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
14098
14455
  if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
14099
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
+ }
14100
14468
  this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
14101
14469
  }
14102
14470
  }
@@ -14119,9 +14487,27 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14119
14487
  ...this.config.agent.secrets !== void 0 ? { secrets: this.config.agent.secrets } : {},
14120
14488
  cacheMetrics: this.cacheMetrics,
14121
14489
  decisionBus: this.routingDecisionBus,
14122
- 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) => {
14123
14500
  const resolver = this.localResolvers.get(name);
14124
- 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
+ };
14125
14511
  }
14126
14512
  });
14127
14513
  } else {
@@ -14218,6 +14604,9 @@ var Orchestrator = class extends import_node_events.EventEmitter {
14218
14604
  isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
14219
14605
  // LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
14220
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,
14221
14610
  // LMLM Phase 7 read surface — hardware / recommendations / model proposals.
14222
14611
  // Each returns null/[] when LMLM is disabled so the route renders 503/[].
14223
14612
  getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
@@ -15283,11 +15672,52 @@ ${messages}`);
15283
15672
  initModelPool(store) {
15284
15673
  const onWarn = (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0);
15285
15674
  const installerCfg = this.config.localModels?.installer;
15286
- this.modelInstaller = new import_local_models4.OllamaInstallAdapter({
15675
+ this.modelInstaller = new import_local_models5.OllamaInstallAdapter({
15287
15676
  baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
15288
- 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
15289
15683
  });
15290
- this.modelPool = new import_local_models4.PoolManager({ store, installer: this.modelInstaller, onWarn });
15684
+ this.modelPool = new import_local_models5.PoolManager({ store, installer: this.modelInstaller, onWarn });
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
+ })();
15291
15721
  }
15292
15722
  /**
15293
15723
  * LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
@@ -15323,8 +15753,8 @@ ${messages}`);
15323
15753
  if (this.modelPool === null) return;
15324
15754
  const pool = this.modelPool;
15325
15755
  const refreshCfg = this.config.localModels?.refresh;
15326
- const frozen = (0, import_local_models4.loadFrozenCandidates)();
15327
- const candidates = (0, import_local_models4.selectCandidates)(frozen.candidates, this.config.localModels?.pool);
15756
+ const frozen = (0, import_local_models5.loadFrozenCandidates)();
15757
+ const candidates = (0, import_local_models5.selectCandidates)(frozen.candidates, this.config.localModels?.pool);
15328
15758
  for (const warning of frozen.warnings) {
15329
15759
  this.logger.warn("LMLM frozen candidate snapshot degraded", { warning });
15330
15760
  }
@@ -15334,10 +15764,10 @@ ${messages}`);
15334
15764
  allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
15335
15765
  });
15336
15766
  }
15337
- const recommend = (0, import_local_models4.createNativeRecommender)({ candidates });
15338
- this.modelRecommender = recommend;
15339
- this.refreshScheduler = new import_local_models4.RefreshScheduler({
15340
- runTick: () => (0, import_local_models4.runRefreshTick)({
15767
+ this.seedRecommender(candidates, "frozen");
15768
+ const recommend = (hardware) => this.modelRecommender(hardware);
15769
+ this.refreshScheduler = new import_local_models5.RefreshScheduler({
15770
+ runTick: () => (0, import_local_models5.runRefreshTick)({
15341
15771
  detectHardware: () => this.detectLmlmHardware(),
15342
15772
  recommend,
15343
15773
  poolManager: pool,
@@ -15365,10 +15795,59 @@ ${messages}`);
15365
15795
  });
15366
15796
  this.refreshScheduler.start();
15367
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
+ }
15368
15847
  /** Resolve the hardware profile for a refresh tick (operator override wins). */
15369
15848
  async detectLmlmHardware() {
15370
15849
  const override = this.config.localModels?.hardware?.override;
15371
- const detector = new import_local_models4.HardwareDetector(override !== void 0 ? { override } : {});
15850
+ const detector = new import_local_models5.HardwareDetector(override !== void 0 ? { override } : {});
15372
15851
  return (await detector.detect()).profile;
15373
15852
  }
15374
15853
  /** Map the on-disk model-proposal queue to F7 dedup pairs (open→pending, rejected→rejected). */
@@ -15412,12 +15891,27 @@ ${messages}`);
15412
15891
  if (this.poolStateStore !== null) {
15413
15892
  await this.poolStateStore.load();
15414
15893
  await this.applyConfiguredPoolBounds();
15894
+ this.redriveInterruptedInstalls();
15415
15895
  }
15416
15896
  for (const resolver of this.localResolvers.values()) {
15417
15897
  await resolver.start();
15418
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
+ }
15419
15908
  }
15420
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
+ }
15421
15915
  this.pipeline = this.createIntelligencePipeline();
15422
15916
  this.server?.setPipeline(this.pipeline);
15423
15917
  }
@@ -15490,6 +15984,10 @@ ${messages}`);
15490
15984
  this.localModelStatusUnsubscribes = [];
15491
15985
  this.routingDecisionBus?.clearListeners();
15492
15986
  this.routingDecisionBus = null;
15987
+ if (this.poolRefreshListener !== null) {
15988
+ this.removeListener("local-models:pool", this.poolRefreshListener);
15989
+ this.poolRefreshListener = null;
15990
+ }
15493
15991
  for (const resolver of this.localResolvers.values()) {
15494
15992
  resolver.stop();
15495
15993
  }
@@ -16482,6 +16980,9 @@ function buildArchiveHooks(opts) {
16482
16980
  }
16483
16981
  };
16484
16982
  }
16983
+
16984
+ // src/index.ts
16985
+ var import_local_models6 = require("@harness-engineering/local-models");
16485
16986
  // Annotate the CommonJS export names for ESM import in node:
16486
16987
  0 && (module.exports = {
16487
16988
  AnalysisArchive,
@@ -16537,6 +17038,7 @@ function buildArchiveHooks(opts) {
16537
17038
  defaultFetchModels,
16538
17039
  deriveSeedPaths,
16539
17040
  detectScopeTier,
17041
+ discoverCandidates,
16540
17042
  discoverSkillCatalog,
16541
17043
  discoverSkillCatalogNames,
16542
17044
  emitProposalApproved,