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