Skip to content

Commit b6fff05

Browse files
committed
feat: agregar código de salida a la ejecución de comandos y mejorar la verificación del estado del turno
1 parent d25e846 commit b6fff05

3 files changed

Lines changed: 99 additions & 11 deletions

File tree

src/cli/chat/actions.rs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,10 @@ pub(crate) fn run_git(cwd: &Path, args: &[&str]) -> String {
3333

3434
/// Ejecuta un comando YA confirmado: salida en vivo (acotada en pantalla; el
3535
/// modelo recibe todo), timeout, cancelación por Ctrl-C y diagnóstico
36-
/// automático de fallos. Devuelve (texto para el modelo, ¿se canceló?).
37-
pub(crate) fn execute_run(cwd: &Path, cmd: &str) -> (String, bool) {
36+
/// automático de fallos. Devuelve (texto para el modelo, ¿se canceló?, código de
37+
/// salida). El código (`Some(0)` = éxito) alimenta el green-gate de forma
38+
/// determinista, sin depender de heurísticas sobre el texto de salida.
39+
pub(crate) fn execute_run(cwd: &Path, cmd: &str) -> (String, bool, Option<i32>) {
3840
let t = std::time::Instant::now();
3941
// La pestaña muestra el comando en curso (acotado para que quepa).
4042
let shown_cmd: String = cmd.chars().take(48).collect();
@@ -69,7 +71,7 @@ pub(crate) fn execute_run(cwd: &Path, cmd: &str) -> (String, bool) {
6971
diag.hint, diag.suggestions
7072
));
7173
}
72-
(text, out.cancelled)
74+
(text, out.cancelled, out.exit_code)
7375
}
7476

7577
/// Resultado de aplicar los cambios propuestos por el modelo en una ronda

src/cli/chat/mod.rs

Lines changed: 73 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -598,6 +598,12 @@ const AUTO_MAX_ROUNDS: usize = 32;
598598
/// ya emitido): antes esto mataba el turno entero — la queja nº 1 del usuario.
599599
const MAX_STREAM_RETRIES: usize = 2;
600600

