merge(opencode): suite de tests fiable en exécution parallèle — #253 (QA verte)

This commit is contained in:
2026-08-24 08:19:49 +02:00
4 changed files with 110 additions and 90 deletions

View File

@ -1,6 +1,14 @@
--- ---
issueRef: "#253" issueRef: "#253"
version: 1 version: 2
updatedBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} updatedBy: {"kind":"agent","agent_id":"1ff94b51-3e17-4a39-8543-7715d1ff0f80"}
updatedAt: 1787551681631 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).

View File

@ -9,9 +9,9 @@ links: []
agentRefs: [] agentRefs: []
attachments: [] attachments: []
createdBy: {"kind":"agent","agent_id":"a6c6ea12-bfc6-4bdc-8031-324102dfa34d"} 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 createdAt: 1787551681631
updatedAt: 1787551681631 updatedAt: 1787551753848
version: 1 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. 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.

View File

@ -3419,7 +3419,7 @@
"kind": "agent", "kind": "agent",
"agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d" "agent_id": "a6c6ea12-bfc6-4bdc-8031-324102dfa34d"
}, },
"updatedAt": 1787551681631 "updatedAt": 1787551753848
} }
] ]
} }

View File

@ -783,6 +783,9 @@ mod tests {
use super::*; use super::*;
#[cfg(unix)]
const LIVE_EVENT_TIMEOUT: Duration = Duration::from_secs(5);
fn without_progress(events: Vec<ReplyEvent>) -> Vec<ReplyEvent> { fn without_progress(events: Vec<ReplyEvent>) -> Vec<ReplyEvent> {
events events
.into_iter() .into_iter()
@ -1034,18 +1037,56 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
fn write_executable_script(path: &Path, body: impl AsRef<str>) { fn write_executable_script(path: &Path, body: impl AsRef<str>) {
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(); let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_mode(0o755); permissions.set_mode(0o755);
fs::set_permissions(path, permissions).unwrap(); 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<String>) {
("sh", vec![path.to_string_lossy().into_owned()])
}
#[cfg(unix)]
fn spawn_send_with_tap(
session: Arc<OpenCodeSession>,
prompt: &'static str,
tx: std::sync::mpsc::Sender<ReplyEvent>,
) -> std::thread::JoinHandle<Result<Vec<ReplyEvent>, 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::<Vec<_>>())
})
})
}
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[tokio::test]
async fn send_prefers_parseable_stdout_over_nonzero_exit_status() { async fn send_prefers_parseable_stdout_over_nonzero_exit_status() {
let tmp = TempDir::new("exit-one"); let tmp = TempDir::new("exit-one");
let script = tmp.path().join("opencode-fixture.sh"); let script = tmp.path().join("opencode-fixture.sh");
fs::write( write_executable_script(
&script, &script,
r#"#!/bin/sh r#"#!/bin/sh
printf '%s\n' '{"type":"step_start"}' printf '%s\n' '{"type":"step_start"}'
@ -1054,15 +1095,12 @@ printf '%s\n' '{"type":"step_finish"}'
printf '%s\n' 'stderr générique' >&2 printf '%s\n' 'stderr générique' >&2
exit 1 exit 1
"#, "#,
) );
.unwrap(); let (command, args) = shell_script_command(&script);
let mut permissions = fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).unwrap();
let session = OpenCodeSession::new( let session = OpenCodeSession::new(
SessionId::new_random(), SessionId::new_random(),
script.to_string_lossy(), command,
Vec::new(), args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
None, None,
Vec::new(), Vec::new(),
@ -1089,25 +1127,28 @@ exit 1
} }
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[test]
async fn send_with_tap_emits_progress_live_before_final_and_tags_idea_tool_as_mcp() { fn send_with_tap_emits_progress_live_before_final_and_tags_idea_tool_as_mcp() {
let tmp = TempDir::new("tap-live"); let tmp = TempDir::new("tap-live");
let script = tmp.path().join("opencode-fixture.sh"); let script = tmp.path().join("opencode-fixture.sh");
let release = tmp.path().join("release");
write_executable_script( write_executable_script(
&script, &script,
r#"#!/bin/sh r#"#!/bin/sh
printf '%s\n' '{"type":"step_start","sessionID":"fresh-engine","part":{"messageID":"m1","type":"step-start"}}' 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":"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":"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"}}' 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( let session = Arc::new(
OpenCodeSession::new( OpenCodeSession::new(
SessionId::new_random(), SessionId::new_random(),
script.to_string_lossy(), command,
Vec::new(), args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
None, None,
Vec::new(), Vec::new(),
@ -1117,24 +1158,12 @@ printf '%s\n' '{"type":"step_finish","sessionID":"fresh-engine","part":{"message
.unwrap(), .unwrap(),
); );
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
let worker = { let worker = spawn_send_with_tap(Arc::clone(&session), "prompt", tx);
let session = Arc::clone(&session);
tokio::spawn(async move {
session
.send_with_tap("prompt", tx)
.await
.map(|stream| stream.collect::<Vec<_>>())
})
};
let (first_live, rx) = tokio::task::spawn_blocking(move || { let first_live = rx
let first_live = rx .recv_timeout(LIVE_EVENT_TIMEOUT)
.recv_timeout(Duration::from_millis(500)) .expect("un événement de progress doit atteindre le tap avant le Final");
.expect("un événement de progress doit atteindre le tap avant le Final"); fs::write(&release, "").unwrap();
(first_live, rx)
})
.await
.unwrap();
assert!( assert!(
matches!( matches!(
first_live, 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:?}" "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(); let live: Vec<_> = std::iter::once(first_live).chain(rx.into_iter()).collect();
assert!( 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"}}' 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( let session = OpenCodeSession::new(
SessionId::new_random(), SessionId::new_random(),
script.to_string_lossy(), command,
Vec::new(), args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
Some("seeded-engine".to_owned()), Some("seeded-engine".to_owned()),
Vec::new(), Vec::new(),
@ -1230,25 +1260,28 @@ printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messag
} }
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[test]
async fn send_with_tap_streams_progress_for_seeded_session_with_single_current_message_id() { fn send_with_tap_streams_progress_for_seeded_session_with_single_current_message_id() {
let tmp = TempDir::new("tap-seeded-single-message"); let tmp = TempDir::new("tap-seeded-single-message");
let script = tmp.path().join("opencode-fixture.sh"); let script = tmp.path().join("opencode-fixture.sh");
let release = tmp.path().join("release");
write_executable_script( write_executable_script(
&script, &script,
r#"#!/bin/sh r#"#!/bin/sh
printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"current","type":"step-start"}}' 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":"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":"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"}}' 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( let session = Arc::new(
OpenCodeSession::new( OpenCodeSession::new(
SessionId::new_random(), SessionId::new_random(),
script.to_string_lossy(), command,
Vec::new(), args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
Some("seeded-engine".to_owned()), Some("seeded-engine".to_owned()),
Vec::new(), Vec::new(),
@ -1258,24 +1291,12 @@ printf '%s\n' '{"type":"step_finish","sessionID":"seeded-engine","part":{"messag
.unwrap(), .unwrap(),
); );
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
let worker = { let worker = spawn_send_with_tap(Arc::clone(&session), "prompt", tx);
let session = Arc::clone(&session);
tokio::spawn(async move {
session
.send_with_tap("prompt", tx)
.await
.map(|stream| stream.collect::<Vec<_>>())
})
};
let (first_live, rx) = tokio::task::spawn_blocking(move || { let first_live = rx
let first_live = rx .recv_timeout(LIVE_EVENT_TIMEOUT)
.recv_timeout(Duration::from_millis(500)) .expect("la session existante doit streamer le progress du tour courant");
.expect("la session existante doit streamer le progress du tour courant"); fs::write(&release, "").unwrap();
(first_live, rx)
})
.await
.unwrap();
assert!( assert!(
matches!( matches!(
first_live, 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:?}" "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(); let live: Vec<_> = std::iter::once(first_live).chain(rx.into_iter()).collect();
assert!( 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() { async fn send_captures_engine_session_id_from_jsonl() {
let tmp = TempDir::new("capture-session"); let tmp = TempDir::new("capture-session");
let script = tmp.path().join("opencode-fixture.sh"); let script = tmp.path().join("opencode-fixture.sh");
fs::write( write_executable_script(
&script, &script,
r#"#!/bin/sh r#"#!/bin/sh
printf '%s\n' '{"type":"step_start","sessionID":"opencode-engine-1"}' 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":"text","sessionID":"opencode-engine-1","part":{"text":"ok"}}'
printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-1"}' printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-1"}'
"#, "#,
) );
.unwrap(); let (command, args) = shell_script_command(&script);
let mut permissions = fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).unwrap();
let session = OpenCodeSession::new( let session = OpenCodeSession::new(
SessionId::new_random(), SessionId::new_random(),
script.to_string_lossy(), command,
Vec::new(), args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
None, None,
Vec::new(), Vec::new(),
@ -1372,7 +1390,7 @@ printf '%s\n' '{"type":"step_finish","sessionID":"opencode-engine-1"}'
let tmp = TempDir::new("resume-arg"); let tmp = TempDir::new("resume-arg");
let script = tmp.path().join("opencode-fixture.sh"); let script = tmp.path().join("opencode-fixture.sh");
let args_file = tmp.path().join("args.txt"); let args_file = tmp.path().join("args.txt");
fs::write( write_executable_script(
&script, &script,
format!( format!(
r#"#!/bin/sh r#"#!/bin/sh
@ -1383,15 +1401,12 @@ printf '%s\n' '{{"type":"step_finish","sessionID":"seeded-engine"}}'
"#, "#,
args_file.display() args_file.display()
), ),
) );
.unwrap(); let (command, args) = shell_script_command(&script);
let mut permissions = fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).unwrap();
let session = OpenCodeSession::new( let session = OpenCodeSession::new(
SessionId::new_random(), SessionId::new_random(),
script.to_string_lossy(), command,
Vec::new(), args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
Some("seeded-engine".to_owned()), Some("seeded-engine".to_owned()),
Vec::new(), 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() { async fn seeded_session_ignores_and_does_not_capture_other_session_events() {
let tmp = TempDir::new("ignore-other-session"); let tmp = TempDir::new("ignore-other-session");
let script = tmp.path().join("opencode-fixture.sh"); let script = tmp.path().join("opencode-fixture.sh");
fs::write( write_executable_script(
&script, &script,
r#"#!/bin/sh r#"#!/bin/sh
printf '%s\n' '{"type":"step_start","sessionID":"seeded-engine","part":{"messageID":"m1"}}' 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":"step_finish","sessionID":"seeded-engine","part":{"messageID":"m1"}}'
printf '%s\n' '{"type":"text","sessionID":"other-engine","part":{"messageID":"m2","text":"pollution"}}' printf '%s\n' '{"type":"text","sessionID":"other-engine","part":{"messageID":"m2","text":"pollution"}}'
"#, "#,
) );
.unwrap(); let (command, args) = shell_script_command(&script);
let mut permissions = fs::metadata(&script).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&script, permissions).unwrap();
let session = OpenCodeSession::new( let session = OpenCodeSession::new(
SessionId::new_random(), SessionId::new_random(),
script.to_string_lossy(), command,
Vec::new(), args,
tmp.path().to_string_lossy(), tmp.path().to_string_lossy(),
Some("seeded-engine".to_owned()), Some("seeded-engine".to_owned()),
Vec::new(), Vec::new(),