mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Portage des v0.9.9 → v0.10.2 de l'amont, la dernière vraie fonctionnalité qui nous manquait. Une consigne, une fréquence (« @every 2h », « tous les jours à 9h », ou une expression cron à 5 champs, dans le fuseau du navigateur), et l'IA l'exécute seule en arrière-plan. Preset épinglé par tâche (bascule de modèle avant l'exécution, attente du rechargement), accès mémoire et web réglables, interrupteur maître pour tout suspendre, bouton « tester maintenant ». Le planificateur est une goroutine, un tic par minute, dans le process qui détient la conversation et parle au moteur. Une seule tâche part par tic : de toute façon une seule inférence tourne à la fois, et étaler les départs évite qu'une rafale monopolise le modèle. Occupé ou modèle en cours de chargement n'est pas un échec — la tâche repasse au tic suivant. Une tâche ne partage QU'UN point avec le chat : le verrou de génération. Ni messages, ni journal d'affichage, ni epoch — elle construit son fil éphémère et le jette, ne gardant que son texte final comme compte-rendu (borné à 4000 caractères, réinjecté au passage suivant pour la continuité). Deux adaptations, parce que loki n'est pas l'amont : — Dossier de travail. Ici il appartient à la DISCUSSION ouverte : une tâche y aurait déposé ses fichiers, et en aurait changé en cours de route si l'utilisateur changeait de discussion — pour disparaître avec elle à la suppression. Chaque tâche a donc le sien (workspace/tasks/<id>/), stable d'un passage à l'autre. La bascule ne touche que les points d'entrée des OUTILS (agentCwd) : le panneau Fichiers, les dépôts et les liens des messages continuent de suivre la discussion de l'utilisateur. — Capacités. Mem est posé explicitement à MemOff quand l'agent est coupé : le zéro de MemMode est la chaîne vide, qu'EnabledTools ne reconnaît pas comme « coupée » — une tâche sans agent se serait vu offrir les outils mem_*. Le mode code reste off : rôles, critères et passe de vérification n'ont pas de sens sans personne en face. Le refus d'un message pendant qu'une tâche tourne dit maintenant LAQUELLE occupe le modèle : « génération en cours » sur un fil vide et immobile n'expliquait rien. Interface : section repliable dans les réglages (liste, état, prochain passage, pastille), modale d'édition bâtie sur le gabarit de l'éditeur de preset, panneau « dernier résultat » en markdown. Vérifié dans un vrai navigateur — création, rendu, réouverture en édition, bascule intervalle/cron, interrupteur maître, suppression — sans une seule erreur JS.
192 lines
5.2 KiB
Go
192 lines
5.2 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)
|
|
}
|
|
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)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
}
|