Files
MichaelandClaude Opus 5.5 9bffff78c2 Optimiseur : loki tune et bouton « Optimiser », essais sans perte sur un moteur privé
Chercher à la main le bon micro-lot, les threads ou la marge --fit d'un preset
demandait de dupliquer, basculer et comparer des bench complets un par un.
L'optimiseur le fait sur un moteur d'essai, sans jamais toucher à config.env ni
à ce que calcule le modèle, et n'écrit rien sans un clic.

- Isolation : moteur principal arrêté puis relancé ; chaque essai est une copie
  de la configuration (LOKI_HOME/tune/run), lancée par « loki serve » sur
  127.0.0.1 et un port libre, groupe de processus tué en sortie
- Verrou exclusif inter-processus (LOKI_HOME/tune.lock, PID + démarrage +
  binaire) vérifié par serviceAction start/restart et par le chat, les tâches,
  bash_bg, /v1, le bench, la bascule et l'enregistrement de preset, GPU, clé
  d'API, moteur ; essai orphelin arrêté avant tout démarrage du moteur
- Essais : lots, threads, délestage, marges --fit, files CUDA ; placement --fit
  et SPEC/CUDA_GRAPH_OPT/--backend-sampling seulement sur demande
- EXTRA_ARGS jeton par jeton, liste blanche seulement ; ligne de commande de
  chaque essai composée à blanc et comparée à la référence (« dénature »),
  contexte, slots et cache KV recontrôlés une fois chargé
- Mesure : bench complet du lot 1 à profondeur fixe ; score = durée d'un tour
  type (médianes de la télémétrie) ; gain > max(3 %, écart entre passages)
- Application sur clic : copie du preset, ou preset actuel sauvegardé, réécrit
  clé par clé, vérifié par une sonde et rétabli en cas d'échec

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 11:56:11 +02:00

215 lines
5.8 KiB
Go

