Files
Loki/internal/loki/tasks.go
T
Claude 9a0455be8f Lot 26 : tâches planifiées tenues par la façade (LOKI_TASKS=rust)
Avec LOKI_TASKS=rust (expérimental), la façade sert les routes
/api/tasks* et fait tourner le planificateur, que le Go ne démarre plus.
Elle exécute elle-même les tâches « script seul » (shell de l'agent,
limite de 10 min, arrêt à la main). Une tâche IA passe par le verrou de
génération et les outils de l'agent, restés au Go : la façade la lui fait
exécuter (/internal/v1/tasks/run), comme son propre planificateur l'aurait
fait. Les notifications partent par la façade si elle les tient, sinon par
le Go (/internal/v1/push/send).

- src/tz.rs : les fuseaux IANA comme le Go (TZif v1/v2 et règle POSIX,
  time.Local, time.Date et ses heures qui sautent ou se répètent) ;
- tasks/cron.rs : les quatre formes de fréquence, avec ParseDuration du Go
  et ses messages d'erreur ; instants sur 128 bits (dates au-delà de
  2262) ;
- tasks/mod.rs : la base, l'exécution, le planificateur (un tic par
  minute, une tâche due par tic) et les sept routes.

Le Go reçoit un crochet de test pour l'heure des tâches (tasksNow).

Correction de la façade : les appels à l'API interne du Go étaient tous
bornés à 10 s. Un outil exécuté par le Go qui dure plus longtemps (page
lente, navigateur, MCP tenu par le Go) était coupé, alors qu'il continuait.
Ces appels sont désormais bornés à 30 min ; une tâche IA est attendue
jusqu'à 24 h.

Parité : parity_tasks_test.go tire du Go un journal rejoué par
tests/tasks.rs, l'heure des tâches fixée pas à pas. Il couvre
l'enregistrement (chaque forme et chaque erreur de fréquence, plusieurs
fuseaux), la bascule, l'interrupteur maître, la suppression, les
lancements et les tics du planificateur. Les mutations (79, dont 77
tuées et 2 équivalentes) ont fait trouver et corriger cinq écarts du
port : dates lointaines, intervalle en jours démesuré, heure qui saute au
printemps, compte-rendu coupé au milieu d'un caractère, noms de fuseau
contenant « .. ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
2026-10-15 23:30:03 +00:00

161 lines
6.3 KiB
Go

