ralph-agent-loop 0.4.0

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
//! Continue-session escalation tests for CI retries.

use super::*;

fn continue_session_for_ci_tests() -> crate::commands::run::supervision::ContinueSession {
    crate::commands::run::supervision::ContinueSession {
        runner: crate::contracts::Runner::Codex,
        model: crate::contracts::Model::Gpt53Codex,
        reasoning_effort: None,
        runner_cli: crate::runner::ResolvedRunnerCliOptions::default(),
        phase_type: crate::commands::run::PhaseType::Implementation,
        session_id: Some("sess-123".to_string()),
        output_handler: None,
        output_stream: crate::runner::OutputStream::Terminal,
        ci_failure_retry_count: CI_GATE_AUTO_RETRY_LIMIT,
        task_id: "RQ-0947".to_string(),
        last_ci_error_pattern: None,
        consecutive_same_error_count: 0,
    }
}

#[test]
fn run_ci_gate_with_continue_session_escalates_on_threshold_same_pattern() -> Result<()> {
    let temp = TempDir::new()?;
    let command = "python3 -c \"import sys; print('ruff failed: TOML parse error at line 44', file=sys.stderr); raise SystemExit(1)\"";

    write_repo_trust(temp.path());
    let resolved = resolved_with_ci_command(temp.path(), Some(command.to_string()), true);
    let mut session = continue_session_for_ci_tests();
    session.ci_failure_retry_count = 0;
    session.last_ci_error_pattern = Some("TOML parse error".to_string());
    session.consecutive_same_error_count = CI_FAILURE_ESCALATION_THRESHOLD - 1;

    let err = run_ci_gate_with_continue_session(
        &resolved,
        crate::contracts::GitRevertMode::Disabled,
        None,
        &mut session,
        |_output, _elapsed| -> Result<()> { panic!("on_resume should not be called") },
        None,
    )
    .expect_err("expected escalation on repeated identical CI error");

    let msg = err.to_string();
    assert!(msg.contains("MANUAL INTERVENTION REQUIRED"));
    assert!(msg.contains("same error"));
    assert!(msg.contains("TOML parse error"));
    assert_eq!(
        session.consecutive_same_error_count,
        CI_FAILURE_ESCALATION_THRESHOLD
    );
    Ok(())
}

#[test]
fn run_ci_gate_with_continue_session_escalation_honors_continue_choice() -> Result<()> {
    let temp = TempDir::new()?;
    let command = "python3 -c \"import sys; print('format-check failed', file=sys.stderr); raise SystemExit(1)\"";

    write_repo_trust(temp.path());
    let resolved = resolved_with_ci_command(temp.path(), Some(command.to_string()), true);
    let mut resolved = resolved;
    resolved.config.agent.codex_bin = Some(
        temp.path()
            .join("missing-codex")
            .to_string_lossy()
            .to_string(),
    );
    let mut session = continue_session_for_ci_tests();
    session.session_id = None;
    session.ci_failure_retry_count = 0;
    session.last_ci_error_pattern = Some("Format check failure".to_string());
    session.consecutive_same_error_count = CI_FAILURE_ESCALATION_THRESHOLD - 1;

    let prompt_handler: crate::runutil::RevertPromptHandler = Arc::new(|context| {
        assert_eq!(context.label, "CI failure escalation");
        Ok(crate::runutil::RevertDecision::Continue {
            message: "Run the formatter and fix the test failure.".to_string(),
        })
    });

    let err = run_ci_gate_with_continue_session(
        &resolved,
        crate::contracts::GitRevertMode::Ask,
        Some(&prompt_handler),
        &mut session,
        |_output, _elapsed| -> Result<()> { panic!("on_resume should not be called") },
        None,
    )
    .expect_err("expected continue path to attempt fresh invocation and fail on missing runner");

    let msg = err.to_string();
    assert!(msg.contains("runner binary not found"));
    assert!(
        !msg.contains("MANUAL INTERVENTION REQUIRED"),
        "escalation continue path should attempt resume instead of immediate manual bailout"
    );
    Ok(())
}

#[test]
fn run_ci_gate_with_continue_session_resets_counter_when_pattern_changes() -> Result<()> {
    let temp = TempDir::new()?;
    let command = "python3 -c \"import sys; print('format-check failed', file=sys.stderr); raise SystemExit(1)\"";

    write_repo_trust(temp.path());
    let resolved = resolved_with_ci_command(temp.path(), Some(command.to_string()), true);
    let mut session = continue_session_for_ci_tests();
    session.ci_failure_retry_count = CI_GATE_AUTO_RETRY_LIMIT;
    session.last_ci_error_pattern = Some("TOML parse error".to_string());
    session.consecutive_same_error_count = CI_FAILURE_ESCALATION_THRESHOLD - 1;

    let _ = run_ci_gate_with_continue_session(
        &resolved,
        crate::contracts::GitRevertMode::Disabled,
        None,
        &mut session,
        |_output, _elapsed| -> Result<()> { panic!("on_resume should not be called") },
        None,
    )
    .expect_err("expected CI failure after counter reset path");

    assert_eq!(session.consecutive_same_error_count, 1);
    assert_eq!(
        session.last_ci_error_pattern.as_deref(),
        Some("Format check failure")
    );
    Ok(())
}

#[test]
fn run_ci_gate_with_continue_session_clears_pattern_tracking_after_success() -> Result<()> {
    let temp = TempDir::new()?;
    let command = "python3 -c \"raise SystemExit(0)\"";

    write_repo_trust(temp.path());
    let resolved = resolved_with_ci_command(temp.path(), Some(command.to_string()), true);
    let mut session = continue_session_for_ci_tests();
    session.last_ci_error_pattern = Some("TOML parse error".to_string());
    session.consecutive_same_error_count = 2;

    run_ci_gate_with_continue_session(
        &resolved,
        crate::contracts::GitRevertMode::Disabled,
        None,
        &mut session,
        |_output, _elapsed| -> Result<()> { Ok(()) },
        None,
    )?;

    assert_eq!(session.last_ci_error_pattern, None);
    assert_eq!(session.consecutive_same_error_count, 0);
    Ok(())
}