From 602be464e8ca9640e4eebb573a9a0ec7df1aa458 Mon Sep 17 00:00:00 2001 From: puppe1990 Date: Thu, 26 Feb 2026 08:52:32 -0300 Subject: [PATCH 1/4] Fix forge run completion status parsing --- crates/forge-core/src/lib.rs | 38 ++++++--------- crates/forge-core/tests/acceptance_run.rs | 4 ++ crates/forge-engine/src/output_parser.rs | 57 ++++++++++++++++++++--- 3 files changed, 69 insertions(+), 30 deletions(-) diff --git a/crates/forge-core/src/lib.rs b/crates/forge-core/src/lib.rs index 392906f..bc53a2b 100644 --- a/crates/forge-core/src/lib.rs +++ b/crates/forge-core/src/lib.rs @@ -167,24 +167,6 @@ pub fn run_loop(req: RunRequest) -> Result { let has_progress = analysis.has_progress_hint || (result.exit_ok && (!result.stdout.trim().is_empty())); - // Early completion check before mutating circuit state - let completed_condition_early = analysis.exit_signal_true - && (analysis.completion_indicators > 0 - || result - .stdout - .to_ascii_lowercase() - .contains("status: complete") - || result.stdout.to_ascii_lowercase().contains("task_complete")); - if completed_condition_early { - finalize_run_status(&mut status, "completed"); - write_json(&runtime_dir.join("status.json"), &status)?; - return Ok(RunOutcome { - reason: ExitReason::Completed, - loops_executed: loop_count, - status, - }); - } - let circuit_action = if has_progress { circuit.record_progress() } else { @@ -231,11 +213,7 @@ pub fn run_loop(req: RunRequest) -> Result { // or when the engine outputs a clear completion marker like "STATUS: COMPLETE". let completed_condition = analysis.exit_signal_true && (analysis.completion_indicators > 0 - || result - .stdout - .to_ascii_lowercase() - .contains("status: complete") - || result.stdout.to_ascii_lowercase().contains("task_complete")); + || has_explicit_completion_marker(&result.stdout)); if completed_condition { finalize_run_status(&mut status, "completed"); write_json(&runtime_dir.join("status.json"), &status)?; @@ -267,6 +245,12 @@ pub fn run_loop(req: RunRequest) -> Result { }) } +fn has_explicit_completion_marker(stdout: &str) -> bool { + stdout.lines().map(|line| line.trim()).any(|line| { + line.eq_ignore_ascii_case("STATUS: COMPLETE") || line.eq_ignore_ascii_case("TASK_COMPLETE") + }) +} + struct RunnerPidGuard { path: PathBuf, } @@ -343,4 +327,12 @@ mod tests { assert_eq!(status.current_loop_started_at_epoch, 0); assert_eq!(status.last_heartbeat_at_epoch, 0); } + + #[test] + fn explicit_completion_marker_requires_standalone_line() { + assert!(has_explicit_completion_marker("STATUS: COMPLETE\n")); + assert!(!has_explicit_completion_marker( + "Do not emit `STATUS: COMPLETE` until finished" + )); + } } diff --git a/crates/forge-core/tests/acceptance_run.rs b/crates/forge-core/tests/acceptance_run.rs index 947575f..38edb2a 100644 --- a/crates/forge-core/tests/acceptance_run.rs +++ b/crates/forge-core/tests/acceptance_run.rs @@ -36,6 +36,10 @@ fn run_completes_when_dual_gate_is_satisfied() { assert_eq!(outcome.reason, ExitReason::Completed); assert_eq!(outcome.status.state, "completed"); + assert_eq!(outcome.status.total_loops_executed, 1); + assert!(outcome.status.exit_signal_seen); + assert!(outcome.status.completion_indicators >= 1); + assert_eq!(outcome.status.current_loop, 0); } #[cfg(unix)] diff --git a/crates/forge-engine/src/output_parser.rs b/crates/forge-engine/src/output_parser.rs index 39c8731..779c55f 100644 --- a/crates/forge-engine/src/output_parser.rs +++ b/crates/forge-engine/src/output_parser.rs @@ -9,7 +9,7 @@ impl OutputParser { let lowercase = text.to_ascii_lowercase(); let mut completion_count = count_completion_indicators(&text, indicators); - let exit_signal_true = lowercase.contains("exit_signal: true"); + let exit_signal_true = has_explicit_exit_signal_true(&text); let has_error = detect_error(&lowercase); let has_progress_hint = detect_progress_hint(&lowercase); @@ -36,12 +36,22 @@ impl OutputParser { } fn count_completion_indicators(text: &str, indicators: &[String]) -> u32 { + let lines = text.lines().map(|line| line.trim()).collect::>(); indicators .iter() - .filter(|item| text.contains(*item)) + .filter(|item| { + let needle = item.trim(); + lines.iter().any(|line| *line == needle) + }) .count() as u32 } +fn has_explicit_exit_signal_true(text: &str) -> bool { + text.lines() + .map(|line| line.trim()) + .any(|line| line.eq_ignore_ascii_case("EXIT_SIGNAL: true")) +} + fn detect_error(lowercase: &str) -> bool { lowercase.contains("\"error\"") || lowercase.contains("error:") } @@ -57,7 +67,7 @@ fn detect_progress_hint(lowercase: &str) -> bool { fn count_json_indicators(value: &Value, indicators: &[String]) -> u32 { indicators .iter() - .filter(|needle| json_contains_string(value, needle)) + .filter(|needle| json_contains_indicator(value, needle)) .count() as u32 } @@ -76,11 +86,15 @@ fn extract_session_id(value: &Value) -> Option { } } -fn json_contains_string(value: &Value, needle: &str) -> bool { +fn json_contains_indicator(value: &Value, needle: &str) -> bool { match value { - Value::String(s) => s.contains(needle), - Value::Array(arr) => arr.iter().any(|v| json_contains_string(v, needle)), - Value::Object(map) => map.values().any(|v| json_contains_string(v, needle)), + Value::String(s) => { + let target = needle.trim(); + s.lines().map(|line| line.trim()).any(|line| line == target) + || s.trim() == target + } + Value::Array(arr) => arr.iter().any(|v| json_contains_indicator(v, needle)), + Value::Object(map) => map.values().any(|v| json_contains_indicator(v, needle)), _ => false, } } @@ -114,6 +128,27 @@ mod tests { assert_eq!(analysis.completion_indicators, 2); } + #[test] + fn does_not_treat_prose_mentions_as_exit_signal() { + let analysis = OutputParser::parse( + "I am not emitting `EXIT_SIGNAL: true` yet.", + "", + &["STATUS: COMPLETE".to_string()], + ); + assert!(!analysis.exit_signal_true); + } + + #[test] + fn does_not_treat_prose_mentions_as_completion_indicator() { + let indicators = vec!["STATUS: COMPLETE".to_string()]; + let analysis = OutputParser::parse( + "Do not emit `STATUS: COMPLETE` until all tasks are done.", + "", + &indicators, + ); + assert_eq!(analysis.completion_indicators, 0); + } + #[test] fn detects_error_from_string() { let analysis = OutputParser::parse("Error: something failed", "", &[]); @@ -184,6 +219,14 @@ mod tests { assert_eq!(analysis.completion_indicators, 1); } + #[test] + fn does_not_count_json_prose_mentions_as_indicator() { + let indicators = vec!["STATUS: COMPLETE".to_string()]; + let json = r#"{"message":"Do not emit STATUS: COMPLETE until done"}"#; + let analysis = OutputParser::parse(json, "", &indicators); + assert_eq!(analysis.completion_indicators, 0); + } + #[test] fn no_progress_when_no_hints() { let analysis = OutputParser::parse("just thinking...", "", &[]); From 3623699c037c516fdbb8f0b2f0933f6529178532 Mon Sep 17 00:00:00 2001 From: puppe1990 Date: Thu, 26 Feb 2026 09:09:42 -0300 Subject: [PATCH 2/4] Fix clippy manual-contains warning --- crates/forge-engine/src/output_parser.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/forge-engine/src/output_parser.rs b/crates/forge-engine/src/output_parser.rs index 779c55f..8ba5ee2 100644 --- a/crates/forge-engine/src/output_parser.rs +++ b/crates/forge-engine/src/output_parser.rs @@ -41,7 +41,7 @@ fn count_completion_indicators(text: &str, indicators: &[String]) -> u32 { .iter() .filter(|item| { let needle = item.trim(); - lines.iter().any(|line| *line == needle) + lines.contains(&needle) }) .count() as u32 } @@ -90,8 +90,7 @@ fn json_contains_indicator(value: &Value, needle: &str) -> bool { match value { Value::String(s) => { let target = needle.trim(); - s.lines().map(|line| line.trim()).any(|line| line == target) - || s.trim() == target + s.lines().map(|line| line.trim()).any(|line| line == target) || s.trim() == target } Value::Array(arr) => arr.iter().any(|v| json_contains_indicator(v, needle)), Value::Object(map) => map.values().any(|v| json_contains_indicator(v, needle)), From ec73f04f678a540f7231b071e65cc79302440cb6 Mon Sep 17 00:00:00 2001 From: puppe1990 Date: Thu, 26 Feb 2026 09:11:20 -0300 Subject: [PATCH 3/4] Handle EXIT_SIGNAL in JSON engine output --- crates/forge-engine/src/output_parser.rs | 31 +++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/forge-engine/src/output_parser.rs b/crates/forge-engine/src/output_parser.rs index 8ba5ee2..ccc300c 100644 --- a/crates/forge-engine/src/output_parser.rs +++ b/crates/forge-engine/src/output_parser.rs @@ -9,7 +9,7 @@ impl OutputParser { let lowercase = text.to_ascii_lowercase(); let mut completion_count = count_completion_indicators(&text, indicators); - let exit_signal_true = has_explicit_exit_signal_true(&text); + let mut exit_signal_true = has_explicit_exit_signal_true(&text); let has_error = detect_error(&lowercase); let has_progress_hint = detect_progress_hint(&lowercase); @@ -19,6 +19,9 @@ impl OutputParser { if session_id.is_none() { session_id = extract_session_id(&value); } + if !exit_signal_true { + exit_signal_true = json_has_explicit_exit_signal_true(&value); + } if completion_count == 0 { completion_count = count_json_indicators(&value, indicators); } @@ -52,6 +55,18 @@ fn has_explicit_exit_signal_true(text: &str) -> bool { .any(|line| line.eq_ignore_ascii_case("EXIT_SIGNAL: true")) } +fn json_has_explicit_exit_signal_true(value: &Value) -> bool { + match value { + Value::String(s) => s + .lines() + .map(|line| line.trim()) + .any(|line| line.eq_ignore_ascii_case("EXIT_SIGNAL: true")), + Value::Array(arr) => arr.iter().any(json_has_explicit_exit_signal_true), + Value::Object(map) => map.values().any(json_has_explicit_exit_signal_true), + _ => false, + } +} + fn detect_error(lowercase: &str) -> bool { lowercase.contains("\"error\"") || lowercase.contains("error:") } @@ -137,6 +152,20 @@ mod tests { assert!(!analysis.exit_signal_true); } + #[test] + fn detects_exit_signal_in_json_string_field() { + let json = r#"{"message":"work done\nEXIT_SIGNAL: true"}"#; + let analysis = OutputParser::parse(json, "", &[]); + assert!(analysis.exit_signal_true); + } + + #[test] + fn does_not_treat_json_prose_mentions_as_exit_signal() { + let json = r#"{"message":"Do not emit EXIT_SIGNAL: true until finished"}"#; + let analysis = OutputParser::parse(json, "", &[]); + assert!(!analysis.exit_signal_true); + } + #[test] fn does_not_treat_prose_mentions_as_completion_indicator() { let indicators = vec!["STATUS: COMPLETE".to_string()]; From fedb8ab93c89b8e46e5e772604d2dd239d55e132 Mon Sep 17 00:00:00 2001 From: puppe1990 Date: Thu, 26 Feb 2026 09:13:05 -0300 Subject: [PATCH 4/4] Format forge-monitor for CI --- crates/forge-monitor/src/lib.rs | 37 ++++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/crates/forge-monitor/src/lib.rs b/crates/forge-monitor/src/lib.rs index d0e878b..7af6471 100644 --- a/crates/forge-monitor/src/lib.rs +++ b/crates/forge-monitor/src/lib.rs @@ -700,7 +700,8 @@ fn is_cli_help_noise(line: &str) -> bool { || normalized == "Options:" || normalized.contains("--help show help") || normalized.contains("--version show version number") - || normalized.contains("--format format: default (formatted) or json (raw JSON events)") + || normalized + .contains("--format format: default (formatted) or json (raw JSON events)") } fn split_log_timestamp(line: &str) -> (Option, String) { @@ -864,7 +865,10 @@ fn parse_activity_event(value: &Value) -> Option { }; return Some(ParsedActivity { kind, - text: format!("tool ({status}): {}", title.chars().take(180).collect::()), + text: format!( + "tool ({status}): {}", + title.chars().take(180).collect::() + ), }); } _ => {} @@ -916,21 +920,32 @@ fn summarize_tool_detail(part: &Value, tool: &str) -> Option { .get("command") .and_then(Value::as_str) .or_else(|| input.get("cmd").and_then(Value::as_str))?; - Some(format!("{}: {}", tool, command.chars().take(120).collect::())) + Some(format!( + "{}: {}", + tool, + command.chars().take(120).collect::() + )) } "read" => { let file_path = input .get("filePath") .or_else(|| input.get("path")) .and_then(Value::as_str)?; - Some(format!("read: {}", file_path.chars().take(120).collect::())) + Some(format!( + "read: {}", + file_path.chars().take(120).collect::() + )) } "write" | "edit" => { let file_path = input .get("filePath") .or_else(|| input.get("path")) .and_then(Value::as_str)?; - Some(format!("{}: {}", tool, file_path.chars().take(120).collect::())) + Some(format!( + "{}: {}", + tool, + file_path.chars().take(120).collect::() + )) } _ => None, } @@ -1297,7 +1312,9 @@ plain text line "#; let recent = extract_recent_activity_lines(raw, 5); assert!(!recent.is_empty()); - assert!(recent.iter().any(|line| line.text.contains("plain text line"))); + assert!(recent + .iter() + .any(|line| line.text.contains("plain text line"))); } #[test] @@ -1307,7 +1324,9 @@ plain text line [14:00:01] {"type":"tool_use","part":{"tool":"bash","state":{"status":"completed","title":"Run build"}}} "#; let recent = extract_recent_activity_lines(raw, 5); - assert!(recent.iter().any(|line| line.text.contains("Working on it"))); + assert!(recent + .iter() + .any(|line| line.text.contains("Working on it"))); assert!(recent.iter().any(|line| line.text.contains("Run build"))); } @@ -1344,7 +1363,9 @@ plain text line "#; let scoped = scope_live_log_to_active_run(raw, "opencode"); let recent = extract_recent_activity_lines(&scoped, 5); - assert!(!recent.iter().any(|line| line.text.contains("codex exec failed"))); + assert!(!recent + .iter() + .any(|line| line.text.contains("codex exec failed"))); assert!(recent.iter().any(|line| line.text.contains("bash: ls -la"))); }