Files
Loki/internal/loki/tasks_cron.go
T
Claude 0f58ad7b49 Tâches planifiées : l'IA travaille toute seule (repris de l'amont AJEAN)
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.
2026-08-20 09:31:59 +00:00

252 lines
7.8 KiB
Go

package loki
// tasks_cron.go — planification des tâches, résolution à la minute. Deux formes :
//
// - Intervalle : "@every <durée>" (ex. "@every 2h", "@every 30m"), parsé par
// time.ParseDuration, borné à ≥ 1 minute.
// - Cron : 5 champs "min hour dom month dow" avec *, listes a,b, plages a-b et
// pas */n (ex. "0 9 * * 1-5" = 9h du lundi au vendredi).
//
// Aucune dépendance externe : Go n'a pas de lib cron et on n'en ajoute pas pour
// si peu. nextAfter avance minute par minute jusqu'au prochain match — largement
// assez rapide pour une poignée de tâches évaluées une fois par minute.
import (
"fmt"
"strconv"
"strings"
"time"
)
// minInterval borne la fréquence : sous la minute, le scheduler (ticker 60 s) ne
// pourrait de toute façon pas suivre, et une tâche qui lance une inférence toutes
// les quelques secondes n'a pas de sens.
const minInterval = time.Minute
// loc résout un fuseau IANA (ex. « Europe/Paris »). Vide ou introuvable → heure
// locale du serveur. Nécessaire parce que le serveur tourne souvent en UTC : sans
// ça, « tous les jours à 23h » et les expressions cron se calculeraient en UTC et
// se déclencheraient à la mauvaise heure pour l'utilisateur.
func loc(tz string) *time.Location {
if tz == "" {
return time.Local
}
l, err := time.LoadLocation(tz)
if err != nil {
return time.Local
}
return l
}
// nextAfter renvoie le prochain instant (strictement après `from`) où `schedule`
// se déclenche, dans le fuseau tz. Erreur si le schedule est invalide.
//
// Formes acceptées :
// - "@every 30m" / "@every 2h" : intervalle glissant depuis `from`.
// - "@every 1d@23:00" : tous les N jours, ancré à une heure de la journée.
// - expression cron 5 champs.
func nextAfter(schedule, tz string, from time.Time) (time.Time, error) {
s := strings.TrimSpace(schedule)
if s == "" {
return time.Time{}, fmt.Errorf("fréquence vide")
}
if rest, ok := cutPrefix(s, "@every"); ok {
return nextEvery(strings.TrimSpace(rest), tz, from)
}
return nextCron(s, tz, from)
}
// nextEvery gère les intervalles « @every … », avec ou sans ancre horaire.
func nextEvery(spec, tz string, from time.Time) (time.Time, error) {
durPart, timePart, hasTime := strings.Cut(spec, "@")
durPart = strings.TrimSpace(durPart)
// Jours : time.ParseDuration ne connaît pas « d ». On les gère à part, ce qui
// permet aussi l'ancre horaire (« tous les N jours à HH:MM »).
if n, ok := cutSuffix(durPart, "d"); ok {
days, err := strconv.Atoi(n)
if err != nil || days < 1 {
return time.Time{}, fmt.Errorf("nombre de jours invalide : %q", durPart)
}
if !hasTime {
return from.Add(time.Duration(days) * 24 * time.Hour), nil
}
hh, mm, err := parseHHMM(timePart)
if err != nil {
return time.Time{}, err
}
l := loc(tz)
now := from.In(l)
cand := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, l)
for !cand.After(now) {
cand = cand.AddDate(0, 0, days)
}
return cand, nil
}
d, err := time.ParseDuration(durPart)
if err != nil {
return time.Time{}, fmt.Errorf("intervalle invalide (ex. « 2h », « 30m ») : %w", err)
}
if d < minInterval {
return time.Time{}, fmt.Errorf("intervalle minimum : 1 minute")
}
return from.Add(d), nil
}
// parseHHMM lit « 23:00 » / « 9:30 » en heures/minutes.
func parseHHMM(s string) (int, int, error) {
hs, ms, ok := strings.Cut(strings.TrimSpace(s), ":")
if !ok {
return 0, 0, fmt.Errorf("heure invalide (attendu HH:MM) : %q", s)
}
h, err1 := strconv.Atoi(strings.TrimSpace(hs))
m, err2 := strconv.Atoi(strings.TrimSpace(ms))
if err1 != nil || err2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return 0, 0, fmt.Errorf("heure hors bornes : %q", s)
}
return h, m, nil
}
// cutSuffix : strings.CutSuffix (Go 1.20+) réécrit pour rester lisible ici.
func cutSuffix(s, suffix string) (string, bool) {
if strings.HasSuffix(s, suffix) {
return s[:len(s)-len(suffix)], true
}
return "", false
}
// validateSchedule vérifie qu'un schedule est acceptable, sans calculer de date.
func validateSchedule(schedule, tz string) error {
_, err := nextAfter(schedule, tz, time.Now())
return err
}
// cronSpec = les 5 champs compilés en ensembles de valeurs autorisées.
type cronSpec struct {
min, hour, dom, month, dow map[int]bool
}
// nextCron parse une expression cron 5 champs et cherche le prochain match à la
// minute, en partant de la minute qui suit `from`. Une borne (~366 j) évite une
// boucle infinie sur une expression jamais satisfiable (ex. « 31 février »).
func nextCron(expr, tz string, from time.Time) (time.Time, error) {
spec, err := parseCron(expr)
if err != nil {
return time.Time{}, err
}
// On repart de la minute suivante, dans le fuseau visé (l'heure murale de
// l'expression cron est celle de l'utilisateur, pas celle du serveur).
t := from.In(loc(tz)).Truncate(time.Minute).Add(time.Minute)
limit := t.Add(366 * 24 * time.Hour)
for ; t.Before(limit); t = t.Add(time.Minute) {
if spec.matches(t) {
return t, nil
}
}
return time.Time{}, fmt.Errorf("fréquence jamais satisfaite : %q", expr)
}
func (s cronSpec) matches(t time.Time) bool {
return s.min[t.Minute()] &&
s.hour[t.Hour()] &&
s.dom[t.Day()] &&
s.month[int(t.Month())] &&
s.dow[int(t.Weekday())]
}
func parseCron(expr string) (cronSpec, error) {
f := strings.Fields(expr)
if len(f) != 5 {
return cronSpec{}, fmt.Errorf("cron : 5 champs attendus « min heure jour mois jour-semaine », reçu %d", len(f))
}
var s cronSpec
var err error
if s.min, err = parseField(f[0], 0, 59); err != nil {
return cronSpec{}, fmt.Errorf("champ minute : %w", err)
}
if s.hour, err = parseField(f[1], 0, 23); err != nil {
return cronSpec{}, fmt.Errorf("champ heure : %w", err)
}
if s.dom, err = parseField(f[2], 1, 31); err != nil {
return cronSpec{}, fmt.Errorf("champ jour-du-mois : %w", err)
}
if s.month, err = parseField(f[3], 1, 12); err != nil {
return cronSpec{}, fmt.Errorf("champ mois : %w", err)
}
// Jour de la semaine : 0 ET 7 valent dimanche. On normalise 7 → 0.
if s.dow, err = parseField(f[4], 0, 7); err != nil {
return cronSpec{}, fmt.Errorf("champ jour-semaine : %w", err)
}
if s.dow[7] {
s.dow[0] = true
}
return s, nil
}
// parseField compile un champ cron (*, a, a-b, a,b, */n, a-b/n) en l'ensemble des
// valeurs autorisées dans [lo, hi].
func parseField(field string, lo, hi int) (map[int]bool, error) {
out := map[int]bool{}
for _, part := range strings.Split(field, ",") {
rng := part
step := 1
if base, st, ok := strings.Cut(part, "/"); ok {
n, err := strconv.Atoi(st)
if err != nil || n < 1 {
return nil, fmt.Errorf("pas invalide : %q", part)
}
step = n
rng = base
}
start, end := lo, hi
if rng != "*" {
if a, b, ok := strings.Cut(rng, "-"); ok {
var err error
if start, err = atoiRange(a, lo, hi); err != nil {
return nil, err
}
if end, err = atoiRange(b, lo, hi); err != nil {
return nil, err
}
} else {
v, err := atoiRange(rng, lo, hi)
if err != nil {
return nil, err
}
start, end = v, v
}
}
if start > end {
return nil, fmt.Errorf("plage inversée : %q", part)
}
for v := start; v <= end; v += step {
out[v] = true
}
}
if len(out) == 0 {
return nil, fmt.Errorf("champ vide")
}
return out, nil
}
func atoiRange(s string, lo, hi int) (int, error) {
v, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
return 0, fmt.Errorf("valeur invalide : %q", s)
}
if v < lo || v > hi {
return 0, fmt.Errorf("valeur %d hors bornes [%d-%d]", v, lo, hi)
}
return v, nil
}
// cutPrefix reconnaît le préfixe "@every" (insensible à la casse) et renvoie le
// reste. strings.CutPrefix existe (Go 1.20+) mais est sensible à la casse.
func cutPrefix(s, prefix string) (string, bool) {
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
return s[len(prefix):], true
}
return "", false
}