sloop-daemon 0.2.0

Agentic coding scheduler — a daemon that runs background coding agents autonomously in isolated git worktrees
Documentation
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::fs;
use std::io;
use std::path::Path;

use serde_json::{Value, json};

use crate::config::{AgentConfig, expand_agent_cmd};
use crate::flow::Flow;
use crate::frontmatter;
use crate::ids::next_id;
use crate::sources::TicketSource;
use crate::store::{ReindexTicket, Store};

#[allow(clippy::too_many_arguments)]
pub fn run(
    root: &Path,
    ticket_source: &dyn TicketSource,
    worktree_dir: &Path,
    store: &Store,
    now_ms: i64,
    ticket_prefix: &str,
    project_ids: &[String],
    agent: Option<&AgentConfig>,
    flows: &BTreeMap<String, Flow>,
    default_flow: &str,
) -> Result<Value, ReindexError> {
    let authored = ticket_source
        .pull()
        .map_err(|error| ReindexError(error.to_string()))?;
    let mut known_ids: Vec<String> = authored
        .iter()
        .filter_map(|ticket| ticket.frontmatter.id.clone())
        .collect();
    let mut unique_ids = BTreeSet::new();
    for id in &known_ids {
        if !unique_ids.insert(id.clone()) {
            return Err(ReindexError(format!(
                "duplicate ticket ID `{id}` in the configured ticket directory"
            )));
        }
    }
    let mut unique_refs = BTreeSet::new();
    for ticket in &authored {
        if !unique_refs.insert((&ticket.source, &ticket.source_ref)) {
            return Err(ReindexError(format!(
                "duplicate source reference `{}` from `{}`",
                ticket.source_ref, ticket.source
            )));
        }
    }
    let known_projects: BTreeSet<&str> = project_ids.iter().map(String::as_str).collect();
    let fallback_project = if known_projects.contains("default") {
        "default".to_owned()
    } else {
        project_ids
            .first()
            .cloned()
            .ok_or_else(|| ReindexError("cannot index tickets without an indexed project".into()))?
    };

    let mut tickets = Vec::with_capacity(authored.len());
    let mut assigned_ids = BTreeSet::new();
    for authored_ticket in authored {
        let prior_id = store
            .ticket_by_source_ref(&authored_ticket.source, &authored_ticket.source_ref)
            .map_err(|error| ReindexError(error.to_string()))?
            .map(|ticket| ticket.id);
        let id = match authored_ticket.frontmatter.id.clone().or(prior_id) {
            Some(id) => id,
            None => {
                let id = next_id(ticket_prefix, known_ids.iter().map(String::as_str))
                    .map_err(|error| ReindexError(error.to_string()))?;
                known_ids.push(id.clone());
                id
            }
        };
        if !assigned_ids.insert(id.clone()) {
            return Err(ReindexError(format!(
                "duplicate ticket ID `{id}` in the pulled ticket source"
            )));
        }
        if !known_ids.contains(&id) {
            known_ids.push(id.clone());
        }
        let authored_project = authored_ticket
            .frontmatter
            .project
            .clone()
            .unwrap_or_else(|| "default".to_owned());
        let mut held_reason = authored_ticket.validation_error.clone();
        if authored_ticket.frontmatter.name.trim().is_empty() {
            held_reason.get_or_insert_with(|| {
                format!(
                    "{}: frontmatter field `name` is required and must be non-empty",
                    authored_ticket.source_ref
                )
            });
        }
        if authored_ticket.body.trim().is_empty() {
            held_reason.get_or_insert_with(|| {
                format!(
                    "{}: ticket body must be non-empty",
                    authored_ticket.source_ref
                )
            });
        }
        let project = if known_projects.contains(authored_project.as_str()) {
            authored_project.clone()
        } else {
            held_reason.get_or_insert_with(|| {
                format!(
                    "{}: project `{authored_project}` is not indexed",
                    authored_ticket.source_ref
                )
            });
            fallback_project.clone()
        };
        let flow = authored_ticket
            .frontmatter
            .flow
            .clone()
            .unwrap_or_else(|| default_flow.to_owned());
        if !flows.contains_key(&flow) {
            held_reason.get_or_insert_with(|| {
                format!(
                    "{}: flow `{flow}` is not defined",
                    authored_ticket.source_ref
                )
            });
        }
        let target = match authored_ticket.frontmatter.target.as_deref() {
            Some(target) if agent.is_some_and(|agent| agent.targets.contains_key(target)) => {
                Some(target.to_owned())
            }
            Some(target) => {
                held_reason.get_or_insert_with(|| {
                    format!(
                        "{}: agent target `{target}` is not configured",
                        authored_ticket.source_ref
                    )
                });
                Some(target.to_owned())
            }
            None => agent.map(|agent| agent.default_target.clone()),
        };
        if let (Some(agent), Some(target)) = (agent, target.as_deref()) {
            if let Some(command) = agent.targets.get(target)
                && let Err(message) = expand_agent_cmd(
                    command,
                    authored_ticket.frontmatter.model.as_deref(),
                    authored_ticket.frontmatter.effort.as_deref(),
                    "",
                )
            {
                held_reason.get_or_insert_with(|| {
                    format!(
                        "{}: ticket using agent target `{target}` {message}",
                        authored_ticket.source_ref
                    )
                });
            }
        }
        let worktree = match authored_ticket.frontmatter.worktree.clone() {
            Some(worktree) => worktree,
            None => {
                let stem = authored_ticket
                    .file_path
                    .as_deref()
                    .and_then(Path::file_stem)
                    .and_then(|stem| stem.to_str());
                match crate::ids::default_worktree(stem, &id) {
                    Ok(branch) => branch,
                    Err(reason) => {
                        held_reason.get_or_insert_with(|| {
                            format!("{}: {reason}", authored_ticket.source_ref)
                        });
                        format!("sloop/{id}")
                    }
                }
            }
        };
        if held_reason.is_none()
            && let (Some(path), Some(content)) = (
                authored_ticket.file_path.as_ref(),
                authored_ticket.original_content.as_ref(),
            )
            && let Some(updated) = frontmatter::stamp(content, &id, &project, &worktree, &flow)
                .map_err(|error| ReindexError(format!("{}: {error}", authored_ticket.source_ref)))?
        {
            let absolute = root.join(path);
            fs::write(&absolute, updated).map_err(|source| ReindexError::io(&absolute, source))?;
        }

        tickets.push(ReindexTicket {
            id,
            project_id: project,
            source: authored_ticket.source,
            source_ref: authored_ticket.source_ref,
            file_path: authored_ticket
                .file_path
                .map(|path| path.to_string_lossy().into_owned()),
            name: authored_ticket.frontmatter.name,
            blocked_by: authored_ticket.frontmatter.blocked_by,
            worktree,
            target,
            model: authored_ticket.frontmatter.model,
            effort: authored_ticket.frontmatter.effort,
            flow,
            body: authored_ticket.body,
            held_reason,
            derived_state: None,
        });
    }

    let ticket_ids: BTreeSet<String> = tickets.iter().map(|ticket| ticket.id.clone()).collect();
    let mut dependencies = BTreeMap::new();
    for ticket in &mut tickets {
        let unknown_blocker = ticket
            .blocked_by
            .iter()
            .find(|blocker| !ticket_ids.contains(*blocker))
            .cloned();
        if let Some(blocker) = unknown_blocker {
            ticket.held_reason.get_or_insert_with(|| {
                format!(
                    "ticket `{}` field `blocked_by` references unknown ticket `{blocker}`; edit `{}` to drop or correct the reference",
                    ticket.id, ticket.source_ref
                )
            });
            ticket.blocked_by.clear();
        } else {
            dependencies.insert(ticket.id.clone(), ticket.blocked_by.clone());
        }
    }
    if let Some(chain) = crate::domain::graph::find_cycle(&dependencies) {
        return Err(ReindexError(format!(
            "field `blocked_by` creates a dependency cycle: {}",
            chain.join(" -> ")
        )));
    }

    crate::reindex_evidence::derive_states(root, worktree_dir, &mut tickets)?;
    let result = store
        .apply_reindex(project_ids, &tickets, now_ms)
        .map_err(|error| ReindexError(error.to_string()))?;
    let state_changes: Vec<Value> = result
        .state_changes
        .into_iter()
        .map(|change| {
            json!({
                "ticket": change.ticket_id,
                "previous_state": change.previous_state,
                "state": change.state,
            })
        })
        .collect();
    Ok(json!({
        "projects_indexed": project_ids.len(),
        "tickets_indexed": tickets.len(),
        "tickets_state_changed": state_changes.len(),
        "state_changes": state_changes,
        "rows_dropped": result.rows_dropped,
    }))
}

#[derive(Debug)]
pub struct ReindexError(pub(crate) String);

impl ReindexError {
    pub(crate) fn io(path: &Path, source: io::Error) -> Self {
        Self(format!("{}: {source}", path.display()))
    }
}

impl fmt::Display for ReindexError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(&self.0)
    }
}

impl std::error::Error for ReindexError {}