Files
Loki/internal/loki/code_jobs.go
T
MichaelandClaude Opus 5 901d518f70 Mode Code : agent de code avec critères, vérification et LSP
Sélecteur Chat|Code par discussion dans le pied du composeur ; en mode
chat, une détection serveur suggère la bascule (puce ignorable, jamais
automatique). Conception reprise d'OpenFox (MIT), réécrite en Go — voir
NOTICE.md.

- Outils fichiers : read (lignes numérotées, borné), grep, glob, et le
  tracker « lu avant d'écrire » qui refuse write/edit sur un fichier non
  lu ou modifié depuis la lecture ; edit préserve les fins de ligne CRLF.
- Politique d'exécution : commandes catastrophiques refusées (rm -rf /,
  mkfs, reboot…), chemins bornés au dossier de la discussion en mode
  code, mutex par fichier.
- Critères d'acceptation : contrat posé via l'outil criteria (éditable
  dans l'UI), passe de vérification indépendante sur contexte isolé —
  seule habilitée à marquer « passed » — puis corrections plafonnées.
- Rôles embarqués (agents/*.md) : builder, planner, verifier, explorer,
  code-reviewer ; badge de rôle dans le fil.
- LSP : gopls / typescript-language-server / pyright (inclus dans
  l'image), diagnostics injectés dans le retour de write/edit.
- Git natif : git_status, git_diff, git_clone (borné à la discussion).
- Jobs d'arrière-plan bash_bg/bash_tail (serveur de dev, build long).
- Auto-retry : un appel d'outil écrit en texte (default_api:…,
  <tool_call>…) relance le tour une fois avec consigne corrective.
- Outil ask : question à choix rendue en carte à boutons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 21:58:57 +02:00

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 := convWorkspace()
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)
}
}
}