diff --git a/README.md b/README.md index f0ff323..39f1c52 100644 --- a/README.md +++ b/README.md @@ -498,6 +498,39 @@ Ajoutées par ce fork : requêtes partent sans `id_slot`, comme avant. Ce que voit le modèle ne change pas : deux slots qui calculent en même temps donnent des logits égaux au bruit de virgule flottante près, comme un découpage de lots différent. + Un « cache KV plein » sans nombre de jetons (le pool, pas le prompt) est + rejoué tel quel et ne déclenche jamais de compaction. +- **État du slot gardé à la bascule de preset** (clé `SLOT_PERSIST`, **off** + par défaut, `loki config set SLOT_PERSIST on`) : passer d'un preset A à B + puis revenir à A relance le moteur deux fois, et la conversation de A est + recalculée en entier au retour. Avec `on`, juste avant une bascule faite + depuis l'interface ou par une tâche, Loki demande au moteur d'écrire l'état + du slot de la discussion (`/slots/0?action=save`, sous `LOKI_HOME/slots`), + et le recharge au retour, avant le premier message de cette discussion. + Conditions strictes, sinon rien n'est écrit ou rechargé et le calcul est + normal : la dernière requête passée par le slot était bien un tour de + cette discussion (pas une vérification, une tâche, un préchauffage ou un + client `/v1`), au moins 4096 jetons, au plus 8 Gio estimés et le double + de place libre ; au retour, le moteur doit avoir **exactement** la même + empreinte — ligne de commande complète (modèle, `CTX`, types KV, `NGL`, + lots, gabarit, `EXTRA_ARGS`…), variables `LLAMA_ARG_*`/`GGML_*`/`CUDA_*`, + taille et date du modèle, du projecteur, du binaire et de ses bibliothèques + — et son slot 0 ne doit encore avoir servi aucune tâche. Une mise à jour du + moteur change l'empreinte : jamais de reprise d'un build à l'autre. Une + seule tentative par fichier, retiré ensuite ; deux fichiers au plus, + supprimés dès que la clé est retirée. Réglage de la machine : il survit aux + bascules comme `HOST` (un preset qui le pose l'emporte). Refusé au lancement (note au journal) + avec le décodage spéculatif (`SPEC`, brouillon dans `EXTRA_ARGS` : la + sauvegarde du moteur ne garde pas l'état du brouillon, et un brouillon MTP + détecté sur le slot bloque aussi), des poids sur CPU (MoE déporté), un + `--slot-save-path` déjà réglé, ou un moteur joignable par d'autres sans clé + d'API (`HOST` autre que `127.0.0.1`). `loki switch` en ligne de commande ne + garde rien (seul le process web sait ce que porte le slot). Le gain est + réel surtout sur un modèle dense ; sur un hybride (Qwen3.5/3.6), le fichier + n'a pas de points de reprise et ne sert que si le message suivant prolonge + exactement les jetons gardés (gabarit qui rend le dernier tour à + l'identique), sinon recalcul comme avant. Ce que voit le modèle ne change + pas : llama.cpp ne reprend un état que sur un préfixe de jetons identique. - **Discussions multiples** : historique complet dans la barre latérale, titre repris du premier message (renommable), suppression. **Chaque discussion a son dossier de fichiers** (`workspace/discussions//`) : les pièces jointes diff --git a/internal/loki/backend_presets.go b/internal/loki/backend_presets.go index 0b57d5e..2a268b4 100644 --- a/internal/loki/backend_presets.go +++ b/internal/loki/backend_presets.go @@ -192,7 +192,10 @@ func safePresetPath(name string) (string, error) { // CUDA_LAUNCH_QUEUES (files de lancement CUDA, voir launchQueuesEnv) suit la // même logique : un « off » posé parce que CETTE machine bloque ne doit pas // disparaître à la bascule suivante. -var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES"} +// SLOT_PERSIST (état du slot gardé à la bascule, backend_serve_persist.go) est +// par nature un réglage de bascule : posé sur A seulement, il disparaîtrait en +// passant sur B, dont le lancement effacerait l'état gardé de A. +var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VISIBLE_DEVICES", "HOST", "CUDA_LAUNCH_QUEUES", "SLOT_PERSIST"} // softPreservedKeys : préservées SEULEMENT si le preset d'arrivée ne les définit // pas lui-même. CUDA_VISIBLE_DEVICES est dans ce cas : c'est d'ordinaire un @@ -203,7 +206,7 @@ var preservedKeys = []string{"MEM_MODE", "CRAWL4AI_URL", "WEB_ENGINE", "CUDA_VIS // modèle se retrouvait entièrement sur une seule carte et mourait sur // « cudaMalloc failed: out of memory ». Le preset explicite gagne. // CUDA_LAUNCH_QUEUES aussi : un preset peut l'imposer pour son modèle. -var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true} +var softPreservedKeys = map[string]bool{"CUDA_VISIBLE_DEVICES": true, "HOST": true, "CUDA_LAUNCH_QUEUES": true, "SLOT_PERSIST": true} // applyPresetFile installe le preset comme configuration active, en réinjectant // les réglages « appareil » par-dessus. Séparé de SwitchToPreset pour être @@ -264,6 +267,10 @@ func presetImposesEngine(p string) bool { // SwitchToPreset installe le preset et redémarre le service. Les réglages // « appareil » (preservedKeys) sont conservés à travers la bascule. func SwitchToPreset(target string) error { + // SLOT_PERSIST : relevé sur la configuration d'avant, gardé juste avant + // l'arrêt (llm_slotpersist.go). Sans la clé, ou hors du process web (rien + // n'y dit ce que porte le slot) : nil, rien ne se passe. + persist := slotPersistPrepare() if err := applyPresetFile(target); err != nil { return err } @@ -277,9 +284,11 @@ func SwitchToPreset(target string) error { if !serviceIsActive() { return nil } + persist.save() return serviceAction("stop") } fmt.Println(dim("[info] redémarrage du service...")) + persist.save() return serviceAction("restart") } diff --git a/internal/loki/backend_serve.go b/internal/loki/backend_serve.go index 69f9406..017e7da 100644 --- a/internal/loki/backend_serve.go +++ b/internal/loki/backend_serve.go @@ -362,12 +362,24 @@ func cmdServe(args []string) error { if spec == "auto" { si.SpecAutoBlocked, specRecord = specAutoPeek(specMark) } + probeSlotPersistEnv(cfg, &si) if strings.Contains(si.Help, "--slot-save-path") && !hasAnyFlag(extra, "--slot-save-path") { - si.SlotDir = prepareSlotDir(LokiHome()) + si.SlotDir = prepareSlotDir(LokiHome(), slotPersistOn(cfg)) } llmArgs, env, notes := buildServeArgs(cfg, extra, bin, si) applyServeEnv(env) + // SLOT_PERSIST (backend_serve_persist.go) : la clé de CE lancement, et les + // états gardés au-delà de deux retirés. Refusé pour ce preset (brouillon, + // poids sur CPU…) : ceux des autres presets restent, pour leur retour. + // Clé retirée : plus aucun état gardé sur le disque. + persistKey := "" + if ok, _ := slotPersistPlan(cfg, extra, si); ok { + persistKey = slotPersistKey(llmArgs, env, si, bin) + slotPersistPrune(si.SlotDir) + } else if !slotPersistOn(cfg) { + slotPersistPurge(filepath.Join(LokiHome(), "slots")) + } for _, n := range notes { fmt.Fprintln(os.Stderr, "[loki serve] "+n) } @@ -395,6 +407,10 @@ func cmdServe(args []string) error { specAutoSettle(specMark, si.SpecAutoBlocked, specRecord, auto) } + // Posée au dernier moment, port libre : l'ancien moteur est parti, la clé + // lue par le process web est celle du moteur qui répondra. + writeSlotPersistMarker(LokiHome(), persistKey) + fmt.Fprintf(os.Stderr, "[loki serve] %s model=%s port=%s\n", bin, filepath.Base(model), port) @@ -644,6 +660,9 @@ func buildServeArgs(cfg map[string]string, extra []string, bin string, si serveS cram, cramNotes := cacheRAMArgs(cfg, extra, si) args = append(append(args, cram...), slotSaveArgs(cfg, extra, si)...) notes = append(notes, cramNotes...) + if n := slotPersistNote(slotPersistPlan(cfg, extra, si)); n != "" { + notes = append(notes, n) + } // Points de reprise des hybrides (voir backend_serve_ckpt.go). ck, ckNotes := ckptArgs(cfg, extra, si) args = append(args, ck...) diff --git a/internal/loki/backend_serve_cache.go b/internal/loki/backend_serve_cache.go index 6fa3b82..f9e592e 100644 --- a/internal/loki/backend_serve_cache.go +++ b/internal/loki/backend_serve_cache.go @@ -450,30 +450,29 @@ func loopbackHost(h string) bool { // restore, qui écrivent des Gio sur le disque, à quiconque joint le moteur. On // ne le pose donc que si le moteur est fermé aux autres (boucle locale, ou clé // d'API exigée), que le dossier existe (le moteur refuse de démarrer sinon), et -// que l'isolation servira : cache actif, CACHE_ISOLATE pas à off. Les proxys de -// Loki refusent de leur côté tout /slots qui n'est pas une lecture. +// qu'il servira : à l'isolation (cache actif, CACHE_ISOLATE pas à off), ou à +// SLOT_PERSIST accepté (backend_serve_persist.go). Les proxys de Loki refusent +// de leur côté tout /slots qui n'est pas une lecture. func slotSaveArgs(cfg map[string]string, extra []string, si serveSysInfo) []string { - if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || - hasAnyFlag(extra, "--slot-save-path") || !cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv) { + if si.SlotDir == "" || !strings.Contains(si.Help, "--slot-save-path") || hasAnyFlag(extra, "--slot-save-path") { return nil } - host := flagValue(extra, "--host") - if host == "" { - host = cfg["HOST"] + persist, _ := slotPersistPlan(cfg, extra, si) + if !persist && (!cacheIsolationOn(cfg) || cacheRAMOff(cfg, extra, si.ArgEnv)) { + return nil } - if host == "" { - host = "0.0.0.0" - } - if !loopbackHost(host) && si.APIKey == "" { + if !slotSaveSafe(cfg, extra, si) { return nil } return []string{"--slot-save-path", si.SlotDir} } -// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : Loki n'y sauve jamais -// rien, un fichier présent ne peut venir que d'un appel qui n'aurait pas dû -// passer. Vide = dossier indisponible, le drapeau ne sera pas posé. -func prepareSlotDir(home string) string { +// prepareSlotDir crée LOKI_HOME/slots (0700) et le vide : un fichier présent ne +// peut venir que d'un appel qui n'aurait pas dû passer. Seule exception, avec +// keepPersist (SLOT_PERSIST demandé) : les états que Loki y a lui-même gardés +// à la bascule de preset, au nom reconnaissable. Vide = dossier indisponible, +// le drapeau ne sera pas posé. +func prepareSlotDir(home string, keepPersist bool) string { dir := filepath.Join(home, "slots") if err := os.MkdirAll(dir, 0o700); err != nil { return "" @@ -483,7 +482,7 @@ func prepareSlotDir(home string) string { } if entries, err := os.ReadDir(dir); err == nil { for _, e := range entries { - if e.Type().IsRegular() { + if e.Type().IsRegular() && !(keepPersist && slotPersistFileRe.MatchString(e.Name())) { _ = os.Remove(filepath.Join(dir, e.Name())) } } diff --git a/internal/loki/backend_serve_cache_test.go b/internal/loki/backend_serve_cache_test.go index 4938eb4..d31610a 100644 --- a/internal/loki/backend_serve_cache_test.go +++ b/internal/loki/backend_serve_cache_test.go @@ -238,7 +238,7 @@ func TestBuildServeArgsCacheRAM(t *testing.T) { func TestPrepareSlotDir(t *testing.T) { home := t.TempDir() - dir := prepareSlotDir(home) + dir := prepareSlotDir(home, false) if dir != filepath.Join(home, "slots") { t.Fatalf("dossier = %q", dir) } @@ -246,7 +246,7 @@ func TestPrepareSlotDir(t *testing.T) { if err := os.WriteFile(stray, []byte("x"), 0o600); err != nil { t.Fatal(err) } - if prepareSlotDir(home) == "" { + if prepareSlotDir(home, false) == "" { t.Fatal("second appel") } if _, err := os.Stat(stray); !os.IsNotExist(err) { @@ -258,7 +258,7 @@ func TestPrepareSlotDir(t *testing.T) { if err := os.WriteFile(filepath.Join(bad, "slots"), nil, 0o600); err != nil { t.Fatal(err) } - if got := prepareSlotDir(bad); got != "" { + if got := prepareSlotDir(bad, false); got != "" { t.Fatalf("slots est un fichier : %q", got) } } diff --git a/internal/loki/backend_serve_persist.go b/internal/loki/backend_serve_persist.go new file mode 100644 index 0000000..751b01f --- /dev/null +++ b/internal/loki/backend_serve_persist.go @@ -0,0 +1,265 @@ +package loki + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// État du slot gardé à travers une bascule de preset (SLOT_PERSIST, off par +// défaut). +// +// Basculer d'un preset A vers B puis revenir à A relance llama-server deux +// fois : au retour, la conversation de A est recalculée en entier — 30 à 80 s +// sur un 27B dense. Avec SLOT_PERSIST=on, Loki demande au moteur, juste avant +// la bascule, d'écrire l'état du slot 0 sur disque (/slots/0?action=save), et +// le recharge au retour, avant la première requête de la discussion +// (llm_slotpersist.go). llama-server ne reprend un état que sur un préfixe de +// jetons identique : au pire, rien n'est repris. +// +// Mais rien, dans le fichier, ne dit quel moteur ni quels réglages ont calculé +// ce cache : llama.cpp ne vérifie que les types et les tailles. Le recharger +// sous un autre build, une autre fenêtre ou un autre placement donnerait au +// modèle un contexte que ce moteur n'aurait jamais produit. D'où la clé : une +// empreinte de TOUT ce qui touche au calcul — ligne de commande complète +// (modèle, fenêtre, types KV, NGL, lots, gabarit, EXTRA_ARGS…), variables +// LLAMA_ARG_*, GGML_* et CUDA_*, identité (taille, date) du modèle et de ses +// tranches, du projecteur, du brouillon, du binaire et de ses bibliothèques. +// Un fichier ne se recharge que sous la même clé ; une mise à jour du moteur +// change donc tout, et n'est jamais un cas de reprise. +// +// Refusé d'emblée : le décodage spéculatif (la sauvegarde de llama.cpp ne +// garde que le modèle cible, pas le brouillon MTP — l'état rechargé serait +// incohérent pour lui), les poids sur CPU (MoE déporté : états de plusieurs +// Gio pour un recalcul de toute façon lent), et un moteur joignable par +// d'autres sans clé d'API : save et restore écrivent des Gio sur le disque. + +// slotPersistOn : la clé SLOT_PERSIST est-elle posée ? +func slotPersistOn(cfg map[string]string) bool { + switch strings.ToLower(strings.TrimSpace(cfg["SLOT_PERSIST"])) { + case "on", "1", "true", "yes", "oui": + return true + } + return false +} + +// slotPersistArgEnv : variables qui contredisent SLOT_PERSIST sans drapeau. +var slotPersistArgEnv = []string{"LLAMA_ARG_SLOT_SAVE_PATH", "LLAMA_ARG_SPEC_TYPE", "LLAMA_ARG_SPEC_DRAFT_MODEL", + "LLAMA_ARG_SPEC_DRAFT_HF_REPO"} + +// probeSlotPersistEnv complète ArgEnv, seulement si la clé est posée. +func probeSlotPersistEnv(cfg map[string]string, si *serveSysInfo) { + if !slotPersistOn(cfg) { + return + } + if si.ArgEnv == nil { + si.ArgEnv = map[string]string{} + } + for _, k := range slotPersistArgEnv { + if v, ok := os.LookupEnv(k); ok && strings.TrimSpace(v) != "" { + si.ArgEnv[k] = v + } + } +} + +// slotPersistPlan : SLOT_PERSIST vaut-il pour ce lancement ? why dit pourquoi +// non (vide = clé absente). +func slotPersistPlan(cfg map[string]string, extra []string, si serveSysInfo) (ok bool, why string) { + if !slotPersistOn(cfg) { + return false, "" + } + switch { + case isExternalConfig(cfg): + return false, "preset externe" + case !strings.Contains(si.Help, "--slot-save-path"): + return false, "ce moteur ne connaît pas --slot-save-path" + case hasAnyFlag(extra, "--slot-save-path") || si.ArgEnv["LLAMA_ARG_SLOT_SAVE_PATH"] != "": + return false, "--slot-save-path est déjà réglé à la main" + case si.SlotDir == "": + return false, "dossier des slots indisponible" + case specMode(cfg) != "off" || specUserSet(extra, si.ArgEnv) || si.ArgEnv["LLAMA_ARG_SPEC_TYPE"] != "": + return false, "décodage spéculatif : la sauvegarde du moteur ne garde pas l'état du brouillon" + } + if !slotSaveSafe(cfg, extra, si) { + return false, "moteur joignable par d'autres sans clé d'API (HOST=127.0.0.1 ou une clé d'API)" + } + blocks := 0 + if si.GGUF != nil { + blocks = si.GGUF.BlockCount + } + if r := cpuWeights(cfg, extra, si.ArgEnv, blocks); r != "" { + return false, r + } + return true, "" +} + +// slotSaveSafe : le moteur n'est joignable que par la machine, ou exige une +// clé d'API. Sans ça, --slot-save-path donnerait à n'importe qui le droit +// d'écrire des Gio sur le disque. +func slotSaveSafe(cfg map[string]string, extra []string, si serveSysInfo) bool { + host := flagValue(extra, "--host") + if host == "" { + host = cfg["HOST"] + } + if host == "" { + host = "0.0.0.0" + } + return loopbackHost(host) || si.APIKey != "" +} + +// slotPersistNote : ce que le lancement dit de SLOT_PERSIST. +func slotPersistNote(ok bool, why string) string { + if ok || why == "" { + return "" + } + return "SLOT_PERSIST refusé (" + why + ") : l'état du slot ne sera pas gardé à la bascule de preset" +} + +// slotPersistFileRe : les seuls fichiers que Loki écrit dans le dossier des +// slots — clé du moteur, empreinte de la discussion. +var slotPersistFileRe = regexp.MustCompile(`^persist-[0-9a-f]{16}-[0-9a-f]{12}\.bin$`) + +// slotPersistMaxFiles : au plus deux états gardés (A→B→A, puis B→A→B). +const slotPersistMaxFiles = 2 + +// slotPersistKey : l'empreinte de tout ce qui décide du calcul du cache. +// args : la ligne de commande finale (la clé d'API n'y entre pas) ; env : ce +// que buildServeArgs pose. Les fichiers sont pris par taille et date : un +// modèle retéléchargé, un moteur mis à jour changent la clé. +func slotPersistKey(args []string, env map[string]string, si serveSysInfo, bin string) string { + h := sha256.New() + w := func(s string) { h.Write([]byte(s)); h.Write([]byte{0}) } + for i := 0; i < len(args); i++ { + if args[i] == "--api-key" { + i++ + continue + } + w(args[i]) + } + vars := map[string]string{} + for _, kv := range os.Environ() { + k, v, _ := strings.Cut(kv, "=") + if strings.HasPrefix(k, "LLAMA_ARG_") || strings.HasPrefix(k, "GGML_") || strings.HasPrefix(k, "CUDA_") { + vars[k] = v + } + } + for k, v := range env { + vars[k] = v + } + delete(vars, "LLAMA_ARG_API_KEY") + keys := make([]string, 0, len(vars)) + for k := range vars { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + w(k + "=" + vars[k]) + } + stat := func(p string) { + if p == "" { + return + } + if st, err := os.Stat(p); err == nil { + w(fmt.Sprintf("%s|%d|%d", p, st.Size(), st.ModTime().UnixNano())) + } else { + w(p + "|?") + } + } + if si.Model != "" { + for _, n := range shardFamily(filepath.Base(si.Model)) { + stat(filepath.Join(filepath.Dir(si.Model), n)) + } + stat(si.Model) + } + stat(si.MMProj) + stat(si.Draft) + stat(bin) + // Bibliothèques du moteur (libllama, ggml-cuda…) : une mise à jour peut + // les changer sans toucher au binaire. + if entries, err := os.ReadDir(filepath.Dir(bin)); err == nil { + for _, e := range entries { + n := strings.ToLower(e.Name()) + if strings.HasSuffix(n, ".so") || strings.Contains(n, ".so.") || strings.HasSuffix(n, ".dll") || strings.HasSuffix(n, ".dylib") { + stat(filepath.Join(filepath.Dir(bin), e.Name())) + } + } + } + w(fmt.Sprintf("build=%d", si.EngineBuild)) + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// slotPersistMarker : la clé du moteur lancé, écrite par « loki serve » juste +// avant de lui céder la place, lue par le process web. Hors du dossier des +// slots : le moteur ne peut pas l'écraser par un save. +func slotPersistMarker(home string) string { return filepath.Join(home, "slots-engine.key") } + +// writeSlotPersistMarker pose (key non vide) ou retire la clé du moteur. +func writeSlotPersistMarker(home, key string) { + p := slotPersistMarker(home) + if key == "" { + _ = os.Remove(p) + return + } + _ = os.WriteFile(p, []byte(key+"\n"), 0o600) +} + +// readSlotPersistMarker : la clé du moteur en service ; vide = SLOT_PERSIST +// refusé ou absent à son lancement. +func readSlotPersistMarker(home string) string { + b, err := os.ReadFile(slotPersistMarker(home)) + if err != nil { + return "" + } + k := strings.TrimSpace(string(b)) + if len(k) != 16 { + return "" + } + if _, err := hex.DecodeString(k); err != nil { + return "" + } + return k +} + +// slotPersistPrune garde les slotPersistMaxFiles états les plus récents. +func slotPersistPrune(dir string) { + type f struct { + name string + at int64 + } + var fs []f + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if !e.Type().IsRegular() || !slotPersistFileRe.MatchString(e.Name()) { + continue + } + if info, err := e.Info(); err == nil { + fs = append(fs, f{e.Name(), info.ModTime().UnixNano()}) + } + } + sort.Slice(fs, func(i, j int) bool { return fs[i].at > fs[j].at }) + for i := slotPersistMaxFiles; i < len(fs); i++ { + _ = os.Remove(filepath.Join(dir, fs[i].name)) + } +} + +// slotPersistPurge retire tous les états gardés : la clé n'est plus là, ils +// n'ont plus rien à faire sur le disque. +func slotPersistPurge(dir string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + for _, e := range entries { + if e.Type().IsRegular() && slotPersistFileRe.MatchString(e.Name()) { + _ = os.Remove(filepath.Join(dir, e.Name())) + } + } +} diff --git a/internal/loki/chat_prewarm.go b/internal/loki/chat_prewarm.go index 7ab0320..3f3a242 100644 --- a/internal/loki/chat_prewarm.go +++ b/internal/loki/chat_prewarm.go @@ -398,6 +398,9 @@ func (c *Conversation) prewarm(why string) { if err != nil { return } + // SLOT_PERSIST : l'état gardé de la discussion d'abord, s'il y en a un — le + // préchauffage le prolonge au lieu de tout recalculer. + slotPersistRestore(ep, convActiveID()) ctx, cancel := context.WithTimeout(context.Background(), prewarmTimeout) defer cancel() run.cancel, run.done = cancel, make(chan struct{}) diff --git a/internal/loki/llm_client.go b/internal/loki/llm_client.go index aa11bf9..68ab445 100644 --- a/internal/loki/llm_client.go +++ b/internal/loki/llm_client.go @@ -1454,6 +1454,12 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools // s'il prépare exactement le début de CETTE requête d'un tour de chat. endReq := func() {} var reqSeq uint64 + // SLOT_PERSIST (llm_slotpersist.go) : l'état gardé à la dernière bascule + // de preset est rechargé avant la première requête de la discussion, + // si tout concorde. Sans la clé, rien. + if !ep.External && ptag.kind == perfMain { + slotPersistRestore(ep, ptag.conv) + } if !ep.External { endReq, reqSeq = engineRequestBeginSide(prewarmKeeper(ptag.kind, sent, payload), payloadOnSideSlot(payload)) } @@ -1645,7 +1651,9 @@ func runChatLoop(ctx context.Context, kt *keepImages, messages []Message, tools engineServed() // le slot porte désormais cette requête (llm_slots.go) // Étape d'un tour de discussion : une compaction en continuation // pourra la prolonger (chat_compact_cont.go). Sans la clé, rien. - if ptag.kind == perfMain && compactContinuationOn(chatCfg) { + // SLOT_PERSIST s'en sert aussi : seul un slot qui porte un tour de + // discussion est gardé à la bascule de preset. + if ptag.kind == perfMain && (compactContinuationOn(chatCfg) || slotPersistOn(chatCfg)) { engineMarkMain(reqSeq, ptag.conv) } } diff --git a/internal/loki/llm_slotpersist.go b/internal/loki/llm_slotpersist.go new file mode 100644 index 0000000..4ca4b7c --- /dev/null +++ b/internal/loki/llm_slotpersist.go @@ -0,0 +1,322 @@ +package loki + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "hash/fnv" + "io" + "math" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// SLOT_PERSIST, côté process web : garder l'état du slot de la discussion +// avant une bascule de preset, le recharger au retour (backend_serve_persist.go +// pour la clé et les refus au lancement). +// +// Sauver : seulement si la dernière requête de Loki passée par le slot 0 était +// une étape d'un tour de la discussion (le tampon d'engineMarkMain) — un +// vérificateur, une tâche, un titre ou un client /v1 passés depuis, et le slot +// ne porte plus ce qu'on croit. Ni brouillon spéculatif actif, ni état trop +// gros (plafond fixe, place libre vérifiée), ni contexte trop court pour valoir +// une écriture. Le fichier est écrit sous un nom provisoire et ne prend son +// nom définitif que si aucune requête n'est partie pendant l'écriture. +// +// Recharger : avant la première requête de la discussion sur le moteur relancé, +// et seulement si le fichier porte la clé de CE moteur (même modèle, même +// build, mêmes réglages — tout ce qui touche au cache) et l'empreinte de CETTE +// discussion, que le slot 0 n'a encore servi aucune tâche (/slots sans +// id_task) et qu'aucun brouillon spéculatif n'y tourne. Une seule chance : le +// fichier est retiré après la tentative, réussie ou non. Toute erreur retombe +// en silence sur un calcul normal — llama.cpp vide lui-même le slot sur un +// rechargement raté. +// +// Sur un modèle hybride (Qwen3.5/3.6), le fichier n'a pas de points de reprise +// : il ne sert que si la requête suivante prolonge exactement les jetons +// gardés, ce qui suppose un gabarit qui rend le dernier tour à l'identique. + +// slotPersistMaxBytes : au-delà, l'état n'est pas gardé. Un 27B dense en f16 à +// 32k jetons pèse 8 Gio ; plus gros, l'écriture et la relecture coûtent autant +// que le recalcul sur un disque lent. +const slotPersistMaxBytes = 8 << 30 + +// slotPersistMinTokens : en dessous, le recalcul est trop court pour valoir une +// écriture. +const slotPersistMinTokens = 4096 + +// Bornes des appels au moteur : écrire ou relire 8 Gio sur un disque lent. +var ( + slotPersistSaveTimeout = 3 * time.Minute + slotPersistRestoreTimeout = 2 * time.Minute +) + +// slotPersistName : le fichier d'une discussion sous une clé de moteur. +func slotPersistName(key, conv string) string { + h := fnv.New64a() + h.Write([]byte(conv)) + return fmt.Sprintf("persist-%s-%012x.bin", key, h.Sum64()&0xffffffffffff) +} + +func slotPersistDir() string { return filepath.Join(LokiHome(), "slots") } + +func slotPersistLog(format string, args ...any) { + fmt.Fprintf(os.Stderr, "[slot] "+format+"\n", args...) +} + +// engineMainConv : la discussion dont le slot porte plausiblement le tour +// (engineSlotHolds), et le numéro de cette requête ; vide = aucune. +func engineMainConv() (conv string, seq uint64) { + model, window := engineMainNow() + engineGate.mu.Lock() + defer engineGate.mu.Unlock() + m := engineGate.main + if m.seq == 0 || m.seq != engineGate.seq || m.model != model || m.window != window { + return "", 0 + } + return m.conv, m.seq +} + +// slotPersistSnap : ce qu'une bascule doit savoir du moteur EN SERVICE, relevé +// avant que la configuration ne change (le modèle et la fenêtre du tampon se +// lisent dans la configuration). +type slotPersistSnap struct { + conv, key string + seq uint64 + cfg map[string]string + iso slotIsolator // port et clé d'API du moteur en service +} + +// slotPersistPrepare : nil = rien à garder (clé absente, moteur sans clé de +// persistance, slot qui ne porte pas un tour de discussion). +func slotPersistPrepare() *slotPersistSnap { + cfg := ReadConfig() + if !slotPersistOn(cfg) || isExternalConfig(cfg) { + return nil + } + key := readSlotPersistMarker(LokiHome()) + if key == "" { + return nil + } + conv, seq := engineMainConv() + if conv == "" { + return nil + } + return &slotPersistSnap{conv: conv, key: key, seq: seq, cfg: cfg, iso: localSlotIsolator(resolveChatEndpoint())} +} + +// slotState : ce que /slots dit du slot 0. +type slotState struct { + found, processing, speculative, used bool + tokens int +} + +func (s slotIsolator) slot0(ctx context.Context) (slotState, error) { + var slots []map[string]json.RawMessage + if err := s.get(ctx, "/slots", &slots); err != nil { + return slotState{}, err + } + for _, sl := range slots { + var id int + if json.Unmarshal(sl["id"], &id) != nil || id != 0 { + continue + } + st := slotState{found: true} + _ = json.Unmarshal(sl["is_processing"], &st.processing) + _ = json.Unmarshal(sl["speculative"], &st.speculative) + _ = json.Unmarshal(sl["n_prompt_tokens"], &st.tokens) + _, st.used = sl["id_task"] + return st, nil + } + return slotState{}, nil +} + +// action : POST /slots/0?action=…, borné par timeout. Rend le nombre d'octets +// écrits ou lus d'après la réponse (0 = non dit). +func (s slotIsolator) action(ctx context.Context, action, filename string, timeout time.Duration) (int64, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + body, _ := json.Marshal(map[string]string{"filename": filename}) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.base+"/slots/0?action="+action, bytes.NewReader(body)) + if err != nil { + return 0, err + } + req.Header.Set("Content-Type", "application/json") + s.auth(req.Header.Set) + resp, err := s.client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<16)) + if resp.StatusCode != http.StatusOK { + return 0, fmt.Errorf("%s : %s %s", action, resp.Status, strings.TrimSpace(string(raw[:min(len(raw), 300)]))) + } + var out struct { + Written int64 `json:"n_written"` + Read int64 `json:"n_read"` + } + _ = json.Unmarshal(raw, &out) + return max(out.Written, out.Read), nil +} + +func localSlotIsolator(ep chatEndpoint) slotIsolator { + return slotIsolator{base: fmt.Sprintf("http://localhost:%d", LLMPort()), auth: ep.auth, client: http.DefaultClient} +} + +// slotPersistSize : slotPersistEstimate, remplaçable par les tests. +var slotPersistSize = slotPersistEstimate + +// slotPersistEstimate : octets de l'état de tokens jetons sous cfg ; 0 = +// inconnu (et l'état n'est alors pas gardé : on ne borne pas l'inconnu). +func slotPersistEstimate(cfg map[string]string, tokens int) int64 { + p, err := resolveServeModelPath(strings.TrimSpace(cfg["MODEL"])) + if err != nil { + return 0 + } + g, err := ggufMeta(p) + if err != nil { + return 0 + } + argEnv := map[string]string{} + for _, k := range fidelityArgEnv { + if v := os.Getenv(k); v != "" { + argEnv[k] = v + } + } + kt, vt, _ := effectiveKVTypes(cfg, splitArgs(cfg["EXTRA_ARGS"]), argEnv) + perTok, rec := ggufStateBytes(g, kt, vt) + if perTok <= 0 { + return 0 + } + return int64(math.Ceil(perTok*float64(tokens))) + rec +} + +// save garde l'état du slot de la discussion, juste avant l'arrêt du moteur. +// Jamais d'erreur rendue : au pire, rien n'est gardé. +func (s *slotPersistSnap) save() { + if s == nil { + return + } + iso := s.iso + ctx := context.Background() + st, err := iso.slot0(ctx) + switch { + case err != nil: + slotPersistLog("état non gardé : /slots illisible (%v)", err) + return + case !st.found || st.processing: + return + case st.speculative: + slotPersistLog("état non gardé : décodage spéculatif actif (le brouillon ne serait pas sauvé)") + return + case st.tokens < slotPersistMinTokens: + return + } + est := slotPersistSize(s.cfg, st.tokens) + if est <= 0 || est > slotPersistMaxBytes { + slotPersistLog("état non gardé : %d jetons, taille estimée %d Mio (plafond %d Mio)", st.tokens, est>>20, slotPersistMaxBytes>>20) + return + } + dir := slotPersistDir() + if free := diskFree(dir); free < 2*est+(1<<30) { + slotPersistLog("état non gardé : place libre insuffisante ou inconnue dans %s", dir) + return + } + name := slotPersistName(s.key, s.conv) + tmp := "tmp-" + name + end, seq := engineRequestBegin(nil) + t0 := time.Now() + n, err := iso.action(ctx, "save", tmp, slotPersistSaveTimeout) + end() + engineGate.mu.Lock() + moved := seq != s.seq+1 || engineGate.seq != seq + engineGate.mu.Unlock() + if err != nil || moved { + _ = os.Remove(filepath.Join(dir, tmp)) + if err != nil { + slotPersistLog("état non gardé : %v", err) + } + return + } + if err := os.Rename(filepath.Join(dir, tmp), filepath.Join(dir, name)); err != nil { + _ = os.Remove(filepath.Join(dir, tmp)) + slotPersistLog("état non gardé : %v", err) + return + } + slotPersistPrune(dir) + slotPersistLog("état de la discussion gardé : %d jetons, %d Mio en %s", st.tokens, n>>20, time.Since(t0).Round(time.Millisecond)) +} + +// slotPersistTried : fichiers déjà tentés (nom et date), pour qu'un fichier +// qu'on n'aurait pas pu retirer ne soit pas relu à chaque requête. +var slotPersistTried struct { + mu sync.Mutex + m map[string]bool +} + +// slotPersistRestore recharge l'état gardé de conv dans le slot 0, si tout +// concorde. Appelée juste avant une requête de la discussion vers le moteur +// local ; sans la clé, une lecture de configuration et rien d'autre. +func slotPersistRestore(ep chatEndpoint, conv string) { + if ep.External || conv == "" { + return + } + cfg := ReadConfig() + if !slotPersistOn(cfg) || isExternalConfig(cfg) { + return + } + key := readSlotPersistMarker(LokiHome()) + if key == "" { + return + } + name := slotPersistName(key, conv) + path := filepath.Join(slotPersistDir(), name) + fi, err := os.Stat(path) + if err != nil { + return + } + // Un fichier, une tentative : le même nom réécrit par une bascule suivante + // (A→B→A→B→A) est un autre fichier. + once := fmt.Sprintf("%s|%d", name, fi.ModTime().UnixNano()) + iso := localSlotIsolator(ep) + ctx := context.Background() + st, err := iso.slot0(ctx) + if err != nil || !st.found { + return // moteur pas encore prêt : la requête suivante retentera + } + slotPersistTried.mu.Lock() + if slotPersistTried.m == nil { + slotPersistTried.m = map[string]bool{} + } + tried := slotPersistTried.m[once] + slotPersistTried.m[once] = true + slotPersistTried.mu.Unlock() + if tried { + return + } + // Une seule chance, quoi qu'il arrive. + defer os.Remove(path) + switch { + case st.used || st.processing: + slotPersistLog("état gardé ignoré : le slot a déjà servi depuis le lancement") + return + case st.speculative: + slotPersistLog("état gardé ignoré : décodage spéculatif actif") + return + } + end, _ := engineRequestBegin(nil) + t0 := time.Now() + n, err := iso.action(ctx, "restore", name, slotPersistRestoreTimeout) + end() + if err != nil { + slotPersistLog("état gardé non rechargé, calcul normal : %v", err) + return + } + slotPersistLog("état de la discussion rechargé : %d Mio en %s", n>>20, time.Since(t0).Round(time.Millisecond)) +} diff --git a/internal/loki/llm_slotpersist_test.go b/internal/loki/llm_slotpersist_test.go new file mode 100644 index 0000000..8a8dce1 --- /dev/null +++ b/internal/loki/llm_slotpersist_test.go @@ -0,0 +1,442 @@ +package loki + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +// persistSI : moteur qui connaît --slot-save-path, dossier prêt, boucle locale. +func persistSI() serveSysInfo { + si := denseGPU() + si.SlotDir = "/data/slots" + return si +} + +func TestSlotPersistPlan(t *testing.T) { + loop := map[string]string{"SLOT_PERSIST": "on", "HOST": "127.0.0.1"} + with := func(kv ...string) map[string]string { + m := map[string]string{} + for k, v := range loop { + m[k] = v + } + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i]] = kv[i+1] + } + return m + } + cases := []struct { + name string + cfg map[string]string + extra []string + si func(*serveSysInfo) + ok bool + why string + }{ + {name: "clé absente", cfg: map[string]string{"HOST": "127.0.0.1"}}, + {name: "boucle locale", cfg: loop, ok: true}, + {name: "0.0.0.0 avec clé d'API", cfg: with("HOST", "0.0.0.0"), si: func(s *serveSysInfo) { s.APIKey = "k" }, ok: true}, + {name: "0.0.0.0 sans clé", cfg: with("HOST", ""), why: "sans clé"}, + {name: "SPEC=auto", cfg: with("SPEC", "auto"), why: "spéculatif"}, + {name: "brouillon dans EXTRA_ARGS", cfg: loop, extra: []string{"-md", "d.gguf"}, why: "spéculatif"}, + {name: "--slot-save-path à la main", cfg: loop, extra: []string{"--slot-save-path", "/x"}, why: "à la main"}, + {name: "variable LLAMA_ARG_SLOT_SAVE_PATH", cfg: loop, si: func(s *serveSysInfo) { s.ArgEnv["LLAMA_ARG_SLOT_SAVE_PATH"] = "/x" }, why: "à la main"}, + {name: "experts sur CPU", cfg: loop, extra: []string{"--n-cpu-moe", "30"}, why: "CPU"}, + {name: "moteur sans le drapeau", cfg: loop, si: func(s *serveSysInfo) { s.Help = helpRecent }, why: "--slot-save-path"}, + {name: "dossier indisponible", cfg: loop, si: func(s *serveSysInfo) { s.SlotDir = "" }, why: "dossier"}, + {name: "preset externe", cfg: with(extKeyFlag, "1"), why: "externe"}, + } + for _, c := range cases { + si := persistSI() + if c.si != nil { + c.si(&si) + } + ok, why := slotPersistPlan(c.cfg, c.extra, si) + if ok != c.ok || !strings.Contains(why, c.why) { + t.Errorf("%s : ok=%v why=%q", c.name, ok, why) + } + } +} + +// --slot-save-path : posé pour SLOT_PERSIST même quand l'isolation n'en veut +// pas (cache coupé) ; sans la clé, la règle du lot 1 inchangée. +func TestSlotPersistSlotSaveArgs(t *testing.T) { + si := serveSysInfo{Help: helpCacheRAM, SlotDir: "/data/slots", ArgEnv: map[string]string{}} + base := map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0"} + if got := slotSaveArgs(base, nil, si); got != nil { + t.Fatalf("sans la clé : %v", got) + } + on := map[string]string{"HOST": "127.0.0.1", "CACHE_RAM": "0", "SLOT_PERSIST": "on"} + if got := slotSaveArgs(on, nil, si); !reflect.DeepEqual(got, []string{"--slot-save-path", "/data/slots"}) { + t.Fatalf("avec la clé : %v", got) + } + open := map[string]string{"CACHE_RAM": "0", "SLOT_PERSIST": "on"} + if got := slotSaveArgs(open, nil, si); got != nil { + t.Fatalf("0.0.0.0 sans clé : %v", got) + } +} + +// Refus : la ligne est celle sans la clé, une note de plus. +func TestSlotPersistRefusLigneInchangee(t *testing.T) { + si := persistSI() + cfg := map[string]string{"CTX": "32768", "SPEC": "auto"} + want, _, wantNotes := buildServeArgs(cfg, nil, "/bin/llama-server", si) + cfg["SLOT_PERSIST"] = "on" + got, _, notes := buildServeArgs(cfg, nil, "/bin/llama-server", si) + if !reflect.DeepEqual(got, want) || len(notes) != len(wantNotes)+1 { + t.Fatalf("refus :\n%v\n%v\n%q", got, want, notes) + } +} + +func TestSlotPersistKey(t *testing.T) { + dir := t.TempDir() + bin := filepath.Join(dir, "llama-server") + lib := filepath.Join(dir, "libggml-cuda.so") + model := filepath.Join(dir, "m.gguf") + for _, p := range []string{bin, lib, model} { + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + si := serveSysInfo{Model: model} + args := []string{bin, "-m", model, "-c", "32768", "--api-key", "secret"} + k0 := slotPersistKey(args, nil, si, bin) + if len(k0) != 16 || k0 != slotPersistKey(args, nil, si, bin) { + t.Fatalf("clé instable : %q", k0) + } + if slotPersistKey([]string{bin, "-m", model, "-c", "32768", "--api-key", "autre"}, nil, si, bin) != k0 { + t.Fatal("la clé d'API ne doit pas entrer dans l'empreinte") + } + if slotPersistKey([]string{bin, "-m", model, "-c", "65536"}, nil, si, bin) == k0 { + t.Fatal("CTX ignoré") + } + if slotPersistKey(args, map[string]string{"GGML_CUDA_GRAPH_OPT": "1"}, si, bin) == k0 { + t.Fatal("environnement ignoré") + } + later := time.Now().Add(time.Hour) + for _, p := range []string{lib, model, bin} { + if err := os.Chtimes(p, later, later); err != nil { + t.Fatal(err) + } + k := slotPersistKey(args, nil, si, bin) + if k == k0 { + t.Fatalf("%s modifié, clé inchangée", filepath.Base(p)) + } + k0 = k + } +} + +func TestSlotPersistFichiers(t *testing.T) { + home := t.TempDir() + dir := prepareSlotDir(home, true) + names := []string{"persist-0123456789abcdef-000000000001.bin", "persist-0123456789abcdef-000000000002.bin", + "persist-fedcba9876543210-000000000003.bin"} + for i, n := range append(append([]string{}, names...), "tmp-"+names[0], "autre.bin") { + p := filepath.Join(dir, n) + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + at := time.Now().Add(time.Duration(i) * time.Minute) + _ = os.Chtimes(p, at, at) + } + prepareSlotDir(home, true) + slotPersistPrune(dir) + left, _ := os.ReadDir(dir) + var got []string + for _, e := range left { + got = append(got, e.Name()) + } + if !reflect.DeepEqual(got, names[1:]) { + t.Fatalf("restent %v, attendu les deux plus récents %v", got, names[1:]) + } + prepareSlotDir(home, false) + if left, _ := os.ReadDir(dir); len(left) != 0 { + t.Fatalf("clé retirée : %d fichier(s) gardé(s)", len(left)) + } + if readSlotPersistMarker(home) != "" { + t.Fatal("marqueur absent : clé vide attendue") + } + writeSlotPersistMarker(home, "0123456789abcdef") + if readSlotPersistMarker(home) != "0123456789abcdef" { + t.Fatal("marqueur illisible") + } + writeSlotPersistMarker(home, "") + if readSlotPersistMarker(home) != "" { + t.Fatal("marqueur non retiré") + } +} + +// moteurSlots : faux llama-server pour /slots, save et restore. Un save écrit +// le fichier dans le dossier des slots, comme le vrai. +type moteurSlots struct { + mu sync.Mutex + actions []string + used, spec bool + restoreFail bool + tokens int +} + +func (m *moteurSlots) start(t *testing.T, home string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + m.mu.Lock() + defer m.mu.Unlock() + switch { + case r.URL.Path == "/slots" && r.Method == http.MethodGet: + s := map[string]any{"id": 0, "is_processing": false, "speculative": m.spec, "n_prompt_tokens": m.tokens} + if m.used { + s["id_task"] = 7 + } + sendJSON(w, 200, []any{s}) + case r.URL.Path == "/slots/0" && r.Method == http.MethodPost: + var body struct { + Filename string `json:"filename"` + } + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &body) + act := r.URL.Query().Get("action") + m.actions = append(m.actions, act+" "+body.Filename) + if act == "save" { + _ = os.WriteFile(filepath.Join(home, "slots", body.Filename), []byte("kv"), 0o600) + sendJSON(w, 200, map[string]any{"n_saved": m.tokens, "n_written": 2 << 20}) + return + } + if m.restoreFail { + sendJSON(w, 400, map[string]any{"error": map[string]any{"message": "Unable to restore slot"}}) + return + } + sendJSON(w, 200, map[string]any{"n_restored": m.tokens, "n_read": 2 << 20}) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + _, port, _ := strings.Cut(strings.TrimPrefix(srv.URL, "http://"), ":") + if err := SetConfigKey("PORT", port); err != nil { + t.Fatal(err) + } +} + +func (m *moteurSlots) got() []string { + m.mu.Lock() + defer m.mu.Unlock() + return append([]string(nil), m.actions...) +} + +const persistTestKey = "0123456789abcdef" + +// persistSetup : discussion « c1 » dont le tour vient de passer par le slot. +func persistSetup(t *testing.T, on bool) (*moteurSlots, string) { + t.Helper() + home := testHome(t) + if err := os.MkdirAll(filepath.Join(home, "slots"), 0o700); err != nil { + t.Fatal(err) + } + for k, v := range map[string]string{"MODEL": "m.gguf", "CTX": "32768"} { + if err := SetConfigKey(k, v); err != nil { + t.Fatal(err) + } + } + if on { + if err := SetConfigKey("SLOT_PERSIST", "on"); err != nil { + t.Fatal(err) + } + } + writeSlotPersistMarker(home, persistTestKey) + prev := slotPersistSize + slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 } + reset := func() { + engineGate.mu.Lock() + engineGate.main = engineMainStamp{} + engineGate.mu.Unlock() + } + reset() + t.Cleanup(func() { slotPersistSize = prev; reset() }) + m := &moteurSlots{tokens: 20000} + m.start(t, home) + end, seq := engineRequestBegin(nil) + end() + engineMarkMain(seq, "c1") + return m, home +} + +// Bascule puis retour : gardé sous la clé du moteur, rechargé une fois avant +// la première requête de la discussion, puis retiré. +func TestSlotPersistSauveEtRecharge(t *testing.T) { + m, home := persistSetup(t, true) + snap := slotPersistPrepare() + if snap == nil { + t.Fatal("rien à garder") + } + snap.save() + name := slotPersistName(persistTestKey, "c1") + path := filepath.Join(home, "slots", name) + if _, err := os.Stat(path); err != nil { + t.Fatalf("état non gardé : %v (%v)", err, m.got()) + } + if _, err := os.Stat(filepath.Join(home, "slots", "tmp-"+name)); !os.IsNotExist(err) { + t.Fatal("fichier provisoire resté") + } + ep := resolveChatEndpoint() + slotPersistRestore(ep, "autre") // autre discussion : rien + slotPersistRestore(ep, "c1") + slotPersistRestore(ep, "c1") // une seule fois + want := []string{"save tmp-" + name, "restore " + name} + if got := m.got(); !reflect.DeepEqual(got, want) { + t.Fatalf("appels %v, attendu %v", got, want) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("fichier gardé après la tentative") + } +} + +// Sans la clé : ni relevé, ni appel au moteur. +func TestSlotPersistDefautRien(t *testing.T) { + m, home := persistSetup(t, false) + if snap := slotPersistPrepare(); snap != nil { + t.Fatal("relevé sans la clé") + } + var snap *slotPersistSnap + snap.save() + name := slotPersistName(persistTestKey, "c1") + if err := os.WriteFile(filepath.Join(home, "slots", name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + slotPersistRestore(resolveChatEndpoint(), "c1") + if got := m.got(); len(got) != 0 { + t.Fatalf("appels sans la clé : %v", got) + } +} + +// Rien n'est gardé si le slot ne porte plus le tour, si un brouillon tourne, +// si l'état est trop court ou trop gros, ou si une requête part pendant +// l'écriture. +func TestSlotPersistSauvegardeRefusee(t *testing.T) { + m, home := persistSetup(t, true) + end := engineRequestStart() // une autre requête est passée par le slot + end() + if slotPersistPrepare() != nil { + t.Fatal("relevé alors que le slot ne porte plus le tour") + } + for _, c := range []struct { + name string + set func() + }{ + {"brouillon spéculatif", func() { m.spec = true }}, + {"contexte court", func() { m.tokens = 100 }}, + {"état trop gros", func() { slotPersistSize = func(map[string]string, int) int64 { return slotPersistMaxBytes + 1 } }}, + } { + m.mu.Lock() + m.spec, m.tokens = false, 20000 + m.mu.Unlock() + slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 } + e, seq := engineRequestBegin(nil) + e() + engineMarkMain(seq, "c1") + snap := slotPersistPrepare() + m.mu.Lock() + c.set() + m.mu.Unlock() + snap.save() + if got := m.got(); len(got) != 0 { + t.Fatalf("%s : appels %v", c.name, got) + } + } + // Une requête entre le relevé et l'écriture : fichier jeté. + slotPersistSize = func(map[string]string, int) int64 { return 1 << 20 } + m.mu.Lock() + m.spec, m.tokens = false, 20000 + m.mu.Unlock() + e, seq := engineRequestBegin(nil) + e() + engineMarkMain(seq, "c1") + snap := slotPersistPrepare() + e2 := engineRequestStart() + e2() + snap.save() + left, _ := os.ReadDir(filepath.Join(home, "slots")) + if len(left) != 0 { + t.Fatalf("fichier gardé malgré une requête pendant l'écriture : %v", left[0].Name()) + } +} + +// Rechargement refusé (slot déjà servi, brouillon) ou raté : calcul normal, +// fichier retiré, aucune erreur. +func TestSlotPersistRechargementPrudent(t *testing.T) { + for _, c := range []struct { + name string + set func(*moteurSlots) + restore bool + }{ + {"slot déjà servi", func(m *moteurSlots) { m.used = true }, false}, + {"brouillon spéculatif", func(m *moteurSlots) { m.spec = true }, false}, + {"échec du moteur", func(m *moteurSlots) { m.restoreFail = true }, true}, + } { + t.Run(c.name, func(t *testing.T) { + m, home := persistSetup(t, true) + name := slotPersistName(persistTestKey, "c1") + path := filepath.Join(home, "slots", name) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + c.set(m) + slotPersistRestore(resolveChatEndpoint(), "c1") + if got := m.got(); (len(got) == 1) != c.restore { + t.Fatalf("appels %v", got) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("fichier gardé après la tentative") + } + }) + } +} + +// Clé du moteur différente (autre build, autres réglages) : le fichier n'est +// même pas cherché. +func TestSlotPersistAutreMoteur(t *testing.T) { + m, home := persistSetup(t, true) + name := slotPersistName(persistTestKey, "c1") + if err := os.WriteFile(filepath.Join(home, "slots", name), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + writeSlotPersistMarker(home, "fedcba9876543210") + slotPersistRestore(resolveChatEndpoint(), "c1") + if got := m.got(); len(got) != 0 { + t.Fatalf("rechargé sous une autre clé : %v", got) + } +} + +// SLOT_PERSIST survit à la bascule (A → B sans la clé → A) : sans ça, le +// lancement de B effacerait l'état gardé de A. Un preset qui la pose gagne. +func TestSlotPersistSurvitALaBascule(t *testing.T) { + home := testHome(t) + setConfig(t, "MODEL=a.gguf\nSLOT_PERSIST=on\n") + b := filepath.Join(home, "b.env") + if err := os.WriteFile(b, []byte("MODEL=b.gguf\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := applyPresetFile(b); err != nil { + t.Fatal(err) + } + if got := ReadConfig()["SLOT_PERSIST"]; got != "on" { + t.Fatalf("SLOT_PERSIST = %q après bascule", got) + } + c := filepath.Join(home, "c.env") + if err := os.WriteFile(c, []byte("MODEL=c.gguf\nSLOT_PERSIST=off\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := applyPresetFile(c); err != nil { + t.Fatal(err) + } + if got := ReadConfig()["SLOT_PERSIST"]; got != "off" { + t.Fatalf("le preset qui pose la clé doit gagner : %q", got) + } +} diff --git a/internal/loki/sys_service.go b/internal/loki/sys_service.go index aef1557..35eba0c 100644 --- a/internal/loki/sys_service.go +++ b/internal/loki/sys_service.go @@ -88,6 +88,11 @@ var configTemplate = []struct{ key, help string }{ "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 ; " + diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index 7ff4ed6..51c63f7 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -1310,6 +1310,9 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) { // SwitchToPreset bloquait la réponse pendant tout l'arrêt de llama-server plus // les 2 s de vérification de checkStarted : côté UI, le clic paraissait mou et // la sélection ne bougeait qu'au bout de plusieurs secondes, pour rien. + // SLOT_PERSIST : relevé avant que la configuration change, gardé juste + // avant l'arrêt du moteur (llm_slotpersist.go). Sans la clé : nil. + persist := slotPersistPrepare() if err := applyPresetFile(target.Path); err != nil { sendJSON(w, 500, map[string]any{"ok": false, "error": err.Error()}) return @@ -1323,6 +1326,7 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) { fmt.Println(dim("[info] preset externe — arrêt du moteur local")) go func() { if serviceIsActive() { + persist.save() if err := serviceAction("stop"); err != nil { fmt.Printf("%s arrêt du moteur après bascule externe : %v\n", red("[ERREUR]"), err) } @@ -1333,6 +1337,7 @@ func handleSwitch(w http.ResponseWriter, r *http.Request) { } fmt.Println(dim("[info] redémarrage du service...")) go func() { + persist.save() if err := serviceAction("restart"); err != nil { fmt.Printf("%s redémarrage après bascule: %v\n", red("[ERREUR]"), err) }