Files
Loki/internal/loki/sys_service.go
T
MichaelandClaude Opus 5.5 28ba44eb46 Slots : SLOT_PERSIST garde l'état de la discussion à la bascule de preset, en opt-in
Passer de A à B puis revenir à A relance llama-server deux fois, et la
conversation de A est recalculée en entier au retour. Avec SLOT_PERSIST=on
(off par défaut), Loki demande au moteur d'écrire l'état du slot 0 juste
avant la bascule, et le recharge au retour avant le premier message de la
discussion — seulement si tout concorde, sinon calcul normal, en silence.

llama.cpp ne vérifie à la relecture que types et tailles : rien ne dit quel
build ni quels réglages ont calculé ce cache. La clé de validité est donc
l'empreinte de tout ce qui touche au calcul, prise par « loki serve » au
lancement : ligne de commande complète (modèle, CTX, types KV, NGL, lots,
gabarit, SPEC, EXTRA_ARGS…), LLAMA_ARG_*/GGML_*/CUDA_*, taille et date du
modèle et de ses tranches, du projecteur, du brouillon, du binaire et de
ses bibliothèques. Pas de cas « mise à jour du moteur » : elle change la clé.

- --slot-save-path posé aussi pour SLOT_PERSIST accepté, sous les mêmes
  conditions de sécurité que l'isolation du lot 1 (boucle locale ou clé
  d'API) ; refusé avec le décodage spéculatif (le save ne garde pas le
  brouillon ; /slots speculative revérifié à chaque fois), des poids sur
  CPU, un --slot-save-path à la main ; la ligne reste alors celle d'avant
