cueloop 0.5.0

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
//! Continue-session policy helpers for runner execution.
//!
//! Purpose:
//! - 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 CueLoop is resuming, rerunning fresh, or lacking a reusable session id.
//!
//! Not handled here:
//! - Retry policy.
//! - Error shaping after runner failures.
//!
//!
//! Usage:
//! - Used through the crate module tree or integration test harness.
//!
//! Invariants/assumptions:
//! - Fresh continue fallbacks stay conservative and runner-specific.
//! - Unknown resume failures must still hard-fail instead of silently rerunning.

use crate::contracts::Runner;
use crate::runner;

use super::backend::{RunnerAttemptContext, 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"))
        }
        Runner::Cursor => {
            text.contains("invalid session")
                || text.contains("invalid chat")
                || text.contains("invalid agent")
                || text.contains("session not found")
                || text.contains("agent not found")
                || text.contains("unknown session")
                || text.contains("unknown agent")
                || text.contains("unknownagenterror")
                || text.contains("no session found")
                || text.contains("no agent found")
                || text.contains("no matching session")
                || (text.contains("resume") && text.contains("not found"))
        }
        _ => false,
    }
}

fn validated_session_id_for_runner<'a>(
    runner_kind: &Runner,
    session_id: Option<&'a str>,
) -> Option<&'a str> {
    session_id.filter(|id| crate::runner::is_valid_runner_session_id(runner_kind, id))
}

fn choose_continue_session_id<'a>(
    runner_kind: &Runner,
    error_session_id: Option<&'a str>,
    invocation_session_id: Option<&'a str>,
) -> Option<&'a str> {
    validated_session_id_for_runner(runner_kind, error_session_id)
        .or_else(|| validated_session_id_for_runner(runner_kind, invocation_session_id))
}

pub(super) fn continue_or_rerun(
    backend: &mut impl RunnerBackend,
    attempt: &RunnerAttemptContext<'_>,
    continue_message: &str,
    fresh_prompt: &str,
    invocation_session_id: Option<&str>,
    error_session_id: Option<&str>,
) -> Result<runner::RunnerOutput, runner::RunnerError> {
    let continue_session_id =
        choose_continue_session_id(attempt.runner_kind, error_session_id, invocation_session_id);
    if let Some(session_id) = continue_session_id {
        match backend.resume_session(attempt.resume_session_request(
            session_id,
            continue_message,
            true,
        )) {
            Ok(output) => {
                log::debug!(
                    "resume: continuing the existing runner session for phase {:?}",
                    attempt.phase_type
                );
                return Ok(output);
            }
            Err(err) if should_fallback_to_fresh_continue(attempt.runner_kind, &err) => {
                log::debug!(
                    "resume: existing runner session could not be reused; starting a fresh invocation: {err}"
                );
            }
            Err(err) => return Err(err),
        }
    } else {
        log::debug!("resume: no runner session id was available; starting a fresh invocation");
    }

    let fresh_session_id =
        validated_session_id_for_runner(attempt.runner_kind, invocation_session_id)
            .map(str::to_string);
    backend.run_prompt(attempt.run_prompt_request(fresh_prompt, fresh_session_id))
}

#[cfg(test)]
mod tests {
    use crate::contracts::Runner;
    use crate::runner::RunnerError;

    use super::{
        choose_continue_session_id, should_fallback_to_fresh_continue,
        validated_session_id_for_runner,
    };

    #[test]
    fn cursor_resume_unknown_session_falls_back_to_fresh() {
        let err = RunnerError::NonZeroExit {
            code: 1,
            stdout: "".into(),
            stderr: "session not found for resume".into(),
            session_id: None,
        };
        assert!(should_fallback_to_fresh_continue(&Runner::Cursor, &err));
    }

    #[test]
    fn cursor_sdk_unknown_agent_falls_back_to_fresh() {
        let err = RunnerError::NonZeroExit {
            code: 1,
            stdout: r#"{"type":"error","name":"UnknownAgentError","message":"agent not found"}"#
                .into(),
            stderr: "".into(),
            session_id: None,
        };
        assert!(should_fallback_to_fresh_continue(&Runner::Cursor, &err));
    }

    #[test]
    fn choose_continue_session_id_prefers_valid_error_id() {
        assert_eq!(
            choose_continue_session_id(
                &Runner::Claude,
                Some("claude-error-session"),
                Some("claude-invocation-session"),
            ),
            Some("claude-error-session")
        );
    }

    #[test]
    fn choose_continue_session_id_preserves_invocation_when_error_id_invalid() {
        assert_eq!(
            choose_continue_session_id(
                &Runner::Claude,
                Some("ambiguous error session"),
                Some("claude-invocation-session"),
            ),
            Some("claude-invocation-session")
        );
    }

    #[test]
    fn choose_continue_session_id_rejects_invalid_opencode_error_id() {
        assert_eq!(
            choose_continue_session_id(&Runner::Opencode, Some("open-789"), Some("ses_previous"),),
            Some("ses_previous")
        );
    }

    #[test]
    fn choose_continue_session_id_returns_none_when_candidates_are_ambiguous() {
        assert_eq!(
            choose_continue_session_id(&Runner::Pi, Some(" "), Some("bad id")),
            None
        );
    }

    #[test]
    fn fresh_fallback_session_id_reuses_only_valid_invocation_id() {
        assert_eq!(
            validated_session_id_for_runner(&Runner::Pi, Some("bad id")),
            None
        );
        assert_eq!(
            validated_session_id_for_runner(&Runner::Pi, Some("known-good")),
            Some("known-good")
        );
    }

    #[test]
    fn cursor_resume_unrecognized_error_does_not_fallback() {
        let err = RunnerError::NonZeroExit {
            code: 1,
            stdout: "".into(),
            stderr: "unexpected failure".into(),
            session_id: None,
        };
        assert!(!should_fallback_to_fresh_continue(&Runner::Cursor, &err));
    }
}