package loki
// code_jobs.go — commandes shell d'ARRIÈRE-PLAN pour le mode code : bash_bg
// lance un process détaché (serveur de dev, watcher, build long) et rend la
// main tout de suite ; bash_tail relit sa sortie et son état. Sans ça, le
// modèle lance `./serveur &` dans bash et le tour reste suspendu aux tubes
// (voir le commentaire WaitDelay de runShell).
//
// La sortie va dans un fichier du dossier de la discussion
// (.loki/jobs/<id>.log) : elle survit au tour, et le panneau Fichiers la montre.
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type bgJob struct {
ID string
Command string
LogPath string
Cmd *exec.Cmd
Started time.Time
mu sync.Mutex
done bool
exit int
}
var (
jobsMu sync.Mutex
jobs = map[string]*bgJob{}
jobSeq int
)
func bashBgTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "bash_bg",
Description: "Start a shell command in the BACKGROUND (dev server, watcher, long build) and return immediately with a job id. Output goes to a log file; check it with bash_tail. Use this instead of appending '&' in bash.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"command": map[string]any{"type": "string", "description": "The command"},
},
"required": []string{"command"},
},
},
}
}
func bashTailTool() Tool {
return Tool{
Type: "function",
Function: ToolFunction{
Name: "bash_tail",
Description: "Read the last lines of a background job's output and its status (running/exited). kill:true stops the job.",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"id": map[string]any{"type": "string", "description": "Job id from bash_bg"},
"lines": map[string]any{"type": "integer", "description": "Lines to show (default 40, max 200)"},
"kill": map[string]any{"type": "boolean", "description": "Stop the job"},
},
"required": []string{"id"},
},
},
}
}
// toolBashBg lance la commande détachée. Même politique que bash (commandes
// dangereuses refusées), même dossier de départ (discussion courante).
func toolBashBg(args map[string]any) string {
command, _ := args["command"].(string)
if strings.TrimSpace(command) == "" {
return "[erreur] commande vide"
}
if reason := dangerousCommand(command); reason != "" {
return refusedCommandResult(reason)
}
// Un serveur de dev ou un build lancé pendant une optimisation fausserait
// ses mesures (CPU, disque) — et un tour de modèle n'a de toute façon pas pu
// démarrer : ceci ne vise qu'un tour lancé juste avant.
if err := tuneGuard(); err != nil {
return "[erreur] " + err.Error()
}
ws := agentCwd()
logDir := filepath.Join(ws, ".loki", "jobs")
if err := os.MkdirAll(logDir, 0o755); err != nil {
return "[erreur] " + err.Error()
}
jobsMu.Lock()
jobSeq++
id := fmt.Sprintf("job%d", jobSeq)
jobsMu.Unlock()
logPath := filepath.Join(logDir, id+".log")
logFile, err := os.Create(logPath)
if err != nil {
return "[erreur] " + err.Error()
}
// Contexte de FOND, pas celui du tour : le job doit survivre à la fin du
// tour — c'est sa raison d'être. Il s'arrête par bash_tail kill:true, ou
// avec le conteneur.
cmd := newShellCmdDetached(command)
cmd.Dir = ws
cmd.Stdout = logFile
cmd.Stderr = logFile
if err := cmd.Start(); err != nil {
logFile.Close()
return "[erreur] " + err.Error()
}
j := &bgJob{ID: id, Command: command, LogPath: logPath, Cmd: cmd, Started: time.Now()}
jobsMu.Lock()
jobs[id] = j
jobsMu.Unlock()
go func() {
err := cmd.Wait()
logFile.Close()
j.mu.Lock()
j.done = true
if ee, ok := err.(*exec.ExitError); ok {
j.exit = ee.ExitCode()
}
j.mu.Unlock()
}()
return fmt.Sprintf("[ok] job %s lancé (pid %d) — sortie dans .loki/jobs/%s.log, suis-la avec bash_tail", id, cmd.Process.Pid, id)
}
// toolBashTail relit la fin du journal d'un job et son état.
func toolBashTail(args map[string]any) string {
id, _ := args["id"].(string)
jobsMu.Lock()
j := jobs[id]
jobsMu.Unlock()
if j == nil {
return "[erreur] job inconnu : " + id
}
if kill, _ := args["kill"].(bool); kill {
j.mu.Lock()
running := !j.done
j.mu.Unlock()
if running && j.Cmd.Process != nil {
_ = j.Cmd.Process.Kill()
}
}
lines := 40
if v, ok := args["lines"].(float64); ok && int(v) > 0 {
lines = int(v)
}
if lines > 200 {
lines = 200
}
b, err := os.ReadFile(j.LogPath)
if err != nil {
return "[erreur] " + err.Error()
}
all := strings.Split(strings.TrimRight(string(b), "\n"), "\n")
if len(all) > lines {
all = all[len(all)-lines:]
}
j.mu.Lock()
status := "en cours"
if j.done {
status = fmt.Sprintf("terminé (exit %d)", j.exit)
}
j.mu.Unlock()
out := fmt.Sprintf("job %s — %s — depuis %s\ncommande : %s\n", j.ID, status, time.Since(j.Started).Round(time.Second), j.Command)
tail := strings.Join(all, "\n")
if strings.TrimSpace(tail) == "" {
tail = "(aucune sortie pour l'instant)"
}
return out + "\n" + tailRunes(tail, toolMaxOutput)
}
// bgJobsRunning : jobs d'arrière-plan encore en vie. L'optimiseur refuse de
// mesurer à côté d'eux : un build ou un serveur de dev prend le CPU et le
// disque que mesurent ses essais.
func bgJobsRunning() int {
jobsMu.Lock()
defer jobsMu.Unlock()
n := 0
for _, j := range jobs {
j.mu.Lock()
if !j.done {
n++
}
j.mu.Unlock()
}
return n
}
// stopConvJobs tue les jobs d'une discussion (suppression / vidage de la
// discussion : ses fichiers partent, ses process aussi).
func stopConvJobs(convID string) {
prefix := filepath.Join(agentWorkspace(), convFilesRoot, safeConvID(convID))
jobsMu.Lock()
defer jobsMu.Unlock()
for id, j := range jobs {
if strings.HasPrefix(j.LogPath, prefix) {
j.mu.Lock()
running := !j.done
j.mu.Unlock()
if running && j.Cmd.Process != nil {
_ = j.Cmd.Process.Kill()
}
delete(jobs, id)
}
}
}