ralph-agent-loop 0.4.0

A Rust CLI for managing AI agent loops with a structured JSON task queue
Documentation
//! Parallel-worker supervision unit tests.
//!
//! Purpose:
//! - Verify focused bookkeeping and marker helpers used by parallel-worker supervision.
//!
//! Responsibilities:
//! - Cover CI marker persistence behavior.
//! - Cover bookkeeping restore and bookkeeping-path filtering helpers.
//!
//! Scope:
//! - Unit tests for helper modules only; end-to-end worker regressions live in runtime tests.
//!
//! Usage:
//! - Compiled when supervision tests include the parallel-worker module.
//!
//! Invariants/assumptions:
//! - Tests use temporary git repositories and synthetic `.ralph` fixtures.
//! - Helper behavior must remain deterministic across repeated restore attempts.

use crate::contracts::Config;
use crate::testsupport::git as git_test;

use super::bookkeeping::{collect_bookkeeping_status_lines, restore_parallel_worker_bookkeeping};
use super::ci_marker::write_ci_failure_marker;

#[test]
fn write_ci_failure_marker_creates_expected_json_payload() {
    let temp = tempfile::TempDir::new().unwrap();

    write_ci_failure_marker(temp.path(), "RQ-1234", "CI gate failed");

    let marker_path = temp
        .path()
        .join(crate::commands::run::parallel::CI_FAILURE_MARKER_FILE);
    assert!(marker_path.exists(), "marker file should exist");

    let raw = std::fs::read_to_string(marker_path).unwrap();
    let payload: serde_json::Value = serde_json::from_str(&raw).unwrap();
    assert_eq!(payload["task_id"], "RQ-1234");
    assert_eq!(payload["error"], "CI gate failed");
    assert!(payload["timestamp"].as_str().is_some());
}

