holon 0.14.1

A headless, event-driven runtime for long-lived agents
Documentation
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;

use anyhow::Result;
use async_trait::async_trait;
use holon::{
    config::{AppConfig, ControlAuthMode},
    host::RuntimeHost,
    provider::{AgentProvider, ModelBlock, ProviderTurnRequest, ProviderTurnResponse},
    system::{WorkspaceAccessMode, WorkspaceProjectionKind},
    types::{TaskRecord, TaskStatus, TrustLevel},
};
use tempfile::tempdir;
use tokio::time::{sleep, Duration, Instant};

fn test_config() -> AppConfig {
    let home_dir = tempdir().unwrap().keep();
    AppConfig {
        default_agent_id: "default".into(),
        http_addr: "127.0.0.1:0".into(),
        callback_base_url: "http://127.0.0.1:0".into(),
        home_dir: home_dir.clone(),
        data_dir: home_dir.clone(),
        socket_path: home_dir.join("run").join("holon.sock"),
        workspace_dir: tempdir().unwrap().keep(),
        context_window_messages: 8,
        context_window_briefs: 8,
        compaction_trigger_messages: 10,
        compaction_keep_recent_messages: 4,
        prompt_budget_estimated_tokens: 4096,
        compaction_trigger_estimated_tokens: 2048,
        compaction_keep_recent_estimated_tokens: 768,
        recent_episode_candidates: 12,
        max_relevant_episodes: 3,
        control_token: Some("secret".into()),
        control_auth_mode: ControlAuthMode::Auto,
        config_file_path: home_dir.join("config.json"),
        stored_config: Default::default(),
        default_model: holon::config::ModelRef::parse("anthropic/claude-sonnet-4-6").unwrap(),
        fallback_models: Vec::new(),
        runtime_max_output_tokens: 8192,
        default_tool_output_tokens: 8_000,
        max_tool_output_tokens: 64_000,
        disable_provider_fallback: false,
        tui_alternate_screen: holon::config::AltScreenMode::Auto,
        validated_model_overrides: std::collections::HashMap::new(),
        validated_unknown_model_fallback: None,
        providers: holon::config::provider_registry_for_tests(
            None,
            Some("dummy"),
            home_dir.join(".codex"),
        ),
        web_config: holon::web::WebConfig::default(),
    }
}

