Files
Loki/rust/loki-rs/tests/tasks.rs
T
Claude e94b625931 Lot 37 : outils task_* de l'agent exécutés par la façade
Quand la façade tient les tâches (LOKI_TASKS=rust), les quatre outils par
lesquels le modèle gère ses tâches planifiées — task_list, task_create,
task_update, task_delete — s'exécutent dans ses tours (src/tasks/tools.rs,
port de tasks_tools.go), sans passer par le Go : mêmes messages, projet
actif, fuseau de l'utilisateur, tâche unique « dans N minutes » ou « à
HH:MM », fréquence validée, script vérifié, compte-rendu coupé comme le Go.
ChatShared porte la même base de tâches que les routes.

Côté Go, les outils prennent l'heure des tâches (tasksNow) au lieu de
time.Now : rien ne change en service, le journal de parité la fixe.

Parité : journal de 44 appels tiré du Go
(parity_tasks_tools_test.go → tests/parity_tasks/tools.json), rejoué par
tests/tasks.rs ; un tour de la façade qui crée une tâche (tests/lot6.rs).

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

556 lines
19 KiB
Rust

//! Lot 26 : tâches planifiées tenues par la façade (`LOKI_TASKS=rust`).
//!
//! Le journal tiré du Go (`internal/loki/parity_tasks_test.go` →
//! `tests/parity_tasks/tasks.json`) est rejoué par une façade dont l'heure
//! des tâches est fixée pas à pas et dont les tics du planificateur sont
//! déclenchés à la demande. Le faux Go tient la base, le moteur occupé et la
//! discussion ouverte ; une tâche IA qu'il reçoit ne tourne pas (le modèle
//! n'est jamais prêt ici, comme dans le journal).
#![cfg(unix)]
mod common;
use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicI64, Ordering};
use std::time::Duration;
use common::fakego;
use common::parity::apply_files;
use loki_rs::store::Store;
use loki_rs::tasks::{self, Tasks};
use regex::Regex;
use serde_json::{Value, json};
fn fixture() -> Value {
serde_json::from_str(include_str!("parity_tasks/tasks.json")).expect("journal")
}
fn s(v: &Value) -> &str {
v.as_str().unwrap_or("")
}
const TUNE_BUSY: &str = "optimisation en cours (loki tune) : le moteur est réservé aux essais — attends la fin ou annule-la";
/// L'heure des tâches du rejeu (ns Unix).
static NOW: AtomicI64 = AtomicI64::new(0);
fn clock() -> i64 {
NOW.load(Ordering::SeqCst)
}
fn rfc3339_ns(v: &str) -> i64 {
let d = &v[..10];
let t = &v[11..19];
let p: Vec<i64> = d.split('-').map(|x| x.parse().unwrap()).collect();
let h: Vec<i64> = t.split(':').map(|x| x.parse().unwrap()).collect();
let utc = loki_rs::tz::Location::utc();
utc.date(p[0], p[1], p[2], h[0], h[1], h[2]) * 1_000_000_000
}
struct Bench {
facade: SocketAddr,
go: Arc<fakego::FakeGo>,
_stop: tokio::sync::oneshot::Sender<()>,
home: PathBuf,
tasks: Arc<Tasks>,
store: Store,
ids: HashMap<String, String>,
order: Vec<String>,
_tmp: tempfile::TempDir,
}
async fn bench(clock: fn() -> i64) -> Bench {
let tmp = tempfile::tempdir().unwrap();
let home = tmp.path().to_path_buf();
let tasks = Arc::new(Tasks::with(home.clone(), Duration::from_secs(3600), clock));
let (facade, go, stop, _) = fakego::start_with(
home.clone(),
fakego::Opts {
tasks: Some(tasks.clone()),
..Default::default()
},
)
.await;
// Pas de modèle prêt : le port du moteur ne répond pas.
let port = std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port();
go.set("config", "PORT", &port.to_string());
let ws = home.join("workspace").join("actif");
std::fs::create_dir_all(&ws).unwrap();
*go.conv_workspace.lock().unwrap() = Some(json!({
"dir": ws, "root": home.join("workspace"), "id": "actif", "title": ""
}));
*go.conv_ensure.lock().unwrap() = true;
let backend = go.backend.lock().unwrap().unwrap();
let store = Store::new(backend, fakego::JETON.to_string());
// Le premier passage du planificateur.
for _ in 0..500 {
if tasks.ticks() > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
Bench {
facade,
go,
_stop: stop,
home,
tasks,
store,
ids: HashMap::new(),
order: Vec::new(),
_tmp: tmp,
}
}
impl Bench {
/// Les identifiants tirés au hasard deviennent {{ID1}}, {{ID2}}… dans
/// l'ordre où ils apparaissent.
fn mask(&mut self, v: &str) -> String {
let id = Regex::new(r"\b[0-9a-f]{12}\b").unwrap();
let dur = Regex::new(r#"exit: (-?[0-9]+) · [^\n"\\]*"#).unwrap();
let created = Regex::new(r#""created":[0-9]+"#).unwrap();
let mut out = String::new();
let mut last = 0;
for m in id.find_iter(v) {
out.push_str(&v[last..m.start()]);
let k = m.as_str().to_string();
if !self.ids.contains_key(&k) {
self.order.push(k.clone());
self.ids
.insert(k.clone(), format!("{{{{ID{}}}}}", self.order.len()));
}
out.push_str(&self.ids[&k]);
last = m.end();
}
out.push_str(&v[last..]);
let out = out.replace(&self.home.to_string_lossy().into_owned(), "{{HOME}}");
let out = created.replace_all(&out, r#""created":"{{T}}""#);
dur.replace_all(&out, "exit: $1 · {{DUR}}").into_owned()
}
fn unmask(&self, v: &str) -> String {
let mut v = v.to_string();
// Les plus longs d'abord : {{ID10}} avant {{ID1}}.
let mut ids: Vec<_> = self.ids.iter().collect();
ids.sort_by_key(|(_, m)| std::cmp::Reverse(m.len()));
for (id, m) in ids {
v = v.replace(m, id);
}
v.replace("{{HOME}}", &self.home.to_string_lossy())
}
async fn call(&mut self, method: &str, path: &str, body: &str) -> (u16, Value) {
let (code, b) = fakego::send(self.facade, method, path, &[], body).await;
let raw = String::from_utf8_lossy(&b).into_owned();
let out = serde_json::from_str(&self.mask(&raw))
.unwrap_or_else(|_| panic!("{path} : corps illisible {raw:?}"));
(code.as_u16(), out)
}
async fn snapshot(&mut self) -> Value {
let list = tasks::list_tasks(&self.store).await;
if list.is_empty() {
return Value::Null;
}
let raw = serde_json::to_string(&list).unwrap();
serde_json::from_str(&self.mask(&raw)).unwrap()
}
async fn last_run(&self, id: &str) -> i64 {
tasks::get_task(&self.store, id)
.await
.map_or(0, |t| t.last_run)
}
async fn tick(&self) {
let before = self.tasks.ticks();
self.tasks.kick();
for _ in 0..2000 {
if self.tasks.ticks() > before {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("le tic ne finit pas");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn parite_des_taches() {
let fx = fixture();
let steps = fx["steps"].as_array().unwrap();
NOW.store(rfc3339_ns(s(&steps[0]["now"])), Ordering::SeqCst);
let mut bn = bench(clock).await;
let mut ia_runs = 0;
for st in steps {
let name = s(&st["name"]);
if let Some(n) = st["now"].as_str() {
NOW.store(rfc3339_ns(n), Ordering::SeqCst);
}
let files: serde_json::Map<String, Value> = st["files"]
.as_object()
.into_iter()
.flatten()
.map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v)))))
.collect();
apply_files(&bn.home, &Value::Object(files));
for (k, v) in st["config"].as_object().into_iter().flatten() {
bn.go.set("config", k, s(v));
}
for (k, v) in st["state"].as_object().into_iter().flatten() {
bn.go.set("state", k, s(v));
}
if let Some(t) = st["put_task"].as_str() {
let v: Value = serde_json::from_str(t).unwrap();
bn.go.set("tasks", s(&v["id"]), t);
}
*bn.go.tune_busy.lock().unwrap() =
(st["tune"].as_bool() == Some(true)).then(|| TUNE_BUSY.to_string());
let generating = st["generating"].as_bool() == Some(true);
*bn.go.generating.lock().unwrap() = generating;
if st["tick"].as_bool() == Some(true) {
bn.tick().await;
}
let before = match st["wait_done"].as_str() {
Some(id) => bn.last_run(&bn.unmask(id)).await,
None => 0,
};
if let Some(path) = st["path"].as_str() {
let body = bn.unmask(s(&st["body"]));
let (code, out) = bn.call(s(&st["method"]), path, &body).await;
assert_eq!(code as i64, st["status"].as_i64().unwrap(), "{name}");
assert_eq!(out, st["out"], "{name}");
}
*bn.go.generating.lock().unwrap() = false;
*bn.go.tune_busy.lock().unwrap() = None;
if let Some(want) = st["wait_running"].as_str() {
let mut ok = false;
for _ in 0..1000 {
let (_, out) = bn.call("GET", "/api/tasks", "").await;
if out["running_id"] == json!(want) {
ok = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(ok, "{name} : la tâche ne tourne pas");
}
if let Some(id) = st["wait_done"].as_str() {
let id = bn.unmask(id);
let mut ok = false;
for _ in 0..2000 {
if bn.last_run(&id).await != before {
ok = true;
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert!(ok, "{name} : le passage ne finit pas");
// Le registre des scripts est vidé juste après l'écriture.
while bn.tasks.script_running() {
tokio::time::sleep(Duration::from_millis(5)).await;
}
}
let snap = bn.snapshot().await;
assert_eq!(snap, st["tasks"], "{name} : tâches");
// Une tâche IA due part au Go, qui ne la fait pas tourner (modèle pas
// prêt) : son état ne bouge pas.
let runs = bn.go.task_runs.lock().unwrap().clone();
if runs.len() != ia_runs {
assert_eq!(name, "tic : une tâche IA due attend le modèle");
assert_eq!(runs.len(), ia_runs + 1, "{name}");
let masked = bn.mask(runs.last().unwrap());
assert_eq!(masked, "{{ID1}}", "{name}");
ia_runs = runs.len();
}
}
assert_eq!(ia_runs, 1, "la tâche IA due n'est jamais partie au Go");
// Les notifications des scripts partent au Go (la façade ne les tient
// pas) : succès, échec, puis les passages du planificateur.
let sends = bn.go.push_sends.lock().unwrap().clone();
let titles: Vec<&str> = sends.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(
titles.first().copied(),
Some("Loki · tâche terminée"),
"{sends:?}"
);
assert!(
sends
.iter()
.any(|(t, b)| t == "Loki · tâche en échec" && b == "K échec"),
"{sends:?}"
);
assert!(
!sends.iter().any(|(_, b)| b == "L lente"),
"un arrêt manuel ne notifie pas : {sends:?}"
);
}
/// Une tâche IA lancée à la main part au Go, qui l'exécute ; le moteur prêt
/// (preset externe), la façade ne la refuse pas.
#[tokio::test]
async fn tache_ia_executee_par_le_go() {
let mut bn = bench(tasks::now_ns).await;
bn.go.set("config", loki_rs::engstatus::EXT_KEY_FLAG, "1");
let (code, out) = bn
.call(
"POST",
"/api/tasks/save",
r#"{"name":"IA","prompt":"Fais le point.","schedule":"@every 1h","enabled":true}"#,
)
.await;
assert_eq!(code, 200, "{out}");
let id = bn.unmask(s(&out["id"]));
// Le Go tient la tâche un moment : l'écran la montre en cours.
*bn.go.task_run_delay.lock().unwrap() = Some(Duration::from_millis(400));
let (code, out) = bn
.call("POST", "/api/tasks/run", &json!({ "id": id }).to_string())
.await;
assert_eq!((code, out), (200, json!({ "ok": true })));
let masked = bn.mask(&id);
let mut seen = false;
for _ in 0..200 {
let (_, v) = bn.call("GET", "/api/tasks", "").await;
if v["running_id"] == json!(masked) {
seen = true;
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(seen, "la tâche IA n'est pas montrée en cours");
for _ in 0..500 {
let (_, v) = bn.call("GET", "/api/tasks", "").await;
if v["running_id"] == json!("") {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
let (_, v) = bn.call("GET", "/api/tasks", "").await;
assert_eq!(v["running_id"], json!(""));
assert_eq!(*bn.go.task_runs.lock().unwrap(), vec![id.clone()]);
// Le Go ne l'a pas fait tourner (occupé) : rien n'est enregistré ici.
let t = tasks::get_task(&bn.store, &id).await.unwrap();
assert_eq!(
(t.last_run, t.last_ok, t.last_report.as_str()),
(0, false, "")
);
// Arrêt d'une tâche IA : la génération du Go.
let (code, _) = bn
.call("POST", "/api/tasks/stop", &json!({ "id": id }).to_string())
.await;
assert_eq!(code, 200);
assert_eq!(*bn.go.lease_log.lock().unwrap(), vec!["stop".to_string()]);
let (code, _) = bn
.call("POST", "/api/tasks/run", r#"{"id":"absente"}"#)
.await;
assert_eq!(code, 404);
}
/// Sans demande, le planificateur tourne à son pas.
#[tokio::test]
async fn tics_au_pas_du_planificateur() {
let tmp = tempfile::tempdir().unwrap();
let t = Arc::new(Tasks::with(
tmp.path().to_path_buf(),
Duration::from_millis(20),
tasks::now_ns,
));
let (_facade, _go, _stop, _) = fakego::start_with(
tmp.path().to_path_buf(),
fakego::Opts {
tasks: Some(t.clone()),
..Default::default()
},
)
.await;
for _ in 0..500 {
if t.ticks() >= 4 {
return;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
panic!("le planificateur ne tourne pas : {} tics", t.ticks());
}
/// Sans la façade pour les tâches, leurs routes partent au Go.
#[tokio::test]
async fn sans_la_facade() {
let tmp = tempfile::tempdir().unwrap();
let (facade, go, _stop) = fakego::start(tmp.path().to_path_buf()).await;
let (c, b) = fakego::send(facade, "GET", "/api/tasks", &[], "").await;
assert_eq!(c.as_u16(), 200);
assert_eq!(String::from_utf8_lossy(&b), "go:/api/tasks");
assert!(
go.relayed
.lock()
.unwrap()
.contains(&"/api/tasks".to_string())
);
}
/// Une horloge qui avance d'une seconde et demie à chaque lecture.
static TICKING: AtomicI64 = AtomicI64::new(1_774_738_800_000_000_000);
fn ticking() -> i64 {
TICKING.fetch_add(1_500_000_000, Ordering::SeqCst)
}
/// La durée d'un passage : de son début à sa fin, à l'horloge des tâches.
#[tokio::test]
async fn duree_du_passage() {
let mut bn = bench(ticking).await;
apply_files(
&bn.home,
&json!({ "scripts/ok.sh": "#!/bin/sh\necho bonjour\n" }),
);
let (code, out) = bn
.call(
"POST",
"/api/tasks/save",
r#"{"name":"S","kind":"script","script":"ok.sh","schedule":"@every 1h","enabled":true}"#,
)
.await;
assert_eq!(code, 200, "{out}");
let id = bn.unmask(s(&out["id"]));
let (code, _) = bn
.call("POST", "/api/tasks/run", &json!({ "id": id }).to_string())
.await;
assert_eq!(code, 200);
let mut t = None;
for _ in 0..1000 {
t = tasks::get_task(&bn.store, &id)
.await
.filter(|t| t.last_run > 0);
if t.is_some() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let t = t.expect("le passage ne finit pas");
assert!(t.last_ok, "{t:?}");
assert!(
t.last_dur_ms > 0 && t.last_dur_ms % 1500 == 0,
"durée : {}",
t.last_dur_ms
);
}
/// Au démarrage, le prochain passage des tâches actives qui n'en ont pas
/// (`primeNextRuns`) ; les autres ne bougent pas.
#[tokio::test]
async fn amorce_au_demarrage() {
let tmp = tempfile::tempdir().unwrap();
let t = Arc::new(Tasks::with(
tmp.path().to_path_buf(),
Duration::from_secs(3600),
tasks::now_ns,
));
let task = |id: &str, enabled: bool, next: i64| {
(
"tasks".to_string(),
id.to_string(),
json!({ "id": id, "name": id, "kind": "script", "script": "ok.sh",
"schedule": "@every 1h", "enabled": enabled, "next_run": next })
.to_string(),
)
};
let (_facade, go, _stop, _) = fakego::start_with(
tmp.path().to_path_buf(),
fakego::Opts {
tasks: Some(t.clone()),
seed: vec![
task("active", true, 0),
task("eteinte", false, 0),
task("posee", true, 42),
],
..Default::default()
},
)
.await;
for _ in 0..500 {
if t.ticks() > 0 {
break;
}
tokio::time::sleep(Duration::from_millis(5)).await;
}
assert!(t.ticks() > 0, "pas de premier passage");
let next = |id: &str| -> i64 {
let v: Value = serde_json::from_str(&go.get("tasks", id).unwrap()).unwrap();
v["next_run"].as_i64().unwrap()
};
let now_ms = tasks::now_ns() / 1_000_000;
let a = next("active");
assert!(
(now_ms + 3_590_000..=now_ms + 3_600_000).contains(&a),
"active : {a}"
);
assert_eq!(next("eteinte"), 0);
assert_eq!(next("posee"), 42);
}
fn zero_clock() -> i64 {
0
}
/// Lot 37 : les outils task_* de l'agent, rejoués contre le journal du Go
/// (`internal/loki/parity_tasks_tools_test.go` → `parity_tasks/tools.json`) :
/// chaque appel, son résultat tel que le modèle le reçoit, les tâches en base
/// après coup.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn parite_des_outils() {
use loki_rs::tasks::tools::{ToolEnv, exec, is_tool};
let fx: Value = serde_json::from_str(include_str!("parity_tasks/tools.json")).expect("journal");
let mut bn = bench(zero_clock).await;
let scripts = bn.home.join("scripts");
let mut now = 0;
for st in fx.as_array().unwrap() {
let name = s(&st["name"]);
if let Some(n) = st["now"].as_str() {
now = rfc3339_ns(n);
}
let files: serde_json::Map<String, Value> = st["files"]
.as_object()
.into_iter()
.flatten()
.map(|(k, v)| (k.clone(), Value::String(bn.unmask(s(v)))))
.collect();
apply_files(&bn.home, &Value::Object(files));
for (k, v) in st["state"].as_object().into_iter().flatten() {
bn.go.set("state", k, s(v));
}
if let Some(t) = st["put_task"].as_str() {
let raw = bn.unmask(t);
let v: Value = serde_json::from_str(&raw).unwrap();
bn.go.set("tasks", s(&v["id"]), &raw);
bn.mask(&raw);
}
let tool = s(&st["tool"]);
assert!(is_tool(tool), "{name}");
let args: serde_json::Map<String, Value> =
serde_json::from_str(&bn.unmask(&st["args"].to_string())).unwrap();
let env = ToolEnv {
store: &bn.store,
scripts_dir: &scripts,
now_ns: now,
};
let out = exec(&env, tool, &args).await.expect("outil task_*");
assert_eq!(bn.mask(&out), s(&st["out"]), "{name}");
assert_eq!(bn.snapshot().await, st["tasks"], "{name}");
}
let env = ToolEnv {
store: &bn.store,
scripts_dir: &scripts,
now_ns: now,
};
assert_eq!(exec(&env, "bash", &serde_json::Map::new()).await, None);
assert!(!is_tool("task_run"));
}