diff --git a/internal/loki/chat_conversation.go b/internal/loki/chat_conversation.go index 76dbfbe..2acb7a4 100644 --- a/internal/loki/chat_conversation.go +++ b/internal/loki/chat_conversation.go @@ -737,7 +737,7 @@ func (c *Conversation) generate(ctx context.Context, caps Caps, temperature floa }() // Télémétrie : tout ce que ce tour envoie au moteur (étapes, compaction, // sous-agents, vérification) est rattaché à la discussion active. - ctx = withPerf(ctx, perfMain, convEnsureActive()) + ctx = withPerf(ctx, perfMain, convActiveID()) // llama-server local seulement : le preset externe garde le seul seuil, et // ses complétions ne nourrissent pas la garde de marge (compactNeeded). diff --git a/internal/loki/chat_persist.go b/internal/loki/chat_persist.go index 9dfd388..9846550 100644 --- a/internal/loki/chat_persist.go +++ b/internal/loki/chat_persist.go @@ -116,8 +116,12 @@ func (p *convPersister) enqueueSnap(s *convSnap) uint64 { func (p *convPersister) enqueueToolRes(j toolResJob) uint64 { p.mu.Lock() defer p.mu.Unlock() - p.mem[j.key] = j.plain - p.res = append(p.res, j) + // Discussion déjà supprimée (un outil finit juste après la suppression) : + // l'écrivain l'écarterait, et la copie en mémoire ne partirait plus jamais. + if !p.deleted[toolResConv(j.key)] { + p.mem[j.key] = j.plain + p.res = append(p.res, j) + } return p.kickLocked() } @@ -461,6 +465,17 @@ func convActiveRemember(id string) { convActiveCache.Store(&convActiveRef{path: dbPath(), id: id}) } +// convActiveID lit la discussion active SANS jamais en créer une : cache chaud, +// sinon la clé en base (vide si aucune). Pour les lecteurs qui, avant le cache, +// lisaient la clé brute — les résultats d'outils d'une tâche de fond sur une base +// neuve vont sous « nosession » au lieu de forger une discussion vide. +func convActiveID() string { + if id := convActiveCached(); id != "" { + return id + } + return getStr(bkChat, ckActive) +} + // convActivate fait de id la discussion active et charge b en mémoire, sous // convActiveMu : le pointeur en base, le cache et le contenu changent ensemble. func convActivate(id string, b []byte) { diff --git a/internal/loki/chat_persist_test.go b/internal/loki/chat_persist_test.go index 1696b01..e3bc843 100644 --- a/internal/loki/chat_persist_test.go +++ b/internal/loki/chat_persist_test.go @@ -423,3 +423,37 @@ func TestConvEnsureActiveConcurrentForgeUnSeulID(t *testing.T) { t.Fatalf("%d entrées d'index, attendu 1", n) } } + +// Un outil qui finit juste après la suppression de sa discussion : son résultat +// n'est ni écrit ni gardé en mémoire pour toujours. +func TestToolResultApresSuppressionNeFuitPas(t *testing.T) { + testHome(t) + resetConvForTest() + a := convEnsureActive() + convNew() + persistQ.forget(a) + key := a + ".deadbeef" + persistQ.enqueueToolRes(toolResJob{path: dbPath(), key: key, plain: "orphelin"}) + persistQ.flush() + if _, ok := persistQ.toolResPending(key); ok { + t.Fatal("résultat d'une discussion supprimée gardé en mémoire") + } + if b := getBytes(bkToolRes, key); b != nil { + t.Fatal("résultat d'une discussion supprimée écrit sur disque") + } +} + +// Base neuve, sans discussion active : un résultat d'outil (tâche de fond) ne +// forge PAS de discussion vide dans la liste — il va sous « nosession », comme +// avant le cache. +func TestSaveToolResultNeCreePasDeDiscussion(t *testing.T) { + testHome(t) + id := saveToolResult(strings.Repeat("r", 3000)) + if !strings.HasPrefix(id, "nosession.") { + t.Fatalf("id = %q, attendu nosession.*", id) + } + persistQ.flush() + if getStr(bkChat, ckActive) != "" || len(convIndex()) != 0 { + t.Fatal("saveToolResult a créé une discussion") + } +} diff --git a/internal/loki/sys_update.go b/internal/loki/sys_update.go index a28d42b..86aa87a 100644 --- a/internal/loki/sys_update.go +++ b/internal/loki/sys_update.go @@ -493,6 +493,7 @@ func restartAfterUpdate() (bool, string) { } go func() { time.Sleep(1500 * time.Millisecond) // laisser la réponse HTTP atteindre le client + flushPersister(persistExitWait) // systemd nous arrêtera : écritures différées de la discussion d'abord // Le service tourne souvent en User=nathan (pas root) : systemctl brut // échouerait alors (polkit). On repli sur « sudo -n systemctl » comme // uiServiceCtl, les sudoers /etc/sudoers.d/loki-ui autorisant le restart. diff --git a/internal/loki/tool_results.go b/internal/loki/tool_results.go index 847d560..391f416 100644 --- a/internal/loki/tool_results.go +++ b/internal/loki/tool_results.go @@ -51,7 +51,7 @@ func toolResID(sid string) string { // l'étape suivante. Le résultat est servi depuis la mémoire jusqu'à ce qu'il // soit sur disque (loadToolResult). func saveToolResult(result string) string { - id := toolResID(convEnsureActive()) + id := toolResID(convActiveID()) // Chiffrement actif mais mémoire verrouillée : on refuse d'écrire en clair, // et l'appelant envoie alors le résultat entier dans le flux. Vérifié ICI, // pas à l'écriture : un id rendu doit désigner un résultat qu'on écrira. diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index 05ebb28..ed7cc49 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -792,6 +792,10 @@ func handleMemUnlock(w http.ResponseWriter, r *http.Request) { // handleMemLock purge la DEK de la RAM (reverrouille sans redémarrer). func handleMemLock(w http.ResponseWriter, r *http.Request) { + // Écritures différées de la discussion (chat_persist.go) d'abord : clé + // purgée, elles seraient sautées et le message tout juste envoyé ne + // toucherait le disque qu'au prochain déverrouillage. + flushPersister(persistExitWait) clearMemDEK() sendJSON(w, 200, map[string]any{"ok": true, "health": memHealth()}) }