fn git(path: &Path, args: &[&str]) -> Result<String> {
    let output = Command::new("git").args(args).current_dir(path).output()?;
    if !output.status.success() {
        return Err(anyhow::anyhow!(
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}

fn init_git_repo(path: &Path) -> Result<()> {
    git(path, &["init"])?;
    git(path, &["config", "user.email", "holon@example.com"])?;
    git(path, &["config", "user.name", "Holon Test"])?;
    std::fs::write(path.join("README.md"), "holon\n")?;
    git(path, &["add", "README.md"])?;
    git(path, &["commit", "-m", "init"])?;
    Ok(())
}

async fn wait_until_async<F, Fut>(predicate: F) -> Result<()>
where
    F: Fn() -> Fut,
    Fut: std::future::Future<Output = Result<bool>>,
{
    for _ in 0..60 {
        if predicate().await? {
            return Ok(());
        }
        sleep(Duration::from_millis(100)).await;
    }
    Err(anyhow::anyhow!("timed out waiting for condition"))
}

async fn attach_default_workspace(host: &RuntimeHost) -> Result<()> {
    let runtime = host.default_runtime().await?;
    let workspace = host.ensure_workspace_entry(host.config().workspace_dir.clone())?;
    runtime.attach_workspace(&workspace).await?;
    runtime
        .enter_workspace(
            &workspace,
            WorkspaceProjectionKind::CanonicalRoot,
            WorkspaceAccessMode::SharedRead,
            Some(host.config().workspace_dir.clone()),
            None,
        )
        .await?;
    Ok(())
}

struct DelayedProvider;

#[async_trait]
impl AgentProvider for DelayedProvider {
    async fn complete_turn(&self, request: ProviderTurnRequest) -> Result<ProviderTurnResponse> {
        sleep(Duration::from_millis(250)).await;
        let prompt = request
            .conversation
            .iter()
            .rev()
            .find_map(|message| match message {
                holon::provider::ConversationMessage::UserText(text) => Some(text.as_str()),
                _ => None,
            })
            .unwrap_or("worktree attempt");

        Ok(ProviderTurnResponse {
            blocks: vec![ModelBlock::Text {
                text: format!("Completed candidate worktree attempt for: {}", prompt),
            }],
            stop_reason: None,
            input_tokens: 100,
            output_tokens: 50,
            cache_usage: None,
            request_diagnostics: None,
        })
    }
}

fn expected_worktree_path(workspace: &Path, task_id: &str) -> PathBuf {
    workspace.parent().unwrap_or(workspace).join(format!(
        ".holon-worktrees-{}/task-{}",
        workspace
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("repo"),
        task_id
    ))
}

async fn wait_for_worktree(
    runtime: &holon::runtime::RuntimeHandle,
    task: &TaskRecord,
) -> Result<PathBuf> {
    let task_id = task.id.clone();
    let summary = task.summary.clone().unwrap_or_default();
    let storage = runtime.storage().clone();
    let deadline = Instant::now() + Duration::from_secs(30);

    loop {
        let events = storage.read_recent_events(200)?;
        if let Some(event) = events.iter().find(|event| {
            event.kind == "worktree_created_for_task"
                && event.data["task_id"].as_str() == Some(task_id.as_str())
        }) {
            let path = event.data["worktree_path"]
                .as_str()
                .ok_or_else(|| anyhow::anyhow!("missing worktree_path for task {}", summary))?;
            return Ok(PathBuf::from(path));
        }

        if Instant::now() >= deadline {
            let recent_kinds = events
                .iter()
                .rev()
                .take(5)
                .map(|event| event.kind.as_str())
                .collect::<Vec<_>>()
                .join(", ");
            return Err(anyhow::anyhow!(
                "timed out waiting for worktree creation for task {} (recent events: {})",
                task_id,
                recent_kinds
            ));
        }
        sleep(Duration::from_millis(100)).await;
    }
}

#[tokio::test]
async fn wt204_parallel_worktree_workflow_demo_is_reviewable_end_to_end() -> Result<()> {
    let config = test_config();
    let workspace = config.workspace_dir.clone();
    std::fs::create_dir_all(&workspace)?;
    init_git_repo(&workspace)?;

    let host = RuntimeHost::new_with_provider(config, Arc::new(DelayedProvider))?;
    attach_default_workspace(&host).await?;
    let runtime = host.default_runtime().await?;

    let attempts = vec![
        (
            "strategy pattern approach".to_string(),
            "Implement the strategy pattern variant and verify it.".to_string(),
            "strategy-approach.txt".to_string(),
        ),
        (
            "middleware composition approach".to_string(),
            "Implement the middleware composition variant and verify it.".to_string(),
            "middleware-approach.txt".to_string(),
        ),
        (
            "adapter interface approach".to_string(),
            "Implement the adapter interface variant and verify it.".to_string(),
            "adapter-approach.txt".to_string(),
        ),
    ];

    let mut tasks = Vec::new();
    for (summary, prompt, _) in &attempts {
        tasks.push(
            runtime
                .schedule_child_agent_task(
                    summary.clone(),
                    prompt.clone(),
                    TrustLevel::TrustedOperator,
                    holon::types::ChildAgentWorkspaceMode::Worktree,
                )
                .await?,
        );
    }

    let mut expected_files = HashMap::new();
    let mut worktree_paths = HashMap::new();
    for (task, (_, _, filename)) in tasks.iter().zip(attempts.iter()) {
        let worktree_path = wait_for_worktree(&runtime, task).await?;
        std::fs::write(
            worktree_path.join(filename),
            format!("candidate generated for {}\n", task.id),
        )?;
        expected_files.insert(task.id.clone(), filename.clone());
        worktree_paths.insert(task.id.clone(), worktree_path);
    }

    wait_until_async(|| async {
        let records = runtime.latest_task_records().await?;
        let all_completed = tasks.iter().all(|task| {
            records
                .iter()
                .any(|record| record.id == task.id && record.status == TaskStatus::Completed)
        });
        Ok(all_completed)
    })
    .await?;

    let summary = runtime.summarize_worktree_tasks().await?;
    assert!(summary.contains("Worktree Task Summary"));
    assert!(summary.contains("Total tasks: 3"));

    for task in &tasks {
        let file = expected_files.get(&task.id).unwrap();
        let worktree_path = worktree_paths.get(&task.id).unwrap();
        let expected_worktree = expected_worktree_path(&workspace, &task.id);
        assert_eq!(&expected_worktree, worktree_path);
        assert!(
            summary.contains(task.summary.as_deref().unwrap_or_default()),
            "summary should include task summary for {}",
            task.id
        );
        assert!(
            summary.contains(file),
            "summary should include changed file {} for task {}",
            file,
            task.id
        );
        assert!(
            summary.contains(worktree_path.to_string_lossy().as_ref()),
            "summary should include worktree path for {}",
            task.id
        );
    }

    let keep_task = &tasks[0];
    let discard_tasks = [&tasks[1], &tasks[2]];

    let state = runtime.agent_state().await?;
    let active_entry = state.active_workspace_entry.as_ref().expect(
        "default runtime should remain in canonical root while discarding retained worktrees",
    );
    assert_eq!(
        active_entry.projection_kind,
        WorkspaceProjectionKind::CanonicalRoot
    );
    assert_eq!(active_entry.access_mode, WorkspaceAccessMode::SharedRead);

    for task in discard_tasks {
        let worktree_path = worktree_paths.get(&task.id).unwrap();
        let output = Command::new("git")
            .args(["worktree", "remove", "--force"])
            .arg(worktree_path)
            .current_dir(&workspace)
            .output()?;
        assert!(
            output.status.success(),
            "manual worktree removal should succeed for {}: {}",
            task.id,
            String::from_utf8_lossy(&output.stderr)
        );
        git(&workspace, &["branch", "-D", &format!("task-{}", task.id)])?;
    }

    let kept_worktree = worktree_paths.get(&keep_task.id).unwrap();
    assert!(
        kept_worktree.exists(),
        "selected worktree should remain for operator review"
    );

    for task in discard_tasks {
        let discarded = worktree_paths.get(&task.id).unwrap();
        assert!(
            !discarded.exists(),
            "discarded worktree should be removed for task {}",
            task.id
        );
        let branch_output = git(
            &workspace,
            &["branch", "--list", &format!("task-{}", task.id)],
        )?;
        assert!(
            branch_output.is_empty(),
            "discarded task branch should be removed for {}",
            task.id
        );
    }
    Ok(())
}