mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Deux reprises d'AJEAN autour des tâches planifiées. Dossier de scripts (/data/scripts) : un dossier durable À CÔTÉ de memory, hors du workspace. Le workspace est jetable — supprimer une discussion emporte ses fichiers — donc un script qu'on veut garder n'y avait pas sa place. Le briefing machine l'annonce à l'IA, qui y écrit et y lance ses scripts normalement. Tâche « script seul » (Task.Kind/Script) : le planificateur lance le script sans charger le modèle ni consommer un token, sa sortie devient le compte-rendu, et l'UI l'affiche comme n'importe quelle tâche. Elle tourne hors du verrou de génération — d'où un registre à part pour l'afficher « en cours » et l'arrêter (/api/tasks/stop), et un « tester » qui n'attend ni le verrou ni le moteur. Sélecteur Consigne IA / Script seul dans la modale. Outils task_list/create/update/delete : l'IA se donne elle-même rappels et veilles récurrentes, cloisonnés par projet (dans un projet, elle ne voit et ne pilote que ses tâches). Le budget du préambule passe de 8600 à 11000 caractères, avec le palier documenté dans le test. Dossier mémoire réservé à ses outils : bash, write et edit ne le touchent plus (guardToolOnly*). Un `cat memory/…` contournait l'index MEMORY.md, et c'est la condition d'un chiffrement de la mémoire à venir. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K6CAgoLJzufeA8rZTSpSpY
135 lines
4.6 KiB
Go
135 lines
4.6 KiB
Go
package loki
|
|
|
|
// tasks_script.go — exécution d'une tâche « script seul » : on lance un script du
|
|
// dossier scriptsDir(), SANS charger le modèle ni consommer de tokens. La sortie
|
|
// du script devient le compte-rendu, exactement comme pour une tâche IA, si bien
|
|
// que l'UI et les outils task_* l'affichent sans traitement particulier.
|
|
//
|
|
// Une tâche script ne passe PAS par le verrou de génération de la conversation
|
|
// (elle n'utilise pas le modèle), donc conv.runningTaskID ne la voit pas. On tient
|
|
// donc un petit registre à part (scriptRunning) pour que l'UI l'affiche « en
|
|
// cours » et puisse l'arrêter.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// scriptTaskTimeout borne l'exécution d'un script planifié. Généreux (un backup ou
|
|
// une synchro peut durer), mais fini : une tâche coincée ne doit pas bloquer le
|
|
// planificateur à vie.
|
|
const scriptTaskTimeout = 10 * time.Minute
|
|
|
|
// scriptRunning suit les tâches script en cours (id → cancel + nom), pour que
|
|
// handleTasks les signale « en cours » et que l'arrêt (/api/tasks/stop) puisse
|
|
// annuler leur contexte.
|
|
var scriptRunning = struct {
|
|
mu sync.Mutex
|
|
cancel map[string]context.CancelFunc
|
|
name map[string]string
|
|
}{cancel: map[string]context.CancelFunc{}, name: map[string]string{}}
|
|
|
|
func scriptRunBegin(id, name string, cancel context.CancelFunc) {
|
|
scriptRunning.mu.Lock()
|
|
scriptRunning.cancel[id] = cancel
|
|
scriptRunning.name[id] = name
|
|
scriptRunning.mu.Unlock()
|
|
}
|
|
|
|
func scriptRunEnd(id string) {
|
|
scriptRunning.mu.Lock()
|
|
delete(scriptRunning.cancel, id)
|
|
delete(scriptRunning.name, id)
|
|
scriptRunning.mu.Unlock()
|
|
}
|
|
|
|
// scriptRunningAny renvoie l'id et le nom d'une tâche script en cours (la
|
|
// première trouvée), ou "","" si aucune. Le scheduler ne lance qu'une tâche à la
|
|
// fois, donc en pratique il y en a au plus une.
|
|
func scriptRunningAny() (string, string) {
|
|
scriptRunning.mu.Lock()
|
|
defer scriptRunning.mu.Unlock()
|
|
for id, n := range scriptRunning.name {
|
|
return id, n
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
// scriptRunStop annule la tâche script en cours d'id `id`. Renvoie true si une
|
|
// exécution correspondante a été trouvée et annulée.
|
|
func scriptRunStop(id string) bool {
|
|
scriptRunning.mu.Lock()
|
|
c := scriptRunning.cancel[id]
|
|
scriptRunning.mu.Unlock()
|
|
if c != nil {
|
|
c()
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// runScriptTask exécute le script d'une tâche et enregistre son état (comme runTask
|
|
// pour une tâche IA), mais sans aucune inférence.
|
|
func runScriptTask(t Task) {
|
|
start := time.Now()
|
|
|
|
name := strings.TrimSpace(t.Script)
|
|
if name == "" {
|
|
recordTaskEnd(t.ID, start, "", fmt.Errorf("aucun script associé à la tâche"))
|
|
return
|
|
}
|
|
full, err := scriptsPath(name)
|
|
if err != nil {
|
|
recordTaskEnd(t.ID, start, "", err)
|
|
return
|
|
}
|
|
if _, err := os.Stat(full); err != nil {
|
|
recordTaskEnd(t.ID, start, "", fmt.Errorf("script introuvable : %s", name))
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), scriptTaskTimeout)
|
|
defer cancel()
|
|
// Registre : rend la tâche visible « en cours » dans l'UI et arrêtable via
|
|
// /api/tasks/stop (qui appelle scriptRunStop → cancel).
|
|
scriptRunBegin(t.ID, t.Name, cancel)
|
|
defer scriptRunEnd(t.ID)
|
|
|
|
// On réutilise runShell : garde-fous, dossier de travail, troncature de sortie
|
|
// et gestion du timeout y sont déjà. La sortie formatée ("exit: N ...") sert à
|
|
// la fois de compte-rendu et de signal de succès.
|
|
out := runShell(ctx, scriptRunCommand(full), int(scriptTaskTimeout/time.Second))
|
|
switch {
|
|
case ctx.Err() == context.Canceled:
|
|
// Arrêt volontaire (bouton stop) : recordTaskEnd l'affiche « interrompue »
|
|
// sans allumer l'indicateur d'échec.
|
|
recordTaskEnd(t.ID, start, "", context.Canceled)
|
|
case strings.HasPrefix(out, "exit: 0"):
|
|
recordTaskEnd(t.ID, start, out, nil)
|
|
default:
|
|
// Sortie non nulle, timeout ou erreur de lancement : échec, avec la sortie
|
|
// complète comme message d'erreur.
|
|
recordTaskEnd(t.ID, start, "", fmt.Errorf("%s", out))
|
|
}
|
|
}
|
|
|
|
// scriptRunCommand construit la ligne de commande qui exécute le script `path`
|
|
// selon la plateforme et l'extension. Sous Unix, on lance le fichier directement
|
|
// (il est écrit en 0755, le shebang choisit l'interpréteur). Sous Windows, un .ps1
|
|
// passe par PowerShell ; le reste (.bat/.cmd/.exe) se lance tel quel via cmd. Le
|
|
// nom de script est déjà bridé par scriptsPath (pas de guillemet ni de métacaractère
|
|
// shell), donc l'entourer de guillemets suffit.
|
|
func scriptRunCommand(path string) string {
|
|
q := `"` + path + `"`
|
|
if runtime.GOOS == "windows" && strings.EqualFold(filepath.Ext(path), ".ps1") {
|
|
return `powershell -NoProfile -ExecutionPolicy Bypass -File ` + q
|
|
}
|
|
return q
|
|
}
|