package loki
// tasks.go — les tâches planifiées : des consignes que l'IA exécute toute seule,
// en arrière-plan, sur une fréquence réglable (intervalle simple ou cron). Chaque
// tâche tourne ISOLÉE de la conversation partagée (voir RunAutonomous dans
// tasks_run.go) : elle ne pollue pas le chat et livre son résultat via les propres
// outils de l'IA (mail via MCP, shell, web…). L'utilisateur peut désactiver une
// tâche, ou tout suspendre d'un coup via l'interrupteur maître (tasks_paused).
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"sort"
"strings"
"time"
bolt "go.etcd.io/bbolt"
)
// Task est une tâche planifiée persistée dans le bucket bkTasks (clé = ID).
type Task struct {
ID string `json:"id"`
Name string `json:"name"`
Prompt string `json:"prompt"`
Schedule string `json:"schedule"` // "@every 2h" | "@every 1d@23:00" | expr cron 5 champs
TZ string `json:"tz"` // fuseau IANA du navigateur (ex. "Europe/Paris") pour l'heure
Preset string `json:"preset"` // id du preset à activer avant l'exécution (vide = preset actif)
// Project = slug du projet visé (projects.go). L'exécution force ce projet
// (setProjectOverride), donc la tâche voit LA MÉMOIRE et les trackers du bon
// chantier — une veille « NAS » n'écrit pas dans la mémoire « recettes ». Vide
// sur les tâches d'avant les projets : traitées comme « Générale ».
Project string `json:"project,omitempty"`
// Kind distingue une tâche IA d'une tâche script. Vide ou "agent" = Prompt est
// une consigne exécutée par le modèle (RunAutonomous). "script" = on lance
// directement le script Script du dossier protégé, SANS charger le modèle ni
// consommer de tokens. Le zéro JSON ("") garde le comportement historique (IA).
Kind string `json:"kind,omitempty"`
Script string `json:"script,omitempty"` // pour Kind=="script" : nom du script dans scriptsDir()
// Accès de la tâche. On stocke la NÉGATION (« pas de… ») pour que le zéro JSON
// (tâches créées avant ces champs) garde le comportement historique : accès.
NoMem bool `json:"no_mem"` // true = pas d'accès à la mémoire pour cette tâche
NoWeb bool `json:"no_web"` // true = pas d'accès au web pour cette tâche
Enabled bool `json:"enabled"`
LastRun int64 `json:"last_run"` // epoch ms, 0 = jamais
NextRun int64 `json:"next_run"` // epoch ms, 0 = à (re)calculer
LastDurMs int64 `json:"last_dur_ms"` // durée du dernier passage en ms
LastOK bool `json:"last_ok"`
LastError string `json:"last_error"`
LastReport string `json:"last_report"` // dernier texte final de l'IA (compte-rendu court)
Running bool `json:"-"` // transitoire : une exécution est en cours
}
// newTaskID génère un identifiant court et unique.
func newTaskID() string {
var b [6]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// listTasks renvoie toutes les tâches, triées par nom pour un affichage stable.
func listTasks() []Task {
var out []Task
_ = view(bkTasks, func(b *bolt.Bucket) error {
return b.ForEach(func(_, v []byte) error {
var t Task
if json.Unmarshal(v, &t) == nil {
out = append(out, t)
}
return nil
})
})
sort.Slice(out, func(i, j int) bool {
if out[i].Name != out[j].Name {
return out[i].Name < out[j].Name
}
return out[i].ID < out[j].ID
})
return out
}
// taskProjectOf renvoie le projet d'une tâche, l'absence valant projet par défaut :
// une tâche d'avant les projets ne doit ni disparaître ni changer de mémoire. Un
// projet SUPPRIMÉ depuis retombe de même sur « Générale » : sans ça, la tâche
// restait rattachée à un slug fantôme et n'apparaissait plus dans aucun projet.
func taskProjectOf(t Task) string {
if s := strings.TrimSpace(t.Project); s != "" && projectExists(s) {
return s
}
return defaultProjectSlug
}
// Le PANNEAU de l'UI et le planificateur restent GLOBAUX : une tâche doit tourner
// même si son projet n'est pas celui affiché, et masquer les tâches des autres
// projets ferait croire qu'elles ne sont plus planifiées. Le projet gouverne ce
// que la tâche VOIT quand elle s'exécute (runTask force setProjectOverride), pas
// sa visibilité.
// tagOrphanTasks rattache au projet donné les tâches qui n'en ont pas encore
// (migration, une seule fois).
func tagOrphanTasks(slug string) {
for _, t := range listTasks() {
if strings.TrimSpace(t.Project) != "" {
continue
}
t.Project = slug
_ = saveTask(t)
}
}
// listTasksForProject ne renvoie que les tâches rattachées au projet `slug`
// (mêmes tri et résolution que listTasks). Sert à cloisonner les outils task_*
// exposés à l'IA : dans un projet, elle ne voit et ne pilote QUE ses tâches.
func listTasksForProject(slug string) []Task {
var out []Task
for _, t := range listTasks() {
if taskProjectOf(t) == slug {
out = append(out, t)
}
}
return out
}
// getTask lit une tâche par ID. Renvoie false si absente.
func getTask(id string) (Task, bool) {
var t Task
ok := getJSON(bkTasks, id, &t)
return t, ok
}
// saveTask enregistre (crée ou remplace) une tâche.
func saveTask(t Task) error { return putJSON(bkTasks, t.ID, t) }
// deleteTask supprime une tâche par ID.
func deleteTask(id string) error { return putBytes(bkTasks, id, nil) }
// tasksPaused indique si l'interrupteur maître suspend TOUTES les tâches. On
// stocke « en pause » plutôt que « actif » pour que l'état par défaut (base
// neuve) soit « actif » sans dépendre d'une écriture : c'est un frein, pas un
// moteur.
func tasksPaused() bool { return getBool(bkState, "tasks_paused") }
func setTasksPaused(on bool) error { return putBool(bkState, "tasks_paused", on) }
// tasksNow : l'heure des tâches (planification, passages), remplaçable par
// les tests (journal de parité des tâches).
var tasksNow = time.Now
// computeNextRun calcule le prochain déclenchement d'une tâche à partir de son
// Schedule (dans le fuseau tz), relatif à `from`. Renvoie 0 si le schedule est
// invalide (la tâche ne se déclenchera alors jamais — l'UI refuse déjà un
// schedule invalide à la sauvegarde, ceci n'est qu'un garde-fou).
func computeNextRun(schedule, tz string, from time.Time) int64 {
next, err := nextAfter(schedule, tz, from)
if err != nil {
return 0
}
return next.UnixMilli()
}