ralph-agent-loop 0.3.1

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
//! Continue-session policy helpers for runner execution.
//!
//! Responsibilities:
//! - Select a resume session ID.
//! - Centralize known-invalid continue-session fallback classification.
//! - Execute continue-session or rerun flows through the backend.
//! - Narrate when Ralph is resuming, rerunning fresh, or lacking a reusable session id.
//!
//! Not handled here:
//! - Retry policy.
//! - Error shaping after runner failures.
//!
//! Invariants/assumptions:
//! - Fresh continue fallbacks stay conservative and runner-specific.
//! - Unknown resume failures must still hard-fail instead of silently rerunning.

use std::path::Path;
use std::time::Duration;

use crate::commands::run::PhaseType;
use crate::contracts::{ClaudePermissionMode, Model, ReasoningEffort, Runner};
use crate::runner;

use super::backend::RunnerBackend;

fn continue_session_error_text(err: &runner::RunnerError) -> String {
    match err {
        runner::RunnerError::NonZeroExit { stdout, stderr, .. }
        | runner::RunnerError::TerminatedBySignal { stdout, stderr, .. } => {
            format!("{} {}", stdout, stderr).to_lowercase()
        }
        _ => format!("{:#}", err).to_lowercase(),
    }
}

pub(crate) fn should_fallback_to_fresh_continue(
    runner_kind: &Runner,
    err: &runner::RunnerError,
) -> bool {
    let text = continue_session_error_text(err);

    match runner_kind {
        Runner::Pi => {
            text.contains("pi session file not found")
                || text.contains("no session found matching")
                || text.contains("read pi session dir")
        }
        Runner::Gemini => {
            text.contains("error resuming session")
                && (text.contains("invalid session identifier") || text.contains("--list-sessions"))
        }
        Runner::Claude => {
            text.contains("--resume requires a valid session id")
                || text.contains("not a valid uuid")
        }
        Runner::Opencode => {
            (text.contains("zoderror")
                && text.contains("sessionid")
                && text.contains("must start with \"ses\""))
                || (text.contains("semantic failure with zero exit status")
                    && text.contains("opencode"))
        }
        _ => false,
    }
}

fn choose_continue_session_id<'a>(
    error_session_id: Option<&'a str>,
    invocation_session_id: Option<&'a str>,
) -> Option<&'a str> {
    error_session_id
        .map(str::trim)
        .filter(|id| !id.is_empty())
        .or_else(|| {
            invocation_session_id
                .map(str::trim)
                .filter(|id| !id.is_empty())
        })
}

#[allow(clippy::too_many_arguments)]
pub(super) fn continue_or_rerun(
    backend: &mut impl RunnerBackend,
    runner_kind: &Runner,
    repo_root: &Path,
    bins: runner::RunnerBinaries<'_>,
    model: &Model,
    reasoning_effort: Option<ReasoningEffort>,
    runner_cli: runner::ResolvedRunnerCliOptions,
    continue_message: &str,
    fresh_prompt: &str,
    timeout: Option<Duration>,
    permission_mode: Option<ClaudePermissionMode>,
    output_handler: Option<runner::OutputHandler>,
    output_stream: runner::OutputStream,
    phase_type: PhaseType,
    invocation_session_id: Option<&str>,
    error_session_id: Option<&str>,
) -> Result<runner::RunnerOutput, runner::RunnerError> {
    let continue_session_id = choose_continue_session_id(error_session_id, invocation_session_id);
    if let Some(session_id) = continue_session_id {
        match backend.resume_session(
            runner_kind.clone(),
            repo_root,
            bins,
            model.clone(),
            reasoning_effort,
            runner_cli,
            session_id,
            continue_message,
            permission_mode,
            timeout,
            output_handler.clone(),
            output_stream,
            phase_type,
            None,
        ) {
            Ok(output) => {
                eprintln!(
                    "Resume: continuing the existing runner session for phase {:?}.",
                    phase_type
                );
                return Ok(output);
            }
            Err(err) if should_fallback_to_fresh_continue(runner_kind, &err) => {
                eprintln!(
                    "Resume: existing runner session could not be reused; starting a fresh invocation."
                );
                eprintln!("  {}", err);
            }
            Err(err) => return Err(err),
        }
    } else {
        eprintln!("Resume: no runner session id was available; starting a fresh invocation.");
    }

    backend.run_prompt(
        runner_kind.clone(),
        repo_root,
        bins,
        model.clone(),
        reasoning_effort,
        runner_cli,
        fresh_prompt,
        timeout,
        permission_mode,
        output_handler,
        output_stream,
        phase_type,
        invocation_session_id.map(str::to_string),
        None,
    )
}