diff --git a/.ideai/tickets/253/carnet.md b/.ideai/tickets/253/carnet.md index 72e5d054..b3c22ce6 100644 --- a/.ideai/tickets/253/carnet.md +++ b/.ideai/tickets/253/carnet.md @@ -1,6 +1,14 @@ --- issueRef: "#253" -version: 1 -updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedAt: 1787551681631 +version: 2 +updatedBy: {"kind":"agent","agent_id":"1ff94b51-3e17-4a39-8543-7715d1ff0f80"} +updatedAt: 1787551753848 --- +# Git — décision de branche (2026-08-24) + +- Branche de travail : `fix/253-opencode-tests-parallel-flakiness` (créée et checkout, base = tip develop `f303cc1`, inclut le merge #252 `4553e5d` — lignage exact où le flakiness a été observé). +- DevBackend : corrections sur cette branche (timeouts, fixtures `sleep`, isolation des tests à timing dans `crates/infrastructure/src/session/opencode.rs`). Commits : `test(opencode): …` (ou `fix(opencode): …` si du code produit est touché) `— #253`. Sans dégrader la couverture : les scénarios restent, seule la fiabilité change. +- QA : preuve = sérialisé toujours vert (`--test-threads=1`) **ET** exécution **parallèle** verte sur ≥5 runs consécutifs de `cargo test -p infrastructure --lib session::opencode` (le mode par défaut, celui qui flake). Consigner runs et sortie réelle dans ce carnet. +- Interdits : aucun push distant ; pas de merge/rebase vers `develop` sans l'agent Git. Fusion locale décidée par Git après QA verte. +- `scripts/restart-idea-over-ssh.sh` reste non suivi — ne pas le committer. +- Contexte : symptômes initiaux documentés dans le carnet #252 (section « Signalement QA » du 2026-08-24). diff --git a/.ideai/tickets/253/issue.md b/.ideai/tickets/253/issue.md index a4e7d186..c6a13e90 100644 --- a/.ideai/tickets/253/issue.md +++ b/.ideai/tickets/253/issue.md @@ -9,9 +9,9 @@ links: [] agentRefs: [] attachments: [] createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} -updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} +updatedBy: {"kind":"agent","agent_id":"1ff94b51-3e17-4a39-8543-7715d1ff0f80"} createdAt: 1787551681631 -updatedAt: 1787551681631 -version: 1 +updatedAt: 1787551753848 +version: 2 --- Des tests anciens de `session::opencode` échouent de façon intermittente en exécution parallèle, alors qu'ils passent en série (`--test-threads=1`). Symptômes remontés pendant le ticket #252: tests différents à chaque run, concernés notamment `seeded_session_ignores_and_does_not_capture_other_session_events`, `seeded_or_captured_session_id_is_passed_as_opencode_session_arg`, etc. Objectif: supprimer la flakiness sans dégrader la couverture, obtenir une exécution verte en parallèle sur la suite ciblée, et documenter la cause racine ainsi que la stratégie retenue. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 6c1c45cd..7a947d6c 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -3419,7 +3419,7 @@ "kind": "agent", "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" }, - "updatedAt": 1787551681631 + "updatedAt": 1787551753848 } ] } \ No newline at end of file diff --git a/crates/infrastructure/src/session/opencode.rs b/crates/infrastructure/src/session/opencode.rs index 5c0cf11b..01679468 100644 --- a/crates/infrastructure/src/session/opencode.rs +++ b/crates/infrastructure/src/session/opencode.rs @@ -783,6 +783,9 @@ mod tests { use super::*; + #[cfg(unix)] + const LIVE_EVENT_TIMEOUT: Duration = Duration::from_secs(5); + fn without_progress(events: Vec) -> Vec { events .into_iter() @@ -1034,18 +1037,56 @@ mod tests { #[cfg(unix)] fn write_executable_script(path: &Path, body: impl AsRef) { - fs::write(path, body.as_ref()).unwrap(); + let tmp = path.with_extension(format!("tmp-{}", uuid::Uuid::new_v4())); + { + use std::io::Write as _; + + let mut file = fs::File::create(&tmp).unwrap(); + file.write_all(body.as_ref().as_bytes()).unwrap(); + file.sync_all().unwrap(); + } + fs::rename(&tmp, path).unwrap(); let mut permissions = fs::metadata(path).unwrap().permissions(); permissions.set_mode(0o755); fs::set_permissions(path, permissions).unwrap(); } + #[cfg(unix)] + fn shell_wait_for_file(path: &Path) -> String { + format!("while [ ! -f '{}' ]; do sleep 0.05; done", path.display()) + } + + #[cfg(unix)] + fn shell_script_command(path: &Path) -> (&'static str, Vec) { + ("sh", vec![path.to_string_lossy().into_owned()]) + } + + #[cfg(unix)] + fn spawn_send_with_tap( + session: Arc, + prompt: &'static str, + tx: std::sync::mpsc::Sender, + ) -> std::thread::JoinHandle, AgentSessionError>> { + std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + session + .send_with_tap(prompt, tx) + .await + .map(|stream| stream.collect::>()) + }) + }) + } + #[cfg(unix)] #[tokio::test] async fn send_prefers_parseable_stdout_over_nonzero_exit_status() { let tmp = TempDir::new("exit-one"); let script = tmp.path().join("opencode-fixture.sh"); - fs::write( + write_executable_script( &script, r#"#!/bin/sh printf '%s\n' '{"type":"step_start"}' @@ -1054,15 +1095,12 @@ printf '%s\n' '{"type":"step_finish"}' printf '%s\n' 'stderr générique' >&2 exit 1 "#, - ) - .unwrap(); - let mut permissions = fs::metadata(&script).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&script, permissions).unwrap(); + ); + let (command, args) = shell_script_command(&script); let session = OpenCodeSession::new( SessionId::new_random(), - script.to_string_lossy(), - Vec::new(), + command, + args, tmp.path().to_string_lossy(), None, Vec::new(), @@ -1089,25 +1127,28 @@ exit 1 } #[cfg(unix)] - #[tokio::test] - async fn send_with_tap_emits_progress_live_before_final_and_tags_idea_tool_as_mcp() { + #[test] + fn send_with_tap_emits_progress_live_before_final_and_tags_idea_tool_as_mcp() { let tmp = TempDir::new("tap-live"); let script = tmp.path().join("opencode-fixture.sh"); + let release = tmp.path().join("release"); write_executable_script( &script, r#"#!/bin/sh printf '%s\n' '{"type":"step_start","sessionID":"fresh-engine","part":{"messageID":"m1","type":"step-start"}}' -sleep 1 +{{WAIT_FOR_RELEASE}} printf '%s\n' '{"type":"tool_use","sessionID":"fresh-engine","part":{"messageID":"m1","type":"tool","tool":"idea_ask_agent"}}' printf '%s\n' '{"type":"text","sessionID":"fresh-engine","part":{"messageID":"m1","type":"text","text":"réponse live"}}' printf '%s\n' '{"type":"step_finish","sessionID":"fresh-engine","part":{"messageID":"m1","type":"step-finish"}}' -"#, +"# + .replace("{{WAIT_FOR_RELEASE}}", &shell_wait_for_file(&release)), ); + let (command, args) = shell_script_command(&script); let session = Arc::new( OpenCodeSession::new( SessionId::new_random(), - script.to_string_lossy(), - Vec::new(), + command, + args, tmp.path().to_string_lossy(), None, Vec::new(), @@ -1117,24 +1158,12 @@ printf '%s\n' '{"type":"step_finish","sessionID":"fresh-engine","part":{"message .unwrap(), ); let (tx, rx) = std::sync::mpsc::channel(); - let worker = { - let session = Arc::clone(&session); - tokio::spawn(async move { - session - .send_with_tap("prompt", tx) - .await - .map(|stream| stream.collect::>()) - }) - }; + let worker = spawn_send_with_tap(Arc::clone(&session), "prompt", tx); - let (first_live, rx) = tokio::task::spawn_blocking(move || { - let first_live = rx - .recv_timeout(Duration::from_millis(500)) - .expect("un événement de progress doit atteindre le tap avant le Final"); - (first_live, rx) - }) - .await - .unwrap(); + let first_live = rx + .recv_timeout(LIVE_EVENT_TIMEOUT) + .expect("un événement de progress doit atteindre le tap avant le Final"); + fs::write(&release, "").unwrap(); assert!( matches!( first_live, @@ -1144,7 +1173,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"fresh-engine","part":{"message "le premier événement live doit être le step_start provider: {first_live:?}" ); - let returned = worker.await.unwrap().unwrap(); + let returned = worker.join().unwrap().unwrap(); let live: Vec<_> = std::iter::once(first_live).chain(rx.into_iter()).collect(); assert!( @@ -1187,10 +1216,11 @@ printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"n printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"new","type":"step-finish"}}' "#, ); + let (command, args) = shell_script_command(&script); let session = OpenCodeSession::new( SessionId::new_random(), - script.to_string_lossy(), - Vec::new(), + command, + args, tmp.path().to_string_lossy(), Some("seeded-engine".to_owned()), Vec::new(), @@ -1230,25 +1260,28 @@ printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messag } #[cfg(unix)] - #[tokio::test] - async fn send_with_tap_streams_progress_for_seeded_session_with_single_current_message_id() { + #[test] + fn send_with_tap_streams_progress_for_seeded_session_with_single_current_message_id() { let tmp = TempDir::new("tap-seeded-single-message"); let script = tmp.path().join("opencode-fixture.sh"); + let release = tmp.path().join("release"); write_executable_script( &script, r#"#!/bin/sh printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"current","type":"step-start"}}' -sleep 1 +{{WAIT_FOR_RELEASE}} printf '%s\n' '{"type":"tool_use","sessionID":"seeded-engine","part":{"messageID":"current","type":"tool","tool":"idea_ask_agent"}}' printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"current","type":"text","text":"réponse courante"}}' printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"current","type":"step-finish"}}' -"#, +"# + .replace("{{WAIT_FOR_RELEASE}}", &shell_wait_for_file(&release)), ); + let (command, args) = shell_script_command(&script); let session = Arc::new( OpenCodeSession::new( SessionId::new_random(), - script.to_string_lossy(), - Vec::new(), + command, + args, tmp.path().to_string_lossy(), Some("seeded-engine".to_owned()), Vec::new(), @@ -1258,24 +1291,12 @@ printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messag .unwrap(), ); let (tx, rx) = std::sync::mpsc::channel(); - let worker = { - let session = Arc::clone(&session); - tokio::spawn(async move { - session - .send_with_tap("prompt", tx) - .await - .map(|stream| stream.collect::>()) - }) - }; + let worker = spawn_send_with_tap(Arc::clone(&session), "prompt", tx); - let (first_live, rx) = tokio::task::spawn_blocking(move || { - let first_live = rx - .recv_timeout(Duration::from_millis(500)) - .expect("la session existante doit streamer le progress du tour courant"); - (first_live, rx) - }) - .await - .unwrap(); + let first_live = rx + .recv_timeout(LIVE_EVENT_TIMEOUT) + .expect("la session existante doit streamer le progress du tour courant"); + fs::write(&release, "").unwrap(); assert!( matches!( first_live, @@ -1285,7 +1306,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messag "le premier événement live doit être le step_start du message courant: {first_live:?}" ); - let returned = worker.await.unwrap().unwrap(); + let returned = worker.join().unwrap().unwrap(); let live: Vec<_> = std::iter::once(first_live).chain(rx.into_iter()).collect(); assert!( @@ -1332,22 +1353,19 @@ printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messag async fn send_captures_engine_session_id_from_jsonl() { let tmp = TempDir::new("capture-session"); let script = tmp.path().join("opencode-fixture.sh"); - fs::write( + write_executable_script( &script, r#"#!/bin/sh printf '%s\n' '{"type":"step_start","sessionID":"opencode-engine-1"}' printf '%s\n' '{"type":"text","sessionID":"opencode-engine-1","part":{"text":"ok"}}' printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-1"}' "#, - ) - .unwrap(); - let mut permissions = fs::metadata(&script).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&script, permissions).unwrap(); + ); + let (command, args) = shell_script_command(&script); let session = OpenCodeSession::new( SessionId::new_random(), - script.to_string_lossy(), - Vec::new(), + command, + args, tmp.path().to_string_lossy(), None, Vec::new(), @@ -1372,7 +1390,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-1"}' let tmp = TempDir::new("resume-arg"); let script = tmp.path().join("opencode-fixture.sh"); let args_file = tmp.path().join("args.txt"); - fs::write( + write_executable_script( &script, format!( r#"#!/bin/sh @@ -1383,15 +1401,12 @@ printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}' "#, args_file.display() ), - ) - .unwrap(); - let mut permissions = fs::metadata(&script).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&script, permissions).unwrap(); + ); + let (command, args) = shell_script_command(&script); let session = OpenCodeSession::new( SessionId::new_random(), - script.to_string_lossy(), - Vec::new(), + command, + args, tmp.path().to_string_lossy(), Some("seeded-engine".to_owned()), Vec::new(), @@ -1417,7 +1432,7 @@ printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}' async fn seeded_session_ignores_and_does_not_capture_other_session_events() { let tmp = TempDir::new("ignore-other-session"); let script = tmp.path().join("opencode-fixture.sh"); - fs::write( + write_executable_script( &script, r#"#!/bin/sh printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"m1"}}' @@ -1425,15 +1440,12 @@ printf '%s\n' '{"type":"text","sessionID":"seeded-engine","part":{"messageID":"m printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messageID":"m1"}}' printf '%s\n' '{"type":"text","sessionID":"other-engine","part":{"messageID":"m2","text":"pollution"}}' "#, - ) - .unwrap(); - let mut permissions = fs::metadata(&script).unwrap().permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&script, permissions).unwrap(); + ); + let (command, args) = shell_script_command(&script); let session = OpenCodeSession::new( SessionId::new_random(), - script.to_string_lossy(), - Vec::new(), + command, + args, tmp.path().to_string_lossy(), Some("seeded-engine".to_owned()), Vec::new(),