Tâches : « rappelle-moi dans 20 minutes » sans cron, à l'heure du navigateur

Repris d'AJEAN 0.17.5.

- task_create accepte in_minutes (dans N minutes) ou at (« HH:MM », prochaine
  occurrence, ou « AAAA-MM-JJ HH:MM ») pour une tâche unique, calculée côté
  serveur : le modèle n'a plus à écrire un cron ni à jongler avec les
  fuseaux pour un simple rappel. Elle s'écrit « @once AAAA-MM-JJ HH:MM » et
  se désactive après son passage ; manquée pendant un arrêt, elle part au
  redémarrage.
- Le navigateur envoie son fuseau (Europe/Paris…) avec chaque message ; il
  est retenu et sert aux tâches que l'IA planifie. Le conteneur tourne en
  UTC : « à 17h » tombait deux heures à côté. La réponse de task_create
  nomme le fuseau utilisé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
MichaelandClaude Opus 5.5 committed 2026-10-02 23:25:04 +02:00
1 parent 8171899ac1
commit 2bbf0ba76d
8 files changed
+160 -12

No files matched your search

+17
View File
@@ -45,11 +45,22 @@ func loc(tz string) *time.Location {
// - "@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.
// - "@once 2026-10-01 16:54" : une seule fois, à cette date et heure murale.
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, "@once"); ok {
at, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(rest), loc(tz))
if err != nil {
return time.Time{}, fmt.Errorf("date invalide (attendu « @once AAAA-MM-JJ HH:MM »)")
}
if !at.After(from) {
return time.Time{}, fmt.Errorf("%s est déjà passé (il est %s)", at.Format("2006-01-02 15:04"), from.In(loc(tz)).Format("2006-01-02 15:04"))
}
return at, nil
}
if rest, ok := cutPrefix(s, "@every"); ok {
return nextEvery(strings.TrimSpace(rest), tz, from)
}
@@ -249,3 +260,9 @@ func cutPrefix(s, prefix string) (string, bool) {
}
return "", false
}
// isOnce : tâche à exécution unique (« @once … »), désactivée après son passage.
func isOnce(schedule string) bool {
_, ok := cutPrefix(strings.TrimSpace(schedule), "@once")
return ok
}
+68
View File
@@ -0,0 +1,68 @@
package loki
import (
"strings"
"testing"
"time"
)
// « @once » : une date future est acceptée, une date passée refusée.
func TestScheduleOnce(t *testing.T) {
from := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC)
at, err := nextAfter("@once 2026-10-02 12:30", "UTC", from)
if err != nil || !at.Equal(from.Add(30*time.Minute)) {
t.Fatalf("@once futur : %v, %v", at, err)
}
if _, err := nextAfter("@once 2026-10-02 11:00", "UTC", from); err == nil {
t.Fatal("@once passé accepté")
}
if !isOnce(" @ONCE 2026-10-02 12:30") || isOnce("@every 2h") {
t.Fatal("isOnce se trompe")
}
}
// L'IA crée un rappel « dans 20 minutes » ou « à HH:MM » sans écrire de cron,
// dans le fuseau du navigateur ; une fois passée, la tâche se désactive.
func TestTacheUniqueParLIA(t *testing.T) {
testHome(t)
rememberUserTZ("Europe/Paris")
if got := defaultTaskTZ(); got != "Europe/Paris" {
t.Fatalf("fuseau retenu = %q", got)
}
rememberUserTZ("Pas/Un_Fuseau") // invalide : ignoré
if got := defaultTaskTZ(); got != "Europe/Paris" {
t.Fatalf("fuseau invalide retenu : %q", got)
}
out := toolTaskCreate(map[string]any{"name": "rappel", "prompt": "dis bonjour", "in_minutes": float64(20)})
if !strings.HasPrefix(out, "[ok]") || !strings.Contains(out, "Europe/Paris") {
t.Fatalf("création « dans 20 min » : %s", out)
}
out = toolTaskCreate(map[string]any{"name": "apéro", "prompt": "rappelle l'apéro", "at": "18:30"})
if !strings.HasPrefix(out, "[ok]") {
t.Fatalf("création « à 18:30 » : %s", out)
}
if out := toolTaskCreate(map[string]any{"name": "flou", "prompt": "x", "at": "demain"}); !strings.HasPrefix(out, "[erreur]") {
t.Fatalf("'at' invalide accepté : %s", out)
}
var once Task
for _, tk := range listTasks() {
if tk.Name == "rappel" {
once = tk
}
}
if !isOnce(once.Schedule) || once.TZ != "Europe/Paris" || once.NextRun == 0 {
t.Fatalf("tâche unique mal posée : %+v", once)
}
want := time.Now().Add(20 * time.Minute)
if d := time.UnixMilli(once.NextRun).Sub(want); d > time.Minute || d < -time.Minute {
t.Fatalf("prochaine exécution à %v, attendu ≈ %v", time.UnixMilli(once.NextRun), want)
}
recordTaskEnd(once.ID, time.Now(), "bonjour", nil)
after, _ := getTask(once.ID)
if after.Enabled || after.NextRun != 0 {
t.Fatalf("tâche unique encore active après son passage : %+v", after)
}
}
+3
View File
@@ -266,6 +266,9 @@ func recordTaskEnd(id string, start time.Time, report string, err error) {
cur.LastDurMs = now.Sub(start).Milliseconds()
cur.LastRun = now.UnixMilli()
cur.NextRun = computeNextRun(cur.Schedule, cur.TZ, now)
if isOnce(cur.Schedule) {
cur.Enabled, cur.NextRun = false, 0 // passée une fois : terminée
}
if errors.Is(err, context.Canceled) {
// Arrêt volontaire (bouton stop) : ce n'est pas un échec. On garde la trace
// « interrompue » sans allumer l'indicateur rouge d'erreur.
+60 -10
View File
@@ -54,10 +54,12 @@ func taskCreateTool() Tool {
"script": map[string]any{"type": "string", "description": "Script task: name of a script file you put in the scripts folder, run each time with no AI involved"},
// Le format du schedule est le seul point que le modèle ne peut pas
// deviner : il vit ici, dans le paramètre, pas dans la phrase de l'outil.
"schedule": map[string]any{"type": "string", "description": "\"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\""},
"enabled": map[string]any{"type": "boolean", "description": "Start active (default true)"},
"schedule": map[string]any{"type": "string", "description": "Repeating: \"@every 2h\" (interval), \"@every 1d@09:00\" (daily time), or a 5-field cron \"min hour dom mon dow\". Omit for a one-time task (use in_minutes or at)"},
"in_minutes": map[string]any{"type": "integer", "description": "One-time task, N minutes from now"},
"at": map[string]any{"type": "string", "description": "One-time task at this local time: \"HH:MM\" (next occurrence) or \"YYYY-MM-DD HH:MM\""},
"enabled": map[string]any{"type": "boolean", "description": "Start active (default true)"},
},
"required": []string{"name", "schedule"},
"required": []string{"name"},
},
},
}
@@ -101,11 +103,30 @@ func taskDeleteTool() Tool {
}
}
// defaultTaskTZ : le modèle n'a pas le fuseau du navigateur. On reprend celui
// d'une tâche existante (posé par l'UI depuis le navigateur) pour que « tous les
// jours à 9h » tombe à la bonne heure ; à défaut, fuseau vide = heure locale du
// serveur (souvent UTC), ce que la description du planificateur assume déjà.
// stUserTZKey : fuseau IANA du navigateur de l'utilisateur, envoyé avec chaque
// message (voir rememberUserTZ).
const stUserTZKey = "user_tz"
// rememberUserTZ retient le fuseau du navigateur (« Europe/Paris ») s'il est
// valide et a changé. Le conteneur tourne en UTC : sans ça, « rappelle-moi à
// 17h » tombait deux heures à côté (AJEAN 0.17.5).
func rememberUserTZ(tz string) {
tz = strings.TrimSpace(tz)
if tz == "" || tz == getStr(bkState, stUserTZKey) {
return
}
if _, err := time.LoadLocation(tz); err == nil {
_ = putStr(bkState, stUserTZKey, tz)
}
}
// defaultTaskTZ : le fuseau de l'utilisateur pour ce que le modèle planifie.
// Celui du navigateur s'il est connu, sinon celui d'une tâche existante (posé
// par l'UI), sinon vide = heure locale du serveur (souvent UTC).
func defaultTaskTZ() string {
if tz := getStr(bkState, stUserTZKey); tz != "" {
return tz
}
for _, t := range listTasks() {
if strings.TrimSpace(t.TZ) != "" {
return t.TZ
@@ -189,8 +210,30 @@ func toolTaskCreate(args map[string]any) string {
prompt := strings.TrimSpace(taskStrArg(args["prompt"]))
script := strings.TrimSpace(taskStrArg(args["script"]))
schedule := strings.TrimSpace(taskStrArg(args["schedule"]))
tz := defaultTaskTZ()
// Tâche unique : « dans N minutes » ou « à HH:MM », calculés ICI dans le
// fuseau de l'utilisateur (AJEAN 0.17.5). Le modèle n'a plus à jongler avec
// les fuseaux ni le cron pour un simple rappel.
if schedule == "" {
now := time.Now().In(loc(tz))
if v, ok := args["in_minutes"].(float64); ok && v >= 1 {
schedule = "@once " + now.Add(time.Duration(v)*time.Minute).Format("2006-01-02 15:04")
} else if at := strings.TrimSpace(taskStrArg(args["at"])); at != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04", at, loc(tz)); err == nil {
schedule = "@once " + t.Format("2006-01-02 15:04")
} else if hh, mm, err := parseHHMM(at); err == nil {
t := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, loc(tz))
if !t.After(now) {
t = t.AddDate(0, 0, 1)
}
schedule = "@once " + t.Format("2006-01-02 15:04")
} else {
return "[erreur] 'at' invalide : « HH:MM » ou « AAAA-MM-JJ HH:MM »"
}
}
}
if name == "" || schedule == "" {
return "[erreur] name et schedule sont obligatoires"
return "[erreur] name obligatoire, et schedule (tâche répétée) ou in_minutes / at (tâche unique)"
}
kind := ""
if script != "" {
@@ -202,7 +245,6 @@ func toolTaskCreate(args map[string]any) string {
} else if prompt == "" {
return "[erreur] fournis soit 'prompt' (tâche IA) soit 'script' (tâche script)"
}
tz := defaultTaskTZ()
if err := validateSchedule(schedule, tz); err != nil {
return "[erreur] fréquence invalide : " + err.Error()
}
@@ -223,7 +265,7 @@ func toolTaskCreate(args map[string]any) string {
}
when := ""
if t.NextRun > 0 && enabled {
when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04")
when = ", prochaine exécution " + time.UnixMilli(t.NextRun).In(loc(tz)).Format("2006-01-02 15:04") + " (" + tzLabel(tz) + ")"
}
return fmt.Sprintf("[ok] tâche '%s' créée (id %s)%s", name, t.ID, when)
}
@@ -279,3 +321,11 @@ func toolTaskDelete(args map[string]any) string {
}
return fmt.Sprintf("[ok] tâche '%s' (id %s) supprimée", t.Name, id)
}
// tzLabel : nom du fuseau à montrer au modèle.
func tzLabel(tz string) string {
if tz == "" {
return "heure du serveur"
}
return tz
}
+4 -1
View File
@@ -7658,6 +7658,9 @@ function loadFullHistory(){
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
// « network error » alarmiste alors que l'IA répond quand même.
// Fuseau du navigateur, envoyé avec chaque message : le conteneur tourne en UTC,
// et « rappelle-moi à 17h » doit tomber à 17h ici (voir rememberUserTZ).
const USER_TZ=(()=>{ try{ return Intl.DateTimeFormat().resolvedOptions().timeZone||''; }catch(_){ return ''; } })();
async function send(){
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
@@ -7686,7 +7689,7 @@ async function send(){
if(PENDING && !queuedAtSend) addMsgFiles(PENDING, attachSent());
for(let attempt=0; attempt<3; attempt++){
try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid})});
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid,tz:USER_TZ})});
if(r.ok) clearAttach();
// 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas.
if(r.status===409){ let m='modèle occupé'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
+4 -1
View File
@@ -683,6 +683,9 @@ function loadFullHistory(){
// (« déjà en cours ») = succès (c'est notre envoi qui est passé). On ne montre une
// erreur qu'après plusieurs échecs ET vérification que rien ne tourne — plus de
// « network error » alarmiste alors que l'IA répond quand même.
// Fuseau du navigateur, envoyé avec chaque message : le conteneur tourne en UTC,
// et « rappelle-moi à 17h » doit tomber à 17h ici (voir rememberUserTZ).
const USER_TZ=(()=>{ try{ return Intl.DateTimeFormat().resolvedOptions().timeZone||''; }catch(_){ return ''; } })();
async function send(){
// Garde-fou : le bouton est déjà désactivé, mais l'Entrée passe aussi par ici.
if(STATUS_SEEN && !MODEL_READY){ toast('le modèle n\'est pas encore prêt'); return; }
@@ -711,7 +714,7 @@ async function send(){
if(PENDING && !queuedAtSend) addMsgFiles(PENDING, attachSent());
for(let attempt=0; attempt<3; attempt++){
try{
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid})});
const r=await jfetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text,files:files,ctx_used:CTX_USED,cid,tz:USER_TZ})});
if(r.ok) clearAttach();
// 409 = une tâche planifiée occupe le modèle : pas de file dans ce cas.
if(r.status===409){ let m='modèle occupé'; try{ m=(await r.json()).error||m; }catch(_){} fail(m); return; }
+3
View File
@@ -1353,6 +1353,9 @@ func handleBenchLast(w http.ResponseWriter, r *http.Request) {
// le décodeur JSON ignore les champs qu'il ne connaît pas.
type chatReq struct {
Temperature float64 `json:"temperature"`
// TZ = fuseau IANA du navigateur (« Europe/Paris »), retenu pour dater ce
// que le modèle planifie. Voir rememberUserTZ.
TZ string `json:"tz"`
// Surcharges par requête, portées par les agents ajean.link qui ont leurs
// propres interrupteurs. nil = on prend la configuration de la machine.
// ⚠️ Elles ne peuvent que RESTREINDRE, jamais rallumer (voir capsFromBody).
+1
View File
@@ -106,6 +106,7 @@ func handleChatSend(w http.ResponseWriter, r *http.Request) {
sendJSON(w, 400, map[string]any{"ok": false, "error": "message vide"})
return
}
rememberUserTZ(body.TZ)
// Génération en cours : au lieu de refuser, on MET EN FILE (AJEAN 0.14.0).
// Le message est injecté dans la réponse en cours à la prochaine frontière
// d'étape, ou traité comme tour suivant. queued=true le signale au client.