mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-19 13:08:15 +02:00
- LSP (lsp.go) : un signal resté dans le tampon par une republication tardive faisait rendre « aucun diagnostic » à l'édition suivante, avant même que le serveur ait vu la nouvelle version. Le canal est vidé, une publication d'une version antérieure est ignorée, et check attend 350 ms de calme après la dernière publication (liste vide puis analyse : c'est l'analyse qui compte), toujours borné par lspDiagWait. Au passage : interblocage latent levé (writeMsg → markDead reprenait mu, déjà tenu par check) — dead devient atomique. - run_shell : ErrWaitDelay (commande finie, process laissé en arrière-plan sur les tubes) rend la sortie au lieu de « WaitDelay expired » qui l'effaçait (AJEAN 7403a8a0). - Clé de pilotage web : lue depuis un cache à chaque requête au lieu d'ouvrir la base sous dbMu (AJEAN #120). Lectures réussies seulement, invalidé par toute écriture du bucket d'état, par la date/taille du fichier (autre process) et au bout d'une seconde. - MCP : l'appel suit le contexte du tour (le bouton stop l'annule), délai par serveur (callTimeoutSec) et allongé par l'argument timeout de l'outil, plafonné à 30 min ; un délai dépassé n'est plus relancé (OpenFox 2ef584b8). - Recall : les blocs d'une compaction s'archivent en une seule transaction au lieu d'une par bloc (AJEAN #130). - Compactage en cours de tour : après un essai qui n'a rien réduit, pas de nouvel essai avant +5 % de la fenêtre (sauf à 90 %), pour les tâches et sous-agents aussi ; la cause d'un résumé raté est journalisée (AJEAN 68659e73). - Motifs de relance : un motif vide (qui correspondait à tout) est écarté, une surcharge sans motif utilisable retombe sur l'embarqué, et un changement de retry_patterns agit sans redémarrer (OpenFox 8e9b4869, c52c5bf7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
505 lines
14 KiB
Go
505 lines
14 KiB
Go
package loki
|
|
|
|
// lsp.go — client LSP minimal pour le VÉRIFICATEUR DE CODE : après chaque
|
|
// write/edit en mode code, les diagnostics du serveur de langage (erreurs de
|
|
// compilation, types, imports) sont ajoutés AU RÉSULTAT DE L'OUTIL — le modèle
|
|
// apprend immédiatement que son code ne compile pas, sans lancer de build.
|
|
// Repris de l'intégration LSP d'OpenFox (manager + languages.json — voir
|
|
// NOTICE.md), réécrit en Go réduit à l'essentiel : initialize, didOpen/
|
|
// didChange, publishDiagnostics. Pas de complétion, pas de hover — Loki n'est
|
|
// pas un éditeur.
|
|
//
|
|
// La table langage → serveur vit dans lsp_languages.json (embarquée). Un
|
|
// serveur absent du PATH désactive silencieusement son langage : le mode code
|
|
// marche sans LSP, il vérifie juste moins tôt.
|
|
|
|
import (
|
|
"bufio"
|
|
_ "embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
//go:embed lsp_languages.json
|
|
var lspLanguagesJSON []byte
|
|
|
|
type lspLanguage struct {
|
|
ID string `json:"id"`
|
|
Command []string `json:"command"`
|
|
Extensions []string `json:"extensions"`
|
|
}
|
|
|
|
var (
|
|
lspLangsOnce sync.Once
|
|
lspLangs []lspLanguage
|
|
)
|
|
|
|
func lspLanguages() []lspLanguage {
|
|
lspLangsOnce.Do(func() {
|
|
_ = json.Unmarshal(lspLanguagesJSON, &lspLangs)
|
|
})
|
|
return lspLangs
|
|
}
|
|
|
|
// lspLangFor renvoie la config du langage d'un fichier, nil si aucun.
|
|
func lspLangFor(path string) *lspLanguage {
|
|
ext := strings.ToLower(filepath.Ext(path))
|
|
if ext == "" {
|
|
return nil
|
|
}
|
|
for i := range lspLanguages() {
|
|
for _, e := range lspLangs[i].Extensions {
|
|
if e == ext {
|
|
return &lspLangs[i]
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- Serveur -----------------------------------------------------------------
|
|
|
|
const (
|
|
lspDiagWait = 2500 * time.Millisecond // attente max des diagnostics
|
|
lspIdleTimeout = 5 * time.Minute // arrêt d'un serveur inactif
|
|
lspMaxDiags = 10 // diagnostics montrés au modèle
|
|
)
|
|
|
|
// lspDiagQuiet : calme attendu après une publication avant de la croire
|
|
// définitive. gopls, rust-analyzer et pyright publient souvent en plusieurs
|
|
// fois pour une même version (liste vide d'abord, puis leur analyse, puis
|
|
// celle d'un outil externe) : rendre la PREMIÈRE donnait « propre » à une
|
|
// édition qui cassait la compilation. OpenFox attend 400 ms ; variable pour
|
|
// les tests.
|
|
var lspDiagQuiet = 350 * time.Millisecond
|
|
|
|
type lspServer struct {
|
|
lang string
|
|
root string
|
|
cmd *exec.Cmd
|
|
stdin io.WriteCloser
|
|
mu sync.Mutex // sérialise les requêtes (un didOpen+attente à la fois)
|
|
nextID int
|
|
version map[string]int // uri → version de document
|
|
// diags : dernier publishDiagnostics reçu par uri, et signal d'arrivée.
|
|
diagMu sync.Mutex
|
|
diags map[string][]lspDiagnostic
|
|
// diagVer : version de document annoncée par la dernière publication de
|
|
// l'uri, -1 quand le serveur n'en donne pas (champ facultatif du protocole).
|
|
diagVer map[string]int
|
|
diagCh chan string // uri fraîchement publié
|
|
lastUsed time.Time
|
|
// dead : atomique, pas sous mu. writeMsg le pose sur une écriture ratée, et
|
|
// writeMsg tourne DANS check, qui tient déjà mu : le prendre là bloquait le
|
|
// serveur (et le tour) à vie. lspGet le lit aussi sans mu.
|
|
dead atomic.Bool
|
|
}
|
|
|
|
type lspDiagnostic struct {
|
|
Range struct {
|
|
Start struct {
|
|
Line int `json:"line"`
|
|
Character int `json:"character"`
|
|
} `json:"start"`
|
|
} `json:"range"`
|
|
Severity int `json:"severity"`
|
|
Message string `json:"message"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
var (
|
|
lspMu sync.Mutex
|
|
lspServers = map[string]*lspServer{} // clé : lang + racine
|
|
)
|
|
|
|
// lspDiagBlock : le bloc à AJOUTER au résultat d'un write/edit réussi.
|
|
// "" si pas de mode code, pas de serveur pour ce langage, ou aucun diagnostic.
|
|
func lspDiagBlock(absPath string, caps Caps) string {
|
|
if !caps.Code {
|
|
return ""
|
|
}
|
|
diags := lspDiagnosticsFor(absPath)
|
|
if diags == "" {
|
|
return ""
|
|
}
|
|
return "\n\n[diagnostics LSP]\n" + diags
|
|
}
|
|
|
|
// lspDiagnosticsFor ouvre (ou met à jour) le document auprès du serveur de son
|
|
// langage et attend ses diagnostics. Best-effort borné : au pire lspDiagWait.
|
|
func lspDiagnosticsFor(absPath string) string {
|
|
lang := lspLangFor(absPath)
|
|
if lang == nil {
|
|
return ""
|
|
}
|
|
if _, err := exec.LookPath(lang.Command[0]); err != nil {
|
|
return "" // serveur non installé : le mode code marche sans
|
|
}
|
|
root := agentCwd()
|
|
// Le dépôt cloné est la vraie racine projet quand le fichier est dedans :
|
|
// gopls a besoin du go.mod, tsserver du tsconfig.
|
|
if r := projectRootFor(absPath, root); r != "" {
|
|
root = r
|
|
}
|
|
srv, err := lspGet(lang, root)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
diags, ok := srv.check(absPath)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if len(diags) == 0 {
|
|
return ""
|
|
}
|
|
// Erreurs d'abord, puis avertissements, puis le reste (OpenFox) : au-delà
|
|
// de lspMaxDiags on tronque, et des indices de style passaient devant
|
|
// l'erreur de compilation qui comptait. Le compte annonce le total.
|
|
rank := func(s int) int {
|
|
if s <= 0 {
|
|
return 1 // sévérité absente : traitée comme une erreur
|
|
}
|
|
return s
|
|
}
|
|
sort.SliceStable(diags, func(i, j int) bool { return rank(diags[i].Severity) < rank(diags[j].Severity) })
|
|
nErr, nWarn := 0, 0
|
|
for _, d := range diags {
|
|
switch rank(d.Severity) {
|
|
case 1:
|
|
nErr++
|
|
case 2:
|
|
nWarn++
|
|
}
|
|
}
|
|
total := len(diags)
|
|
if len(diags) > lspMaxDiags {
|
|
diags = diags[:lspMaxDiags]
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, "%d erreur(s), %d avertissement(s)", nErr, nWarn)
|
|
if total > len(diags) {
|
|
fmt.Fprintf(&b, " — %d premiers sur %d", len(diags), total)
|
|
}
|
|
b.WriteString("\n")
|
|
for _, d := range diags {
|
|
sev := "info"
|
|
switch d.Severity {
|
|
case 1:
|
|
sev = "error"
|
|
case 2:
|
|
sev = "warning"
|
|
}
|
|
fmt.Fprintf(&b, "%d:%d [%s] %s\n", d.Range.Start.Line+1, d.Range.Start.Character+1, sev, strings.TrimSpace(d.Message))
|
|
}
|
|
return strings.TrimRight(b.String(), "\n")
|
|
}
|
|
|
|
// projectRootFor remonte du fichier vers ws en cherchant un marqueur de racine
|
|
// de projet. Renvoie "" si rien trouvé au-dessus du fichier (dans la borne ws).
|
|
func projectRootFor(absPath, ws string) string {
|
|
dir := filepath.Dir(absPath)
|
|
for {
|
|
for _, marker := range []string{"go.mod", "package.json", "pyproject.toml", ".git"} {
|
|
if _, err := os.Stat(filepath.Join(dir, marker)); err == nil {
|
|
return dir
|
|
}
|
|
}
|
|
if rel, ok := relWithin(ws, dir); !ok || rel == "" || rel == "." {
|
|
return ""
|
|
}
|
|
parent := filepath.Dir(dir)
|
|
if parent == dir {
|
|
return ""
|
|
}
|
|
dir = parent
|
|
}
|
|
}
|
|
|
|
// lspGet renvoie le serveur (lang, racine), démarré au besoin.
|
|
func lspGet(lang *lspLanguage, root string) (*lspServer, error) {
|
|
key := lang.ID + "\x00" + root
|
|
lspMu.Lock()
|
|
defer lspMu.Unlock()
|
|
if s := lspServers[key]; s != nil && !s.dead.Load() {
|
|
return s, nil
|
|
}
|
|
s, err := lspStart(lang, root)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
lspServers[key] = s
|
|
return s, nil
|
|
}
|
|
|
|
func lspStart(lang *lspLanguage, root string) (*lspServer, error) {
|
|
cmd := exec.Command(lang.Command[0], lang.Command[1:]...)
|
|
cmd.Dir = root
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cmd.Stderr = io.Discard
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, err
|
|
}
|
|
s := &lspServer{
|
|
lang: lang.ID, root: root, cmd: cmd, stdin: stdin,
|
|
version: map[string]int{}, diags: map[string][]lspDiagnostic{}, diagVer: map[string]int{},
|
|
diagCh: make(chan string, 16), lastUsed: time.Now(),
|
|
}
|
|
go s.readLoop(stdout)
|
|
go s.idleWatch()
|
|
// initialize / initialized. La réponse est consommée par readLoop ; on
|
|
// n'attend pas les capacités — on n'utilise que les diagnostics.
|
|
s.send("initialize", map[string]any{
|
|
"processId": nil,
|
|
"rootUri": fileURI(root),
|
|
"capabilities": map[string]any{
|
|
"textDocument": map[string]any{
|
|
"publishDiagnostics": map[string]any{},
|
|
"synchronization": map[string]any{"didSave": false},
|
|
},
|
|
},
|
|
"workspaceFolders": []map[string]any{{"uri": fileURI(root), "name": filepath.Base(root)}},
|
|
}, true)
|
|
s.notify("initialized", map[string]any{})
|
|
return s, nil
|
|
}
|
|
|
|
// check ouvre (ou met à jour) le document et attend ses diagnostics.
|
|
func (s *lspServer) check(absPath string) ([]lspDiagnostic, bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.dead.Load() {
|
|
return nil, false
|
|
}
|
|
s.lastUsed = time.Now()
|
|
content, err := os.ReadFile(absPath)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
uri := fileURI(absPath)
|
|
// Signaux en attente : tous PÉRIMÉS. Un serveur qui republie après le
|
|
// retour du check précédent laisse son uri dans le tampon ; lu ici tout de
|
|
// suite après didChange, il faisait rendre « aucun diagnostic » avant même
|
|
// que le serveur ait vu la nouvelle version.
|
|
for drained := false; !drained; {
|
|
select {
|
|
case <-s.diagCh:
|
|
default:
|
|
drained = true
|
|
}
|
|
}
|
|
// Vide les publications en attente pour cet uri : on veut la PROCHAINE.
|
|
s.diagMu.Lock()
|
|
delete(s.diags, uri)
|
|
delete(s.diagVer, uri)
|
|
s.diagMu.Unlock()
|
|
v, open := s.version[uri]
|
|
if !open {
|
|
v = 1
|
|
s.version[uri] = v
|
|
s.notify("textDocument/didOpen", map[string]any{
|
|
"textDocument": map[string]any{
|
|
"uri": uri, "languageId": s.lang, "version": v, "text": string(content),
|
|
},
|
|
})
|
|
} else {
|
|
v++
|
|
s.version[uri] = v
|
|
s.notify("textDocument/didChange", map[string]any{
|
|
"textDocument": map[string]any{"uri": uri, "version": v},
|
|
"contentChanges": []map[string]any{{"text": string(content)}},
|
|
})
|
|
}
|
|
if s.dead.Load() {
|
|
return nil, false // écriture ratée : le serveur ne verra jamais cette version
|
|
}
|
|
// current : les diagnostics publiés pour CETTE version (ou sans version),
|
|
// pas ceux d'une version antérieure arrivés en retard.
|
|
current := func() ([]lspDiagnostic, bool) {
|
|
s.diagMu.Lock()
|
|
defer s.diagMu.Unlock()
|
|
d, ok := s.diags[uri]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
if pv, has := s.diagVer[uri]; has && pv >= 0 && pv < v {
|
|
return nil, false
|
|
}
|
|
return d, true
|
|
}
|
|
// Attend la publication pour CET uri, puis un calme de lspDiagQuiet sans
|
|
// nouvelle publication ; le tout borné par lspDiagWait.
|
|
deadline := time.NewTimer(lspDiagWait)
|
|
defer deadline.Stop()
|
|
var quiet <-chan time.Time
|
|
var quietT *time.Timer
|
|
defer func() {
|
|
if quietT != nil {
|
|
quietT.Stop()
|
|
}
|
|
}()
|
|
for {
|
|
select {
|
|
case got := <-s.diagCh:
|
|
if got != uri {
|
|
continue
|
|
}
|
|
if _, ok := current(); !ok {
|
|
continue // publication d'une version antérieure
|
|
}
|
|
if quietT == nil {
|
|
quietT = time.NewTimer(lspDiagQuiet)
|
|
} else {
|
|
quietT.Reset(lspDiagQuiet)
|
|
}
|
|
quiet = quietT.C
|
|
case <-quiet:
|
|
d, _ := current()
|
|
return d, true
|
|
case <-deadline.C:
|
|
// Pas de publication à temps : certains serveurs ne publient rien
|
|
// quand il n'y a AUCUN diagnostic. On rend « rien », pas un échec.
|
|
d, _ := current()
|
|
return d, true
|
|
}
|
|
}
|
|
}
|
|
|
|
// readLoop parse les messages LSP (en-têtes Content-Length) et garde les
|
|
// publishDiagnostics ; tout le reste est ignoré.
|
|
func (s *lspServer) readLoop(r io.Reader) {
|
|
br := bufio.NewReaderSize(r, 1<<20)
|
|
for {
|
|
length := 0
|
|
for {
|
|
line, err := br.ReadString('\n')
|
|
if err != nil {
|
|
s.markDead()
|
|
return
|
|
}
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
break
|
|
}
|
|
if v, ok := strings.CutPrefix(line, "Content-Length:"); ok {
|
|
fmt.Sscanf(strings.TrimSpace(v), "%d", &length)
|
|
}
|
|
}
|
|
if length <= 0 || length > 32<<20 {
|
|
s.markDead()
|
|
return
|
|
}
|
|
buf := make([]byte, length)
|
|
if _, err := io.ReadFull(br, buf); err != nil {
|
|
s.markDead()
|
|
return
|
|
}
|
|
var msg struct {
|
|
Method string `json:"method"`
|
|
Params struct {
|
|
URI string `json:"uri"`
|
|
Version *int `json:"version"`
|
|
Diagnostics []lspDiagnostic `json:"diagnostics"`
|
|
} `json:"params"`
|
|
}
|
|
if json.Unmarshal(buf, &msg) != nil {
|
|
continue
|
|
}
|
|
if msg.Method == "textDocument/publishDiagnostics" {
|
|
s.diagMu.Lock()
|
|
s.diags[msg.Params.URI] = msg.Params.Diagnostics
|
|
ver := -1
|
|
if msg.Params.Version != nil {
|
|
ver = *msg.Params.Version
|
|
}
|
|
s.diagVer[msg.Params.URI] = ver
|
|
s.diagMu.Unlock()
|
|
select {
|
|
case s.diagCh <- msg.Params.URI:
|
|
default: // personne n'attend : la publication reste dans s.diags
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *lspServer) idleWatch() {
|
|
t := time.NewTicker(time.Minute)
|
|
defer t.Stop()
|
|
for range t.C {
|
|
s.mu.Lock()
|
|
idle := time.Since(s.lastUsed) > lspIdleTimeout
|
|
dead := s.dead.Load()
|
|
s.mu.Unlock()
|
|
if dead {
|
|
return
|
|
}
|
|
if idle {
|
|
s.shutdown()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *lspServer) shutdown() {
|
|
s.markDead()
|
|
if s.cmd.Process != nil {
|
|
// shutdown/exit protocolaires seraient plus polis, mais le process peut
|
|
// être déjà figé — kill borné est fiable et le serveur n'a pas d'état.
|
|
_ = s.cmd.Process.Kill()
|
|
}
|
|
lspMu.Lock()
|
|
delete(lspServers, s.lang+"\x00"+s.root)
|
|
lspMu.Unlock()
|
|
}
|
|
|
|
func (s *lspServer) markDead() { s.dead.Store(true) }
|
|
|
|
func (s *lspServer) send(method string, params any, isRequest bool) {
|
|
s.nextID++
|
|
msg := map[string]any{"jsonrpc": "2.0", "method": method, "params": params}
|
|
if isRequest {
|
|
msg["id"] = s.nextID
|
|
}
|
|
s.writeMsg(msg)
|
|
}
|
|
|
|
func (s *lspServer) notify(method string, params any) { s.send(method, params, false) }
|
|
|
|
func (s *lspServer) writeMsg(msg map[string]any) {
|
|
b, err := json.Marshal(msg)
|
|
if err != nil {
|
|
return
|
|
}
|
|
_, err = fmt.Fprintf(s.stdin, "Content-Length: %d\r\n\r\n%s", len(b), b)
|
|
if err != nil {
|
|
s.markDead()
|
|
}
|
|
}
|
|
|
|
// fileURI convertit un chemin absolu en URI file://.
|
|
func fileURI(path string) string {
|
|
p := filepath.ToSlash(path)
|
|
if !strings.HasPrefix(p, "/") {
|
|
p = "/" + p // Windows : C:\x → /C:/x
|
|
}
|
|
return "file://" + p
|
|
}
|
|
|
|
// Pas d'arrêt global : les serveurs LSP sont des enfants du process loki et
|
|
// meurent avec lui (et l'inactivité de 5 min couvre le reste — idleWatch).
|