mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
TestToolResultsSaveLoadDelete échouait de temps en temps en suite complète (1 fois sur ~300 en isolé). Vraie course dans le code, pas dans le test : deleteToolResultsFor retirait les résultats EN ATTENTE de l'écrivain différé, mais pas ceux qu'il avait déjà pris dans son lot. Si la transaction de suppression passait avant celle de l'écrivain, le résultat était écrit juste après avoir été effacé, et renaissait. En production, la suppression d'une discussion passe d'abord par forget + flush, ce qui masquait le problème ; mais forget non plus ne pouvait rien contre un lot déjà parti. - l'écrivain note les résultats du lot en vol ; dropToolRes (donc forget et deleteToolResultsFor) les marque annulés - l'écrivain vérifie l'annulation DANS sa transaction ; la suppression annule AVANT d'ouvrir la sienne : bbolt sérialisant les deux, soit le résultat est sauté, soit il est écrit puis effacé - annulations oubliées à la fin du lot : un nouveau résultat s'écrit - test déterministe (écrivain retenu en plein lot) qui échouait avant la correction ; suite complète passée 3 fois, TestToolResults ×400 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
4.4 KiB
Go
140 lines
4.4 KiB
Go
package loki
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"strings"
|
|
"sync/atomic"
|
|
|
|
bolt "go.etcd.io/bbolt"
|
|
)
|
|
|
|
// tool_results.go — résultats COMPLETS des outils, pour le bouton « voir plus ».
|
|
// Repris d'AJEAN 0.15.5 / 0.15.7. Chiffré comme les discussions quand le
|
|
// chiffrement mémoire est actif (bkToolRes figure dans encryptedBuckets).
|
|
//
|
|
// Le flux n'envoie à l'UI qu'un aperçu (toolPreviewChars) ; le reste se charge
|
|
// au clic. Il ne peut pas être cherché dans conv.Messages : pendant un tour
|
|
// d'agent, les messages tool n'y sont versés qu'à la FIN du tour, et après un
|
|
// compactage les anciens ont disparu de la vue modèle. On garde donc chaque
|
|
// résultat coupé dans un bucket dédié, sous un id PROPRE (pas le tool_call_id,
|
|
// que certains parseurs réutilisent d'un tour à l'autre).
|
|
//
|
|
// Rangement PAR DISCUSSION : la clé est « <id de discussion>.<aléa> ». Les
|
|
// résultats vivent aussi longtemps que leur discussion (supprimés avec elle,
|
|
// voir deleteToolResultsFor) ; les orphelins sont nettoyés de temps en temps.
|
|
|
|
const (
|
|
bkToolRes = "toolres"
|
|
toolResPruneGap = 200 // nettoyage tenté toutes les N écritures
|
|
)
|
|
|
|
var toolResWrites atomic.Int64
|
|
|
|
func toolResID(sid string) string {
|
|
var b [12]byte
|
|
if _, err := rand.Read(b[:]); err != nil {
|
|
return ""
|
|
}
|
|
if sid = strings.TrimSpace(sid); sid == "" {
|
|
sid = "nosession"
|
|
}
|
|
return sid + "." + hex.EncodeToString(b[:])
|
|
}
|
|
|
|
// saveToolResult enregistre un résultat complet de la discussion ACTIVE et
|
|
// renvoie son id ("" en cas d'échec : l'appelant envoie alors le résultat
|
|
// entier dans le flux).
|
|
//
|
|
// L'écriture est confiée à l'écrivain de la discussion (chat_persist.go) : un
|
|
// tour d'agent qui coupe vingt résultats payait vingt fsync entre l'outil et
|
|
// 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(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.
|
|
if id == "" {
|
|
return ""
|
|
}
|
|
if _, err := memEncoderNow(); err != nil {
|
|
return ""
|
|
}
|
|
persistQ.enqueueToolRes(toolResJob{path: dbPath(), key: id, plain: result})
|
|
if toolResWrites.Add(1)%toolResPruneGap == 0 {
|
|
go pruneToolResults()
|
|
}
|
|
return id
|
|
}
|
|
|
|
// loadToolResult relit un résultat enregistré.
|
|
func loadToolResult(id string) (string, bool) {
|
|
if strings.ContainsAny(id, "/\\") {
|
|
return "", false
|
|
}
|
|
if s, ok := persistQ.toolResPending(id); ok {
|
|
return s, true
|
|
}
|
|
b, err := getStoreBytesErr(bkToolRes, id)
|
|
if err != nil || b == nil {
|
|
return "", false
|
|
}
|
|
return string(b), true
|
|
}
|
|
|
|
// deleteToolResultsFor supprime les résultats d'une discussion supprimée.
|
|
func deleteToolResultsFor(sid string) {
|
|
if sid == "" {
|
|
return
|
|
}
|
|
prefix := sid + "."
|
|
// D'abord l'écrivain (chat_persist.go) : ce qui l'attend est jeté, et ce
|
|
// qu'il a déjà pris dans son lot est annulé — sans ça, il l'écrirait après
|
|
// l'effacement ci-dessous et le résultat renaîtrait. Annulé AVANT notre
|
|
// transaction : l'écrivain vérifie dans la sienne, et bbolt les sérialise.
|
|
persistQ.dropToolRes(prefix)
|
|
_ = update(bkToolRes, func(b *bolt.Bucket) error {
|
|
c := b.Cursor()
|
|
var keys [][]byte
|
|
for k, _ := c.Seek([]byte(prefix)); k != nil && strings.HasPrefix(string(k), prefix); k, _ = c.Next() {
|
|
keys = append(keys, append([]byte(nil), k...))
|
|
}
|
|
for _, k := range keys {
|
|
if err := b.Delete(k); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// pruneToolResults supprime les résultats de discussions qui n'existent plus.
|
|
func pruneToolResults() {
|
|
// Mémoire verrouillée : l'index des discussions est illisible et reviendrait
|
|
// vide — tout passerait pour orphelin. On attend le déverrouillage.
|
|
if memEncActive() && !memUnlocked() {
|
|
return
|
|
}
|
|
alive := map[string]bool{getStr(bkChat, ckActive): true}
|
|
for _, m := range convIndex() {
|
|
alive[m.ID] = true
|
|
}
|
|
_ = update(bkToolRes, func(b *bolt.Bucket) error {
|
|
var orphans []string
|
|
_ = b.ForEach(func(k, _ []byte) error {
|
|
key := string(k)
|
|
if i := strings.LastIndexByte(key, '.'); i > 0 && key[:i] != "nosession" && !alive[key[:i]] {
|
|
orphans = append(orphans, key)
|
|
}
|
|
return nil
|
|
})
|
|
for _, k := range orphans {
|
|
if err := b.Delete([]byte(k)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|