mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-16 19:59:56 +02:00
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:
16 files changed
+1132
-4
No files matched your search
@@ -0,0 +1,82 @@
|
||||
# Lot 43 — Jobs d'arrière-plan du mode Code dans la façade
|
||||
|
||||
## Contexte
|
||||
|
||||
En mode Code, le modèle lance un serveur de dev, un watcher ou un build long
|
||||
avec `bash_bg`. Il en suit la sortie avec `bash_tail`, qui sait aussi
|
||||
l'arrêter. Les deux outils partaient au Go (`/internal/v1/tool/exec`), qui
|
||||
gardait les processus. C'étaient les derniers outils shell du tour encore au
|
||||
Go, `bash` étant porté depuis le lot 7.
|
||||
|
||||
## Le lot
|
||||
|
||||
`src/tools/jobs.rs` porte `code_jobs.go`.
|
||||
|
||||
**`bash_bg`** :
|
||||
|
||||
- refus avant tout lancement :
|
||||
- commande vide ;
|
||||
- commande dangereuse (même politique que `bash`) ;
|
||||
- optimisation en cours (`/internal/v1/tune/guard`) ;
|
||||
- journal `.loki/jobs/<id>.log` dans le dossier de la discussion (le
|
||||
contexte des outils du Go) ;
|
||||
- numéros `job1`, `job2`… ;
|
||||
- shell de la plateforme, sans le jeton interne dans l'environnement ;
|
||||
- le processus survit au tour ;
|
||||
- code de sortie du Go : -1 pour un processus tué.
|
||||
|
||||
**`bash_tail`** :
|
||||
|
||||
- 40 lignes par défaut, 200 au plus ; un nombre est tronqué comme
|
||||
`int(float64)` ;
|
||||
- état « en cours » ou « terminé (exit N) » et durée arrondie à la
|
||||
seconde ;
|
||||
- sortie vide signalée ;
|
||||
- fin gardée en caractères ;
|
||||
- octets invalides en U+FFFD un à un ;
|
||||
- `kill:true` arrête le job et relit aussitôt, comme le Go : l'état peut
|
||||
encore dire « en cours ».
|
||||
|
||||
Les erreurs d'écriture ont le texte du Go. `paths::go_mkdir_all` reproduit
|
||||
`os.MkdirAll` : l'erreur nomme l'élément du chemin qui a échoué.
|
||||
|
||||
`ChatShared.jobs` (les jobs du processus) sert les tours :
|
||||
`GoTurnHost::exec_tool` exécute les deux outils. Sans contexte d'outils,
|
||||
`bash_bg` passe au Go comme avant.
|
||||
|
||||
Les jobs d'une discussion supprimée s'arrêtent avec elle
|
||||
(`GoSessionHooks::dropped`, `stopConvJobs`).
|
||||
|
||||
L'optimiseur refuse de mesurer tant qu'un job tourne. Côté Go,
|
||||
`bgJobsRunning` ajoute ceux de la façade quand elle tient la conversation
|
||||
(`POST /internal/facade/v1/bgjobs` → `{running}`) ; façade injoignable :
|
||||
les siens seulement.
|
||||
|
||||
## Parité
|
||||
|
||||
`internal/loki/parity_jobs_test.go` (Linux) tire du Go un journal de 24
|
||||
étapes (`tests/parity_jobs/jobs.json`). Chaque étape porte le résultat (pid
|
||||
et durées masqués) et les journaux présents. Il couvre :
|
||||
|
||||
- commande vide, refusée ;
|
||||
- sortie des deux flux, lignes demandées (nulles, en texte, tronquées) ;
|
||||
- arrêt d'un job fini ;
|
||||
- job inconnu, sans id ;
|
||||
- sortie en erreur sans rien d'écrit ;
|
||||
- job long, en cours, arrêté (état relu masqué), puis fini à -1 ;
|
||||
- quarante lignes par défaut, deux cents au plus ;
|
||||
- sortie démesurée en caractères multi-octets ;
|
||||
- octets invalides.
|
||||
|
||||
`tests/jobs.rs` le rejoue contre le port.
|
||||
|
||||
D'autres tests complètent le journal :
|
||||
|
||||
- `tests/lot6.rs` (`job_arriere_plan_par_la_facade`) : un tour de la façade
|
||||
lance un job dans le dossier de la discussion sans passer par le Go ;
|
||||
supprimer une autre discussion n'y touche pas, la sienne l'arrête ;
|
||||
- `anneau_de_la_facade_pour_le_go` : le compte des jobs par l'API interne ;
|
||||
- `TestFacadeJobsArrierePlan` : le Go les ajoute aux siens, façade en panne
|
||||
comprise ;
|
||||
- tests unitaires : refus de l'optimiseur, discussion supprimée, dossier
|
||||
impossible.
|
||||
@@ -177,10 +177,25 @@ func toolBashTail(args map[string]any) string {
|
||||
return out + "\n" + tailRunes(tail, toolMaxOutput)
|
||||
}
|
||||
|
||||
// bgJobsRunning : jobs d'arrière-plan encore en vie. L'optimiseur refuse de
|
||||
// mesurer à côté d'eux : un build ou un serveur de dev prend le CPU et le
|
||||
// disque que mesurent ses essais.
|
||||
// bgJobsRunning : jobs d'arrière-plan encore en vie, ceux de la façade
|
||||
// compris quand elle tient la conversation (lot 43 : ses tours lancent les
|
||||
// leurs). L'optimiseur refuse de mesurer à côté d'eux : un build ou un
|
||||
// serveur de dev prend le CPU et le disque que mesurent ses essais.
|
||||
func bgJobsRunning() int {
|
||||
n := localBgJobsRunning()
|
||||
if facadeChat() {
|
||||
var out struct {
|
||||
Running int `json:"running"`
|
||||
}
|
||||
if err := facadePerfJSON("/internal/facade/v1/bgjobs", map[string]any{}, &out); err == nil {
|
||||
n += out.Running
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// localBgJobsRunning : les jobs lancés par ce processus.
|
||||
func localBgJobsRunning() int {
|
||||
jobsMu.Lock()
|
||||
defer jobsMu.Unlock()
|
||||
n := 0
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Lot 43 : les jobs d'arrière-plan des tours de la façade comptent pour
|
||||
// l'optimiseur du Go ; façade en panne, seuls les siens.
|
||||
func TestFacadeJobsArrierePlan(t *testing.T) {
|
||||
testHome(t)
|
||||
jobsMu.Lock()
|
||||
saved := jobs
|
||||
jobs = map[string]*bgJob{"job1": {ID: "job1"}}
|
||||
jobsMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
jobsMu.Lock()
|
||||
jobs = saved
|
||||
jobsMu.Unlock()
|
||||
})
|
||||
f := demarrerFacade(t)
|
||||
f.internalStatus = 200
|
||||
f.internalBody = `{"running":2}`
|
||||
if n := bgJobsRunning(); n != 3 {
|
||||
t.Fatalf("jobs : %d, attendu 3 (1 ici, 2 à la façade)", n)
|
||||
}
|
||||
vu := f.recu()
|
||||
if len(vu) != 1 || !strings.HasPrefix(vu[0], "POST /internal/facade/v1/bgjobs jeton ") {
|
||||
t.Fatalf("appel : %v", vu)
|
||||
}
|
||||
f.internalStatus = 500
|
||||
if n := bgJobsRunning(); n != 1 {
|
||||
t.Fatalf("façade en panne : %d", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//go:build linux
|
||||
|
||||
package loki
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// parity_jobs_test.go — journal de référence des jobs d'arrière-plan du mode
|
||||
// Code (lot 43 de la migration Rust) : bash_bg et bash_tail, de la commande
|
||||
// refusée au job tué. Pid et durées sont masqués ; l'état relu juste après
|
||||
// un arrêt aussi (le Go relit sans attendre la fin). Rangé dans
|
||||
// rust/loki-rs/tests/parity_jobs/jobs.json ; tests/jobs.rs le rejoue contre
|
||||
// le port.
|
||||
//
|
||||
// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteJobs
|
||||
|
||||
const parityJobsFile = "../../rust/loki-rs/tests/parity_jobs/jobs.json"
|
||||
|
||||
type parityJobStep struct {
|
||||
Name string `json:"name"`
|
||||
Tool string `json:"tool"`
|
||||
Args map[string]any `json:"args"`
|
||||
Wait string `json:"wait,omitempty"` // job dont attendre la fin avant l'appel
|
||||
After string `json:"after,omitempty"`
|
||||
Result string `json:"result"`
|
||||
Logs []string `json:"logs"` // journaux présents dans .loki/jobs
|
||||
}
|
||||
|
||||
var (
|
||||
parityJobsPid = regexp.MustCompile(`\(pid [0-9]+\)`)
|
||||
parityJobsSince = regexp.MustCompile(`depuis [0-9hms.]+`)
|
||||
parityJobsState = regexp.MustCompile(`— (en cours|terminé \(exit -1\)) —`)
|
||||
)
|
||||
|
||||
func TestPariteJobs(t *testing.T) {
|
||||
root := testHome(t)
|
||||
if r, err := filepath.EvalSymlinks(root); err == nil {
|
||||
root = r
|
||||
}
|
||||
ws := filepath.Join(root, "ws")
|
||||
if err := os.MkdirAll(ws, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setTaskWorkspace(ws)
|
||||
t.Cleanup(func() { setTaskWorkspace("") })
|
||||
jobsMu.Lock()
|
||||
jobs, jobSeq = map[string]*bgJob{}, 0
|
||||
jobsMu.Unlock()
|
||||
t.Cleanup(func() {
|
||||
for _, j := range jobs {
|
||||
if j.Cmd.Process != nil {
|
||||
_ = j.Cmd.Process.Kill()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wait := func(id string) {
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
jobsMu.Lock()
|
||||
j := jobs[id]
|
||||
jobsMu.Unlock()
|
||||
if j != nil {
|
||||
j.mu.Lock()
|
||||
done := j.done
|
||||
j.mu.Unlock()
|
||||
if done {
|
||||
return
|
||||
}
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("job %s : pas fini", id)
|
||||
}
|
||||
var steps []parityJobStep
|
||||
run := func(st parityJobStep) {
|
||||
if st.Wait != "" {
|
||||
wait(st.Wait)
|
||||
}
|
||||
args := map[string]any{}
|
||||
b, _ := json.Marshal(st.Args)
|
||||
_ = json.Unmarshal(b, &args)
|
||||
res := execTool(t.Context(), st.Tool, args, toolCallLabel(st.Tool, args), Caps{Agent: true, Code: true})
|
||||
r := parityJobsPid.ReplaceAllString(res.Result, "(pid {{PID}})")
|
||||
r = parityJobsSince.ReplaceAllString(r, "depuis {{D}}")
|
||||
if st.After == "kill" {
|
||||
r = parityJobsState.ReplaceAllString(r, "— {{ETAT}} —")
|
||||
}
|
||||
st.Result = r
|
||||
st.Logs = []string{}
|
||||
entries, _ := os.ReadDir(filepath.Join(ws, ".loki", "jobs"))
|
||||
for _, e := range entries {
|
||||
st.Logs = append(st.Logs, e.Name())
|
||||
}
|
||||
if st.Args == nil {
|
||||
st.Args = map[string]any{}
|
||||
}
|
||||
steps = append(steps, st)
|
||||
}
|
||||
a := func(kv ...any) map[string]any {
|
||||
m := map[string]any{}
|
||||
for i := 0; i+1 < len(kv); i += 2 {
|
||||
m[kv[i].(string)] = kv[i+1]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
run(parityJobStep{Name: "commande vide", Tool: "bash_bg", Args: a("command", " ")})
|
||||
run(parityJobStep{Name: "commande refusée", Tool: "bash_bg", Args: a("command", "rm -rf /")})
|
||||
run(parityJobStep{Name: "lancement", Tool: "bash_bg", Args: a("command", "echo un; echo deux >&2; printf 'trois\\n\\n'")})
|
||||
run(parityJobStep{Name: "sortie", Wait: "job1", Tool: "bash_tail", Args: a("id", "job1")})
|
||||
run(parityJobStep{Name: "deux lignes", Tool: "bash_tail", Args: a("id", "job1", "lines", 2.0)})
|
||||
run(parityJobStep{Name: "lignes nulles", Tool: "bash_tail", Args: a("id", "job1", "lines", 0.0)})
|
||||
run(parityJobStep{Name: "lignes en texte", Tool: "bash_tail", Args: a("id", "job1", "lines", "1")})
|
||||
run(parityJobStep{Name: "lignes tronquées", Tool: "bash_tail", Args: a("id", "job1", "lines", 1.9)})
|
||||
run(parityJobStep{Name: "arrêt d'un job fini", Tool: "bash_tail", Args: a("id", "job1", "kill", true)})
|
||||
run(parityJobStep{Name: "job inconnu", Tool: "bash_tail", Args: a("id", "job9")})
|
||||
run(parityJobStep{Name: "sans id", Tool: "bash_tail", Args: a()})
|
||||
run(parityJobStep{Name: "sortie en erreur", Tool: "bash_bg", Args: a("command", "exit 3")})
|
||||
run(parityJobStep{Name: "rien d'écrit", Wait: "job2", Tool: "bash_tail", Args: a("id", "job2")})
|
||||
run(parityJobStep{Name: "job long", Tool: "bash_bg", Args: a("command", "sleep 30")})
|
||||
run(parityJobStep{Name: "en cours", Tool: "bash_tail", Args: a("id", "job3")})
|
||||
run(parityJobStep{Name: "arrêt", Tool: "bash_tail", Args: a("id", "job3", "kill", true), After: "kill"})
|
||||
run(parityJobStep{Name: "après l'arrêt", Wait: "job3", Tool: "bash_tail", Args: a("id", "job3")})
|
||||
run(parityJobStep{Name: "beaucoup de lignes", Tool: "bash_bg", Args: a("command", "for i in $(seq 1 300); do echo ligne $i; done")})
|
||||
run(parityJobStep{Name: "quarante par défaut", Wait: "job4", Tool: "bash_tail", Args: a("id", "job4")})
|
||||
run(parityJobStep{Name: "deux cents au plus", Tool: "bash_tail", Args: a("id", "job4", "lines", 500.0)})
|
||||
run(parityJobStep{Name: "sortie démesurée", Tool: "bash_bg", Args: a("command", "for i in $(seq 1 9000); do printf 'é'; done; echo")})
|
||||
run(parityJobStep{Name: "fin gardée", Wait: "job5", Tool: "bash_tail", Args: a("id", "job5")})
|
||||
run(parityJobStep{Name: "octets invalides", Tool: "bash_bg", Args: a("command", "printf 'a\\377b\\n'")})
|
||||
run(parityJobStep{Name: "octets relus", Wait: "job6", Tool: "bash_tail", Args: a("id", "job6")})
|
||||
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(map[string]any{"steps": steps}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := buf.Bytes()
|
||||
if os.Getenv("LOKI_PARITY_UPDATE") == "1" {
|
||||
if err := os.MkdirAll(filepath.Dir(parityJobsFile), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(parityJobsFile, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
want, err := os.ReadFile(parityJobsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("les jobs d'arrière-plan ne produisent plus le journal rangé dans %s.\n"+
|
||||
"LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityJobsFile)
|
||||
}
|
||||
}
|
||||
@@ -1323,6 +1323,7 @@ impl ChatShared {
|
||||
tasks: None,
|
||||
memory: None,
|
||||
web: Default::default(),
|
||||
jobs: crate::tools::jobs::global(),
|
||||
go_version: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +349,8 @@ pub struct ChatShared {
|
||||
pub memory: Option<Arc<crate::memory::Memory>>,
|
||||
/// Les outils web (lot 42) : cache des pages, Crawl4AI, DuckDuckGo.
|
||||
pub web: Arc<crate::webtools::Web>,
|
||||
/// Les jobs d'arrière-plan du mode Code (lot 43).
|
||||
pub jobs: Arc<crate::tools::jobs::BgJobs>,
|
||||
/// La version du Go, annoncée aux serveurs MCP à une reconnexion ; lue
|
||||
/// une fois.
|
||||
pub go_version: tokio::sync::OnceCell<String>,
|
||||
@@ -707,6 +709,35 @@ impl TurnHost for GoTurnHost {
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
// Jobs d'arrière-plan du mode Code (lot 43), dans le dossier de la
|
||||
// discussion ; contexte indisponible : le Go s'en charge.
|
||||
if name == "bash_tail" {
|
||||
return ToolOutcome {
|
||||
result: self.shared.jobs.bash_tail(args).await,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
if name == "bash_bg" {
|
||||
let st = crate::tools::GoToolState {
|
||||
store: self.shared.store.clone(),
|
||||
caps: self.caps.clone(),
|
||||
};
|
||||
match crate::tools::ToolState::context(&st).await {
|
||||
Ok(ctx) => {
|
||||
let busy = crate::backends::tune_busy(&self.shared.store).await;
|
||||
let result = self
|
||||
.shared
|
||||
.jobs
|
||||
.bash_bg(&ctx.cwd, &self.compact.main_conv, args, busy)
|
||||
.await;
|
||||
return ToolOutcome {
|
||||
result,
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
Err(e) => eprintln!("[outils] contexte indisponible, bash_bg par le Go : {e}"),
|
||||
}
|
||||
}
|
||||
// Outils task_*, quand la façade tient les tâches (lot 37).
|
||||
if crate::tasks::tools::is_tool(name)
|
||||
&& let Some(tasks) = &self.shared.tasks
|
||||
|
||||
@@ -65,6 +65,8 @@ pub struct GoSessionHooks(pub Store);
|
||||
|
||||
impl SessionHooks for GoSessionHooks {
|
||||
async fn dropped(&self, id: &str) {
|
||||
// Ses jobs d'arrière-plan s'arrêtent avec elle (`stopConvJobs`).
|
||||
crate::tools::jobs::global().stop_conv(id);
|
||||
let body = serde_json::json!({ "id": id });
|
||||
if let Err(e) = self
|
||||
.0
|
||||
|
||||
@@ -71,6 +71,7 @@ pub fn is_internal(path: &str) -> bool {
|
||||
| "/internal/facade/v1/chat/prewarm/cancel"
|
||||
| "/internal/facade/v1/perf/record"
|
||||
| "/internal/facade/v1/perf/snapshot"
|
||||
| "/internal/facade/v1/bgjobs"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,6 +137,12 @@ pub async fn serve_internal(chat: &Arc<ChatService>, req: Request<Incoming>) ->
|
||||
},
|
||||
// L'anneau, de la plus ancienne à la plus récente (poids de
|
||||
// l'optimiseur).
|
||||
// Jobs d'arrière-plan encore en vie (lot 43) : l'optimiseur du Go
|
||||
// refuse de mesurer à côté.
|
||||
"/internal/facade/v1/bgjobs" => send_json(
|
||||
StatusCode::OK,
|
||||
&json!({ "running": chat.shared.jobs.running() }),
|
||||
),
|
||||
"/internal/facade/v1/perf/snapshot" => send_json(
|
||||
StatusCode::OK,
|
||||
&json!({ "recs": chat.shared.perf.snapshot() }),
|
||||
|
||||
@@ -176,6 +176,55 @@ fn mkdir_all(dir: &str) -> io::Result<()> {
|
||||
b.create(dir)
|
||||
}
|
||||
|
||||
/// `os.MkdirAll(p, 0o755)` du Go, erreur comprise : elle nomme l'élément du
|
||||
/// chemin qui a échoué (un parent qui est un fichier, par exemple).
|
||||
pub fn go_mkdir_all(p: &Path) -> Result<(), String> {
|
||||
let err = |e: &io::Error| os_err("mkdir", &p.to_string_lossy(), e);
|
||||
match std::fs::metadata(p) {
|
||||
Ok(m) if m.is_dir() => return Ok(()),
|
||||
Ok(_) => return Err(err(&io::Error::from_raw_os_error(libc_enotdir()))),
|
||||
Err(_) => {}
|
||||
}
|
||||
let s = p.to_string_lossy();
|
||||
let b = s.as_bytes();
|
||||
let mut i = b.len();
|
||||
while i > 0 && b[i - 1] == b'/' {
|
||||
i -= 1;
|
||||
}
|
||||
let mut j = i;
|
||||
while j > 0 && b[j - 1] != b'/' {
|
||||
j -= 1;
|
||||
}
|
||||
if j > 1 {
|
||||
go_mkdir_all(Path::new(&s[..j - 1]))?;
|
||||
}
|
||||
let mut d = std::fs::DirBuilder::new();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::DirBuilderExt;
|
||||
d.mode(0o755);
|
||||
}
|
||||
if let Err(e) = d.create(p) {
|
||||
if std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir()) {
|
||||
return Ok(());
|
||||
}
|
||||
return Err(err(&e));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// ENOTDIR de la plateforme.
|
||||
fn libc_enotdir() -> i32 {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
libc::ENOTDIR
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
267 // ERROR_DIRECTORY
|
||||
}
|
||||
}
|
||||
|
||||
/// Équivalent de `os.CreateTemp(dir, ".probe-*")` suivi de sa suppression.
|
||||
fn probe_writable(dir: &str) -> bool {
|
||||
for _ in 0..16 {
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Commandes shell d'arrière-plan du mode Code (lot 43) — port de
|
||||
//! `code_jobs.go` : `bash_bg` lance un processus détaché (serveur de dev,
|
||||
//! watcher, build long) et rend la main ; `bash_tail` relit sa sortie et son
|
||||
//! état, et l'arrête sur demande.
|
||||
//!
|
||||
//! La sortie va dans `.loki/jobs/<id>.log` du dossier de la discussion : elle
|
||||
//! survit au tour, et le panneau Fichiers la montre. Les jobs d'une
|
||||
//! discussion supprimée sont arrêtés ; l'optimiseur refuse de mesurer tant
|
||||
//! qu'il en tourne (le Go demande leur nombre à la façade).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use super::policy;
|
||||
use super::shell::{TOOL_MAX_OUTPUT, go_duration, shell_cmd};
|
||||
use crate::system::go_lossy;
|
||||
|
||||
/// Un job lancé par `bash_bg`.
|
||||
struct Job {
|
||||
id: String,
|
||||
command: String,
|
||||
log_path: PathBuf,
|
||||
/// Discussion du tour qui l'a lancé.
|
||||
conv: String,
|
||||
started: Instant,
|
||||
/// `None` tant qu'il tourne, puis son code de sortie.
|
||||
exit: Mutex<Option<i32>>,
|
||||
kill: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
impl Job {
|
||||
fn running(&self) -> bool {
|
||||
self.exit
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// Les jobs d'arrière-plan de la façade.
|
||||
#[derive(Default)]
|
||||
pub struct BgJobs {
|
||||
state: Mutex<(u64, HashMap<String, Arc<Job>>)>,
|
||||
}
|
||||
|
||||
/// Les jobs de ce processus (les tours et la suppression des discussions).
|
||||
pub fn global() -> Arc<BgJobs> {
|
||||
static G: OnceLock<Arc<BgJobs>> = OnceLock::new();
|
||||
G.get_or_init(Default::default).clone()
|
||||
}
|
||||
|
||||
/// `args[k].(string)`.
|
||||
fn arg_str<'a>(args: &'a Map<String, Value>, k: &str) -> &'a str {
|
||||
args.get(k).and_then(Value::as_str).unwrap_or("")
|
||||
}
|
||||
|
||||
impl BgJobs {
|
||||
/// `toolBashBg` : `tune_busy`, le refus de l'optimiseur en cours s'il y
|
||||
/// en a un.
|
||||
pub async fn bash_bg(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
conv: &str,
|
||||
args: &Map<String, Value>,
|
||||
tune_busy: Option<String>,
|
||||
) -> String {
|
||||
let command = arg_str(args, "command");
|
||||
if command.trim().is_empty() {
|
||||
return "[erreur] commande vide".into();
|
||||
}
|
||||
if let Some(reason) = policy::dangerous_command(command) {
|
||||
return policy::refused_command_result(reason);
|
||||
}
|
||||
// Un serveur ou un build lancé pendant une optimisation fausserait
|
||||
// ses mesures.
|
||||
if let Some(e) = tune_busy {
|
||||
return format!("[erreur] {e}");
|
||||
}
|
||||
let log_dir = cwd.join(".loki").join("jobs");
|
||||
if let Err(e) = crate::paths::go_mkdir_all(&log_dir) {
|
||||
return format!("[erreur] {e}");
|
||||
}
|
||||
let id = {
|
||||
let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
st.0 += 1;
|
||||
format!("job{}", st.0)
|
||||
};
|
||||
let log_path = log_dir.join(format!("{id}.log"));
|
||||
let log = match std::fs::File::create(&log_path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"[erreur] {}",
|
||||
crate::paths::os_err("open", &log_path.to_string_lossy(), &e)
|
||||
);
|
||||
}
|
||||
};
|
||||
let err_log = match log.try_clone() {
|
||||
Ok(f) => f,
|
||||
Err(e) => return format!("[erreur] {e}"),
|
||||
};
|
||||
// Pas le contexte du tour : le job lui survit, c'est sa raison d'être.
|
||||
let mut cmd = shell_cmd(command);
|
||||
cmd.current_dir(cwd)
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(log)
|
||||
.stderr(err_log)
|
||||
.kill_on_drop(false);
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"[erreur] {}",
|
||||
crate::paths::os_err("fork/exec", "/bin/bash", &e)
|
||||
);
|
||||
}
|
||||
};
|
||||
let pid = child.id().unwrap_or(0);
|
||||
let job = Arc::new(Job {
|
||||
id: id.clone(),
|
||||
command: command.to_string(),
|
||||
log_path,
|
||||
conv: conv.to_string(),
|
||||
started: Instant::now(),
|
||||
exit: Mutex::new(None),
|
||||
kill: tokio::sync::Notify::new(),
|
||||
});
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.1
|
||||
.insert(id.clone(), job.clone());
|
||||
tokio::spawn(async move {
|
||||
let status = tokio::select! {
|
||||
s = child.wait() => s,
|
||||
_ = job.kill.notified() => {
|
||||
let _ = child.start_kill();
|
||||
child.wait().await
|
||||
}
|
||||
};
|
||||
// `ExitCode()` du Go : -1 pour un processus tué par un signal ;
|
||||
// 0 si l'attente elle-même a échoué.
|
||||
let code = match status {
|
||||
Ok(s) => s.code().unwrap_or(-1),
|
||||
Err(_) => 0,
|
||||
};
|
||||
*job.exit.lock().unwrap_or_else(|e| e.into_inner()) = Some(code);
|
||||
});
|
||||
format!(
|
||||
"[ok] job {id} lancé (pid {pid}) — sortie dans .loki/jobs/{id}.log, suis-la avec bash_tail"
|
||||
)
|
||||
}
|
||||
|
||||
/// `toolBashTail` : la fin du journal d'un job et son état ; `kill`
|
||||
/// l'arrête d'abord.
|
||||
pub async fn bash_tail(&self, args: &Map<String, Value>) -> String {
|
||||
let id = arg_str(args, "id");
|
||||
let job = self
|
||||
.state
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.1
|
||||
.get(id)
|
||||
.cloned();
|
||||
let Some(job) = job else {
|
||||
return format!("[erreur] job inconnu : {id}");
|
||||
};
|
||||
// Tué puis relu aussitôt, comme le Go : l'état peut encore dire « en
|
||||
// cours » le temps que l'attente constate la fin.
|
||||
if args.get("kill").and_then(Value::as_bool) == Some(true) && job.running() {
|
||||
job.kill.notify_one();
|
||||
}
|
||||
let mut lines = 40usize;
|
||||
if let Some(v) = args.get("lines").and_then(Value::as_f64) {
|
||||
let n = crate::webtools::go_int(v);
|
||||
if n > 0 {
|
||||
lines = n.min(200) as usize;
|
||||
}
|
||||
}
|
||||
let b = match std::fs::read(&job.log_path) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
return format!(
|
||||
"[erreur] {}",
|
||||
crate::paths::os_err("open", &job.log_path.to_string_lossy(), &e)
|
||||
);
|
||||
}
|
||||
};
|
||||
let text = go_lossy(&b);
|
||||
let all: Vec<&str> = text.trim_end_matches('\n').split('\n').collect();
|
||||
let all = &all[all.len().saturating_sub(lines)..];
|
||||
let status = match *job.exit.lock().unwrap_or_else(|e| e.into_inner()) {
|
||||
None => "en cours".to_string(),
|
||||
Some(c) => format!("terminé (exit {c})"),
|
||||
};
|
||||
let since = Duration::from_secs((job.started.elapsed().as_millis() as u64 + 500) / 1000);
|
||||
let out = format!(
|
||||
"job {} — {status} — depuis {}\ncommande : {}\n",
|
||||
job.id,
|
||||
go_duration(since),
|
||||
job.command
|
||||
);
|
||||
let mut tail = all.join("\n");
|
||||
if tail.trim().is_empty() {
|
||||
tail = "(aucune sortie pour l'instant)".into();
|
||||
}
|
||||
format!(
|
||||
"{out}\n{}",
|
||||
crate::webtools::tail_runes(&tail, TOOL_MAX_OUTPUT)
|
||||
)
|
||||
}
|
||||
|
||||
/// `bgJobsRunning` : les jobs encore en vie.
|
||||
pub fn running(&self) -> usize {
|
||||
self.state
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.1
|
||||
.values()
|
||||
.filter(|j| j.running())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// `stopConvJobs` : les jobs d'une discussion supprimée, arrêtés et
|
||||
/// oubliés.
|
||||
pub fn stop_conv(&self, conv: &str) {
|
||||
let mut st = self.state.lock().unwrap_or_else(|e| e.into_inner());
|
||||
st.1.retain(|_, j| {
|
||||
if j.conv != conv {
|
||||
return true;
|
||||
}
|
||||
if j.running() {
|
||||
j.kill.notify_one();
|
||||
}
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,76 @@
|
||||
use super::*;
|
||||
|
||||
fn args(kv: &[(&str, Value)]) -> Map<String, Value> {
|
||||
kv.iter().map(|(k, v)| (k.to_string(), v.clone())).collect()
|
||||
}
|
||||
|
||||
/// Optimisation en cours : refus du Go recopié ; discussion supprimée : ses
|
||||
/// jobs arrêtés et oubliés, ceux des autres gardés.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn refus_et_discussion_supprimee() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let jobs = BgJobs::default();
|
||||
let a = args(&[("command", Value::from("sleep 30"))]);
|
||||
assert_eq!(
|
||||
jobs.bash_bg(tmp.path(), "c", &a, Some("optimisation en cours".into()))
|
||||
.await,
|
||||
"[erreur] optimisation en cours"
|
||||
);
|
||||
assert!(!tmp.path().join(".loki").exists());
|
||||
assert!(
|
||||
jobs.bash_bg(tmp.path(), "c1", &a, None)
|
||||
.await
|
||||
.starts_with("[ok] job job1 lancé")
|
||||
);
|
||||
assert!(
|
||||
jobs.bash_bg(tmp.path(), "c2", &a, None)
|
||||
.await
|
||||
.starts_with("[ok] job job2 lancé")
|
||||
);
|
||||
assert_eq!(jobs.running(), 2);
|
||||
jobs.stop_conv("c1");
|
||||
for _ in 0..500 {
|
||||
if jobs.running() == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(jobs.running(), 1);
|
||||
assert_eq!(
|
||||
jobs.bash_tail(&args(&[("id", Value::from("job1"))])).await,
|
||||
"[erreur] job inconnu : job1"
|
||||
);
|
||||
jobs.stop_conv("c2");
|
||||
for _ in 0..500 {
|
||||
if jobs.running() == 0 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(jobs.running(), 0);
|
||||
}
|
||||
|
||||
/// Dossier des journaux impossible à créer : l'erreur du Go.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn dossier_impossible() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join(".loki"), "un fichier").unwrap();
|
||||
let jobs = BgJobs::default();
|
||||
let got = jobs
|
||||
.bash_bg(
|
||||
tmp.path(),
|
||||
"c",
|
||||
&args(&[("command", Value::from("true"))]),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
got,
|
||||
format!(
|
||||
"[erreur] mkdir {}: not a directory",
|
||||
tmp.path().join(".loki").display()
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
pub mod diff;
|
||||
pub mod files;
|
||||
pub mod git;
|
||||
pub mod jobs;
|
||||
pub mod policy;
|
||||
pub mod shell;
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ fn drain<R: tokio::io::AsyncRead + Unpin + Send + 'static>(
|
||||
|
||||
/// La commande du shell de la plateforme. Le jeton de l'API interne n'a rien
|
||||
/// à faire dans le shell de l'agent.
|
||||
fn shell_cmd(command: &str) -> tokio::process::Command {
|
||||
pub(crate) fn shell_cmd(command: &str) -> tokio::process::Command {
|
||||
#[cfg(windows)]
|
||||
let mut c = {
|
||||
let mut c = tokio::process::Command::new("cmd");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user