#[test]
fn write_ci_failure_marker_overwrites_existing_marker_contents() {
    let temp = tempfile::TempDir::new().unwrap();
    let marker_path = temp
        .path()
        .join(crate::commands::run::parallel::CI_FAILURE_MARKER_FILE);
    std::fs::create_dir_all(marker_path.parent().unwrap()).unwrap();
    std::fs::write(&marker_path, r#"{"task_id":"RQ-0001","error":"old"}"#).unwrap();

    write_ci_failure_marker(temp.path(), "RQ-9999", "new failure");

    let raw = std::fs::read_to_string(marker_path).unwrap();
    let payload: serde_json::Value = serde_json::from_str(&raw).unwrap();
    assert_eq!(payload["task_id"], "RQ-9999");
    assert_eq!(payload["error"], "new failure");
}

#[test]
fn write_ci_failure_marker_uses_fallback_when_primary_path_is_unusable() {
    let temp = tempfile::TempDir::new().unwrap();
    let primary_parent = temp.path().join(".ralph");
    std::fs::write(&primary_parent, "not-a-directory").unwrap();

    write_ci_failure_marker(temp.path(), "RQ-8888", "ci fallback");

    let fallback = temp
        .path()
        .join(crate::commands::run::parallel::CI_FAILURE_MARKER_FALLBACK_FILE);
    assert!(fallback.exists(), "fallback marker should exist");

    let raw = std::fs::read_to_string(fallback).unwrap();
    let payload: serde_json::Value = serde_json::from_str(&raw).unwrap();
    assert_eq!(payload["task_id"], "RQ-8888");
    assert_eq!(payload["error"], "ci fallback");
}

#[test]
fn restore_bookkeeping_uses_workspace_paths_when_coordinator_paths_are_overridden() {
    let temp = tempfile::TempDir::new().unwrap();
    let repo_root = temp.path().join("workspace");
    std::fs::create_dir_all(repo_root.join(".ralph/cache")).unwrap();
    git_test::init_repo(&repo_root).unwrap();

    let workspace_queue = repo_root.join(".ralph/queue.jsonc");
    let workspace_done = repo_root.join(".ralph/done.jsonc");
    let productivity = repo_root.join(".ralph/cache/productivity.json");
    std::fs::write(&workspace_queue, "{\"version\":1,\"tasks\":[]}").unwrap();
    std::fs::write(&workspace_done, "{\"version\":1,\"tasks\":[]}").unwrap();
    std::fs::write(&productivity, "{\"stats\":[]}").unwrap();
    git_test::git_run(
        &repo_root,
        &[
            "add",
            "-f",
            ".ralph/queue.jsonc",
            ".ralph/done.jsonc",
            ".ralph/cache/productivity.json",
        ],
    )
    .unwrap();
    git_test::commit_all(&repo_root, "init bookkeeping").unwrap();

    let coordinator_root = temp.path().join("coordinator");
    std::fs::create_dir_all(coordinator_root.join(".ralph")).unwrap();
    let coordinator_queue = coordinator_root.join(".ralph/queue.jsonc");
    let coordinator_done = coordinator_root.join(".ralph/done.jsonc");
    std::fs::write(
        &coordinator_queue,
        "{\"version\":1,\"tasks\":[{\"id\":\"RQ-1\"}]}",
    )
    .unwrap();
    std::fs::write(&coordinator_done, "{\"version\":1,\"tasks\":[]}").unwrap();

    std::fs::write(
        &workspace_queue,
        "{\"version\":1,\"tasks\":[{\"id\":\"W\"}]}",
    )
    .unwrap();
    std::fs::write(
        &workspace_done,
        "{\"version\":1,\"tasks\":[{\"id\":\"W\"}]}",
    )
    .unwrap();
    std::fs::write(&productivity, "{\"stats\":[\"dirty\"]}").unwrap();

    let resolved = crate::config::Resolved {
        config: Config::default(),
        repo_root: repo_root.clone(),
        queue_path: coordinator_queue,
        done_path: coordinator_done,
        id_prefix: "RQ".to_string(),
        id_width: 4,
        global_config_path: None,
        project_config_path: None,
    };

    restore_parallel_worker_bookkeeping(&resolved, "RQ-0001").unwrap();

    assert_eq!(
        std::fs::read_to_string(&workspace_queue).unwrap(),
        "{\"version\":1,\"tasks\":[]}"
    );
    assert_eq!(
        std::fs::read_to_string(&workspace_done).unwrap(),
        "{\"version\":1,\"tasks\":[]}"
    );
    assert_eq!(
        std::fs::read_to_string(&productivity).unwrap(),
        "{\"stats\":[]}"
    );
}

#[test]
fn collect_bookkeeping_status_lines_matches_tracked_paths() {
    let status = "\
 M .ralph/queue.jsonc
M  src/lib.rs
 R .ralph/done.jsonc -> .ralph/done-old.jsonc
?? scratch.txt
";

    let matches = collect_bookkeeping_status_lines(status);
    assert_eq!(matches.len(), 2);
    assert!(matches[0].contains(".ralph/queue.jsonc"));
    assert!(matches[1].contains(".ralph/done.jsonc"));
}

#[test]
fn collect_bookkeeping_status_lines_ignores_non_bookkeeping_changes() {
    let status = "\
M  src/lib.rs
A  docs/notes.md
?? temp.log
";

    let matches = collect_bookkeeping_status_lines(status);
    assert!(matches.is_empty());
}

#[test]
fn collect_bookkeeping_status_lines_matches_generated_cache_paths() {
    let status = "\
?? .ralph/cache/plans/RQ-0001.md
?? .ralph/cache/phase2_final/RQ-0001.md
?? .ralph/logs/parallel-debug.log
M  src/lib.rs
";

    let matches = collect_bookkeeping_status_lines(status);
    assert_eq!(matches.len(), 3);
    assert!(matches[0].contains(".ralph/cache/plans/RQ-0001.md"));
    assert!(matches[1].contains(".ralph/cache/phase2_final/RQ-0001.md"));
    assert!(matches[2].contains(".ralph/logs/parallel-debug.log"));
}

#[test]
fn restore_bookkeeping_removes_generated_worker_cache_artifacts() {
    let temp = tempfile::TempDir::new().unwrap();
    let repo_root = temp.path().join("workspace");
    std::fs::create_dir_all(repo_root.join(".ralph/cache")).unwrap();
    git_test::init_repo(&repo_root).unwrap();

    let workspace_queue = repo_root.join(".ralph/queue.jsonc");
    let workspace_done = repo_root.join(".ralph/done.jsonc");
    let productivity = repo_root.join(".ralph/cache/productivity.json");
    std::fs::write(&workspace_queue, "{\"version\":1,\"tasks\":[]}").unwrap();
    std::fs::write(&workspace_done, "{\"version\":1,\"tasks\":[]}").unwrap();
    std::fs::write(&productivity, "{\"stats\":[]}").unwrap();
    git_test::git_run(
        &repo_root,
        &[
            "add",
            "-f",
            ".ralph/queue.jsonc",
            ".ralph/done.jsonc",
            ".ralph/cache/productivity.json",
        ],
    )
    .unwrap();
    git_test::commit_all(&repo_root, "init bookkeeping").unwrap();

    let generated_plan = repo_root.join(".ralph/cache/plans/RQ-0001.md");
    let generated_phase2 = repo_root.join(".ralph/cache/phase2_final/RQ-0001.md");
    let generated_session = repo_root.join(".ralph/cache/session.jsonc");
    let generated_logs = repo_root.join(".ralph/logs/parallel.log");
    std::fs::create_dir_all(generated_plan.parent().unwrap()).unwrap();
    std::fs::create_dir_all(generated_phase2.parent().unwrap()).unwrap();
    std::fs::create_dir_all(generated_logs.parent().unwrap()).unwrap();
    std::fs::write(&generated_plan, "plan").unwrap();
    std::fs::write(&generated_phase2, "phase2").unwrap();
    std::fs::write(&generated_session, "{\"task\":\"RQ-0001\"}").unwrap();
    std::fs::write(&generated_logs, "debug").unwrap();

    let resolved = crate::config::Resolved {
        config: Config::default(),
        repo_root: repo_root.clone(),
        queue_path: workspace_queue.clone(),
        done_path: workspace_done.clone(),
        id_prefix: "RQ".to_string(),
        id_width: 4,
        global_config_path: None,
        project_config_path: None,
    };

    restore_parallel_worker_bookkeeping(&resolved, "RQ-0001").unwrap();

    assert!(!generated_plan.exists());
    assert!(!generated_phase2.exists());
    assert!(!generated_session.exists());
    assert!(!generated_logs.exists());
}

#[test]
fn restore_bookkeeping_restores_tracked_plan_cache() {
    let temp = tempfile::TempDir::new().unwrap();
    let repo_root = temp.path().join("workspace");
    std::fs::create_dir_all(repo_root.join(".ralph/cache")).unwrap();
    git_test::init_repo(&repo_root).unwrap();

    let workspace_queue = repo_root.join(".ralph/queue.jsonc");
    let workspace_done = repo_root.join(".ralph/done.jsonc");
    let productivity = repo_root.join(".ralph/cache/productivity.json");
    std::fs::write(&workspace_queue, "{\"version\":1,\"tasks\":[]}").unwrap();
    std::fs::write(&workspace_done, "{\"version\":1,\"tasks\":[]}").unwrap();
    std::fs::write(&productivity, "{\"stats\":[]}").unwrap();
    git_test::git_run(
        &repo_root,
        &[
            "add",
            "-f",
            ".ralph/queue.jsonc",
            ".ralph/done.jsonc",
            ".ralph/cache/productivity.json",
        ],
    )
    .unwrap();
    git_test::commit_all(&repo_root, "init bookkeeping").unwrap();

    let plan_path = repo_root.join(".ralph/cache/plans/RQ-0001.md");
    std::fs::create_dir_all(plan_path.parent().unwrap()).unwrap();
    std::fs::write(&plan_path, "initial plan").unwrap();
    git_test::git_run(&repo_root, &["add", "-f", ".ralph/cache/plans/RQ-0001.md"]).unwrap();
    git_test::commit_all(&repo_root, "track plan cache").unwrap();

    std::fs::write(&plan_path, "generated plan").unwrap();

    let resolved = crate::config::Resolved {
        config: Config::default(),
        repo_root: repo_root.clone(),
        queue_path: workspace_queue.clone(),
        done_path: workspace_done.clone(),
        id_prefix: "RQ".to_string(),
        id_width: 4,
        global_config_path: None,
        project_config_path: None,
    };

    restore_parallel_worker_bookkeeping(&resolved, "RQ-0001").unwrap();

    assert_eq!(std::fs::read_to_string(&plan_path).unwrap(), "initial plan");
}