Files
Loki/internal/loki/tasks_run.go
T
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

317 lines
12 KiB
Go

package loki
// tasks_run.go — exécution d'une tâche, ISOLÉE de la discussion ouverte.
// Repris de l'amont AJEAN (v0.9.9 → v0.10.2), adapté au fonctionnement de loki
// (dossier de travail par discussion, modes de la machine).
//
// Une tâche ne doit ni apparaître dans le chat, ni casser le rejeu SSE, ni entrer
// en collision avec un tour utilisateur. On ne partage donc qu'UN SEUL point avec
// la conversation : son verrou de génération (conv.Generating), qui garantit
// qu'une seule inférence tourne à la fois — llama-server est lancé en
// --parallel 1. Tout le reste — messages, journal d'affichage — n'est jamais
// touché : la tâche construit son propre fil éphémère et le jette à la fin, ne
// gardant que le texte final comme compte-rendu.
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// RunAutonomous exécute un prompt en arrière-plan, sans laisser de trace dans la
// discussion ouverte, et renvoie le texte final produit par l'IA (le compte-rendu).
// Prend le MÊME verrou que StartTurn : renvoie ErrBusy si un tour (utilisateur ou
// une autre tâche) est déjà en cours, ou errModelLoading si le modèle n'est pas
// prêt. caps gouverne l'accès aux outils (agent, mémoire, internet).
// lastReport est le compte-rendu du passage précédent (vide au premier) : il est
// réinjecté pour donner à l'IA une continuité d'une exécution à l'autre.
func (c *Conversation) RunAutonomous(ctx context.Context, taskID, taskName, prompt, lastReport string, caps Caps, temperature float64) (string, error) {
if err := tuneGuard(); err != nil {
return "", err
}
if !healthCheck() {
return "", errModelLoading
}
// Contexte annulable propre à la tâche, exposé via c.cancel : le bouton stop
// du chat (/api/chat/stop → conv.Stop) interrompt donc VRAIMENT une tâche de
// fond, au lieu de l'afficher « en cours » sans pouvoir l'arrêter.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
c.mu.Lock()
if c.Generating {
c.mu.Unlock()
return "", ErrBusy
}
c.Generating = true
c.cancel = cancel
c.runningTaskID = taskID
c.runningTaskName = taskName
c.mu.Unlock()
// Libère le verrou quoi qu'il arrive. On ne touche NI c.Messages NI c.Log NI
// c.epoch : la discussion ouverte reste totalement à l'écart.
defer func() {
c.mu.Lock()
c.Generating = false
c.cancel = nil
c.runningTaskID = ""
c.runningTaskName = ""
c.mu.Unlock()
}()
// Dossier de travail de la tâche : le sien, pas celui de la discussion
// ouverte (chat_workspace.go). Stable d'un passage à l'autre — une tâche qui
// tient un fichier de suivi le retrouve la fois suivante.
setTaskWorkspace(taskWorkspaceDir(taskID))
defer setTaskWorkspace("")
if temperature == 0 {
temperature = 0.7
}
// La note de tâche (conscience du mode autonome + compte-rendu du passage
// précédent) va EN TÊTE DU MESSAGE UTILISATEUR, pas dans le système : elle
// n'ajoute aucun écart au préfixe système + outils. Il n'est pas pour autant
// celui du chat : le briefing machine porte le dossier de travail de la
// tâche, et taskCaps ne reprend ni le pilotage du navigateur ni une mémoire
// coupée. Une note dans le système faisait
// tout recalculer à la tâche, PUIS au message suivant de l'utilisateur (12 s
// mesurées en amont pour un simple « salut », AJEAN 0.17.5).
if note := taskContextNote(taskName, lastReport); note != "" {
prompt = note + "\n\n" + prompt
}
msgs := []Message{{Role: "user", Content: prompt}}
// Même préambule que la vraie génération : consigne personnelle + préambule
// agent + briefing machine (via prepareTurn), pour que l'IA ait le même
// contexte et les mêmes outils qu'en chat.
// Contexte du projet de la tâche : le projet est déjà forcé par l'appelant
// (setProjectOverride dans runTask), donc ces messages décrivent le bon
// chantier — la tâche voit la mémoire et les trackers qu'elle vise.
final := append(projectSystemMessages(), msgs...)
if sys := readSysPrompt(); sys != "" {
final = append([]Message{{Role: "system", Content: sys}}, final...)
}
var content strings.Builder
// La tâche a pris le slot de la conversation : effacé une fois la tâche
// finie, verrou de génération encore tenu (llm_slots.go).
defer engineSideJob()()
sent, tools := prepareTurn(final, caps)
_, err := runChatTools(withPerf(ctx, perfTask, "task:"+taskID), sent, tools, temperature, caps, func(ev StreamEvent) bool {
if ev.Content != "" {
content.WriteString(ev.Content)
}
return true
})
// Les messages d'outils ne sont pas conservés : la tâche est éphémère, seul
// son compte-rendu survit.
return strings.TrimSpace(content.String()), err
}
// taskWorkspaceDir donne (et crée) le dossier de travail d'une tâche, sous la
// racine du dossier de travail — jamais dans le dossier d'une discussion, dont
// il partagerait le sort à la suppression.
func taskWorkspaceDir(taskID string) string {
root := agentWorkspace()
if taskID == "" {
return root
}
dir := filepath.Join(root, "tasks", taskID)
if err := os.MkdirAll(dir, 0o755); err != nil {
return root
}
return dir
}
// taskContextNote construit la note de contexte injectée en tête du fil d'une
// tâche : d'abord la conscience du mode (l'IA tourne seule, sans utilisateur en
// face), puis — s'il existe — le compte-rendu du passage précédent, pour la
// continuité.
func taskContextNote(taskName, lastReport string) string {
var b strings.Builder
b.WriteString("[Tâche planifiée")
if taskName != "" {
b.WriteString(" « " + taskName + " »")
}
b.WriteString("] Tu es exécuté automatiquement en arrière-plan, sans utilisateur " +
"présent pour te répondre. Mène la tâche à son terme de façon autonome, puis " +
"termine par un compte-rendu clair et court de ce que tu as fait ou trouvé.")
if r := strings.TrimSpace(lastReport); r != "" {
if len(r) > reportMax {
r = r[:reportMax] + "…"
}
b.WriteString("\n\nCompte-rendu de ta dernière exécution (pour la continuité — " +
"appuie-toi dessus, ne le répète pas tel quel) :\n" + r)
}
return b.String()
}
// reportMax borne la taille du compte-rendu conservé : le texte final peut être
// long, on n'en garde qu'un aperçu pour l'interface et pour la relance suivante.
const reportMax = 4000
// runTask exécute une tâche et met à jour son état persisté (dernier passage,
// prochain passage, succès/échec, compte-rendu).
func runTask(t Task) {
start := time.Now()
// Tâche « script seul » : aucun modèle à charger, aucun token consommé — on
// lance le script du dossier protégé et sa sortie devient le compte-rendu.
// Testé AVANT le preset : basculer le moteur pour un script serait absurde.
if t.Kind == "script" {
runScriptTask(t)
return
}
// Preset épinglé : on bascule le moteur dessus AVANT d'exécuter (rechargement
// du modèle). Vide = on garde le preset actif. Un échec de bascule est un vrai
// échec de la tâche : la faire tourner sur le mauvais modèle serait pire.
if err := ensureTaskPreset(t.Preset); err != nil {
recordTaskEnd(t.ID, start, "", fmt.Errorf("changement de preset : %w", err))
return
}
// Préchauffage de la discussion ouverte (PREWARM, off par défaut) une fois
// la tâche finie : différé AVANT la levée du projet forcé, donc exécuté
// APRÈS elle — le contexte préparé est celui de la discussion, pas de la
// tâche. Le slot a déjà été effacé à la fin de RunAutonomous.
defer conv.prewarmKick(prewarmAfterTask)
// Projet visé : on force le projet vu par memoryDir() et par les trackers, le
// temps du passage. Sans ça, une veille rattachée au projet « NAS » écrirait
// dans la mémoire du projet ouvert à l'écran au moment du tic. Sûr : une seule
// inférence tourne à la fois (verrou de génération), et RunAutonomous a déjà
// rendu la main quand on libère.
setProjectOverride(taskProjectOf(t))
defer setProjectOverride("")
report, err := conv.RunAutonomous(context.Background(), t.ID, t.Name, t.Prompt, t.LastReport, taskCaps(t), 0)
// Occupé, ou modèle pas encore prêt : ce n'est pas un échec de la tâche, juste
// un mauvais moment. On ne touche pas à son état (NextRun reste dans le passé)
// pour qu'elle soit réessayée au tick suivant.
if err == ErrBusy || err == errModelLoading {
return
}
recordTaskEnd(t.ID, start, report, err)
}
// taskCaps dérive les capacités d'une tâche : on part du mode agent de la machine
// (qui débloque les outils), puis on applique les réglages propres à la tâche pour
// la mémoire et le web. Le web reste borné à ce que la machine offre réellement
// (serveur Crawl4AI configuré et joignable) : une tâche ne peut pas l'inventer.
//
// Le mode code reste OFF : c'est un mode de discussion (rôles, critères,
// vérification, puces dans le fil) qui n'a pas de sens sans personne en face.
func taskCaps(t Task) Caps {
// Mem posé EXPLICITEMENT à MemOff : le zéro de MemMode est la chaîne vide,
// que EnabledTools ne reconnaît pas comme « coupée » (il compare à MemOff) —
// une tâche sans agent se serait donc vu offrir les outils mem_*.
c := Caps{Agent: agentEnabled(), Mem: MemOff}
if !c.Agent {
return c // agent coupé : aucun outil, mémoire et web n'ont plus de sens
}
if t.NoWeb {
c.Internet = false
} else {
c.Internet = internetEnabled() && crawlReachable()
}
if t.NoMem {
c.Mem = MemOff
} else if m := memMode(); m == MemOff {
// La tâche veut la mémoire mais la machine l'a coupée globalement : on donne
// au moins les outils à la demande, sans l'injection proactive.
c.Mem = MemOnDemand
} else {
c.Mem = m
}
return c
}
// ensureTaskPreset bascule le moteur sur le preset d'id `id` s'il n'est pas déjà
// actif, puis attend que le modèle ait rechargé. Vide = rien à faire.
func ensureTaskPreset(id string) error {
if id == "" {
return nil
}
list, err := ListPresets()
if err != nil {
return err
}
var target *Preset
for i := range list {
if list[i].ID == id {
target = &list[i]
break
}
}
if target == nil {
return fmt.Errorf("preset introuvable : %s", id)
}
if target.Active {
return nil
}
if err := SwitchToPreset(target.Path); err != nil {
return err
}
// Le moteur redémarre : on attend qu'il réponde à nouveau (charger un gros
// modèle prend un moment).
deadline := time.Now().Add(4 * time.Minute)
for time.Now().Before(deadline) {
if healthCheck() {
return nil
}
time.Sleep(3 * time.Second)
}
return fmt.Errorf("le modèle n'a pas fini de charger après le changement de preset")
}
// recordTaskEnd met à jour l'état persisté d'une tâche après une exécution :
// durée, dernier passage, prochain passage, succès/échec, compte-rendu. Relit la
// tâche juste avant d'écrire pour ne pas écraser une modification concurrente
// (bascule ou édition depuis l'interface pendant l'exécution).
func recordTaskEnd(id string, start time.Time, report string, err error) {
now := time.Now()
cur, ok := getTask(id)
if !ok {
return // supprimée entre-temps : rien à écrire
}
cur.LastDurMs = now.Sub(start).Milliseconds()
cur.LastRun = now.UnixMilli()
cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now)
if isOnce(cur.Schedule) {
cur.Enabled, cur.NextRun = false, 0 // passée une fois : terminée
}
if errors.Is(err, context.Canceled) {
// Arrêt volontaire (bouton stop) : ce n'est pas un échec. On garde la trace
// « interrompue » sans allumer l'indicateur rouge d'erreur.
cur.LastOK = true
cur.LastError = ""
cur.LastReport = "(interrompue manuellement)"
} else if err != nil {
cur.LastOK = false
cur.LastError = err.Error()
} else {
cur.LastOK = true
cur.LastError = ""
if len(report) > reportMax {
report = report[:reportMax] + "…"
}
cur.LastReport = report
}
_ = saveTask(cur)
// Notification Web Push : une tâche planifiée tourne SANS personne devant
// l'écran — c'est le cas où être prévenu compte le plus. Détaché (l'envoi
// HTTP ne doit pas retenir le planificateur) et silencieux sur une
// interruption manuelle : celui qui a cliqué « stop » est déjà là.
if hasPushSubs() && !errors.Is(err, context.Canceled) {
title, body := "Loki · tâche terminée", cur.Name
if err != nil {
title = "Loki · tâche en échec"
}
go sendPushToAll(title, body)
}
}