- sauvegarde depuis l'interface ou une tâche, avant l'arrêt : le slot doit
  porter un tour de la discussion (tampon d'engineMarkMain), ≥ 4096 jetons,
  ≤ 8 Gio estimés, double de place libre ; nom provisoire, renommé
  seulement si aucune requête n'est partie pendant l'écriture
- rechargement avant la première requête de la discussion (ou son
  préchauffage) : même clé de moteur, même discussion, slot 0 vierge (/slots
  sans id_task) ; une seule tentative, fichier retiré ; toute erreur =
  calcul normal
- deux fichiers au plus ; clé retirée = tout effacé au lancement suivant ;
  SLOT_PERSIST préservé à la bascule comme HOST (sinon B effaçait l'état de A)
- README : SIDE_SLOT, le cache KV plein ne déclenche jamais de compaction
- tests : plan et refus, ligne inchangée, empreinte (clé d'API exclue,
  fichiers et réglages inclus), rotation, séquence save puis restore sur un
  faux moteur, refus à la sauvegarde et au rechargement, autre clé, défaut
  sans aucun appel

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 10:17:08 +02:00

253 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loki
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// sys_service.go holds the platform-neutral pieces of service management. The
// actual start/stop/restart/status/logs implementation is platform-specific:
// - sys_service_linux.go → systemd (systemctl/journalctl)
// - sys_service_darwin.go → launchd (launchctl)
// - sys_service_windows.go → PID-file background process supervisor
//
// editConfig and showVram live here because they work the same everywhere.
// preflightEngine vérifie ce sans quoi le moteur ne PEUT pas démarrer, avant de
// lancer le service. Sinon llama-server sortait en erreur, systemd le relançait
// toutes les 3 s, et `loki start` affichait un « activating » rassurant pendant
// que `loki test` répondait « /health ne répond pas ». Le diagnostic n'était
// visible que dans le journal.
func preflightEngine() error {
cfg := ReadConfig()
bin := strings.TrimSpace(cfg["BIN"])
if bin == "" {
return fmt.Errorf("BIN non défini — installe un moteur : %s (ou renseigne BIN avec %s)",
bold("loki llamacpp install"), bold("loki edit"))
}
if !filepath.IsAbs(bin) {
bin = filepath.Join(LokiHome(), bin)
}
if _, err := os.Stat(prebuiltResolveBin(bin)); err != nil {
return fmt.Errorf("moteur introuvable : %s — relance %s", bin, bold("loki llamacpp install"))
}
model := strings.TrimSpace(cfg["MODEL"])
if model == "" {
return fmt.Errorf("MODEL non défini — indique un .gguf avec %s (dossier des modèles : %s)",
bold("loki edit"), modelsDir())
}
p, err := resolveServeModelPath(model)
if err != nil {
return fmt.Errorf("MODEL=%s : %w", model, err)
}
if _, err := os.Stat(p); err != nil {
return fmt.Errorf("modèle introuvable : %s — corrige MODEL avec %s", p, bold("loki edit"))
}
// Modèle en plusieurs fichiers : une tranche manquante ne se voit qu'au moment
// où llama-server réclame un tenseur absent, dans le journal du service.
if missing := shardFamilyMissing(filepath.Dir(p), filepath.Base(p)); len(missing) > 0 {
return fmt.Errorf("modèle incomplet : il manque %s dans %s — ce modèle tient en %d fichiers, télécharge-les tous",
strings.Join(missing, ", "), filepath.Dir(p), len(shardFamily(filepath.Base(p))))
}
return nil
}
// configTemplate est le squelette commenté proposé par `loki edit` : la
// configuration vit en base, donc sur une installation neuve le fichier
// temporaire était QUASI VIDE — impossible de deviner quoi écrire. On y déroule
// donc les clés utiles avec leur rôle, les valeurs déjà définies telles quelles,
// les autres commentées.
var configTemplate = []struct{ key, help string }{
{"BIN", "chemin de llama-server (posé par « loki llamacpp install »)"},
{"MODEL", "nom de fichier .gguf ou chemin complet"},
{"HOST", "adresse d'écoute du moteur (défaut 0.0.0.0)"},
{"PORT", "port du moteur (défaut 8080)"},
{"CTX", "taille du contexte (défaut 32768)"},
{"NGL", "couches déportées sur le GPU (999 ou auto = ce qui tient en VRAM ; all = tout, quitte à saturer)"},
{"BATCH", "batch (défaut 2048)"},
{"UBATCH", "micro-batch (défaut 512)"},
{"THREADS", "threads CPU ; vide ou 0 = auto (cœurs physiques ; cœurs P seulement sur Intel hybride Linux)"},
{"THREADS_BATCH", "threads CPU du prefill et de la vérification spéculative (MTP) ; vide ou 0 = comme THREADS"},
{"FIT_TARGET", "Mio laissés libres par carte par le placement auto (--fit-target), ex. 1024,3072 dans l'ordre des cartes ; " +
"vide = 1024, minimum 1024 ; sans effet si NGL chiffré, --tensor-split, -ot ou --n-cpu-moe"},
{"OP_OFFLOAD_MIN_BATCH", "experts MoE sur CPU : taille de lot à partir de laquelle ils sont recopiés vers le GPU ; " +
"vide = défaut du moteur (32) ; plus haut = lots courts sur CPU, à mesurer"},
{"CUDA_GRAPH_OPT", "on = GGML_CUDA_GRAPH_OPT=1, branches Q/K/V en parallèle au décodage (expérimental, " +
"sortie identique, gain de 0 à quelques %) ; off puis redémarrage s'il plante"},
{"KV_TYPE", "quantization du cache KV ; vide = f16, sorties de référence ; q8_0 les modifie légèrement, " +
"q4_0 avec perte mesurable ; KV_TYPE_K / KV_TYPE_V pour les séparer"},
{"CACHE_RAM", "cache de prompts en RAM hôte (--cache-ram, Mio), copie exacte des conversations quittées, jamais la VRAM ; " +
"vide = auto (agrandi seulement pour un modèle tout-GPU), -1 = sans limite, 0 = coupé"},
{"CACHE_ISOLATE", "off = ne plus effacer le slot après un sous-agent, une vérification, une tâche ou un bench"},
{"SIDE_SLOT", "on = second slot pour les travaux annexes : le moteur ouvre 2 slots de CTX jetons chacun (-c 2×CTX, " +
"--parallel 2, cache KV non unifié : aucun slot ne déborde sur l'autre), la discussion garde le slot 0, vérification, " +
"sous-agents, tâches, bench, résumé sur transcription et clients /v1 passent par le 1 (id_slot) — l'état de la " +
"discussion ne bouge plus. Cache KV et état récurrent en double en VRAM : refusé si le compte ne tient pas, si des " +
"poids sont sur CPU, ou si PARALLEL≠2, -c, -np, -kvu, --cache-idle-slots… sont réglés à la main. Vide/off (défaut) = un slot"},
{"SLOT_PERSIST", "on = garder sur disque (LOKI_HOME/slots, 2 fichiers au plus, 8 Gio chacun au plus) l'état du slot de " +
"la discussion à la bascule de preset, et le recharger au retour avant son premier message : moteur, modèle et réglages " +
"identiques à l'octet près (empreinte), slot encore vierge, sinon calcul normal. Refusé avec le décodage spéculatif, " +
"des poids sur CPU, ou un moteur joignable sans clé d'API hors 127.0.0.1. Jamais à travers une mise à jour du moteur. " +
"Réglage de machine, gardé à la bascule. Vide/off (défaut) = rien n'est écrit"},
{"CTX_CHECKPOINTS", "points de reprise par slot d'un modèle hybride (--ctx-checkpoints) ; vide = défaut du moteur (32) ; " +
"chacun pèse 70 à 200 Mio de RAM hôte : 8 à 16 si le modèle remplit déjà la RAM"},
{"CKPT_MIN_STEP", "espacement minimal en jetons entre deux points de reprise (--checkpoint-min-step), > 0 ; " +
"vide = défaut du moteur, ou 2048 d'office sur un hybride avec un moteur officiel antérieur à b10864"},
{"SPEC", "décodage spéculatif, sortie inchangée : off (défaut) / auto = tête MTP du modèle ou MODEL_DRAFT si --fit place " +
"tout et qu'aucun essai n'a échoué / mtp = imposé ; ~1-2 Go de VRAM en plus"},
{"MODEL_DRAFT", "tête MTP publiée à part (mtp-*.gguf) ou petit modèle brouillon, nom ou chemin comme MODEL ; utilisé si SPEC≠off"},
{"SPEC_N_MAX", "jetons anticipés par étape (--spec-draft-n-max) ; vide = défaut du moteur (3)"},
{"SPEC_SAMPLING", "tirage du brouillon : greedy (défaut, exact) ; probabilistic seulement avec SPEC=mtp"},
{"REASONING", "passthrough du mode raisonnement (on/auto/deepseek)"},
{"REASONING_PRESERVE", "on/off = garder ou non la réflexion des tours passés dans le gabarit (--reasoning-preserve) ; " +
"vide = défaut du moteur (on depuis b10763). Sans REASONING_ECHO, Loki ne renvoie pas cette réflexion : off rend " +
"l'ancien historique aux gabarits type Qwen3.6, mais change celui des gabarits qui la gardent d'eux-mêmes (Qwen3.8). " +
"Avec REASONING_ECHO=on, on = préfixe stable d'un message à l'autre (mode entraîné mais optionnel selon la fiche " +
"Qwen3.6), au prix de plus de contexte par tour, donc d'une compaction plus tôt"},
{"REASONING_ECHO", "on = renvoyer au moteur local le raisonnement du modèle (reasoning_content), au format entraîné : " +
"les étapes d'une boucle d'outils se relisent avec leur réflexion au lieu de blocs vides, et le moteur ne recalcule " +
"plus le dernier message ; plus de contexte par tour, compaction plus tôt. Vide/off (défaut) = rien n'est renvoyé. " +
"Jamais vers une API externe ni vers un autre modèle ; suspendu de lui-même si le gabarit le refuse"},
{"PROJ_SNAPSHOT", "on = figer le bloc projet (description, index mémoire, trackers, AGENTS.md) par discussion et livrer " +
"ses changements en <context_update> en tête du message suivant : une page créée ou une valeur de tracker ne fait plus " +
"recalculer toute la conversation. Repris tout neuf à chaque compaction, redémarrage ou changement de modèle/projet. " +
"Vide/off (défaut) = bloc reconstruit à chaque tour ; sans effet sur un preset externe"},
{"PREWARM", "on = préparer le prochain tour pendant que tu lis : après un tour (compaction comprise) ou une tâche, " +
"Loki envoie au moteur local la requête suivante avec un message « . » et 1 jeton de réponse, jetés ; le vrai message " +
"ne calcule plus que lui-même. Annulé dès qu'une autre requête part, sauf si elle prolonge exactement ce préfixe ; " +
"jamais avec plus d'un slot (sauf les deux de SIDE_SLOT : slot de la discussion). full = aussi au changement de discussion " +
"(après 3 s). Vide/off (défaut) = aucune requête de plus"},
{"KEEP_TURN_IMAGES", "zone grise — on = garder dans l'historique les images montrées par les outils (captures, see_image), " +
"rangées par référence, au lieu de les oublier en fin de tour : le tour suivant ne recalcule plus la boucle d'outils " +
"depuis la première capture. Coût mesuré par le moteur, total borné à 10 % de la fenêtre, retirées d'abord à toute " +
"compaction (qui arrive donc plus tôt) ; vision active seulement, preset externe s'il déclare la vision (images " +
"refacturées à chaque tour). Vide/off (défaut) = images éphémères, requête inchangée"},
{"NUDGE_IN_TOOL", "zone grise — on = le rappel de budget d'outils part au bout du dernier résultat d'outil au lieu d'un " +
"message à part, sur le moteur local et seulement si la sonde de gabarit dit que le rendu bouge quand un message " +
"s'ajoute (Qwen3.5) : la boucle d'outils du tour n'est plus recalculée. Un modèle peut moins bien suivre une consigne " +
"lue dans une sortie d'outil : à comparer avant de l'adopter. Vide/off (défaut) = message à part"},
{"REASONING_BUDGET", "plafond de tokens de réflexion ; -1 = illimité"},
{"REASONING_EFFORT", "intensité du raisonnement : vide (auto) / none / low / medium / high / xhigh"},
{"TEMP", "température d'échantillonnage ; vide = défaut du moteur"},
{"TOP_P", "noyau de probabilité (top_p) ; vide = défaut du moteur"},
{"TOP_K", "top_k ; 0 = désactivé, vide = défaut du moteur"},
{"MIN_P", "seuil de probabilité (min_p) ; vide = défaut du moteur"},
{"PRESENCE_PENALTY", "pénalité de présence ; vide = défaut du moteur"},
{"REPEAT_PENALTY", "pénalité de répétition (1 = neutre) ; vide = défaut du moteur"},
{"COMPACT", "compactage automatique du contexte (off pour couper)"},
{"COMPACT_CONTINUATION", "on = le résumé d'une compaction prolonge la requête du tour que le moteur local a en cache " +
"(mêmes messages, outils et réglages du gabarit, plus une demande de résumé) au lieu d'une transcription calculée à froid ; " +
"repli sur la transcription au moindre écart (autre requête passée par le slot, marge, refus, appel d'outil, résumé vide). " +
"Ne redemande pas un résumé voué au refus. Vide/off (défaut) = compaction d'avant ; sans effet sur un preset externe"},
{"MEM_MODE", "mémoire de l'IA : off / ondemand / always"},
{"EXTRA_ARGS", "ajouté tel quel à la ligne de commande de llama-server"},
}
// configEditorText rend la configuration au format présenté dans $EDITOR.
func configEditorText(cfg map[string]string) string {
var b strings.Builder
b.WriteString("# Configuration du moteur Loki (loki-engine).\n")
b.WriteString("# Une clé par ligne : CLE=valeur. Les lignes commentées (#) sont ignorées :\n")
b.WriteString("# décommente celles dont tu as besoin. « loki restart » applique.\n\n")
seen := map[string]bool{}
for _, f := range configTemplate {
seen[f.key] = true
fmt.Fprintf(&b, "# %s\n", f.help)
if v, ok := cfg[f.key]; ok && v != "" {
fmt.Fprintf(&b, "%s=%s\n\n", f.key, quoteValue(v))
} else {
fmt.Fprintf(&b, "#%s=\n\n", f.key)
}
}
// Tout ce que le squelette ne connaît pas (clés d'une version plus récente,
// réglages posés par l'UI) : conservé tel quel, en fin de fichier.
rest := map[string]string{}
for k, v := range cfg {
if !seen[k] {
rest[k] = v
}
}
if len(rest) > 0 {
b.WriteString("# --- autres clés déjà définies ---\n")
b.WriteString(formatEnv(rest))
}
return b.String()
}
// editConfig ouvre la configuration dans $EDITOR. La configuration vit en base
// (voir store.go) : on la déroule dans un fichier temporaire au format clé=valeur,
// on laisse l'éditeur faire son travail, puis on relit. Le contenu n'est réécrit
// que si l'éditeur sort proprement — un éditeur avorté ne doit rien effacer.
func editConfig() error {
editor := os.Getenv("EDITOR")
if editor == "" {
editor = defaultEditor()
}
tmp, err := os.CreateTemp("", "loki-config-*.env")
if err != nil {
return err
}
path := tmp.Name()
defer os.Remove(path)
if _, err := tmp.WriteString(configEditorText(ReadConfig())); err != nil {
tmp.Close()
return err
}
tmp.Close()
cmd := exec.Command(editor, path)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := cmd.Run(); err != nil {
return err
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
if err := WriteConfig(parseEnv(string(b))); err != nil {
return err
}
fmt.Println(dim("[info] loki restart pour appliquer"))
return nil
}
// showVram parses `nvidia-smi --query-gpu=...` and renders a colored bar.
// nvidia-smi is available on both Linux and Windows when an NVIDIA driver is
// installed, so this is platform-neutral.
func showVram() error {
out, err := hideCmd(exec.Command("nvidia-smi",
"--query-gpu=name,memory.used,memory.total,utilization.gpu,temperature.gpu",
"--format=csv,noheader,nounits")).Output()
if err != nil {
return fmt.Errorf("nvidia-smi indisponible: %w", err)
}
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
parts := strings.Split(line, ",")
if len(parts) != 5 {
continue
}
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
name := parts[0]
used, _ := strconv.Atoi(parts[1])
total, _ := strconv.Atoi(parts[2])
util, _ := strconv.Atoi(parts[3])
temp, _ := strconv.Atoi(parts[4])
pct := 0
if total > 0 {
pct = used * 100 / total
}
full := pct / 5
bar := strings.Repeat("█", full) + strings.Repeat("░", 20-full)
fmt.Printf("\n %s\n", cyan(name))
fmt.Printf(" VRAM %s %3d%% %.1f / %.1f GiB\n", green(bar), pct, float64(used)/1024, float64(total)/1024)
fmt.Printf(" GPU %3d%% Temp %d°C\n\n", util, temp)
}
return nil
}