Lot 31 : slots du moteur pour le tour tenu par la façade

SIDE_SLOT et SLOT_PERSIST ne gardent plus la conversation au Go. La
machinerie reste au Go (configuration, requêtes en vol, tampon du slot) ;
la façade la consulte avant chaque requête au moteur local.

Go :
- /internal/v1/turn/slot (facade_slots.go) rend engineSlotFor et, pour
  une étape de tour, recharge d'abord l'état gardé de la discussion
  (slotPersistRestore). La sauvegarde à la bascule voit les étapes de la
  façade : /internal/v1/oai/served marque le slot (engineMarkMain).
- facadeChatBlocker : SIDE_SLOT et SLOT_PERSIST retirés.

Rust :
- TurnHost::engine_slot : l'id_slot posé dans chaque requête (0 pour le
  fil de la discussion, 1 pour la vérification), le ticket « en vol »
  ouvert sur le second slot.
- Cache KV plein en plein calcul (sideSlotPoolError) : rejoué deux fois
  tel quel, sans compaction, puis l'erreur.
- Résumés de compaction comptés en vol comme postSummary : transcription
  sur le second slot, continuation sur le slot 0.
- La passe de vérification est un travail annexe (engineSideJob) : le Go
  efface le slot à la fin s'il a servi (écart du lot 7 levé).

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:13:20 +00:00
1 parent 89ed527c96
commit a005626e66
14 files changed
+679 -58

No files matched your search

