Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 15 additions & 23 deletions crates/forge-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,24 +167,6 @@ pub fn run_loop(req: RunRequest) -> Result<RunOutcome> {
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 {
Expand Down Expand Up @@ -231,11 +213,7 @@ pub fn run_loop(req: RunRequest) -> Result<RunOutcome> {
// 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)?;
Expand Down Expand Up @@ -267,6 +245,12 @@ pub fn run_loop(req: RunRequest) -> Result<RunOutcome> {
})
}

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,
}
Expand Down Expand Up @@ -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"
));
}
}
4 changes: 4 additions & 0 deletions crates/forge-core/tests/acceptance_run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
85 changes: 78 additions & 7 deletions crates/forge-engine/src/output_parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 mut exit_signal_true = has_explicit_exit_signal_true(&text);
let has_error = detect_error(&lowercase);
let has_progress_hint = detect_progress_hint(&lowercase);

Expand All @@ -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);
}
Expand All @@ -36,12 +39,34 @@ impl OutputParser {
}

fn count_completion_indicators(text: &str, indicators: &[String]) -> u32 {
let lines = text.lines().map(|line| line.trim()).collect::<Vec<_>>();
indicators
.iter()
.filter(|item| text.contains(*item))
.filter(|item| {
let needle = item.trim();
lines.contains(&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 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:")
}
Expand All @@ -57,7 +82,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
}

Expand All @@ -76,11 +101,14 @@ fn extract_session_id(value: &Value) -> Option<String> {
}
}

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,
}
}
Expand Down Expand Up @@ -114,6 +142,41 @@ 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 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()];
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", "", &[]);
Expand Down Expand Up @@ -184,6 +247,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...", "", &[]);
Expand Down
37 changes: 29 additions & 8 deletions crates/forge-monitor/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>, String) {
Expand Down Expand Up @@ -864,7 +865,10 @@ fn parse_activity_event(value: &Value) -> Option<ParsedActivity> {
};
return Some(ParsedActivity {
kind,
text: format!("tool ({status}): {}", title.chars().take(180).collect::<String>()),
text: format!(
"tool ({status}): {}",
title.chars().take(180).collect::<String>()
),
});
}
_ => {}
Expand Down Expand Up @@ -916,21 +920,32 @@ fn summarize_tool_detail(part: &Value, tool: &str) -> Option<String> {
.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::<String>()))
Some(format!(
"{}: {}",
tool,
command.chars().take(120).collect::<String>()
))
}
"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::<String>()))
Some(format!(
"read: {}",
file_path.chars().take(120).collect::<String>()
))
}
"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::<String>()))
Some(format!(
"{}: {}",
tool,
file_path.chars().take(120).collect::<String>()
))
}
_ => None,
}
Expand Down Expand Up @@ -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]
Expand All @@ -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")));
}

Expand Down Expand Up @@ -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")));
}

Expand Down