mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
bash_bg et bash_tail des tours de la façade ne passent plus par /tool/exec du Go : src/tools/jobs.rs porte code_jobs.go. Refus avant lancement (commande vide ou dangereuse, optimisation en cours), journal .loki/jobs/<id>.log dans le dossier de la discussion, processus qui survit au tour, shell sans le jeton interne ; relecture bornée (40 lignes par défaut, 200 au plus), état et durée, arrêt relu aussitôt comme le Go. paths::go_mkdir_all reproduit os.MkdirAll, erreur comprise. Les jobs d'une discussion supprimée s'arrêtent avec elle. L'optimiseur du Go compte ceux de la façade (POST /internal/facade/v1/bgjobs) avant de mesurer ; façade injoignable : les siens seulement. Parité : journal de 24 étapes tiré du Go (parity_jobs_test.go → tests/parity_jobs/jobs.json), rejoué par tests/jobs.rs ; un tour de la façade qui lance un job (tests/lot6.rs) ; TestFacadeJobsArrierePlan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
230 lines
6.3 KiB
Go
230 lines
6.3 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, ceux de la façade
|
|
// compris quand elle tient la conversation (lot 43 : ses tours lancent les
|
|
// leurs). 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 {
|
|
n := localBgJobsRunning()
|
|
if facadeChat() {
|
|
var out struct {
|
|
Running int `json:"running"`
|
|
}
|
|
if err := facadePerfJSON("/internal/facade/v1/bgjobs", map[string]any{}, &out); err == nil {
|
|
n += out.Running
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// localBgJobsRunning : les jobs lancés par ce processus.
|
|
func localBgJobsRunning() 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)
|
|
}
|
|
}
|
|
}
|