Lot 33 : préchauffage (PREWARM) tenu par la façade

PREWARM ne garde plus la conversation au Go. La façade tient
l'historique : elle assemble et envoie la requête du prochain tour ; le
Go garde le compteur des requêtes en vol et ce qui l'annule.

Go (facade_prewarm.go) :
- /internal/v1/prewarm/check : moteur libre d'un vrai travail, loin de
  minuit, en bonne santé, slot visé ;
- /internal/v1/prewarm/begin : rechargement SLOT_PERSIST puis inscription
  comme LA requête en vol ; son annulation est relayée à la façade ;
- /internal/v1/prewarm/end : place rendue, cache repris noté ;
- /internal/v1/oai/begin : prewarm_keep pour l'étape qui le prolonge ;
- fin de tâche planifiée : le déclencheur part à la façade.

Rust (chat/prewarm.rs) :
- assemblage par les mêmes fonctions que le tour, sentinelle « . », un
  seul jeton, non streamé ; abstention (capacités inconnues, tour en
  cours, discussion vide, compaction attendue, assemblage inattendu) ;
- déclencheurs : fin de tour, fin de tâche, changement de discussion ou
  de projet (full : après 3 s) ;
- le tour garde le préchauffage qu'il prolonge (empreintes des messages
  et des options de rendu) et coupe tout de suite les autres ;
- télémétrie kind=prewarm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-16 02:52:44 +00:00
1 parent f79b74dac3
commit ff5c6d4571
21 files changed
+1457 -35

No files matched your search