+5 -7
View File
@@ -48,19 +48,17 @@ 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é, images gardées, slots du moteur, préchauffage). 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.
// figé, images gardées, préchauffage). 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).
func facadeChatBlocker(cfg map[string]string) string {
switch {
case projSnapEnabled(cfg):
return "PROJ_SNAPSHOT"
case keepTurnImagesOn(cfg):
return "KEEP_TURN_IMAGES"
case sideSlotOn(cfg):
return "SIDE_SLOT"
case slotPersistOn(cfg):
return "SLOT_PERSIST"
case prewarmMode(cfg) != "":
return "PREWARM"
}
+2 -2
View File
@@ -298,8 +298,8 @@ func TestFacadeReglageQuiGardeLaConversation(t *testing.T) {
t.Fatalf("remis à la façade malgré le réglage : %q %v", rec.Body.String(), f.recu())
}
for k, want := range map[string]string{
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "SIDE_SLOT",
"SLOT_PERSIST": "SLOT_PERSIST", "PREWARM": "PREWARM",
"PROJ_SNAPSHOT": "PROJ_SNAPSHOT", "NUDGE_IN_TOOL": "", "SIDE_SLOT": "",
"SLOT_PERSIST": "", "PREWARM": "PREWARM",
} {
if got := facadeChatBlocker(map[string]string{k: "on"}); got != want {
t.Errorf("%s : %q", k, got)
+39
View File
@@ -0,0 +1,39 @@
package loki
import (
"net/http"
)
// facade_slots.go — les slots du moteur pour le tour tenu par la façade (lot 31
// de la migration). La machinerie reste ici, où vivent la configuration, le
// compteur des requêtes en vol et le tampon du slot de la discussion
// (llm_slots.go) ; la façade demande, avant chaque requête au moteur local,
// le slot où l'envoyer.
//
// - SIDE_SLOT (llm_sideslot.go) : le fil de la discussion sur le slot 0, le
// reste (vérification, résumé sur transcription) sur le 1, tant que le
// second slot est en service ; sinon aucun id_slot.
// - 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. La sauvegarde, elle, se fait à la bascule (déjà ici) : le slot
// porte la discussion dès que le moteur a accepté une étape de son tour
// (/internal/v1/oai/served, engineMarkMain).
// handleInternalTurnSlot : POST {"conv":"…","main":true} → {"slot":0|1|-1}.
// main : requête du fil de la discussion (étape d'un tour, résumé en
// continuation) ; conv : la discussion d'une étape de tour, pour le
// rechargement SLOT_PERSIST — vide pour un résumé. -1 : aucun id_slot.
func handleInternalTurnSlot(w http.ResponseWriter, r *http.Request) {
var in struct {
Conv string `json:"conv"`
Main bool `json:"main"`
}
if !readInternalJSON(w, r, &in) {
return
}
ep := resolveChatEndpoint()
if in.Main && in.Conv != "" {
slotPersistRestore(ep, in.Conv)
}
sendJSON(w, http.StatusOK, map[string]any{"slot": engineSlotFor(ep, in.Main)})
}
+129
View File
@@ -0,0 +1,129 @@
package loki
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"testing"
)
// Le slot d'une requête de la façade : aucun sans la clé (et /props jamais
// lu), le fil de la discussion sur le 0 et le reste sur le 1 quand le second
// slot est en service.
func TestFacadeTurnSlot(t *testing.T) {
testHome(t)
t.Setenv(internalTokenEnv, jetonTest)
slot := func(body string) (int, float64) {
w := appelOAIInterne(t, "POST", "/internal/v1/turn/slot", body)
var out struct {
Slot float64 `json:"slot"`
}
_ = json.Unmarshal(w.Body.Bytes(), &out)
return w.Code, out.Slot
}
prev := sideSlotProps
sideSlotProps = func(chatEndpoint) (int, int, bool) { t.Fatal("/props lu sans la clé"); return 0, 0, false }
for _, b := range []string{`{"conv":"c1","main":true}`, `{"main":false}`} {
if code, s := slot(b); code != 200 || s != -1 {
t.Fatalf("sans la clé %s : %d %v", b, code, s)
}
}
sideSlotProps = prev
if err := SetConfigKey("SIDE_SLOT", "on"); err != nil {
t.Fatal(err)
}
if err := SetConfigKey("CTX", "8192"); err != nil {
t.Fatal(err)
}
stubSideProps(t, 2, 8192)
if _, s := slot(`{"conv":"c1","main":true}`); s != 0 {
t.Fatalf("étape du tour : slot %v", s)
}
if _, s := slot(`{"main":false}`); s != 1 {
t.Fatalf("vérification : slot %v", s)
}
if _, s := slot(`{"main":true}`); s != 0 {
t.Fatalf("résumé en continuation : slot %v", s)
}
// Second slot hors service : plus d'id_slot.
stubSideProps(t, 1, 8192)
if _, s := slot(`{"main":false}`); s != -1 {
t.Fatalf("un seul slot : %v", s)
}
if code, _ := slot(`{`); code != 400 {
t.Fatalf("corps illisible : %d", code)
}
if w := appelOAIInterne(t, "GET", "/internal/v1/turn/slot", ""); w.Code != 405 {
t.Fatalf("GET : %d", w.Code)
}
}
// SLOT_PERSIST : l'état gardé à la bascule est rechargé avant la première
// étape du tour de CETTE discussion, une seule fois ; jamais pour une autre
// discussion, une requête annexe ou un résumé.
func TestFacadeTurnSlotRecharge(t *testing.T) {
m, home := persistSetup(t, true)
t.Setenv(internalTokenEnv, jetonTest)
snap := slotPersistPrepare()
if snap == nil {
t.Fatal("rien à garder")
}
snap.save()
name := slotPersistName(persistTestKey, "c1")
for _, b := range []string{
`{"conv":"autre","main":true}`,
`{"conv":"c1","main":false}`,
`{"main":true}`,
`{"conv":"c1","main":true}`,
`{"conv":"c1","main":true}`,
} {
if w := appelOAIInterne(t, "POST", "/internal/v1/turn/slot", b); w.Code != 200 {
t.Fatalf("%s : %d", b, w.Code)
}
}
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(filepath.Join(home, "slots", name)); !os.IsNotExist(err) {
t.Fatal("fichier gardé après la tentative")
}
}
// Une étape de tour de la façade acceptée par le moteur marque le slot :
// c'est ce que la sauvegarde SLOT_PERSIST relit à la bascule.
func TestFacadeTurnSlotSauvegarde(t *testing.T) {
m, home := persistSetup(t, true)
t.Setenv(internalTokenEnv, jetonTest)
engineGate.mu.Lock()
engineGate.main = engineMainStamp{}
engineGate.mu.Unlock()
if slotPersistPrepare() != nil {
t.Fatal("slot sans discussion : rien à garder")
}
w := appelOAIInterne(t, "POST", "/internal/v1/oai/begin", `{"main_conv":"c2"}`)
var b struct {
Ticket uint64 `json:"ticket"`
}
if err := json.Unmarshal(w.Body.Bytes(), &b); err != nil || b.Ticket == 0 {
t.Fatalf("begin : %s", w.Body.String())
}
tk := `{"ticket":` + jsonNum(b.Ticket) + `}`
appelOAIInterne(t, "POST", "/internal/v1/oai/served", tk)
appelOAIInterne(t, "POST", "/internal/v1/oai/end", tk)
snap := slotPersistPrepare()
if snap == nil {
t.Fatal("étape de la façade non vue par la sauvegarde")
}
snap.save()
name := slotPersistName(persistTestKey, "c2")
if _, err := os.Stat(filepath.Join(home, "slots", name)); err != nil {
t.Fatalf("état non gardé : %v (%v)", err, m.got())
}
}
func jsonNum(n uint64) string {
b, _ := json.Marshal(n)
return string(b)
}
+2
View File
@@ -73,6 +73,8 @@ func withInternalAPI(next http.Handler) http.Handler {
handleInternalOAIServed(w, r)
case "/internal/v1/turn/perf":
handleInternalTurnPerf(w, r)
case "/internal/v1/turn/slot":
handleInternalTurnSlot(w, r)
case "/internal/v1/tool/exec":
handleInternalToolExec(w, r)
case "/internal/v1/tool/context":