fix(orchestrator): la délégation simple vers UX renvoie son Final structuré (#207, QA verte)
Une demande idea_ask_agent vers UX échouait avec « finished without a structured Final response » alors qu'Architect répondait. Deux causes corrigées: - session/process: un tour CLI terminé en code non nul SANS stdout (ex. erreur 401/auth) renvoyait une sortie vide au lieu du stderr réel; l'orchestrateur concluait alors à un « no Final ». On remonte désormais le stderr effectif (drain sync + async + helper process_exit_message + test dédié). - orchestrator/service: la branche « ask waiting on background » masquait le premier Final structuré; la réponse synchrone redevient le premier Final (suppression de active_rendezvous_has_background_tasks / mark_rendezvous_task_waiting). Nettoyage runtime corollaire (#204): arrêt du live runtime stale (PTY + structuré) d'un agent recréé afin que la délégation route vers l'agent courant (LiveSessions, stop_live_agent_runtime_in_project, wiring backend via DeleteAgent::with_runtime_cleanup, lifecycle, workstate cascade).
This commit is contained in:
@ -186,6 +186,58 @@ mod tests {
|
||||
assert!(matches!(err, AgentSessionError::Start(_)), "vu: {err:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_turn_nonzero_without_stdout_surfaces_stderr() {
|
||||
use std::io::Write as _;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static C: AtomicU64 = AtomicU64::new(0);
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"idea-empty-failing-cli-{}-{}",
|
||||
std::process::id(),
|
||||
C.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
{
|
||||
let mut file = std::fs::File::create(&path).expect("write fake cli");
|
||||
file.write_all(
|
||||
b"#!/bin/sh\nprintf '%s\\n' '401 Unauthorized: Missing bearer' >&2\nexit 1\n",
|
||||
)
|
||||
.expect("write fake cli");
|
||||
file.sync_all().expect("sync fake cli");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = std::fs::metadata(&path).unwrap().permissions();
|
||||
perms.set_mode(0o755);
|
||||
std::fs::set_permissions(&path, perms).unwrap();
|
||||
}
|
||||
|
||||
let spec = super::process::SpawnLine {
|
||||
command: path.to_string_lossy().into_owned(),
|
||||
args: Vec::new(),
|
||||
cwd: "/".to_owned(),
|
||||
env: Vec::new(),
|
||||
stdin: None,
|
||||
sandbox: None,
|
||||
};
|
||||
|
||||
let err = run_turn(&spec, None, None, None)
|
||||
.await
|
||||
.expect_err("empty nonzero process must be a session error");
|
||||
match err {
|
||||
AgentSessionError::Io(message) => {
|
||||
assert!(
|
||||
message.contains("401 Unauthorized: Missing bearer"),
|
||||
"stderr must be preserved, got: {message}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Io, got {other:?}"),
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
// -- parse_event Claude (format RÉEL vérifié 2026-06-09) --------------
|
||||
|
||||
#[test]
|
||||
|
||||
@ -32,7 +32,7 @@ use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
|
||||
use domain::ports::AgentSessionError;
|
||||
@ -219,7 +219,7 @@ fn drain_sandboxed(
|
||||
killer_tx: tokio::sync::oneshot::Sender<Arc<std::sync::Mutex<std::process::Child>>>,
|
||||
line_tap: Option<Sender<String>>,
|
||||
) -> Result<Vec<String>, AgentSessionError> {
|
||||
use std::io::{BufRead, BufReader as StdBufReader, Write as _};
|
||||
use std::io::{BufRead, BufReader as StdBufReader, Read as _, Write as _};
|
||||
use std::process::{Command as StdCommand, Stdio};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
@ -266,6 +266,17 @@ fn drain_sandboxed(
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?;
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?;
|
||||
let stderr_handle = std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
StdBufReader::new(stderr)
|
||||
.read_to_end(&mut bytes)
|
||||
.map(|_| bytes)
|
||||
.map_err(|e| AgentSessionError::Io(e.to_string()))
|
||||
});
|
||||
let child = Arc::new(StdMutex::new(child));
|
||||
// Donne au côté async de quoi tuer l'enfant en cas de timeout.
|
||||
let _ = killer_tx.send(Arc::clone(&child));
|
||||
@ -285,8 +296,20 @@ fn drain_sandboxed(
|
||||
}
|
||||
|
||||
// 6. Reap (pas de zombie). Lock tenu brièvement.
|
||||
if let Ok(mut c) = child.lock() {
|
||||
let _ = c.wait();
|
||||
let status = child
|
||||
.lock()
|
||||
.map_err(|_| AgentSessionError::Io("child lock empoisonné".to_owned()))?
|
||||
.wait()
|
||||
.map_err(|e| AgentSessionError::Io(e.to_string()))?;
|
||||
let stderr_bytes = stderr_handle
|
||||
.join()
|
||||
.map_err(|_| AgentSessionError::Io("stderr thread terminé sans résultat".to_owned()))??;
|
||||
if !status.success() && collected.is_empty() {
|
||||
return Err(AgentSessionError::Io(process_exit_message(
|
||||
&command,
|
||||
status.code(),
|
||||
&stderr_bytes,
|
||||
)));
|
||||
}
|
||||
Ok(collected)
|
||||
}
|
||||
@ -333,6 +356,14 @@ async fn drain(
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| AgentSessionError::Io("stdout pipe indisponible".to_owned()))?;
|
||||
let mut stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| AgentSessionError::Io("stderr pipe indisponible".to_owned()))?;
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut bytes = Vec::new();
|
||||
stderr.read_to_end(&mut bytes).await.map(|_| bytes)
|
||||
});
|
||||
let mut lines = BufReader::new(stdout).lines();
|
||||
|
||||
let mut collected = Vec::new();
|
||||
@ -350,9 +381,21 @@ async fn drain(
|
||||
}
|
||||
|
||||
// Réifie le statut du process pour ne pas laisser de zombie ; un statut non nul
|
||||
// n'est pas en soi une erreur de session (la CLI peut signaler une erreur
|
||||
// *dans* sa sortie structurée, captée par le parser).
|
||||
let _ = child.wait().await.map_err(map_io)?;
|
||||
// n'est pas en soi une erreur de session si la CLI a imprimé une sortie
|
||||
// structurée, captée ensuite par le parser. Sans stdout, il n'y a rien à parser :
|
||||
// on remonte le stderr réel au lieu de laisser l'appelant conclure à un no-Final.
|
||||
let status = child.wait().await.map_err(map_io)?;
|
||||
let stderr_bytes = stderr_task
|
||||
.await
|
||||
.map_err(|e| AgentSessionError::Io(format!("lecture stderr: {e}")))?
|
||||
.map_err(map_io)?;
|
||||
if !status.success() && collected.is_empty() {
|
||||
return Err(AgentSessionError::Io(process_exit_message(
|
||||
&spec.command,
|
||||
status.code(),
|
||||
&stderr_bytes,
|
||||
)));
|
||||
}
|
||||
Ok(collected)
|
||||
}
|
||||
|
||||
@ -360,3 +403,14 @@ async fn drain(
|
||||
fn map_io(e: io::Error) -> AgentSessionError {
|
||||
AgentSessionError::Io(e.to_string())
|
||||
}
|
||||
|
||||
fn process_exit_message(command: &str, code: Option<i32>, stderr: &[u8]) -> String {
|
||||
let status = code.map_or_else(|| "signal".to_owned(), |code| format!("code {code}"));
|
||||
let stderr = String::from_utf8_lossy(stderr);
|
||||
let stderr = stderr.trim();
|
||||
if stderr.is_empty() {
|
||||
format!("{command} exited with {status} without structured output")
|
||||
} else {
|
||||
format!("{command} exited with {status}: {stderr}")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user