@harness-engineering/orchestrator 0.11.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +132 -3
- package/dist/index.d.ts +132 -3
- package/dist/index.js +709 -207
- package/dist/index.mjs +702 -200
- 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();
|
|
@@ -9143,6 +9444,7 @@ async function runForceRefresh(res, scheduler, deps) {
|
|
|
9143
9444
|
|
|
9144
9445
|
// src/server/routes/v1/local-models-pool-mutation.ts
|
|
9145
9446
|
import { createModelProposal, updateProposal as updateProposal4 } from "@harness-engineering/core";
|
|
9447
|
+
import { estimateDiskGb } from "@harness-engineering/local-models";
|
|
9146
9448
|
var INSTALL_RE = /^\/api\/v1\/local-models\/pool\/install(?:\?.*)?$/;
|
|
9147
9449
|
var REMOVE_RE = /^\/api\/v1\/local-models\/pool\/remove(?:\?.*)?$/;
|
|
9148
9450
|
var RESOLVE_TOP = 50;
|
|
@@ -9201,6 +9503,11 @@ async function handleInstall(req, res, deps) {
|
|
|
9201
9503
|
action: "add",
|
|
9202
9504
|
target: { hfRepoId: match.hfRepoId, ollamaName: match.ollamaName },
|
|
9203
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,
|
|
9204
9511
|
justification: {
|
|
9205
9512
|
summary: `Operator-initiated install of ${match.ollamaName} (${match.quant}).`,
|
|
9206
9513
|
benchmarkBasis: [],
|
|
@@ -9208,34 +9515,52 @@ async function handleInstall(req, res, deps) {
|
|
|
9208
9515
|
evidence: match.evidence,
|
|
9209
9516
|
freshness: `snapshot ${match.benchmarkSnapshot}`
|
|
9210
9517
|
},
|
|
9211
|
-
//
|
|
9212
|
-
//
|
|
9213
|
-
|
|
9518
|
+
// Estimate the on-disk size from the candidate's params + quant (the same
|
|
9519
|
+
// `estimateDiskGb` the automated proposal engine uses). The pool needs a
|
|
9520
|
+
// size for its pre-commit budget check *before* the pull; it cannot inspect
|
|
9521
|
+
// the target first, because ollama `/api/show` 404s for a model that is not
|
|
9522
|
+
// yet pulled locally — which would surface as a spurious "no longer
|
|
9523
|
+
// available on HuggingFace" 404 on every operator install.
|
|
9524
|
+
diskImpactGb: estimateDiskGb({
|
|
9525
|
+
sizeB: match.sizeB,
|
|
9526
|
+
quant: match.quant,
|
|
9527
|
+
...match.activeB !== void 0 ? { activeB: match.activeB } : {}
|
|
9528
|
+
})
|
|
9214
9529
|
};
|
|
9215
9530
|
const record = await createModelProposal(deps.projectPath, content, {
|
|
9216
9531
|
proposedBy: decidedByOf(deps, req)
|
|
9217
9532
|
});
|
|
9218
|
-
const
|
|
9219
|
-
|
|
9220
|
-
|
|
9221
|
-
|
|
9222
|
-
|
|
9223
|
-
evicted: outcome.evicted.map((e) => e.ollamaName)
|
|
9224
|
-
};
|
|
9225
|
-
return sendJSON10(res, 200, result);
|
|
9226
|
-
}
|
|
9227
|
-
if (outcome.status === "failed_target_missing") {
|
|
9228
|
-
return sendJSON10(res, 404, {
|
|
9229
|
-
error: `${hfRepoId} is no longer available on HuggingFace`,
|
|
9230
|
-
proposalId: record.id
|
|
9231
|
-
});
|
|
9232
|
-
}
|
|
9233
|
-
const status = outcome.code === "not_allowed" || outcome.code === "budget_exceeded" ? 409 : 502;
|
|
9234
|
-
return sendJSON10(res, status, {
|
|
9235
|
-
error: outcome.message,
|
|
9236
|
-
code: outcome.code,
|
|
9237
|
-
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
|
|
9238
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);
|
|
9239
9564
|
}
|
|
9240
9565
|
async function handleRemove(req, res, deps) {
|
|
9241
9566
|
const pool = deps.getModelPool();
|
|
@@ -10188,6 +10513,12 @@ var V1_BRIDGE_ROUTES = [
|
|
|
10188
10513
|
scope: "manage-proposals",
|
|
10189
10514
|
description: "Force a background-scheduler refresh tick and return emitted proposals (O4)."
|
|
10190
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
|
+
},
|
|
10191
10522
|
// ── LMLM dashboard pool mutation — operator-initiated install/remove ──
|
|
10192
10523
|
// Modeled as auto-approved model proposals, so the same `manage-proposals`
|
|
10193
10524
|
// write scope that governs approve/reject governs these too.
|
|
@@ -10378,6 +10709,7 @@ var OrchestratorServer = class {
|
|
|
10378
10709
|
// LMLM Phase 6 — live model pool + refresh scheduler accessors.
|
|
10379
10710
|
getModelPoolFn = null;
|
|
10380
10711
|
getRefreshSchedulerFn = null;
|
|
10712
|
+
getRefreshCandidatesFn = null;
|
|
10381
10713
|
// LMLM Phase 7 — hardware / recommendations / model-proposal read accessors.
|
|
10382
10714
|
getHardwareProfileFn = null;
|
|
10383
10715
|
getRecommendationsFn = null;
|
|
@@ -10388,6 +10720,8 @@ var OrchestratorServer = class {
|
|
|
10388
10720
|
// LMLM Phase 7 — bus→WS fan-out listeners for the model proposal/pool topics.
|
|
10389
10721
|
modelProposalListener = null;
|
|
10390
10722
|
modelPoolListener = null;
|
|
10723
|
+
// LMLM Phase 10 — bus→WS fan-out for byte-level install progress (D3 async install).
|
|
10724
|
+
modelInstallListener = null;
|
|
10391
10725
|
recorder = null;
|
|
10392
10726
|
planWatcher = null;
|
|
10393
10727
|
tokenStore;
|
|
@@ -10434,6 +10768,7 @@ var OrchestratorServer = class {
|
|
|
10434
10768
|
this.getBackendsFn = deps?.getBackends ?? null;
|
|
10435
10769
|
this.getModelPoolFn = deps?.getModelPool ?? null;
|
|
10436
10770
|
this.getRefreshSchedulerFn = deps?.getRefreshScheduler ?? null;
|
|
10771
|
+
this.getRefreshCandidatesFn = deps?.getRefreshCandidates ?? null;
|
|
10437
10772
|
this.getHardwareProfileFn = deps?.getHardwareProfile ?? null;
|
|
10438
10773
|
this.getRecommendationsFn = deps?.getRecommendations ?? null;
|
|
10439
10774
|
this.listModelProposalsFn = deps?.listModelProposals ?? null;
|
|
@@ -10456,8 +10791,10 @@ var OrchestratorServer = class {
|
|
|
10456
10791
|
}
|
|
10457
10792
|
this.modelProposalListener = (data) => this.broadcaster.broadcast(MODEL_PROPOSAL_TOPIC, data);
|
|
10458
10793
|
this.modelPoolListener = (data) => this.broadcaster.broadcast(MODEL_POOL_TOPIC, data);
|
|
10794
|
+
this.modelInstallListener = (data) => this.broadcaster.broadcast(MODEL_INSTALL_TOPIC, data);
|
|
10459
10795
|
this.orchestrator.on(MODEL_PROPOSAL_TOPIC, this.modelProposalListener);
|
|
10460
10796
|
this.orchestrator.on(MODEL_POOL_TOPIC, this.modelPoolListener);
|
|
10797
|
+
this.orchestrator.on(MODEL_INSTALL_TOPIC, this.modelInstallListener);
|
|
10461
10798
|
}
|
|
10462
10799
|
/**
|
|
10463
10800
|
* Broadcast a new interaction to all WebSocket clients.
|
|
@@ -10654,6 +10991,7 @@ var OrchestratorServer = class {
|
|
|
10654
10991
|
// only when configured so absent ones surface as 503 (LMLM disabled).
|
|
10655
10992
|
(req, res) => handleV1LocalModelsRoute(req, res, {
|
|
10656
10993
|
getRefreshScheduler: this.getRefreshSchedulerFn ?? (() => null),
|
|
10994
|
+
...this.getRefreshCandidatesFn ? { getRefreshCandidates: this.getRefreshCandidatesFn } : {},
|
|
10657
10995
|
getModelPool: () => this.getModelPoolFn?.() ?? null,
|
|
10658
10996
|
...this.getHardwareProfileFn ? { getHardwareProfile: this.getHardwareProfileFn } : {},
|
|
10659
10997
|
...this.getRecommendationsFn ? { getRecommendations: this.getRecommendationsFn } : {},
|
|
@@ -10769,6 +11107,10 @@ var OrchestratorServer = class {
|
|
|
10769
11107
|
this.orchestrator.removeListener(MODEL_POOL_TOPIC, this.modelPoolListener);
|
|
10770
11108
|
this.modelPoolListener = null;
|
|
10771
11109
|
}
|
|
11110
|
+
if (this.modelInstallListener) {
|
|
11111
|
+
this.orchestrator.removeListener(MODEL_INSTALL_TOPIC, this.modelInstallListener);
|
|
11112
|
+
this.modelInstallListener = null;
|
|
11113
|
+
}
|
|
10772
11114
|
if (this.planWatcher) {
|
|
10773
11115
|
this.planWatcher.stop();
|
|
10774
11116
|
this.planWatcher = null;
|
|
@@ -13869,6 +14211,13 @@ var Orchestrator = class extends EventEmitter {
|
|
|
13869
14211
|
* so this map is the single source of truth post-migration.
|
|
13870
14212
|
*/
|
|
13871
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;
|
|
13872
14221
|
/** Phase 4 (D5): pool-state port shared by all local/pi resolvers. Null when LMLM disabled. */
|
|
13873
14222
|
poolStateProvider = null;
|
|
13874
14223
|
poolStateStore = null;
|
|
@@ -13906,6 +14255,13 @@ var Orchestrator = class extends EventEmitter {
|
|
|
13906
14255
|
* see the Phase 2 candidate-parser gap noted on `startRefreshScheduler`.
|
|
13907
14256
|
*/
|
|
13908
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
|
+
};
|
|
13909
14265
|
/** Test seam: injected timer/clock for the scheduler so no real 24h timer runs. */
|
|
13910
14266
|
schedulerTimerOverride;
|
|
13911
14267
|
/**
|
|
@@ -13999,6 +14355,7 @@ var Orchestrator = class extends EventEmitter {
|
|
|
13999
14355
|
constructor(config, promptTemplate, overrides) {
|
|
14000
14356
|
super();
|
|
14001
14357
|
this.schedulerTimerOverride = overrides?.schedulerTimer ?? null;
|
|
14358
|
+
this.discoverCandidatesFn = overrides?.discoverCandidates ?? (async () => ({ candidates: [], warnings: [] }));
|
|
14002
14359
|
this.setMaxListeners(50);
|
|
14003
14360
|
this.config = config;
|
|
14004
14361
|
this.promptTemplate = promptTemplate;
|
|
@@ -14067,6 +14424,17 @@ var Orchestrator = class extends EventEmitter {
|
|
|
14067
14424
|
if (def.apiKey !== void 0) resolverOpts.apiKey = def.apiKey;
|
|
14068
14425
|
if (def.probeIntervalMs !== void 0) resolverOpts.probeIntervalMs = def.probeIntervalMs;
|
|
14069
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
|
+
}
|
|
14070
14438
|
this.localResolvers.set(name, new LocalModelResolver(resolverOpts));
|
|
14071
14439
|
}
|
|
14072
14440
|
}
|
|
@@ -14089,9 +14457,27 @@ var Orchestrator = class extends EventEmitter {
|
|
|
14089
14457
|
...this.config.agent.secrets !== void 0 ? { secrets: this.config.agent.secrets } : {},
|
|
14090
14458
|
cacheMetrics: this.cacheMetrics,
|
|
14091
14459
|
decisionBus: this.routingDecisionBus,
|
|
14092
|
-
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) => {
|
|
14093
14470
|
const resolver = this.localResolvers.get(name);
|
|
14094
|
-
|
|
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
|
+
};
|
|
14095
14481
|
}
|
|
14096
14482
|
});
|
|
14097
14483
|
} else {
|
|
@@ -14188,6 +14574,9 @@ var Orchestrator = class extends EventEmitter {
|
|
|
14188
14574
|
isModelInUse: (ollamaName) => this.isLocalModelInUse(ollamaName),
|
|
14189
14575
|
// LMLM Phase 6: expose the refresh scheduler for POST /local-models/refresh.
|
|
14190
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,
|
|
14191
14580
|
// LMLM Phase 7 read surface — hardware / recommendations / model proposals.
|
|
14192
14581
|
// Each returns null/[] when LMLM is disabled so the route renders 503/[].
|
|
14193
14582
|
getHardwareProfile: () => this.modelPool ? this.detectLmlmHardware() : null,
|
|
@@ -15255,10 +15644,51 @@ ${messages}`);
|
|
|
15255
15644
|
const installerCfg = this.config.localModels?.installer;
|
|
15256
15645
|
this.modelInstaller = new OllamaInstallAdapter({
|
|
15257
15646
|
baseUrl: installerCfg?.ollamaEndpoint ?? "http://localhost:11434",
|
|
15258
|
-
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
|
|
15259
15653
|
});
|
|
15260
15654
|
this.modelPool = new PoolManager({ store, installer: this.modelInstaller, onWarn });
|
|
15261
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
|
+
}
|
|
15262
15692
|
/**
|
|
15263
15693
|
* LMLM Phase 7 wiring: apply the operator's configured pool bounds (disk
|
|
15264
15694
|
* budget + org/family allowlist) from `localModels.pool` to the live pool.
|
|
@@ -15304,8 +15734,8 @@ ${messages}`);
|
|
|
15304
15734
|
allowedOrgs: this.config.localModels?.pool?.allowedOrgs ?? []
|
|
15305
15735
|
});
|
|
15306
15736
|
}
|
|
15307
|
-
|
|
15308
|
-
this.modelRecommender
|
|
15737
|
+
this.seedRecommender(candidates, "frozen");
|
|
15738
|
+
const recommend = (hardware) => this.modelRecommender(hardware);
|
|
15309
15739
|
this.refreshScheduler = new RefreshScheduler({
|
|
15310
15740
|
runTick: () => runRefreshTick({
|
|
15311
15741
|
detectHardware: () => this.detectLmlmHardware(),
|
|
@@ -15335,6 +15765,55 @@ ${messages}`);
|
|
|
15335
15765
|
});
|
|
15336
15766
|
this.refreshScheduler.start();
|
|
15337
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
|
+
}
|
|
15338
15817
|
/** Resolve the hardware profile for a refresh tick (operator override wins). */
|
|
15339
15818
|
async detectLmlmHardware() {
|
|
15340
15819
|
const override = this.config.localModels?.hardware?.override;
|
|
@@ -15382,12 +15861,27 @@ ${messages}`);
|
|
|
15382
15861
|
if (this.poolStateStore !== null) {
|
|
15383
15862
|
await this.poolStateStore.load();
|
|
15384
15863
|
await this.applyConfiguredPoolBounds();
|
|
15864
|
+
this.redriveInterruptedInstalls();
|
|
15385
15865
|
}
|
|
15386
15866
|
for (const resolver of this.localResolvers.values()) {
|
|
15387
15867
|
await resolver.start();
|
|
15388
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
|
+
}
|
|
15389
15878
|
}
|
|
15390
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
|
+
}
|
|
15391
15885
|
this.pipeline = this.createIntelligencePipeline();
|
|
15392
15886
|
this.server?.setPipeline(this.pipeline);
|
|
15393
15887
|
}
|
|
@@ -15460,6 +15954,10 @@ ${messages}`);
|
|
|
15460
15954
|
this.localModelStatusUnsubscribes = [];
|
|
15461
15955
|
this.routingDecisionBus?.clearListeners();
|
|
15462
15956
|
this.routingDecisionBus = null;
|
|
15957
|
+
if (this.poolRefreshListener !== null) {
|
|
15958
|
+
this.removeListener("local-models:pool", this.poolRefreshListener);
|
|
15959
|
+
this.poolRefreshListener = null;
|
|
15960
|
+
}
|
|
15463
15961
|
for (const resolver of this.localResolvers.values()) {
|
|
15464
15962
|
resolver.stop();
|
|
15465
15963
|
}
|
|
@@ -16454,6 +16952,9 @@ function buildArchiveHooks(opts) {
|
|
|
16454
16952
|
}
|
|
16455
16953
|
};
|
|
16456
16954
|
}
|
|
16955
|
+
|
|
16956
|
+
// src/index.ts
|
|
16957
|
+
import { discoverCandidates } from "@harness-engineering/local-models";
|
|
16457
16958
|
export {
|
|
16458
16959
|
AnalysisArchive,
|
|
16459
16960
|
BUILT_IN_TASKS,
|
|
@@ -16508,6 +17009,7 @@ export {
|
|
|
16508
17009
|
defaultFetchModels,
|
|
16509
17010
|
deriveSeedPaths,
|
|
16510
17011
|
detectScopeTier,
|
|
17012
|
+
discoverCandidates,
|
|
16511
17013
|
discoverSkillCatalog,
|
|
16512
17014
|
discoverSkillCatalogNames,
|
|
16513
17015
|
emitProposalApproved,
|