Persistance : corrections de relecture — un résultat d'outil ne forge plus de discussion

Relecture de l'écrivain différé (chat_persist.go) : l'ordre, la suppression
et les bascules tiennent ; trois écarts de comportement corrigés.

- saveToolResult et la télémétrie de generate lisaient la clé brute de la
  discussion active ; passés à convEnsureActive, ils pouvaient en CRÉER une
  (tâche de fond sur base neuve → discussion vide dans la liste). Nouveau
  convActiveID : cache chaud, sinon la clé en base, jamais de création.
- Un résultat d'outil confié après la suppression de sa discussion restait en
  mémoire pour toujours (l'écrivain l'écarte, la copie ne partait plus) : il
  n'est plus retenu.
- Vidage de l'écrivain avant « verrouiller la mémoire » (sinon l'envoi tout
  juste fait ne touchait le disque qu'au déverrouillage) et avant le restart
  systemd après MAJ (SIGTERM ne laisse rien finir).

Tests : résultat d'une discussion supprimée ni écrit ni retenu ; base neuve,
saveToolResult reste sous « nosession » sans créer de discussion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-04 02:27:54 +02:00
1 parent c3005f5b56
commit 667e340e57
6 files changed
+58 -4

No files matched your search

+1 -1
View File
@@ -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).
+17 -2
View File
@@ -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) {
+34
View File
@@ -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")
}
}
+1
View File
@@ -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.
+1 -1
View File
@@ -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.
+4
View File
@@ -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()})
}