+6
View File
@@ -267,10 +267,16 @@ var (
)
// prewarmKick : un déclencheur. Sans la clé, rien — ni goroutine, ni requête.
// Quand la façade tient la conversation, le déclencheur lui part
// (facade_prewarm.go) : c'est elle qui a l'historique.
func (c *Conversation) prewarmKick(why string) {
if prewarmMode(ReadConfig()) == "" {
return
}
if facadeChat() {
go func() { _ = facadePost("/internal/facade/v1/chat/prewarm", map[string]any{"why": why}) }()
return
}
if prewarmAsync {
go c.prewarm(why)
return
+4 -4
View File
@@ -48,18 +48,18 @@ func facadeChat() bool {
// facadeChatBlocker : le réglage qui garde la conversation ici, "" s'il n'y
// en a pas. Ces clés opt-in demandent une machinerie restée au Go (bloc projet
// figé, préchauffage). NUDGE_IN_TOOL n'en est plus (lot 23) : la façade a sa
// figé). NUDGE_IN_TOOL n'en est plus (lot 23) : la façade a sa
// sonde de gabarit ; ni les motifs de relance personnalisés (retry_patterns,
// lot 30) : la façade les lit et les compile ; ni SIDE_SLOT et SLOT_PERSIST
// (lot 31) : la façade demande son slot avant chaque requête
// (facade_slots.go) ; ni KEEP_TURN_IMAGES (lot 32) : la façade tient l'état
// du tour, le Go range les images (facade_keepimg.go).
// du tour, le Go range les images (facade_keepimg.go) ; ni PREWARM (lot 33) :
// la façade assemble le préchauffage, le Go l'inscrit et l'annule
// (facade_prewarm.go).
func facadeChatBlocker(cfg map[string]string) string {
switch {
case projSnapEnabled(cfg):
return "PROJ_SNAPSHOT"
case prewarmMode(cfg) != "":
return "PREWARM"
}
return ""
}
+4 -4
View File
@@ -277,7 +277,7 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
t.Fatalf("détenteur : %d %s", w.Code, w.Body.String())
}
// Posé après la décision : sans effet avant le redémarrage.
if err := SetConfigKey("PREWARM", "on"); err != nil {
if err := SetConfigKey("PROJ_SNAPSHOT", "on"); err != nil {
t.Fatal(err)
}
if !facadeChat() {
@@ -285,10 +285,10 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
}
oublierDecisionFacade(t)
if facadeChat() {
t.Fatal("PREWARM posé : la conversation doit rester au Go")
t.Fatal("PROJ_SNAPSHOT posé : la conversation doit rester au Go")
}
w := appelOAIInterne(t, "GET", "/internal/v1/chat/holder", "")
if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "PREWARM") {
if !strings.Contains(w.Body.String(), `"holder":"go"`) || !strings.Contains(w.Body.String(), "PROJ_SNAPSHOT") {
t.Fatalf("détenteur : %s", w.Body.String())
}
local := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "go") }
@@ -299,7 +299,7 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
}
for k, want := range map[string]string{
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "",
"SLOT_PERSIST": "", "KEEP_TURN_IMAGES": "", "PREWARM": "PREWARM",
"SLOT_PERSIST": "", "KEEP_TURN_IMAGES": "", "PREWARM": "",
} {
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
t.Errorf("%s : %q", k, got)
+150
View File
@@ -0,0 +1,150 @@
package loki
import (
"net/http"
"sync"
)
// facade_prewarm.go — PREWARM quand la façade tient la conversation (lot 33 de
// la migration). La façade assemble la requête du prochain tour (elle tient
// l'historique) et l'envoie ; le Go garde ce qui décide si elle peut partir
// et ce qui l'annule :
//
// - /internal/v1/prewarm/check : le moteur est-il libre d'un vrai travail
// (travail annexe, bench, optimiseur), loin de minuit, en bonne santé, et
// sur quel slot viser (un seul slot, ou celui de la discussion avec
// SIDE_SLOT) ;
// - /internal/v1/prewarm/begin : recharge l'état SLOT_PERSIST de la
// discussion, puis inscrit le préchauffage comme LA requête en vol
// (prewarmBegin). Toute requête de Loki qui part ensuite l'annule — sauf
// un tour de la façade qui le prolonge (/internal/v1/oai/begin,
// prewarm_keep) : l'annulation est relayée à la façade, qui coupe sa
// connexion au moteur ;
// - /internal/v1/prewarm/end : la fin, avec ce que le moteur a repris du
// cache (noteEnginePrompt) ;
// - après une tâche planifiée, le déclencheur part à la façade
// (prewarmKick).
// Préchauffages de la façade inscrits ici : id → fin.
var facadePrewarms = struct {
mu sync.Mutex
next uint64
m map[uint64]func()
}{m: map[uint64]func(){}}
// handleInternalPrewarmCheck : POST {} → {"ok":true,"slot":-1|0} ou
// {"ok":false,"why":"…"}.
func handleInternalPrewarmCheck(w http.ResponseWriter, r *http.Request) {
var in struct{}
if !readInternalJSON(w, r, &in) {
return
}
refuse := func(why string) {
sendJSON(w, http.StatusOK, map[string]any{"ok": false, "why": why})
}
if prewarmMode(ReadConfig()) == "" {
refuse("clé absente")
return
}
ep := resolveChatEndpoint()
if ep.External {
refuse("preset externe")
return
}
if sideJobs.Load() > 0 || benchRunning() || tuneGuard() != nil {
refuse("moteur occupé")
return
}
if prewarmNearMidnight(prewarmNow()) {
refuse("minuit proche")
return
}
if !healthCheck() {
refuse("moteur indisponible")
return
}
// Un seul slot, ou les deux de SIDE_SLOT : le préchauffage vise alors le
// slot de la discussion (id_slot 0), celui où partira le vrai tour.
slot := -1
if n := prewarmSlots(ep); n != 1 {
if slot = engineSlotFor(ep, true); n != 2 || slot != 0 {
refuse("plusieurs slots")
return
}
}
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "slot": slot})
}
// handleInternalPrewarmBegin : POST {"conv","n","prefix","opts"} →
// {"ok":true,"id":n} ou {"ok":false} (une requête de Loki est en vol, ou un
// autre préchauffage). n : messages du préfixe préchauffé (sans la
// sentinelle) ; prefix : son empreinte cumulée (perfPrefix) ; opts : celle
// des champs qui façonnent le rendu.
func handleInternalPrewarmBegin(w http.ResponseWriter, r *http.Request) {
var in struct {
Conv string `json:"conv"`
N int `json:"n"`
Prefix uint64 `json:"prefix"`
Opts uint64 `json:"opts"`
}
if !readInternalJSON(w, r, &in) {
return
}
// SLOT_PERSIST : l'état gardé de la discussion d'abord — le préchauffage
// le prolonge au lieu de tout recalculer.
slotPersistRestore(resolveChatEndpoint(), in.Conv)
facadePrewarms.mu.Lock()
facadePrewarms.next++
id := facadePrewarms.next
facadePrewarms.mu.Unlock()
run := &prewarmRun{n: in.N, prefix: in.Prefix, opts: in.Opts, done: make(chan struct{})}
// Annulé par une autre requête : la façade coupe sa connexion.
run.cancel = func() {
go func() { _ = facadePost("/internal/facade/v1/chat/prewarm/cancel", map[string]any{"id": id}) }()
}
end, ok := prewarmBegin(run)
if !ok {
sendJSON(w, http.StatusOK, map[string]any{"ok": false})
return
}
facadePrewarms.mu.Lock()
facadePrewarms.m[id] = func() {
end()
close(run.done)
}
facadePrewarms.mu.Unlock()
sendJSON(w, http.StatusOK, map[string]any{"ok": true, "id": id})
}
// handleInternalPrewarmEnd : POST {"id","total","cached"} → 204. total : la
// taille du prompt préchauffé (0 : inconnue) ; cached : ce que le moteur a
// repris de son cache (absent : inconnu).
func handleInternalPrewarmEnd(w http.ResponseWriter, r *http.Request) {
var in struct {
ID uint64 `json:"id"`
Total int `json:"total"`
Cached *int `json:"cached"`
}
if !readInternalJSON(w, r, &in) {
return
}
facadePrewarms.mu.Lock()
end := facadePrewarms.m[in.ID]
delete(facadePrewarms.m, in.ID)
facadePrewarms.mu.Unlock()
if end == nil {
sendJSON(w, http.StatusNotFound, map[string]any{"error": "préchauffage inconnu"})
return
}
end()
// Après une tâche, c'est lui qui retrouve (ou non) la conversation dans le
// cache RAM : la vérification de llm_slots.go se fait ici.
if in.Total > 0 {
cached := 0
if in.Cached != nil {
cached = *in.Cached
}
noteEnginePrompt(in.Total, cached, in.Cached != nil)
}
w.WriteHeader(http.StatusNoContent)
}
+167
View File
@@ -0,0 +1,167 @@
package loki
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
// moteurSain : un llama-server de test qui répond à /health.
func moteurSain(t *testing.T) {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
if err := SetConfigKey("PORT", u.Port()); err != nil {
t.Fatal(err)
}
}
func prewarmPost(t *testing.T, path, body string) (int, map[string]any) {
t.Helper()
w := appelOAIInterne(t, "POST", path, body)
var out map[string]any
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out
}
// Le moteur est-il libre d'un vrai travail, et sur quel slot viser ?
func TestFacadePrewarmCheck(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
check := func() map[string]any {
_, out := prewarmPost(t, "/internal/v1/prewarm/check", `{}`)
return out
}
if out := check(); out["ok"] != false || out["why"] != "clé absente" {
t.Fatalf("sans la clé : %v", out)
}
if err := SetConfigKey("PREWARM", "on"); err != nil {
t.Fatal(err)
}
moteurSain(t)
prevSlots, prevNow := prewarmSlots, prewarmNow
t.Cleanup(func() { prewarmSlots, prewarmNow = prevSlots, prevNow })
prewarmSlots = func(chatEndpoint) int { return 1 }
prewarmNow = func() time.Time { return time.Date(2026, 10, 16, 14, 0, 0, 0, time.Local) }
if out := check(); out["ok"] != true || out["slot"] != float64(-1) {
t.Fatalf("un seul slot : %v", out)
}
prewarmSlots = func(chatEndpoint) int { return 4 }
if out := check(); out["ok"] != false || out["why"] != "plusieurs slots" {
t.Fatalf("quatre slots : %v", out)
}
prewarmSlots = func(chatEndpoint) int { return 1 }
prewarmNow = func() time.Time { return time.Date(2026, 10, 16, 23, 55, 0, 0, time.Local) }
if out := check(); out["why"] != "minuit proche" {
t.Fatalf("minuit : %v", out)
}
prewarmNow = func() time.Time { return time.Date(2026, 10, 16, 14, 0, 0, 0, time.Local) }
sideJobs.Add(1)
out := check()
sideJobs.Add(-1)
if out["why"] != "moteur occupé" {
t.Fatalf("travail annexe : %v", out)
}
if err := SetConfigKey("PORT", "1"); err != nil {
t.Fatal(err)
}
if out := check(); out["why"] != "moteur indisponible" {
t.Fatalf("moteur muet : %v", out)
}
}
// Inscrit comme LA requête en vol ; annulé par toute autre requête (la
// façade l'apprend), sauf un tour qui le prolonge ; rendu à la fin.
func TestFacadePrewarmEnVol(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
begin := func() (bool, float64) {
_, out := prewarmPost(t, "/internal/v1/prewarm/begin", `{"conv":"c1","n":3,"prefix":11,"opts":22}`)
id, _ := out["id"].(float64)
return out["ok"] == true, id
}
ok, id := begin()
if !ok || id == 0 || !prewarmLive.Load() || enVol() != 1 {
t.Fatalf("inscription : ok=%v id=%v en vol=%d", ok, id, enVol())
}
if ok, _ := begin(); ok {
t.Fatal("deux préchauffages en vol")
}
// Un tour de la façade qui le prolonge : gardé.
w := appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c1","prewarm_keep":true}`)
var tk struct {
Ticket uint64 `json:"ticket"`
}
_ = json.Unmarshal(w.Body.Bytes(), &tk)
if !prewarmLive.Load() {
t.Fatal("préchauffage annulé par le tour qui le prolonge")
}
appelOAIInterne(t, "POST", "/internal/v1/oai/end", fmt.Sprintf(`{"ticket":%d}`, tk.Ticket))
// Toute autre requête : annulé, la façade coupe sa connexion.
w = appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c1"}`)
_ = json.Unmarshal(w.Body.Bytes(), &tk)
if prewarmLive.Load() {
t.Fatal("préchauffage gardé malgré une autre requête")
}
deadline := time.Now().Add(2 * time.Second)
want := fmt.Sprintf(`POST /internal/facade/v1/chat/prewarm/cancel jeton {"id":%d}`, int(id))
for !recuContient(f.recu(), want) {
if time.Now().After(deadline) {
t.Fatalf("annulation non relayée : %v", f.recu())
}
time.Sleep(10 * time.Millisecond)
}
appelOAIInterne(t, "POST", "/internal/v1/oai/end", fmt.Sprintf(`{"ticket":%d}`, tk.Ticket))
// La fin rend la place, une seule fois.
if code, _ := prewarmPost(t, "/internal/v1/prewarm/end", fmt.Sprintf(`{"id":%d,"total":900,"cached":800}`, int(id))); code != 204 {
t.Fatalf("fin : %d", code)
}
if enVol() != 0 {
t.Fatalf("encore %d requête(s) en vol", enVol())
}
if code, _ := prewarmPost(t, "/internal/v1/prewarm/end", fmt.Sprintf(`{"id":%d}`, int(id))); code != 404 {
t.Fatalf("fin répétée : %d", code)
}
// Une requête déjà en vol : rien ne part.
w = appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c1"}`)
_ = json.Unmarshal(w.Body.Bytes(), &tk)
if ok, _ := begin(); ok {
t.Fatal("préchauffage inscrit malgré une requête en vol")
}
appelOAIInterne(t, "POST", "/internal/v1/oai/end", fmt.Sprintf(`{"ticket":%d}`, tk.Ticket))
}
// Après une tâche planifiée, le déclencheur part à la façade.
func TestFacadePrewarmApresTache(t *testing.T) {
testHome(t)
f := demarrerFacade(t)
if err := SetConfigKey("PREWARM", "on"); err != nil {
t.Fatal(err)
}
conv.prewarmKick(prewarmAfterTask)
deadline := time.Now().Add(2 * time.Second)
want := `POST /internal/facade/v1/chat/prewarm jeton {"why":"après-tâche"}`
for !recuContient(f.recu(), want) {
if time.Now().After(deadline) {
t.Fatalf("déclencheur non relayé : %v", f.recu())
}
time.Sleep(10 * time.Millisecond)
}
}
func recuContient(l []string, s string) bool {
for _, x := range l {
if strings.TrimSpace(x) == s {
return true
}
}
return false
}
+6
View File
@@ -75,6 +75,12 @@ func withInternalAPI(next http.Handler) http.Handler {
handleInternalTurnPerf(w, r)
case "/internal/v1/turn/slot":
handleInternalTurnSlot(w, r)
case "/internal/v1/prewarm/check":
handleInternalPrewarmCheck(w, r)
case "/internal/v1/prewarm/begin":
handleInternalPrewarmBegin(w, r)
case "/internal/v1/prewarm/end":
handleInternalPrewarmEnd(w, r)
case "/internal/v1/turn/keepimg":
handleInternalTurnKeepImg(w, r)
case "/internal/v1/turn/keepimg/store":
+9 -1
View File
@@ -70,6 +70,10 @@ func handleInternalOAIBegin(w http.ResponseWriter, r *http.Request) {
// MainConv : étape d'un tour de cette discussion, tenue par la façade
// (LOKI_CHAT=rust) — comptée comme la requête principale du Go.
MainConv string `json:"main_conv"`
// PrewarmKeep : étape d'un tour de la façade qui prolonge exactement
// le préchauffage en vol (PREWARM, facade_prewarm.go) — il n'est pas
// annulé, la requête le suit dans le même slot.
PrewarmKeep bool `json:"prewarm_keep"`
}
if r.Method != http.MethodPost || json.NewDecoder(r.Body).Decode(&in) != nil {
sendJSON(w, http.StatusBadRequest, map[string]any{"error": "POST JSON attendu"})
@@ -80,7 +84,11 @@ func handleInternalOAIBegin(w http.ResponseWriter, r *http.Request) {
if in.Read {
end, seq = engineRequestBeginSide(func(*prewarmRun) bool { return true }, true)
} else {
end, seq = engineRequestBeginSide(nil, in.Side)
var keep func(*prewarmRun) bool
if in.PrewarmKeep {
keep = func(*prewarmRun) bool { return true }
}
end, seq = engineRequestBeginSide(keep, in.Side)
}
if in.PostV1 {
refuse := func(retry, msg, code string) {