roder-core 0.4.0

Agentic software development tools and SDKs for Roder.
Documentation
use super::*;

fn make_controller(directory: &std::path::Path) -> RuntimeGoalController {
    RuntimeGoalController::new(
        EventBus::new(64),
        Some(Arc::new(
            roder_ext_jsonl_thread_store::store::JsonlThreadStore {
                base_path: directory.to_path_buf(),
            },
        )),
    )
}

async fn create_goal(controller: &RuntimeGoalController, id: &ThreadId) -> ThreadGoal {
    controller
        .create_thread_goal(id, "Durable accounting".to_string(), Some(100))
        .await
        .unwrap()
}

async fn write_durable_goal(controller: &RuntimeGoalController, goal: &ThreadGoal) {
    tokio::fs::write(
        controller.goal_path(&goal.thread_id).unwrap(),
        serde_json::to_vec_pretty(goal).unwrap(),
    )
    .await
    .unwrap();
}

#[tokio::test]
async fn committed_usage_write_error_does_not_request_a_second_own_or_ancestor_charge() {
    for recipient in ["own", "ancestor"] {
        let directory = tempfile::tempdir().unwrap();
        let controller = make_controller(directory.path());
        let id = recipient.to_string();
        let initial = create_goal(&controller, &id).await;
        controller.begin_turn(&id, "turn", false).await.unwrap();
        let _guard = controller.mutation.lock().await;
        let mut attempts = 0;
        for _ in 0..2 {
            attempts += 1;
            let mut desired = controller.load_goal(&id).await.unwrap().unwrap();
            desired.tokens_used += 25;
            desired.time_used_seconds += 3;
            desired.updated_at += Duration::seconds(1);
            let result = controller
                .persist_usage_goal(&desired, async {
                    write_durable_goal(&controller, &desired).await;
                    Err(anyhow::anyhow!("injected post-commit error"))
                })
                .await;
            if result.is_ok() {
                break;
            }
        }
        assert_eq!(attempts, 1, "{recipient} must not request a retry");
        let cached = controller.load_goal(&id).await.unwrap().unwrap();
        assert_eq!(cached.tokens_used, 25);
        assert_eq!(cached.time_used_seconds, 3);
        assert_eq!(cached.created_at, initial.created_at);
        drop(_guard);
        match recipient {
            "own" => {
                controller.record_turn_usage(&id, "turn", 10).await.unwrap();
            }
            "ancestor" => {
                controller.record_descendant_usage(&id, 10).await.unwrap();
            }
            _ => unreachable!(),
        }
        let reloaded = make_controller(directory.path());
        assert_eq!(
            reloaded
                .get_thread_goal(&id)
                .await
                .unwrap()
                .unwrap()
                .tokens_used,
            35
        );
    }
}

#[tokio::test]
async fn precommit_failure_keeps_the_original_error_and_remains_retryable() {
    let directory = tempfile::tempdir().unwrap();
    let controller = make_controller(directory.path());
    let id = "precommit".to_string();
    let initial = create_goal(&controller, &id).await;
    let mut desired = initial.clone();
    desired.tokens_used = 25;
    let original = anyhow::Error::new(std::io::Error::other("injected precommit error"));
    let _guard = controller.mutation.lock().await;
    let error = controller
        .persist_usage_goal(&desired, async { Err(original) })
        .await
        .unwrap_err();
    assert!(error.downcast_ref::<std::io::Error>().is_some());
    assert_eq!(error.to_string(), "injected precommit error");
    assert_eq!(controller.load_goal(&id).await.unwrap(), Some(initial));
    controller
        .persist_usage_goal(&desired, controller.store_goal(desired.clone()))
        .await
        .unwrap();
    assert_eq!(controller.load_goal(&id).await.unwrap(), Some(desired));
}

#[tokio::test]
async fn postcommit_confirmation_rejects_a_different_goal_generation() {
    let directory = tempfile::tempdir().unwrap();
    let controller = make_controller(directory.path());
    let id = "different-generation".to_string();
    let mut desired = create_goal(&controller, &id).await;
    desired.tokens_used = 25;
    let mut replacement = desired.clone();
    replacement.created_at += Duration::seconds(1);
    let _guard = controller.mutation.lock().await;
    let error = controller
        .persist_usage_goal(&desired, async {
            write_durable_goal(&controller, &replacement).await;
            Err(anyhow::anyhow!("write failed"))
        })
        .await
        .unwrap_err();
    assert_eq!(error.to_string(), "write failed");
}

#[tokio::test]
async fn postcommit_confirmation_requires_the_entire_goal_to_match() {
    let directory = tempfile::tempdir().unwrap();
    let controller = make_controller(directory.path());
    let id = "different-goal-state".to_string();
    let mut desired = create_goal(&controller, &id).await;
    desired.tokens_used = 25;
    let _guard = controller.mutation.lock().await;
    for field in [
        "tokens",
        "seconds",
        "status",
        "objective",
        "budget",
        "updated_at",
    ] {
        let mut persisted = desired.clone();
        match field {
            "tokens" => persisted.tokens_used += 1,
            "seconds" => persisted.time_used_seconds += 1,
            "status" => persisted.status = ThreadGoalStatus::Paused,
            "objective" => persisted.objective = "Another objective".to_string(),
            "budget" => persisted.token_budget = Some(200),
            "updated_at" => persisted.updated_at += Duration::seconds(1),
            _ => unreachable!(),
        }
        let error = controller
            .persist_usage_goal(&desired, async {
                write_durable_goal(&controller, &persisted).await;
                Err(anyhow::anyhow!("write failed"))
            })
            .await
            .unwrap_err();
        assert_eq!(error.to_string(), "write failed", "{field}");
    }
}

#[tokio::test]
async fn unconfirmable_commit_preserves_the_original_error() {
    let directory = tempfile::tempdir().unwrap();
    let controller = make_controller(directory.path());
    let id = "unreadable-goal".to_string();
    let desired = create_goal(&controller, &id).await;
    let _guard = controller.mutation.lock().await;
    let error = controller
        .persist_usage_goal(&desired, async {
            tokio::fs::write(controller.goal_path(&id).unwrap(), b"{invalid")
                .await
                .unwrap();
            Err(anyhow::anyhow!("original write failure"))
        })
        .await
        .unwrap_err();
    assert_eq!(error.to_string(), "original write failure");
}