@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.d.mts +132 -3
- package/dist/index.d.ts +132 -3
- package/dist/index.js +688 -195
- package/dist/index.mjs +690 -197
- package/package.json +6 -6
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
|
-
|
|
3980
|
-
|
|
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 =
|
|
4105
|
+
const match = this.selectMatch(candidates, detected);
|
|
4026
4106
|
this.resolved = match;
|
|
4027
4107
|
this.available = match !== null;
|
|
4028
4108
|
this.warnings = match ? [] : [
|
|
@@ -4052,8 +4132,92 @@ var LocalModelResolver = class {
|
|
|
4052
4132
|
}
|
|
4053
4133
|
}
|
|
4054
4134
|
}
|
|
4135
|
+
if (this.resolved !== null && this.resolved !== prevResolved) {
|
|
4136
|
+
this.warm(this.resolved);
|
|
4137
|
+
}
|
|
4055
4138
|
return status;
|
|
4056
4139
|
}
|
|
4140
|
+
/** Best-effort warm-hook invocation — a throwing warm never breaks a probe. */
|
|
4141
|
+
warm(ollamaName) {
|
|
4142
|
+
if (!this.warmModel) return;
|
|
4143
|
+
try {
|
|
4144
|
+
this.warmModel(ollamaName);
|
|
4145
|
+
} catch (err) {
|
|
4146
|
+
this.logger.warn("local-model-resolver warm hook threw", {
|
|
4147
|
+
error: err instanceof Error ? err.message : String(err)
|
|
4148
|
+
});
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
/**
|
|
4152
|
+
* Consumption Phase 3 (T10): pick the resolved model from the candidates that
|
|
4153
|
+
* are actually loaded, preferring ones whose circuit breaker is not tripped.
|
|
4154
|
+
* Candidate order (score-desc from the pool) is preserved within each tier, so
|
|
4155
|
+
* a tripped top pick sinks below a healthy lower pick but still resolves as a
|
|
4156
|
+
* last resort when every loaded candidate is tripped (better a flaky model than
|
|
4157
|
+
* none). Returns null when no candidate is loaded.
|
|
4158
|
+
*/
|
|
4159
|
+
selectMatch(candidates, detected) {
|
|
4160
|
+
const detectedSet = new Set(detected);
|
|
4161
|
+
const available = candidates.filter((id) => detectedSet.has(id));
|
|
4162
|
+
if (available.length === 0) return null;
|
|
4163
|
+
const healthy = available.filter((id) => !this.isTripped(id));
|
|
4164
|
+
return healthy[0] ?? available[0] ?? null;
|
|
4165
|
+
}
|
|
4166
|
+
/**
|
|
4167
|
+
* Whether `model`'s circuit breaker is currently tripped. Lazily clears the
|
|
4168
|
+
* trip once its cooldown has elapsed so the model becomes eligible again on the
|
|
4169
|
+
* next resolution without needing a separate timer.
|
|
4170
|
+
*/
|
|
4171
|
+
isTripped(model) {
|
|
4172
|
+
const until = this.trippedUntil.get(model);
|
|
4173
|
+
if (until === void 0) return false;
|
|
4174
|
+
if (this.now() >= until) {
|
|
4175
|
+
this.trippedUntil.delete(model);
|
|
4176
|
+
this.consecutiveFailures.delete(model);
|
|
4177
|
+
return false;
|
|
4178
|
+
}
|
|
4179
|
+
return true;
|
|
4180
|
+
}
|
|
4181
|
+
/**
|
|
4182
|
+
* Record a successful inference on `model`: clears its failure count and any
|
|
4183
|
+
* active trip so a recovered model is immediately re-preferred.
|
|
4184
|
+
*/
|
|
4185
|
+
recordSuccess(model) {
|
|
4186
|
+
if (!model) return;
|
|
4187
|
+
const hadState = this.consecutiveFailures.has(model) || this.trippedUntil.has(model);
|
|
4188
|
+
this.consecutiveFailures.delete(model);
|
|
4189
|
+
this.trippedUntil.delete(model);
|
|
4190
|
+
if (hadState) this.refresh();
|
|
4191
|
+
}
|
|
4192
|
+
/**
|
|
4193
|
+
* Record a failed inference on `model`. On the Nth consecutive failure the
|
|
4194
|
+
* breaker trips (deprioritizing the model for `breakerCooldownMs`) and a
|
|
4195
|
+
* re-probe is scheduled so the resolver rolls to a healthy alternative.
|
|
4196
|
+
*/
|
|
4197
|
+
recordFailure(model) {
|
|
4198
|
+
if (!model) return;
|
|
4199
|
+
const next = (this.consecutiveFailures.get(model) ?? 0) + 1;
|
|
4200
|
+
this.consecutiveFailures.set(model, next);
|
|
4201
|
+
if (next >= this.breakerThreshold) {
|
|
4202
|
+
this.trippedUntil.set(model, this.now() + this.breakerCooldownMs);
|
|
4203
|
+
this.refresh();
|
|
4204
|
+
}
|
|
4205
|
+
}
|
|
4206
|
+
/**
|
|
4207
|
+
* Event-driven refresh: schedule a debounced re-probe. Called when a
|
|
4208
|
+
* `local-models:pool` mutation fires so a just-installed/swapped model becomes
|
|
4209
|
+
* usable within a debounce window instead of waiting up to `probeIntervalMs`.
|
|
4210
|
+
* A burst of frames coalesces into one probe (the timer is only armed once).
|
|
4211
|
+
*/
|
|
4212
|
+
refresh() {
|
|
4213
|
+
if (this.refreshTimer !== null) return;
|
|
4214
|
+
this.refreshTimer = setTimeout(() => {
|
|
4215
|
+
this.refreshTimer = null;
|
|
4216
|
+
void this.probe();
|
|
4217
|
+
}, this.refreshDebounceMs);
|
|
4218
|
+
const handle = this.refreshTimer;
|
|
4219
|
+
handle.unref?.();
|
|
4220
|
+
}
|
|
4057
4221
|
async start() {
|
|
4058
4222
|
if (this.timer !== null) {
|
|
4059
4223
|
return;
|
|
@@ -4070,6 +4234,10 @@ var LocalModelResolver = class {
|
|
|
4070
4234
|
clearInterval(this.timer);
|
|
4071
4235
|
this.timer = null;
|
|
4072
4236
|
}
|
|
4237
|
+
if (this.refreshTimer !== null) {
|
|
4238
|
+
clearTimeout(this.refreshTimer);
|
|
4239
|
+
this.refreshTimer = null;
|
|
4240
|
+
}
|
|
4073
4241
|
}
|
|
4074
4242
|
snapshotForDiff() {
|
|
4075
4243
|
return JSON.stringify({
|
|
@@ -4087,6 +4255,215 @@ var LocalModelResolver = class {
|
|
|
4087
4255
|
var import_local_models5 = require("@harness-engineering/local-models");
|
|
4088
4256
|
var import_core18 = require("@harness-engineering/core");
|
|
4089
4257
|
|
|
4258
|
+
// src/proposals/model-handlers.ts
|
|
4259
|
+
function isInUse(deps, ollamaName) {
|
|
4260
|
+
return deps.isModelInUse ? deps.isModelInUse(ollamaName) : false;
|
|
4261
|
+
}
|
|
4262
|
+
var MODEL_PROPOSAL_TOPIC = "local-models:proposal";
|
|
4263
|
+
var MODEL_POOL_TOPIC = "local-models:pool";
|
|
4264
|
+
var MODEL_INSTALL_TOPIC = "local-models:install";
|
|
4265
|
+
function makeInstallProgressForwarder(bus, base) {
|
|
4266
|
+
const emit4 = (phase, patch = {}) => {
|
|
4267
|
+
const frame = { ...base, ...patch, phase };
|
|
4268
|
+
bus.emit(MODEL_INSTALL_TOPIC, frame);
|
|
4269
|
+
};
|
|
4270
|
+
const onInstallEvent = (event) => {
|
|
4271
|
+
if (event.kind === "progress") {
|
|
4272
|
+
emit4("progress", {
|
|
4273
|
+
completedBytes: event.completedBytes,
|
|
4274
|
+
totalBytes: event.totalBytes,
|
|
4275
|
+
...event.message !== void 0 ? { message: event.message } : {}
|
|
4276
|
+
});
|
|
4277
|
+
} else if (event.kind === "pulling") {
|
|
4278
|
+
emit4("progress", { message: event.message });
|
|
4279
|
+
}
|
|
4280
|
+
};
|
|
4281
|
+
return { emit: emit4, onInstallEvent };
|
|
4282
|
+
}
|
|
4283
|
+
function decisionOf(deps, action, reason) {
|
|
4284
|
+
return {
|
|
4285
|
+
decidedAt: (deps.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
|
|
4286
|
+
decidedBy: deps.decidedBy ?? "orchestrator",
|
|
4287
|
+
action,
|
|
4288
|
+
...reason !== void 0 ? { reason } : {}
|
|
4289
|
+
};
|
|
4290
|
+
}
|
|
4291
|
+
function emitApproved(deps, proposal) {
|
|
4292
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
4293
|
+
id: proposal.id,
|
|
4294
|
+
status: "approved",
|
|
4295
|
+
action: proposal.model.action,
|
|
4296
|
+
target: proposal.model.target.ollamaName
|
|
4297
|
+
});
|
|
4298
|
+
}
|
|
4299
|
+
async function deferEviction(deps, proposal, deferredName, event, evicted) {
|
|
4300
|
+
deps.pool.markPendingEviction(deferredName);
|
|
4301
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
4302
|
+
status: "approved",
|
|
4303
|
+
decision: decisionOf(deps, "approved")
|
|
4304
|
+
});
|
|
4305
|
+
deps.bus.emit(MODEL_POOL_TOPIC, event);
|
|
4306
|
+
emitApproved(deps, proposal);
|
|
4307
|
+
return { status: "approved", proposal: updated, evicted };
|
|
4308
|
+
}
|
|
4309
|
+
async function onApproveModelProposal(deps, proposal) {
|
|
4310
|
+
const { model } = proposal;
|
|
4311
|
+
if (model.action === "evict") {
|
|
4312
|
+
return applyEvictOnly(deps, proposal);
|
|
4313
|
+
}
|
|
4314
|
+
await deps.updateProposal(proposal.id, { status: "installing" });
|
|
4315
|
+
const installResult = await deps.pool.install({
|
|
4316
|
+
hfRepoId: model.target.hfRepoId,
|
|
4317
|
+
ollamaName: model.target.ollamaName,
|
|
4318
|
+
...model.replaces !== void 0 ? { replaces: model.replaces.ollamaName } : {},
|
|
4319
|
+
...model.diskImpactGb > 0 ? { sizeOnDiskGb: model.diskImpactGb } : {},
|
|
4320
|
+
...model.targetScore !== void 0 ? { initialScore: model.targetScore } : {},
|
|
4321
|
+
...deps.onInstallEvent ? { onEvent: deps.onInstallEvent } : {}
|
|
4322
|
+
});
|
|
4323
|
+
if (installResult.status === "error") {
|
|
4324
|
+
if (installResult.code === "failed_target_missing") {
|
|
4325
|
+
const updated2 = await deps.updateProposal(proposal.id, {
|
|
4326
|
+
status: "failed_target_missing"
|
|
4327
|
+
});
|
|
4328
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
4329
|
+
id: proposal.id,
|
|
4330
|
+
status: "failed_target_missing",
|
|
4331
|
+
action: model.action,
|
|
4332
|
+
target: model.target.ollamaName
|
|
4333
|
+
});
|
|
4334
|
+
return { status: "failed_target_missing", proposal: updated2 };
|
|
4335
|
+
}
|
|
4336
|
+
await deps.updateProposal(proposal.id, { status: "open" });
|
|
4337
|
+
return { status: "error", code: installResult.code, message: installResult.message };
|
|
4338
|
+
}
|
|
4339
|
+
const evicted = [...installResult.evicted];
|
|
4340
|
+
if (model.action === "swap" && model.replaces !== void 0) {
|
|
4341
|
+
const replacesName = model.replaces.ollamaName;
|
|
4342
|
+
if (isInUse(deps, replacesName)) {
|
|
4343
|
+
return deferEviction(
|
|
4344
|
+
deps,
|
|
4345
|
+
proposal,
|
|
4346
|
+
replacesName,
|
|
4347
|
+
{
|
|
4348
|
+
id: proposal.id,
|
|
4349
|
+
action: model.action,
|
|
4350
|
+
installed: model.target.ollamaName,
|
|
4351
|
+
phase: "evict_deferred",
|
|
4352
|
+
deferred: replacesName,
|
|
4353
|
+
...installResult.evicted.length > 0 ? { evicted: installResult.evicted.map((e) => e.ollamaName) } : {}
|
|
4354
|
+
},
|
|
4355
|
+
evicted
|
|
4356
|
+
);
|
|
4357
|
+
}
|
|
4358
|
+
const evictResult = await deps.pool.evict({ ollamaName: replacesName });
|
|
4359
|
+
if (evictResult.status === "error") {
|
|
4360
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
4361
|
+
id: proposal.id,
|
|
4362
|
+
action: model.action,
|
|
4363
|
+
installed: model.target.ollamaName,
|
|
4364
|
+
phase: "swap_evict_failed"
|
|
4365
|
+
});
|
|
4366
|
+
await deps.updateProposal(proposal.id, { status: "open" });
|
|
4367
|
+
return { status: "error", code: evictResult.code, message: evictResult.message };
|
|
4368
|
+
}
|
|
4369
|
+
if (evictResult.removed !== null) {
|
|
4370
|
+
evicted.push(evictResult.removed);
|
|
4371
|
+
}
|
|
4372
|
+
}
|
|
4373
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
4374
|
+
status: "approved",
|
|
4375
|
+
decision: decisionOf(deps, "approved")
|
|
4376
|
+
});
|
|
4377
|
+
const evictedNames = [
|
|
4378
|
+
...installResult.evicted.map((e) => e.ollamaName),
|
|
4379
|
+
...model.replaces !== void 0 ? [model.replaces.ollamaName] : []
|
|
4380
|
+
];
|
|
4381
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
4382
|
+
id: proposal.id,
|
|
4383
|
+
action: model.action,
|
|
4384
|
+
installed: model.target.ollamaName,
|
|
4385
|
+
...evictedNames.length > 0 ? { evicted: evictedNames } : {}
|
|
4386
|
+
});
|
|
4387
|
+
emitApproved(deps, proposal);
|
|
4388
|
+
return { status: "approved", proposal: updated, evicted };
|
|
4389
|
+
}
|
|
4390
|
+
async function applyEvictOnly(deps, proposal) {
|
|
4391
|
+
const targetName = proposal.model.target.ollamaName;
|
|
4392
|
+
if (isInUse(deps, targetName)) {
|
|
4393
|
+
return deferEviction(
|
|
4394
|
+
deps,
|
|
4395
|
+
proposal,
|
|
4396
|
+
targetName,
|
|
4397
|
+
{ id: proposal.id, action: "evict", phase: "evict_deferred", deferred: targetName },
|
|
4398
|
+
[]
|
|
4399
|
+
);
|
|
4400
|
+
}
|
|
4401
|
+
const evictResult = await deps.pool.evict({ ollamaName: targetName });
|
|
4402
|
+
if (evictResult.status === "error") {
|
|
4403
|
+
return { status: "error", code: evictResult.code, message: evictResult.message };
|
|
4404
|
+
}
|
|
4405
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
4406
|
+
status: "approved",
|
|
4407
|
+
decision: decisionOf(deps, "approved")
|
|
4408
|
+
});
|
|
4409
|
+
deps.bus.emit(MODEL_POOL_TOPIC, {
|
|
4410
|
+
id: proposal.id,
|
|
4411
|
+
action: "evict",
|
|
4412
|
+
// XP-2: `evicted` is string[] at EVERY local-models:pool emit site (swap/add
|
|
4413
|
+
// list multiple removals) — the evict-only path wraps its single removal in
|
|
4414
|
+
// an array too, so consumers never branch on string-vs-array.
|
|
4415
|
+
evicted: [proposal.model.target.ollamaName]
|
|
4416
|
+
});
|
|
4417
|
+
emitApproved(deps, proposal);
|
|
4418
|
+
return {
|
|
4419
|
+
status: "approved",
|
|
4420
|
+
proposal: updated,
|
|
4421
|
+
evicted: evictResult.removed !== null && evictResult.removed !== void 0 ? [evictResult.removed] : []
|
|
4422
|
+
};
|
|
4423
|
+
}
|
|
4424
|
+
async function onRejectModelProposal(deps, proposal, reason) {
|
|
4425
|
+
const updated = await deps.updateProposal(proposal.id, {
|
|
4426
|
+
status: "rejected",
|
|
4427
|
+
decision: decisionOf(deps, "rejected", reason)
|
|
4428
|
+
});
|
|
4429
|
+
deps.bus.emit(MODEL_PROPOSAL_TOPIC, {
|
|
4430
|
+
id: proposal.id,
|
|
4431
|
+
status: "rejected",
|
|
4432
|
+
target: proposal.model.target.ollamaName,
|
|
4433
|
+
replaces: proposal.model.replaces?.ollamaName,
|
|
4434
|
+
reason
|
|
4435
|
+
});
|
|
4436
|
+
return updated;
|
|
4437
|
+
}
|
|
4438
|
+
async function redriveInstallingProposals(deps, proposals, opts = {}) {
|
|
4439
|
+
for (const proposal of proposals) {
|
|
4440
|
+
if (proposal.status !== "installing") continue;
|
|
4441
|
+
if (proposal.model.action === "evict") continue;
|
|
4442
|
+
const { emit: emit4, onInstallEvent } = makeInstallProgressForwarder(deps.bus, {
|
|
4443
|
+
proposalId: proposal.id,
|
|
4444
|
+
hfRepoId: proposal.model.target.hfRepoId,
|
|
4445
|
+
ollamaName: proposal.model.target.ollamaName
|
|
4446
|
+
});
|
|
4447
|
+
emit4("started");
|
|
4448
|
+
try {
|
|
4449
|
+
const outcome = await onApproveModelProposal({ ...deps, onInstallEvent }, proposal);
|
|
4450
|
+
if (outcome.status === "approved") {
|
|
4451
|
+
emit4("complete");
|
|
4452
|
+
} else if (outcome.status === "failed_target_missing") {
|
|
4453
|
+
emit4("error", {
|
|
4454
|
+
code: "failed_target_missing",
|
|
4455
|
+
message: `${proposal.model.target.hfRepoId} is no longer available on HuggingFace`
|
|
4456
|
+
});
|
|
4457
|
+
} else {
|
|
4458
|
+
emit4("error", { code: outcome.code, message: outcome.message });
|
|
4459
|
+
}
|
|
4460
|
+
} catch (err) {
|
|
4461
|
+
opts.onWarn?.("re-drive of interrupted install failed", err);
|
|
4462
|
+
emit4("error", { message: err instanceof Error ? err.message : String(err) });
|
|
4463
|
+
}
|
|
4464
|
+
}
|
|
4465
|
+
}
|
|
4466
|
+
|
|
4090
4467
|
// src/agent/config-migration.ts
|
|
4091
4468
|
var MIGRATION_GUIDE = "docs/guides/multi-backend-routing.md";
|
|
4092
4469
|
var CASE1_ALWAYS_SUPPRESS = /* @__PURE__ */ new Set(["agent.backend"]);
|
|
@@ -5129,6 +5506,8 @@ var LocalBackend = class {
|
|
|
5129
5506
|
name = "local";
|
|
5130
5507
|
config;
|
|
5131
5508
|
getModel;
|
|
5509
|
+
onModelUsed;
|
|
5510
|
+
onModelFailed;
|
|
5132
5511
|
client;
|
|
5133
5512
|
constructor(config = {}) {
|
|
5134
5513
|
this.config = {
|
|
@@ -5138,6 +5517,8 @@ var LocalBackend = class {
|
|
|
5138
5517
|
timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
5139
5518
|
};
|
|
5140
5519
|
this.getModel = config.getModel;
|
|
5520
|
+
this.onModelUsed = config.onModelUsed;
|
|
5521
|
+
this.onModelFailed = config.onModelFailed;
|
|
5141
5522
|
this.client = new import_openai2.default({
|
|
5142
5523
|
apiKey: this.config.apiKey,
|
|
5143
5524
|
baseURL: this.config.endpoint,
|
|
@@ -5204,6 +5585,7 @@ var LocalBackend = class {
|
|
|
5204
5585
|
}
|
|
5205
5586
|
} catch (err) {
|
|
5206
5587
|
const errorMessage2 = err instanceof Error ? err.message : "Local backend request failed";
|
|
5588
|
+
this.notify(this.onModelFailed, localSession.resolvedModel);
|
|
5207
5589
|
yield {
|
|
5208
5590
|
type: "error",
|
|
5209
5591
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
@@ -5217,6 +5599,7 @@ var LocalBackend = class {
|
|
|
5217
5599
|
error: errorMessage2
|
|
5218
5600
|
};
|
|
5219
5601
|
}
|
|
5602
|
+
this.notify(this.onModelUsed, localSession.resolvedModel);
|
|
5220
5603
|
const usage = { inputTokens, outputTokens, totalTokens };
|
|
5221
5604
|
yield {
|
|
5222
5605
|
type: "usage",
|
|
@@ -5230,6 +5613,14 @@ var LocalBackend = class {
|
|
|
5230
5613
|
usage
|
|
5231
5614
|
};
|
|
5232
5615
|
}
|
|
5616
|
+
/** Best-effort telemetry hook invocation — a throwing hook never breaks a turn. */
|
|
5617
|
+
notify(hook, model) {
|
|
5618
|
+
if (!hook) return;
|
|
5619
|
+
try {
|
|
5620
|
+
hook(model);
|
|
5621
|
+
} catch {
|
|
5622
|
+
}
|
|
5623
|
+
}
|
|
5233
5624
|
async stopSession(_session) {
|
|
5234
5625
|
return (0, import_types14.Ok)(void 0);
|
|
5235
5626
|
}
|
|
@@ -5391,7 +5782,8 @@ var PiBackend = class {
|
|
|
5391
5782
|
backendName: this.name,
|
|
5392
5783
|
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
5393
5784
|
piSession,
|
|
5394
|
-
unsubscribe: null
|
|
5785
|
+
unsubscribe: null,
|
|
5786
|
+
...resolvedModelName !== void 0 && { resolvedModel: resolvedModelName }
|
|
5395
5787
|
};
|
|
5396
5788
|
return (0, import_types15.Ok)(session);
|
|
5397
5789
|
} catch (err) {
|
|
@@ -5483,7 +5875,9 @@ var PiBackend = class {
|
|
|
5483
5875
|
}
|
|
5484
5876
|
}
|
|
5485
5877
|
const totalTokens = inputTokens + outputTokens;
|
|
5878
|
+
const resolvedModel = session.resolvedModel;
|
|
5486
5879
|
if (promptErrorMsg) {
|
|
5880
|
+
if (resolvedModel !== void 0) this.notify(this.config.onModelFailed, resolvedModel);
|
|
5487
5881
|
return {
|
|
5488
5882
|
success: false,
|
|
5489
5883
|
sessionId: session.sessionId,
|
|
@@ -5491,12 +5885,21 @@ var PiBackend = class {
|
|
|
5491
5885
|
usage: { inputTokens, outputTokens, totalTokens }
|
|
5492
5886
|
};
|
|
5493
5887
|
}
|
|
5888
|
+
if (resolvedModel !== void 0) this.notify(this.config.onModelUsed, resolvedModel);
|
|
5494
5889
|
return {
|
|
5495
5890
|
success: true,
|
|
5496
5891
|
sessionId: session.sessionId,
|
|
5497
5892
|
usage: { inputTokens, outputTokens, totalTokens }
|
|
5498
5893
|
};
|
|
5499
5894
|
}
|
|
5895
|
+
/** Best-effort telemetry hook invocation — a throwing hook never breaks a turn. */
|
|
5896
|
+
notify(hook, model) {
|
|
5897
|
+
if (!hook) return;
|
|
5898
|
+
try {
|
|
5899
|
+
hook(model);
|
|
5900
|
+
} catch {
|
|
5901
|
+
}
|
|
5902
|
+
}
|
|
5500
5903
|
/**
|
|
5501
5904
|
* Consume events from the queue, yielding mapped AgentEvents until agent_end or prompt completion.
|
|
5502
5905
|
*/
|
|
@@ -6598,8 +7001,9 @@ var OrchestratorBackendFactory = class {
|
|
|
6598
7001
|
let backend;
|
|
6599
7002
|
const createOpts = this.opts.cacheMetrics ? { cacheMetrics: this.opts.cacheMetrics } : {};
|
|
6600
7003
|
if ((def.type === "local" || def.type === "pi") && this.opts.getResolverModelFor) {
|
|
6601
|
-
const getModel = this.opts.getResolverModelFor(name);
|
|
6602
|
-
|
|
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"
|
|
9362
|
+
if (method !== "POST") return false;
|
|
9363
|
+
if (CANDIDATES_REFRESH_RE.test(url)) {
|
|
9364
|
+
const refresh = deps.getRefreshCandidates?.() ?? null;
|
|
9365
|
+
if (refresh === null) {
|
|
9366
|
+
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
9367
|
+
return true;
|
|
9368
|
+
}
|
|
9369
|
+
void runCandidatesRefresh(res, refresh);
|
|
9370
|
+
return true;
|
|
9371
|
+
}
|
|
9372
|
+
if (!REFRESH_RE.test(url)) return false;
|
|
9082
9373
|
const scheduler = deps.getRefreshScheduler();
|
|
9083
9374
|
if (scheduler === null) {
|
|
9084
9375
|
sendJSON9(res, 503, { error: "LMLM disabled" });
|
|
@@ -9087,6 +9378,16 @@ function handleV1LocalModelsRoute(req, res, deps) {
|
|
|
9087
9378
|
void runForceRefresh(res, scheduler, deps);
|
|
9088
9379
|
return true;
|
|
9089
9380
|
}
|
|
9381
|
+
async function runCandidatesRefresh(res, refresh) {
|
|
9382
|
+
try {
|
|
9383
|
+
sendJSON9(res, 200, await refresh());
|
|
9384
|
+
} catch (err) {
|
|
9385
|
+
sendJSON9(res, 500, {
|
|
9386
|
+
error: "candidate refresh failed",
|
|
9387
|
+
detail: err instanceof Error ? err.message : "unknown"
|
|
9388
|
+
});
|
|
9389
|
+
}
|
|
9390
|
+
}
|
|
9090
9391
|
async function runGet(res, handler) {
|
|
9091
9392
|
try {
|
|
9092
9393
|
await handler();
|
|
@@ -9247,6 +9548,11 @@ async function handleInstall(req, res, deps) {
|
|
|
9247
9548
|
action: "add",
|
|
9248
9549
|
target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
|
|
9249
9550
|
scoreDelta: match.score,
|
|
9551
|
+
// Consumption Phase 2 (T7): carry the absolute ranked score so the new pool
|
|
9552
|
+
// entry seeds `currentScore` at its real rank instead of 0 — an
|
|
9553
|
+
// operator-initiated install is usable immediately, not buried until the
|
|
9554
|
+
// scheduler's next re-rank.
|
|
9555
|
+
targetScore: match.score,
|
|
9250
9556
|
justification: {
|
|
9251
9557
|
summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
|
|
9252
9558
|
benchmarkBasis: [],
|
|
@@ -9269,27 +9575,37 @@ async function handleInstall(req, res, deps) {
|
|
|
9269
9575
|
const record = await (0, import_core11.createModelProposal)(deps.projectPath, content, {
|
|
9270
9576
|
proposedBy: decidedByOf(deps, req)
|
|
9271
9577
|
});
|
|
9272
|
-
const
|
|
9273
|
-
|
|
9274
|
-
|
|
9275
|
-
|
|
9276
|
-
|
|
9277
|
-
|
|
9278
|
-
|
|
9279
|
-
|
|
9280
|
-
|
|
9281
|
-
|
|
9282
|
-
|
|
9283
|
-
|
|
9284
|
-
|
|
9285
|
-
|
|
9286
|
-
|
|
9287
|
-
|
|
9288
|
-
|
|
9289
|
-
|
|
9290
|
-
|
|
9291
|
-
|
|
9578
|
+
const ollamaName = match.ollamaName;
|
|
9579
|
+
const { emit: emit4, onInstallEvent } = makeInstallProgressForwarder(deps.bus, {
|
|
9580
|
+
proposalId: record.id,
|
|
9581
|
+
hfRepoId: match.hfRepoId,
|
|
9582
|
+
ollamaName
|
|
9583
|
+
});
|
|
9584
|
+
const handler = { ...handlerDeps(deps, req, pool), onInstallEvent };
|
|
9585
|
+
emit4("started");
|
|
9586
|
+
void onApproveModelProposal(handler, record).then((outcome) => {
|
|
9587
|
+
if (outcome.status === "approved") {
|
|
9588
|
+
emit4("complete", {});
|
|
9589
|
+
return;
|
|
9590
|
+
}
|
|
9591
|
+
if (outcome.status === "failed_target_missing") {
|
|
9592
|
+
emit4("error", {
|
|
9593
|
+
code: "failed_target_missing",
|
|
9594
|
+
message: `${hfRepoId} is no longer available on HuggingFace`
|
|
9595
|
+
});
|
|
9596
|
+
return;
|
|
9597
|
+
}
|
|
9598
|
+
emit4("error", { code: outcome.code, message: outcome.message });
|
|
9599
|
+
}).catch((err) => {
|
|
9600
|
+
emit4("error", { message: err instanceof Error ? err.message : String(err) });
|
|
9292
9601
|
});
|
|
9602
|
+
const result = {
|
|
9603
|
+
disposition: "installing",
|
|
9604
|
+
proposalId: record.id,
|
|
9605
|
+
evicted: [],
|
|
9606
|
+
message: `installing ${ollamaName} \u2014 progress streams on the local-models:install channel`
|
|
9607
|
+
};
|
|
9608
|
+
return sendJSON10(res, 202, result);
|
|
9293
9609
|
}
|
|
9294
9610
|
async function handleRemove(req, res, deps) {
|
|
9295
9611
|
const pool = deps.getModelPool();
|
|
@@ -10235,6 +10551,12 @@ var V1_BRIDGE_ROUTES = [
|
|
|
10235
10551
|
scope: "manage-proposals",
|
|
10236
10552
|
description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
|
|
10237
10553
|
},
|
|
10554
|
+
{
|
|
10555
|
+
method: "POST",
|
|
10556
|
+
pattern: /^\/api\/v1\/local-models\/candidates\/refresh(?:\?.*)?$/,
|
|
10557
|
+
scope: "manage-proposals",
|
|
10558
|
+
description: "Re-discover candidates live from HuggingFace, re-seed the recommender, re-rank."
|
|
10559
|
+
},
|
|
10238
10560
|
// ── LMLM dashboard pool mutation — operator-initiated install/remove ──
|
|
10239
10561
|
// Modeled as auto-approved model proposals, so the same `manage-proposals`
|
|
10240
10562
|
// write scope that governs approve/reject governs these too.
|
|
@@ -10425,6 +10747,7 @@ var OrchestratorServer = class {
|
|
|
10425
10747
|
// LMLM Phase 6 — live model pool + refresh scheduler accessors.
|
|
10426
10748
|
getModelPoolFn = null;
|
|
10427
10749
|
getRefreshSchedulerFn = null;
|
|
10750
|
+
getRefreshCandidatesFn = null;
|
|
10428
10751
|
// LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
|
|
10429
10752
|
getHardwareProfileFn = null;
|
|
10430
10753
|
getRecommendationsFn = null;
|
|
@@ -10435,6 +10758,8 @@ var OrchestratorServer = class {
|
|
|
10435
10758
|
// LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
|
|
10436
10759
|
modelProposalListener = null;
|
|
10437
10760
|
modelPoolListener = null;
|
|
10761
|
+
// LMLM Phase 10 — bus→WS fan-out for byte-level install progress (D3 async install).
|
|
10762
|
+
modelInstallListener = null;
|
|
10438
10763
|
recorder = null;
|
|
10439
10764
|
planWatcher = null;
|
|
10440
10765
|
tokenStore;
|
|
@@ -10481,6 +10806,7 @@ var OrchestratorServer = class {
|
|
|
10481
10806
|
this.getBackendsFn = deps?.getBackends ?? null;
|
|
10482
10807
|
this.getModelPoolFn = deps?.getModelPool ?? null;
|
|
10483
10808
|
this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
|
|
10809
|
+
this.getRefreshCandidatesFn = deps?.getRefreshCandidates ?? null;
|
|
10484
10810
|
this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
|
|
10485
10811
|
this.getRecommendationsFn = deps?.getRecommendations ?? null;
|
|
10486
10812
|
this.listModelProposalsFn = deps?.listModelProposals ?? null;
|
|
@@ -10503,8 +10829,10 @@ var OrchestratorServer = class {
|
|
|
10503
10829
|
}
|
|
10504
10830
|
this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
|
|
10505
10831
|
this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
|
|
10832
|
+
this.modelInstallListener = (data) => this.broadcaster.broadcast(MODEL_INSTALL_TOPIC, data);
|
|
10506
10833
|
this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
|
|
10507
10834
|
this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
|
|
10835
|
+
this.orchestrator.on(MODEL_INSTALL_TOPIC, this.modelInstallListener);
|
|
10508
10836
|
}
|
|
10509
10837
|
/**
|
|
10510
10838
|
* Broadcast a new interaction to all WebSocket clients.
|
|
@@ -10701,6 +11029,7 @@ var OrchestratorServer = class {
|
|
|
10701
11029
|
// only when configured so absent ones surface as 503 (LMLM disabled).
|
|
10702
11030
|
(req, res) => handleV1LocalModelsRoute(req, res, {
|
|
10703
11031
|
getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
|
|
11032
|
+
...this.getRefreshCandidatesFn ? { getRefreshCandidates: this.getRefreshCandidatesFn } : {},
|
|
10704
11033
|
getModelPool: () => this.getModelPoolFn?.() ?? null,
|
|
10705
11034
|
...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
|
|
10706
11035
|
...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
|
|
@@ -10816,6 +11145,10 @@ var OrchestratorServer = class {
|
|
|
10816
11145
|
this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
|
|
10817
11146
|
this.modelPoolListener = null;
|
|
10818
11147
|
}
|
|
11148
|
+
if (this.modelInstallListener) {
|
|
11149
|
+
this.orchestrator.removeListener(MODEL_INSTALL_TOPIC, this.modelInstallListener);
|
|
11150
|
+
this.modelInstallListener = null;
|
|
11151
|
+
}
|
|
10819
11152
|
if (this.planWatcher) {
|
|
10820
11153
|
this.planWatcher.stop();
|
|
10821
11154
|
this.planWatcher = null;
|
|
@@ -13908,6 +14241,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13908
14241
|
* so this map is the single source of truth post-migration.
|
|
13909
14242
|
*/
|
|
13910
14243
|
localResolvers = /* @__PURE__ */ new Map();
|
|
14244
|
+
/**
|
|
14245
|
+
* Consumption Phase 1 (T2): bus listener that debounce-refreshes every local
|
|
14246
|
+
* resolver when a `local-models:pool` mutation fires, so a just-installed or
|
|
14247
|
+
* swapped model becomes usable within the refresh window instead of waiting up
|
|
14248
|
+
* to `probeIntervalMs` for the next poll. Held for removal in {@link stop}.
|
|
14249
|
+
*/
|
|
14250
|
+
poolRefreshListener = null;
|
|
13911
14251
|
/** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
|
|
13912
14252
|
poolStateProvider = null;
|
|
13913
14253
|
poolStateStore = null;
|
|
@@ -13945,6 +14285,13 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
13945
14285
|
* see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
|
|
13946
14286
|
*/
|
|
13947
14287
|
modelRecommender = null;
|
|
14288
|
+
/** Live HF candidate discovery (injectable for tests so startup makes no network calls). */
|
|
14289
|
+
discoverCandidatesFn;
|
|
14290
|
+
/** Snapshot of the last candidate seeding, surfaced to the refresh route. */
|
|
14291
|
+
candidateSourceState = {
|
|
14292
|
+
source: "frozen",
|
|
14293
|
+
count: 0
|
|
14294
|
+
};
|
|
13948
14295
|
/** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
|
|
13949
14296
|
schedulerTimerOverride;
|
|
13950
14297
|
/**
|
|
@@ -14038,6 +14385,7 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
14038
14385
|
constructor(config, promptTemplate, overrides) {
|
|
14039
14386
|
super();
|
|
14040
14387
|
this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
|
|
14388
|
+
this.discoverCandidatesFn = overrides?.discoverCandidates ?? (async () => ({ candidates: [], warnings: [] }));
|
|
14041
14389
|
this.setMaxListeners(50);
|
|
14042
14390
|
this.config = config;
|
|
14043
14391
|
this.promptTemplate = promptTemplate;
|
|
@@ -14106,6 +14454,17 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
14106
14454
|
if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
|
|
14107
14455
|
if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
|
|
14108
14456
|
if (this.poolStateProvider !== null) resolverOpts.poolState = this.poolStateProvider;
|
|
14457
|
+
const endpoint = def.endpoint;
|
|
14458
|
+
const apiKey = def.apiKey;
|
|
14459
|
+
if (def.type === "local") {
|
|
14460
|
+
resolverOpts.warmModel = (ollamaName) => {
|
|
14461
|
+
void defaultWarmModel(endpoint, ollamaName, apiKey);
|
|
14462
|
+
};
|
|
14463
|
+
} else {
|
|
14464
|
+
resolverOpts.warmModel = (model) => {
|
|
14465
|
+
void defaultWarmModelViaCompletion(endpoint, model, apiKey);
|
|
14466
|
+
};
|
|
14467
|
+
}
|
|
14109
14468
|
this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
|
|
14110
14469
|
}
|
|
14111
14470
|
}
|
|
@@ -14128,9 +14487,27 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
14128
14487
|
...this.config.agent.secrets !== void 0 ? { secrets: this.config.agent.secrets } : {},
|
|
14129
14488
|
cacheMetrics: this.cacheMetrics,
|
|
14130
14489
|
decisionBus: this.routingDecisionBus,
|
|
14131
|
-
getResolverModelFor: (name) => {
|
|
14490
|
+
getResolverModelFor: (name, useCase) => {
|
|
14491
|
+
const resolver = this.localResolvers.get(name);
|
|
14492
|
+
return resolver ? () => resolver.resolveModel(useCase) : void 0;
|
|
14493
|
+
},
|
|
14494
|
+
// Consumption Phase 3 (T11): bind per-backend runtime feedback. A
|
|
14495
|
+
// successful turn stamps `lastUsedAt` (LRU) via the pool and clears the
|
|
14496
|
+
// resolver's circuit breaker; a failed turn feeds the breaker so a
|
|
14497
|
+
// repeatedly-failing model is deprioritized. `modelPool` is read lazily
|
|
14498
|
+
// (per dispatch) because it loads in start(), after this constructor.
|
|
14499
|
+
getModelUsageHooksFor: (name) => {
|
|
14132
14500
|
const resolver = this.localResolvers.get(name);
|
|
14133
|
-
|
|
14501
|
+
if (!resolver) return void 0;
|
|
14502
|
+
return {
|
|
14503
|
+
onModelUsed: (model) => {
|
|
14504
|
+
resolver.recordSuccess(model);
|
|
14505
|
+
void this.modelPool?.markUsed(model);
|
|
14506
|
+
},
|
|
14507
|
+
onModelFailed: (model) => {
|
|
14508
|
+
resolver.recordFailure(model);
|
|
14509
|
+
}
|
|
14510
|
+
};
|
|
14134
14511
|
}
|
|
14135
14512
|
});
|
|
14136
14513
|
} else {
|
|
@@ -14227,6 +14604,9 @@ var Orchestrator = class extends import_node_events.EventEmitter {
|
|
|
14227
14604
|
isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
|
|
14228
14605
|
// LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
|
|
14229
14606
|
getRefreshScheduler: () => this.refreshScheduler,
|
|
14607
|
+
// Live candidate refresh for POST /local-models/candidates/refresh (the
|
|
14608
|
+
// "Refresh" button). Null when LMLM is disabled → route 503s.
|
|
14609
|
+
getRefreshCandidates: () => this.modelPool ? () => this.refreshCandidatesLive() : null,
|
|
14230
14610
|
// LMLM Phase 7 read surface — hardware / recommendations / model proposals.
|
|
14231
14611
|
// Each returns null/[] when LMLM is disabled so the route renders 503/[].
|
|
14232
14612
|
getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
|
|
@@ -15294,10 +15674,51 @@ ${messages}`);
|
|
|
15294
15674
|
const installerCfg = this.config.localModels?.installer;
|
|
15295
15675
|
this.modelInstaller = new import_local_models5.OllamaInstallAdapter({
|
|
15296
15676
|
baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
|
|
15297
|
-
onWarn
|
|
15677
|
+
onWarn,
|
|
15678
|
+
// Survive transient `/api/pull` drops (most often the host sleeping mid
|
|
15679
|
+
// multi-GB download): ollama resumes from cached blobs, and any forward
|
|
15680
|
+
// progress resets the budget, so an install nibbled through across several
|
|
15681
|
+
// sleep cycles still completes instead of dead-ending in an error.
|
|
15682
|
+
maxPullRetries: 5
|
|
15298
15683
|
});
|
|
15299
15684
|
this.modelPool = new import_local_models5.PoolManager({ store, installer: this.modelInstaller, onWarn });
|
|
15300
15685
|
}
|
|
15686
|
+
/**
|
|
15687
|
+
* Resume installs interrupted by a restart. A proposal left `installing` had
|
|
15688
|
+
* its background `ollama pull` cut short when the orchestrator went down; the
|
|
15689
|
+
* pull is idempotent (ollama resumes from cached blobs), so we re-drive it.
|
|
15690
|
+
* Fire-and-forget with its own error isolation — a resumed multi-GB download
|
|
15691
|
+
* must not block startup, and a re-drive failure only logs.
|
|
15692
|
+
*/
|
|
15693
|
+
redriveInterruptedInstalls() {
|
|
15694
|
+
const pool = this.modelPool;
|
|
15695
|
+
if (pool === null) return;
|
|
15696
|
+
void (async () => {
|
|
15697
|
+
try {
|
|
15698
|
+
const modelProposals = await (0, import_core18.listProposals)(this.projectRoot, {
|
|
15699
|
+
kind: "model"
|
|
15700
|
+
});
|
|
15701
|
+
const installing = modelProposals.filter((p) => p.status === "installing");
|
|
15702
|
+
if (installing.length === 0) return;
|
|
15703
|
+
this.logger.info(`Resuming ${installing.length} model install(s) interrupted by a restart`);
|
|
15704
|
+
await redriveInstallingProposals(
|
|
15705
|
+
{
|
|
15706
|
+
pool,
|
|
15707
|
+
bus: this,
|
|
15708
|
+
updateProposal: (id, patch) => (0, import_core18.updateProposal)(this.projectRoot, id, patch),
|
|
15709
|
+
decidedBy: "orchestrator",
|
|
15710
|
+
isModelInUse: (name) => this.isLocalModelInUse(name)
|
|
15711
|
+
},
|
|
15712
|
+
installing,
|
|
15713
|
+
{
|
|
15714
|
+
onWarn: (message, cause) => this.logger.warn(message, cause !== void 0 ? { cause } : void 0)
|
|
15715
|
+
}
|
|
15716
|
+
);
|
|
15717
|
+
} catch (err) {
|
|
15718
|
+
this.logger.warn("interrupted-install re-drive failed", { cause: err });
|
|
15719
|
+
}
|
|
15720
|
+
})();
|
|
15721
|
+
}
|
|
15301
15722
|
/**
|
|
15302
15723
|
* LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
|
|
15303
15724
|
* budget + org/family allowlist) from `localModels.pool` to the live pool.
|
|
@@ -15343,8 +15764,8 @@ ${messages}`);
|
|
|
15343
15764
|
allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
|
|
15344
15765
|
});
|
|
15345
15766
|
}
|
|
15346
|
-
|
|
15347
|
-
this.modelRecommender
|
|
15767
|
+
this.seedRecommender(candidates, "frozen");
|
|
15768
|
+
const recommend = (hardware) => this.modelRecommender(hardware);
|
|
15348
15769
|
this.refreshScheduler = new import_local_models5.RefreshScheduler({
|
|
15349
15770
|
runTick: () => (0, import_local_models5.runRefreshTick)({
|
|
15350
15771
|
detectHardware: () => this.detectLmlmHardware(),
|
|
@@ -15374,6 +15795,55 @@ ${messages}`);
|
|
|
15374
15795
|
});
|
|
15375
15796
|
this.refreshScheduler.start();
|
|
15376
15797
|
}
|
|
15798
|
+
/** (Re)build the recommender over `candidates` and record the seeding source. */
|
|
15799
|
+
seedRecommender(candidates, source) {
|
|
15800
|
+
this.modelRecommender = (0, import_local_models5.createNativeRecommender)({ candidates });
|
|
15801
|
+
this.candidateSourceState = { source, count: candidates.length };
|
|
15802
|
+
}
|
|
15803
|
+
/**
|
|
15804
|
+
* Refresh ranking candidates live from HuggingFace, merge the curated
|
|
15805
|
+
* `ollamaName`/`family` tags from the frozen snapshot (so results stay
|
|
15806
|
+
* installable — decision A), and re-seed the recommender. Fail-closed: on any
|
|
15807
|
+
* error or an empty installable result, the current candidates stand. Runs a
|
|
15808
|
+
* `forceRefresh` tick so recommendations + proposals reflect the fresh set.
|
|
15809
|
+
* Used by both the startup background refresh and the operator "Refresh" button.
|
|
15810
|
+
*/
|
|
15811
|
+
async refreshCandidatesLive(signal) {
|
|
15812
|
+
const poolCfg = this.config.localModels?.pool;
|
|
15813
|
+
const orgs = poolCfg?.allowedOrgs ?? [];
|
|
15814
|
+
if (orgs.length === 0) return this.candidateSourceState;
|
|
15815
|
+
const curation = (0, import_local_models5.curationFromCandidates)((0, import_local_models5.loadFrozenCandidates)().candidates);
|
|
15816
|
+
let result;
|
|
15817
|
+
try {
|
|
15818
|
+
result = await this.discoverCandidatesFn({
|
|
15819
|
+
orgs,
|
|
15820
|
+
curation,
|
|
15821
|
+
...signal ? { signal } : {},
|
|
15822
|
+
onWarn: (m, cause) => this.logger.warn(m, cause !== void 0 ? { cause } : void 0)
|
|
15823
|
+
});
|
|
15824
|
+
} catch (err) {
|
|
15825
|
+
this.logger.warn("LMLM live candidate discovery failed; keeping current candidates", {
|
|
15826
|
+
cause: err
|
|
15827
|
+
});
|
|
15828
|
+
return this.candidateSourceState;
|
|
15829
|
+
}
|
|
15830
|
+
const selected = (0, import_local_models5.selectCandidates)(result.candidates, poolCfg);
|
|
15831
|
+
if (selected.length === 0) {
|
|
15832
|
+
this.logger.warn("LMLM live discovery yielded no installable candidates; keeping current", {
|
|
15833
|
+
warnings: result.warnings
|
|
15834
|
+
});
|
|
15835
|
+
return this.candidateSourceState;
|
|
15836
|
+
}
|
|
15837
|
+
this.seedRecommender(selected, "live");
|
|
15838
|
+
this.logger.info("LMLM candidates refreshed from HuggingFace", { count: selected.length });
|
|
15839
|
+
await this.refreshScheduler?.forceRefresh();
|
|
15840
|
+
this.emit("local-models:pool", {
|
|
15841
|
+
phase: "candidates_refreshed",
|
|
15842
|
+
source: "live",
|
|
15843
|
+
count: selected.length
|
|
15844
|
+
});
|
|
15845
|
+
return this.candidateSourceState;
|
|
15846
|
+
}
|
|
15377
15847
|
/** Resolve the hardware profile for a refresh tick (operator override wins). */
|
|
15378
15848
|
async detectLmlmHardware() {
|
|
15379
15849
|
const override = this.config.localModels?.hardware?.override;
|
|
@@ -15421,12 +15891,27 @@ ${messages}`);
|
|
|
15421
15891
|
if (this.poolStateStore !== null) {
|
|
15422
15892
|
await this.poolStateStore.load();
|
|
15423
15893
|
await this.applyConfiguredPoolBounds();
|
|
15894
|
+
this.redriveInterruptedInstalls();
|
|
15424
15895
|
}
|
|
15425
15896
|
for (const resolver of this.localResolvers.values()) {
|
|
15426
15897
|
await resolver.start();
|
|
15427
15898
|
}
|
|
15899
|
+
if (this.poolRefreshListener === null && this.localResolvers.size > 0) {
|
|
15900
|
+
const listener = () => {
|
|
15901
|
+
for (const resolver of this.localResolvers.values()) {
|
|
15902
|
+
resolver.refresh();
|
|
15903
|
+
}
|
|
15904
|
+
};
|
|
15905
|
+
this.poolRefreshListener = listener;
|
|
15906
|
+
this.on("local-models:pool", listener);
|
|
15907
|
+
}
|
|
15428
15908
|
}
|
|
15429
15909
|
this.startRefreshScheduler();
|
|
15910
|
+
if (this.modelPool !== null) {
|
|
15911
|
+
void this.refreshCandidatesLive().catch(
|
|
15912
|
+
(err) => this.logger.warn("LMLM startup candidate refresh failed", { cause: err })
|
|
15913
|
+
);
|
|
15914
|
+
}
|
|
15430
15915
|
this.pipeline = this.createIntelligencePipeline();
|
|
15431
15916
|
this.server?.setPipeline(this.pipeline);
|
|
15432
15917
|
}
|
|
@@ -15499,6 +15984,10 @@ ${messages}`);
|
|
|
15499
15984
|
this.localModelStatusUnsubscribes = [];
|
|
15500
15985
|
this.routingDecisionBus?.clearListeners();
|
|
15501
15986
|
this.routingDecisionBus = null;
|
|
15987
|
+
if (this.poolRefreshListener !== null) {
|
|
15988
|
+
this.removeListener("local-models:pool", this.poolRefreshListener);
|
|
15989
|
+
this.poolRefreshListener = null;
|
|
15990
|
+
}
|
|
15502
15991
|
for (const resolver of this.localResolvers.values()) {
|
|
15503
15992
|
resolver.stop();
|
|
15504
15993
|
}
|
|
@@ -16491,6 +16980,9 @@ function buildArchiveHooks(opts) {
|
|
|
16491
16980
|
}
|
|
16492
16981
|
};
|
|
16493
16982
|
}
|
|
16983
|
+
|
|
16984
|
+
// src/index.ts
|
|
16985
|
+
var import_local_models6 = require("@harness-engineering/local-models");
|
|
16494
16986
|
// Annotate the CommonJS export names for ESM import in node:
|
|
16495
16987
|
0 && (module.exports = {
|
|
16496
16988
|
AnalysisArchive,
|
|
@@ -16546,6 +17038,7 @@ function buildArchiveHooks(opts) {
|
|
|
16546
17038
|
defaultFetchModels,
|
|
16547
17039
|
deriveSeedPaths,
|
|
16548
17040
|
detectScopeTier,
|
|
17041
|
+
discoverCandidates,
|
|
16549
17042
|
discoverSkillCatalog,
|
|
16550
17043
|
discoverSkillCatalogNames,
|
|
16551
17044
|
emitProposalApproved,
|