codewhale-tui 0.9.8

Terminal UI for open-source and open-weight coding models
Documentation
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OperationClass {
    ModelRequest,
    ModelStream,
    ToolTransport,
    ToolExecution,
    ContextCompaction,
    Verification,
    ChildRun,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Idempotency {
    ReadOnly,
    IdempotentWrite,
    NonIdempotentWrite,
    Unknown,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetryAttempt {
    pub operation: OperationClass,
    pub idempotency: Idempotency,
    pub attempt: u32,
    pub max_attempts: u32,
    pub content_observed: bool,
    pub side_effect_observed: bool,
    pub canceled: bool,
    pub reason: String,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RetryDecision {
    Retry,
    Stop { reason: String },
}

#[must_use]
pub fn decide_retry(attempt: &RetryAttempt) -> RetryDecision {
    if attempt.canceled {
        return RetryDecision::Stop {
            reason: "operation was canceled".to_string(),
        };
    }
    if attempt.attempt >= attempt.max_attempts {
        return RetryDecision::Stop {
            reason: "retry budget exhausted".to_string(),
        };
    }
    if attempt.side_effect_observed
        && matches!(
            attempt.idempotency,
            Idempotency::NonIdempotentWrite | Idempotency::Unknown
        )
    {
        return RetryDecision::Stop {
            reason: "uncertain write side effect prevents an automatic retry".to_string(),
        };
    }
    if attempt.content_observed && attempt.operation == OperationClass::ModelStream {
        return RetryDecision::Stop {
            reason: "model stream already emitted content".to_string(),
        };
    }
    RetryDecision::Retry
}

#[cfg(test)]
mod tests {
    use super::*;

    fn attempt() -> RetryAttempt {
        RetryAttempt {
            operation: OperationClass::ModelStream,
            idempotency: Idempotency::ReadOnly,
            attempt: 0,
            max_attempts: 2,
            content_observed: false,
            side_effect_observed: false,
            canceled: false,
            reason: "network".to_string(),
        }
    }

    #[test]
    fn model_stream_retries_only_before_content() {
        assert_eq!(decide_retry(&attempt()), RetryDecision::Retry);
        let mut after_content = attempt();
        after_content.content_observed = true;
        assert!(matches!(
            decide_retry(&after_content),
            RetryDecision::Stop { .. }
        ));
    }

    #[test]
    fn uncertain_write_never_retries_after_side_effect() {
        let mut write = attempt();
        write.operation = OperationClass::ToolExecution;
        write.idempotency = Idempotency::Unknown;
        write.side_effect_observed = true;
        assert!(matches!(decide_retry(&write), RetryDecision::Stop { .. }));
    }
}