601+
/// Rondas EXTRA que el green-gate fuerza cuando el modelo intenta cerrar con el
602+
/// build/tests en rojo. Acotado: si tras estos intentos sigue rojo, se cierra
603+
/// con un aviso ROJO honesto en vez de fingir verde (mejor "incompleto" claro
604+
/// que un falso "listo"). No queremos un bucle infinito contra un fallo real.
605+
const GATE_MAX_FORCED: usize = 3;
606+
601607
/// Resultado de un turno completo.
602608
enum TurnOutcome {
603609
/// Respuesta (posiblemente parcial) del asistente, para memoria.
@@ -790,6 +796,12 @@ async fn run_turn(
790796
let mut round = 0usize;
791797
let mut round_budget = MAX_TURN_ROUNDS;
792798
let mut stream_retries = 0usize;
799+
// GREEN-GATE: veredicto determinista de la ÚLTIMA verificación automática
800+
// (build/tests que inyecta dpx). `true` = quedó en rojo. Bloquea el cierre
801+
// del turno hasta que vuelva a verde. `gate_forced` acota cuántas rondas
802+
// extra forzamos para que un build irreparable no cicle eternamente.
803+
let mut verify_red = false;
804+
let mut gate_forced = 0usize;
793805
// Checkpoint del turno: captura el estado de cada archivo antes de tocarlo
794806
// (vía fs::apply/delete_file) y lo apila al terminar para que `/undo` pueda
795807
// revertir TODO lo que dpx escribió este turno. Se commitea al soltar el
@@ -1035,6 +1047,9 @@ async fn run_turn(
10351047
// tenga que pedirlo. Se omite si el modelo ya pidió el mismo build tool.
10361048
let mut auto_built = false;
10371049
let mut auto_tested = false;
1050+
// Frontera: lo que va DESPUÉS de este índice en `runs` es verificación
1051+
// que inyectó dpx (no comandos del modelo) → es lo que vigila el gate.
1052+
let runs_before_verify = runs.len();
10381053
if crate::fs::touches_build(&writes)
10391054
|| crate::fs::edits_touch_build(&edits)
10401055
|| crate::fs::touches_build(&s_writes)
@@ -1083,10 +1098,20 @@ async fn run_turn(
10831098
&& round >= round_budget
10841099
&& !extend_rounds(&mut *ask, round, auto, &mut round_budget)
10851100
{
1086-
println!(
1087-
"{}",
1088-
ui::dim("⏸ turno detenido · el plan y la memoria quedan guardados para retomar")
1089-
);
1101+
// Si encima la verificación quedó en ROJO, no lo disfraces de pausa
1102+
// tranquila: dilo claro para que el usuario sepa que el build/tests
1103+
// siguen rotos al detenerse.
1104+
if verify_red {
1105+
println!(
1106+
"{}",
1107+
ui::red("🔴 turno detenido CON EL BUILD/TESTS EN ROJO · revisa y retoma")
1108+
);
1109+
} else {
1110+
println!(
1111+
"{}",
1112+
ui::dim("⏸ turno detenido · el plan y la memoria quedan guardados para retomar")
1113+
);
1114+
}
10901115
break;
10911116
}
10921117
if wants_more {
@@ -1132,7 +1157,14 @@ async fn run_turn(
11321157
} else if auto_built {
11331158
println!("\n{}", ui::dim("dpx verifica el proyecto (compila/linter)…"));
11341159
}
1135-
for cmd in &runs {
1160+
// Si esta ronda corre verificación automática, partimos de VERDE y
1161+
// cualquier comando de verificación que falle lo pone en rojo (OR de
1162+
// todos: clippy Y tests deben pasar). Si la ronda no verifica, el
1163+
// veredicto previo se conserva (un read no "limpia" un build roto).
1164+
if auto_built || auto_tested {
1165+
verify_red = false;
1166+
}
1167+
for (idx, cmd) in runs.iter().enumerate() {
11361168
match confirm_run(&mut *ask, store, cwd, cmd, auto) {
11371169
RunDecision::Blocked(reason) => {
11381170
ctx.push_str(&format!(
@@ -1148,7 +1180,7 @@ async fn run_turn(
11481180
}
11491181
RunDecision::Run => {}
11501182
}
1151-
let (out_text, cancelled) = execute_run(cwd, cmd);
1183+
let (out_text, cancelled, exit_code) = execute_run(cwd, cmd);
11521184
ctx.push_str(&format!("\n--- salida de `{cmd}` ---\n{out_text}\n--- fin ---\n"));
11531185
if cancelled {
11541186
ui::clear_cancel();
@@ -1159,6 +1191,14 @@ async fn run_turn(
11591191
TurnOutcome::Reply(full)
11601192
};
11611193
}
1194+
// GREEN-GATE: si este comando es la verificación que inyectó dpx
1195+
// (no uno del modelo) y su exit code no fue 0, el turno queda en
1196+
// rojo. `Some(0)` es la única señal de verde; `None` (ni arrancó)
1197+
// o cualquier otro código cuenta como rojo.
1198+
let is_auto_verify = (auto_built || auto_tested) && idx >= runs_before_verify;
1199+
if is_auto_verify && exit_code != Some(0) {
1200+
verify_red = true;
1201+
}
11621202
}
11631203
// Si solo hubo tool calls nativas, sus resultados ya viajan en el
11641204
// historial como tool results: el prompt es solo la instrucción.
@@ -1176,6 +1216,32 @@ async fn run_turn(
11761216
};
11771217
continue;
11781218
}
1219+
// 🟢 GREEN-GATE: el modelo no pidió más acciones → se da por terminado.
1220+
// Pero si la última verificación automática quedó en ROJO, no cerramos en
1221+
// falso: lo empujamos a una ronda de corrección. Acotado por
1222+
// `GATE_MAX_FORCED` para no ciclar contra un fallo que no sabe resolver;
1223+
// agotados los intentos, cerramos con un aviso ROJO honesto (mejor un
1224+
// "incompleto" claro que un falso "listo" con el build roto).
1225+
if verify_red {
1226+
if gate_forced < GATE_MAX_FORCED {
1227+
gate_forced += 1;
1228+
println!(
1229+
"{}",
1230+
ui::red("🔴 build/tests en ROJO · no cierro el turno hasta verde")
1231+
);
1232+
to_send = "[GREEN-GATE: la última verificación automática (build/tests) quedó en \
1233+
ROJO, pero diste el turno por terminado. NO lo está: vuelve a los errores \
1234+
reportados arriba y arréglalos hasta que el proyecto compile y los tests \
1235+
pasen en verde. Si un error no lo causaste tú o no se puede arreglar, dilo \
1236+
EXPLÍCITAMENTE y explica por qué; no lo dejes pasar en silencio.]"
1237+
.to_string();
1238+
continue;
1239+
}
1240+
println!(
1241+
"{}",
1242+
ui::red("🔴 INCOMPLETO · el build/tests sigue en rojo tras varios intentos de arreglo")
1243+
);
1244+
}
11791245
break;
11801246
}
11811247

@@ -1435,7 +1501,7 @@ async fn run_tool_call(
14351501
ToolOutcome::Done(format!("[el usuario rechazó ejecutar `{command}`]"))
14361502
}
14371503
RunDecision::Run => {
1438-
let (out, cancelled) = execute_run(cwd, &command);
1504+
let (out, cancelled, _exit) = execute_run(cwd, &command);
14391505
if cancelled { ToolOutcome::Cancelled(out) } else { ToolOutcome::Done(out) }
14401506
}
14411507
},

src/fs/mod.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ pub struct RunResult {
204204
pub output: String,
205205
/// True si el usuario lo interrumpió (Ctrl-C): el turno debería abortarse.
206206
pub cancelled: bool,
207+
/// Código de salida REAL del proceso (`Some(0)` = éxito determinista). `None`
208+
/// si ni siquiera arrancó. Es la fuente de verdad para el green-gate: el texto
209+
/// `exit code: N` puede perderse si `cap_tail` recorta una salida muy larga.
210+
pub exit_code: Option<i32>,
207211
}
208212

209213
enum StreamLine {
@@ -280,6 +284,7 @@ pub fn run_command_streaming(
280284
return RunResult {
281285
output: format!("error al ejecutar el comando: {e}"),
282286
cancelled: false,
287+
exit_code: None,
283288
};
284289
}
285290
};
@@ -357,7 +362,7 @@ pub fn run_command_streaming(
357362
if cancelled {
358363
s.push_str("[interrumpido por el usuario con Ctrl-C]\n");
359364
}
360-
RunResult { output: cap_tail(&s, 200), cancelled }
365+
RunResult { output: cap_tail(&s, 200), cancelled, exit_code: Some(code) }
361366
}
362367

363368
/// Ejecuta un comando y devuelve su salida acotada (sin streaming ni cancelación;
@@ -1889,9 +1894,24 @@ mod tests {
18891894
);
18901895
assert!(!res.cancelled);
18911896
assert!(res.output.contains("exit code: 0"));
1897+
// Fuente de verdad del green-gate: el código de salida REAL, no el texto.
1898+
assert_eq!(res.exit_code, Some(0));
18921899
assert!(lines.iter().any(|l| l.contains("hola")));
18931900
}
18941901

1902+
#[test]
1903+
fn run_command_fallido_expone_exit_code_distinto_de_cero() {
1904+
// El green-gate distingue verde (Some(0)) de rojo por el código REAL,
1905+
// que sobrevive aunque `cap_tail` recorte la línea `exit code:` en una
1906+
// salida larga. Un comando que sale con error debe reportarse en rojo.
1907+
let dir = std::env::temp_dir();
1908+
let fail = if cfg!(windows) { "cmd /c exit 1" } else { "sh -c 'exit 1'" };
1909+
let res = run_command_streaming(&dir, fail, 30, &mut |_| {}, &|| false);
1910+
assert!(!res.cancelled);
1911+
assert_ne!(res.exit_code, Some(0), "un fallo no puede pasar por verde");
1912+
assert_eq!(res.exit_code, Some(1));
1913+
}
1914+
18951915
#[test]
18961916
fn run_command_corta_por_timeout() {
18971917
let dir = std::env::temp_dir();

0 commit comments

Comments
 (0)