Lot 43 : jobs d'arrière-plan du mode Code tenus par la façade

bash_bg et bash_tail des tours de la façade ne passent plus par /tool/exec
du Go : src/tools/jobs.rs porte code_jobs.go. Refus avant lancement
(commande vide ou dangereuse, optimisation en cours), journal
.loki/jobs/<id>.log dans le dossier de la discussion, processus qui survit
au tour, shell sans le jeton interne ; relecture bornée (40 lignes par
défaut, 200 au plus), état et durée, arrêt relu aussitôt comme le Go.
paths::go_mkdir_all reproduit os.MkdirAll, erreur comprise.

Les jobs d'une discussion supprimée s'arrêtent avec elle. L'optimiseur du
Go compte ceux de la façade (POST /internal/facade/v1/bgjobs) avant de
mesurer ; façade injoignable : les siens seulement.

Parité : journal de 24 étapes tiré du Go (parity_jobs_test.go →
tests/parity_jobs/jobs.json), rejoué par tests/jobs.rs ; un tour de la
façade qui lance un job (tests/lot6.rs) ; TestFacadeJobsArrierePlan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-16 05:42:32 +00:00
1 parent f66d1b7621
commit 7288db632e
16 files changed
+1132 -4

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
//! Lot 43 : jobs d'arrière-plan du mode Code (`bash_bg`, `bash_tail`) tenus
//! par la façade, rejoués sur le journal du Go
//! (`internal/loki/parity_jobs_test.go` → `tests/parity_jobs/jobs.json`).
#![cfg(target_os = "linux")]
use std::time::Duration;
use loki_rs::tools::jobs::BgJobs;
use regex::Regex;
use serde_json::{Map, Value};
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn jobs_comme_le_go() {
let fx: Value = serde_json::from_str(include_str!("parity_jobs/jobs.json")).expect("journal");
let tmp = tempfile::tempdir().unwrap();
let ws = tmp.path().join("ws");
std::fs::create_dir_all(&ws).unwrap();
let jobs = BgJobs::default();
let pid = Regex::new(r"\(pid [0-9]+\)").unwrap();
let since = Regex::new(r"depuis [0-9hms.]+").unwrap();
let state = Regex::new(r"— (en cours|terminé \(exit -1\)) —").unwrap();
for st in fx["steps"].as_array().unwrap() {
let name = st["name"].as_str().unwrap();
if let Some(id) = st["wait"].as_str() {
// Le job attendu est le seul qui tourne encore.
for _ in 0..1000 {
if jobs.running() == 0 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(jobs.running(), 0, "{name} : {id} pas fini");
}
let args: Map<String, Value> = serde_json::from_value(st["args"].clone()).unwrap();
let got = match st["tool"].as_str().unwrap() {
"bash_bg" => jobs.bash_bg(&ws, "c1", &args, None).await,
_ => jobs.bash_tail(&args).await,
};
let got = pid.replace_all(&got, "(pid {{PID}})");
let mut got = since.replace_all(&got, "depuis {{D}}").into_owned();
if st["after"] == "kill" {
got = state.replace_all(&got, "— {{ETAT}} —").into_owned();
}
assert_eq!(got, st["result"].as_str().unwrap(), "{name}");
let mut logs: Vec<String> = std::fs::read_dir(ws.join(".loki/jobs"))
.into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
logs.sort();
assert_eq!(serde_json::json!(logs), st["logs"], "{name} : journaux");
}
}
+52
View File
@@ -672,6 +672,7 @@ async fn tour_complet_par_le_go() {
tasks: None,
memory: None,
web: Default::default(),
jobs: Default::default(),
go_version: Default::default(),
});
let cfg = std::collections::HashMap::new();
@@ -2686,6 +2687,9 @@ async fn anneau_de_la_facade_pour_le_go() {
.collect();
assert_eq!(kinds, ["subagent", "foreign", "subagent"]);
assert_eq!(chat.shared.perf.snapshot().len(), 3);
// Lot 43 : les jobs d'arrière-plan encore en vie, pour l'optimiseur du Go.
let jobs = post(serde_json::json!({}), "/internal/facade/v1/bgjobs").await;
assert_eq!(jobs["running"], chat.shared.jobs.running() as u64);
// Sans jeton : refusé.
let (code, _) = fakego::send(
facade,
@@ -2788,3 +2792,51 @@ async fn outil_web_par_la_facade() {
)
);
}
/// Lot 43 : `bash_bg` d'un tour de la façade lance le job chez elle, dans le
/// dossier de la discussion ; le Go n'exécute rien et l'API interne compte
/// le job pour son optimiseur.
#[cfg(unix)]
#[tokio::test]
async fn job_arriere_plan_par_la_facade() {
let call = sse(&[
serde_json::json!({"choices":[{"delta":{"tool_calls":[{"index":0,"id":"b1","type":"function","function":{"name":"bash_bg","arguments":"{\"command\":\"sleep 30\"}"}}]}}]}),
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
]);
let (url, seen) = fake_engine(vec![call, answer("lancé")]).await;
let ws = tempfile::tempdir().unwrap();
let jobs = Arc::new(loki_rs::tools::jobs::BgJobs::default());
let (svc, go, _stop, _t) = service_with(Some(&url), |s| s.jobs = jobs.clone()).await;
*go.tool_ctx.lock().unwrap() = Some(serde_json::json!({
"cwd": ws.path(), "memory_dir": ws.path().join("mem"), "target": ""
}));
svc.send(serde_json::json!({"message": "lance le serveur", "cid": "j"}))
.await
.unwrap();
until_done(&svc, 1).await;
assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log);
assert!(ws.path().join(".loki/jobs/job1.log").is_file());
assert_eq!(jobs.running(), 1);
let seen = seen.lock().unwrap().clone();
let tool = seen[1].1["messages"]
.as_array()
.unwrap()
.iter()
.rfind(|m| m["role"] == "tool")
.unwrap()["content"]
.as_str()
.unwrap()
.to_string();
assert!(tool.starts_with("[ok] job job1 lancé (pid "), "{tool}");
// Une autre discussion n'y touche pas ; la sienne l'arrête.
jobs.stop_conv("autre");
assert_eq!(jobs.running(), 1);
jobs.stop_conv(&svc.sessions.conv().id());
for _ in 0..500 {
if jobs.running() == 0 {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
assert_eq!(jobs.running(), 0);
}
+313
View File
@@ -0,0 +1,313 @@
{
"steps": [
{
"name": "commande vide",
"tool": "bash_bg",
"args": {
"command": " "
},
"result": "[erreur] commande vide",
"logs": []
},
{
"name": "commande refusée",
"tool": "bash_bg",
"args": {
"command": "rm -rf /"
},
"result": "[refusé] Commande bloquée par la politique de sécurité : efface la racine du disque ou le home. Ne la réessaie pas, même reformulée. Si l'utilisateur la veut vraiment, dis-lui de la lancer lui-même dans un terminal.",
"logs": []
},
{
"name": "lancement",
"tool": "bash_bg",
"args": {
"command": "echo un; echo deux >&2; printf 'trois\\n\\n'"
},
"result": "[ok] job job1 lancé (pid {{PID}}) — sortie dans .loki/jobs/job1.log, suis-la avec bash_tail",
"logs": [
"job1.log"
]
},
{
"name": "sortie",
"tool": "bash_tail",
"args": {
"id": "job1"
},
"wait": "job1",
"result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\nun\ndeux\ntrois",
"logs": [
"job1.log"
]
},
{
"name": "deux lignes",
"tool": "bash_tail",
"args": {
"id": "job1",
"lines": 2
},
"result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\ndeux\ntrois",
"logs": [
"job1.log"
]
},
{
"name": "lignes nulles",
"tool": "bash_tail",
"args": {
"id": "job1",
"lines": 0
},
"result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\nun\ndeux\ntrois",
"logs": [
"job1.log"
]
},
{
"name": "lignes en texte",
"tool": "bash_tail",
"args": {
"id": "job1",
"lines": "1"
},
"result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\nun\ndeux\ntrois",
"logs": [
"job1.log"
]
},
{
"name": "lignes tronquées",
"tool": "bash_tail",
"args": {
"id": "job1",
"lines": 1.9
},
"result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\ntrois",
"logs": [
"job1.log"
]
},
{
"name": "arrêt d'un job fini",
"tool": "bash_tail",
"args": {
"id": "job1",
"kill": true
},
"result": "job job1 — terminé (exit 0) — depuis {{D}}\ncommande : echo un; echo deux >&2; printf 'trois\\n\\n'\n\nun\ndeux\ntrois",
"logs": [
"job1.log"
]
},
{
"name": "job inconnu",
"tool": "bash_tail",
"args": {
"id": "job9"
},
"result": "[erreur] job inconnu : job9",
"logs": [
"job1.log"
]
},
{
"name": "sans id",
"tool": "bash_tail",
"args": {},
"result": "[erreur] job inconnu : ",
"logs": [
"job1.log"
]
},
{
"name": "sortie en erreur",
"tool": "bash_bg",
"args": {
"command": "exit 3"
},
"result": "[ok] job job2 lancé (pid {{PID}}) — sortie dans .loki/jobs/job2.log, suis-la avec bash_tail",
"logs": [
"job1.log",
"job2.log"
]
},
{
"name": "rien d'écrit",
"tool": "bash_tail",
"args": {
"id": "job2"
},
"wait": "job2",
"result": "job job2 — terminé (exit 3) — depuis {{D}}\ncommande : exit 3\n\n(aucune sortie pour l'instant)",
"logs": [
"job1.log",
"job2.log"
]
},
{
"name": "job long",
"tool": "bash_bg",
"args": {
"command": "sleep 30"
},
"result": "[ok] job job3 lancé (pid {{PID}}) — sortie dans .loki/jobs/job3.log, suis-la avec bash_tail",
"logs": [
"job1.log",
"job2.log",
"job3.log"
]
},
{
"name": "en cours",
"tool": "bash_tail",
"args": {
"id": "job3"
},
"result": "job job3 — en cours — depuis {{D}}\ncommande : sleep 30\n\n(aucune sortie pour l'instant)",
"logs": [
"job1.log",
"job2.log",
"job3.log"
]
},
{
"name": "arrêt",
"tool": "bash_tail",
"args": {
"id": "job3",
"kill": true
},
"after": "kill",
"result": "job job3 — {{ETAT}} — depuis {{D}}\ncommande : sleep 30\n\n(aucune sortie pour l'instant)",
"logs": [
"job1.log",
"job2.log",
"job3.log"
]
},
{
"name": "après l'arrêt",
"tool": "bash_tail",
"args": {
"id": "job3"
},
"wait": "job3",
"result": "job job3 — terminé (exit -1) — depuis {{D}}\ncommande : sleep 30\n\n(aucune sortie pour l'instant)",
"logs": [
"job1.log",
"job2.log",
"job3.log"
]
},
{
"name": "beaucoup de lignes",
"tool": "bash_bg",
"args": {
"command": "for i in $(seq 1 300); do echo ligne $i; done"
},
"result": "[ok] job job4 lancé (pid {{PID}}) — sortie dans .loki/jobs/job4.log, suis-la avec bash_tail",
"logs": [
"job1.log",
"job2.log",
"job3.log",
"job4.log"
]
},
{
"name": "quarante par défaut",
"tool": "bash_tail",
"args": {
"id": "job4"
},
"wait": "job4",
"result": "job job4 — terminé (exit 0) — depuis {{D}}\ncommande : for i in $(seq 1 300); do echo ligne $i; done\n\nligne 261\nligne 262\nligne 263\nligne 264\nligne 265\nligne 266\nligne 267\nligne 268\nligne 269\nligne 270\nligne 271\nligne 272\nligne 273\nligne 274\nligne 275\nligne 276\nligne 277\nligne 278\nligne 279\nligne 280\nligne 281\nligne 282\nligne 283\nligne 284\nligne 285\nligne 286\nligne 287\nligne 288\nligne 289\nligne 290\nligne 291\nligne 292\nligne 293\nligne 294\nligne 295\nligne 296\nligne 297\nligne 298\nligne 299\nligne 300",
"logs": [
"job1.log",
"job2.log",
"job3.log",
"job4.log"
]
},
{
"name": "deux cents au plus",
"tool": "bash_tail",
"args": {
"id": "job4",
"lines": 500
},
"result": "job job4 — terminé (exit 0) — depuis {{D}}\ncommande : for i in $(seq 1 300); do echo ligne $i; done\n\nligne 101\nligne 102\nligne 103\nligne 104\nligne 105\nligne 106\nligne 107\nligne 108\nligne 109\nligne 110\nligne 111\nligne 112\nligne 113\nligne 114\nligne 115\nligne 116\nligne 117\nligne 118\nligne 119\nligne 120\nligne 121\nligne 122\nligne 123\nligne 124\nligne 125\nligne 126\nligne 127\nligne 128\nligne 129\nligne 130\nligne 131\nligne 132\nligne 133\nligne 134\nligne 135\nligne 136\nligne 137\nligne 138\nligne 139\nligne 140\nligne 141\nligne 142\nligne 143\nligne 144\nligne 145\nligne 146\nligne 147\nligne 148\nligne 149\nligne 150\nligne 151\nligne 152\nligne 153\nligne 154\nligne 155\nligne 156\nligne 157\nligne 158\nligne 159\nligne 160\nligne 161\nligne 162\nligne 163\nligne 164\nligne 165\nligne 166\nligne 167\nligne 168\nligne 169\nligne 170\nligne 171\nligne 172\nligne 173\nligne 174\nligne 175\nligne 176\nligne 177\nligne 178\nligne 179\nligne 180\nligne 181\nligne 182\nligne 183\nligne 184\nligne 185\nligne 186\nligne 187\nligne 188\nligne 189\nligne 190\nligne 191\nligne 192\nligne 193\nligne 194\nligne 195\nligne 196\nligne 197\nligne 198\nligne 199\nligne 200\nligne 201\nligne 202\nligne 203\nligne 204\nligne 205\nligne 206\nligne 207\nligne 208\nligne 209\nligne 210\nligne 211\nligne 212\nligne 213\nligne 214\nligne 215\nligne 216\nligne 217\nligne 218\nligne 219\nligne 220\nligne 221\nligne 222\nligne 223\nligne 224\nligne 225\nligne 226\nligne 227\nligne 228\nligne 229\nligne 230\nligne 231\nligne 232\nligne 233\nligne 234\nligne 235\nligne 236\nligne 237\nligne 238\nligne 239\nligne 240\nligne 241\nligne 242\nligne 243\nligne 244\nligne 245\nligne 246\nligne 247\nligne 248\nligne 249\nligne 250\nligne 251\nligne 252\nligne 253\nligne 254\nligne 255\nligne 256\nligne 257\nligne 258\nligne 259\nligne 260\nligne 261\nligne 262\nligne 263\nligne 264\nligne 265\nligne 266\nligne 267\nligne 268\nligne 269\nligne 270\nligne 271\nligne 272\nligne 273\nligne 274\nligne 275\nligne 276\nligne 277\nligne 278\nligne 279\nligne 280\nligne 281\nligne 282\nligne 283\nligne 284\nligne 285\nligne 286\nligne 287\nligne 288\nligne 289\nligne 290\nligne 291\nligne 292\nligne 293\nligne 294\nligne 295\nligne 296\nligne 297\nligne 298\nligne 299\nligne 300",
"logs": [
"job1.log",
"job2.log",
"job3.log",
"job4.log"
]
},
{
"name": "sortie démesurée",
"tool": "bash_bg",
"args": {
"command": "for i in $(seq 1 9000); do printf 'é'; done; echo"
},
"result": "[ok] job job5 lancé (pid {{PID}}) — sortie dans .loki/jobs/job5.log, suis-la avec bash_tail",
"logs": [
"job1.log",
"job2.log",
"job3.log",
"job4.log",
"job5.log"
]
},
{
"name": "fin gardée",
"tool": "bash_tail",
"args": {
"id": "job5"
},
"wait": "job5",
"result": "job job5 — terminé (exit 0) — depuis {{D}}\ncommande : for i in $(seq 1 9000); do printf 'é'; done; echo\n\nééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééLine truncated
"logs": [
"job1.log",
"job2.log",
"job3.log",
"job4.log",
"job5.log"
]
},
{
"name": "octets invalides",
"tool": "bash_bg",
"args": {
"command": "printf 'a\\377b\\n'"
},
"result": "[ok] job job6 lancé (pid {{PID}}) — sortie dans .loki/jobs/job6.log, suis-la avec bash_tail",
"logs": [
"job1.log",
"job2.log",
"job3.log",
"job4.log",
"job5.log",
"job6.log"
]
},
{
"name": "octets relus",
"tool": "bash_tail",
"args": {
"id": "job6"
},
"wait": "job6",
"result": "job job6 — terminé (exit 0) — depuis {{D}}\ncommande : printf 'a\\377b\\n'\n\na\ufffdb",
"logs": [
"job1.log",
"job2.log",
"job3.log",
"job4.log",
"job5.log",
"job6.log"
]
}
]
}