qqq-cli 0.7.0

Local-first task queue for coding agent sessions
use crate::{
    db::{Db, EditTransition, Task, nonempty},
    errors::{Code, Info},
    herdr::{self, Link, Pane},
};
use anyhow::{Context, Result, ensure};
use serde::Deserialize;
use serde_json::Value;

#[derive(Deserialize)]
struct Created {
    root_pane: Pane,
}
#[derive(Deserialize)]
struct Agent {
    agent: Pane,
}

fn quote(value: &str) -> String {
    format!("'{}'", value.replace('\'', "'\\''"))
}

pub fn next(
    db: &mut Db,
    caller: Option<&str>,
    overrides: &crate::identity::Identity,
    filter: Option<&crate::sql_filter::CompiledFilter>,
) -> Result<Option<Task>> {
    let caller = match caller {
        Some(session) => {
            nonempty(session, "Session")?;
            session.to_owned()
        }
        None => crate::session::dispatch_caller(db)?,
    };
    if let Some(task) = db.owned_with_identity(&caller, overrides)? {
        return Ok(Some(task));
    }
    if !db.has_ready_filtered(filter)? {
        return Ok(None);
    }
    ensure!(
        std::env::var("HERDR_ENV").as_deref() == Ok("1"),
        Info::new(
            Code::DispatchError,
            "Herdr dispatch requires HERDR_ENV=1; use next --local outside Herdr"
        )
        .detail("reason", "missing_herdr_context")
    );
    let workspace = std::env::var("HERDR_WORKSPACE_ID").context(
        Info::new(
            Code::DispatchError,
            "HERDR_WORKSPACE_ID missing; use next --local",
        )
        .detail("reason", "missing_herdr_workspace"),
    )?;
    nonempty(&workspace, "HERDR_WORKSPACE_ID")?;
    let cwd = std::env::current_dir()?.canonicalize()?;
    let cwd = cwd
        .to_str()
        .context("Herdr dispatch requires a UTF-8 working directory")?;
    let executable = std::env::current_exe()?;
    let executable = executable
        .to_str()
        .context("qqq executable path is not UTF-8")?;
    let unique = tempfile::Builder::new().prefix("qqq-dispatch-").tempdir()?;
    let name = unique
        .path()
        .file_name()
        .context("Missing dispatch name")?
        .to_string_lossy()
        .to_ascii_lowercase();
    let name = format!("{name}-{:x}", std::process::id());
    let Some(task) = db.next_filtered(&name, None, filter)? else {
        return Ok(None);
    };
    let mut location = "before tab creation".to_owned();
    let prepared = (|| -> Result<Pane> {
        let created: Created = herdr::call(
            None,
            &[
                "tab",
                "create",
                "--workspace",
                &workspace,
                "--cwd",
                cwd,
                "--no-focus",
                "--env",
                &format!("QQQ_SESSION={name}"),
            ],
        )?;
        let pane = created.root_pane;
        location = format!("tab {}, pane {}", pane.tab_id, pane.pane_id);
        nonempty(&pane.pane_id, "Created pane ID")?;
        nonempty(&pane.tab_id, "Created tab ID")?;
        ensure!(
            pane.workspace_id == workspace,
            "Created tab belongs to a different workspace"
        );
        let _: Value = herdr::call(
            None,
            &[
                "agent",
                "start",
                &name,
                "--kind",
                "codex",
                "--pane",
                &pane.pane_id,
            ],
        )?;
        let current: Agent = herdr::call(None, &["agent", "get", &pane.pane_id])?;
        ensure!(
            current.agent.pane_id == pane.pane_id,
            "Herdr returned a different agent pane"
        );
        let identity = herdr::pane_identity(&current.agent)?;
        ensure!(identity.agent == "codex", "Started agent is not Codex");
        db.set_link_with_identity(
            task.id,
            &Link {
                server: herdr::server_session(),
                identity,
                pane: current.agent.clone(),
            },
            overrides,
            Some(&name),
        )?;
        Ok(current.agent)
    })();
    let pane = match prepared {
        Ok(pane) => pane,
        Err(error) => {
            db.edit(
                task.id,
                None,
                Some(EditTransition::NewExact(&name)),
                &[],
                None,
            )
            .with_context(|| {
                format!(
                    "Dispatch failed ({error:#}); could not release task {} assigned to {name}",
                    task.id
                )
            })?;
            return Err(error).with_context(|| Info::new(Code::DispatchError, format!("Task {} returned to new; dispatch startup failed. Any created tab remains for inspection", task.id))
                .detail("task_id", task.id).detail("actual_status", "new").detail("reason", "startup_failed").detail("delivery_possible", false)
                .human(format!("Task {} returned to new; dispatch failed at {location}. Any created tab remains for inspection", task.id)));
        }
    };
    let prompt = format!(
        "Work on qqq task #{id} in {cwd}. Task is already assigned to you; keep inherited QQQ_SESSION set so commands use this claim automatically. Do not call qqq next or dispatch another agent. Run {bin} show {id} --json to read task, messages and attachments. Complete requested work, verify changes, record progress using {bin} message {id} '<progress>', then run {bin} complete {id} only when finished. If work fails and needs manual handling, run {bin} edit {id} --set-status error --reason '<failure details>'; task stays out of queue until user explicitly retries. Use next --local only if you need to retrieve this existing claim.",
        id = task.id,
        cwd = quote(cwd),
        bin = quote(executable)
    );
    let _: Value = herdr::call(None, &["agent", "prompt", &pane.pane_id, &prompt])
        .with_context(|| Info::new(Code::DispatchError, format!("Prompt may have been delivered. Task {} remains assigned; inspect agent before retrying", task.id))
            .detail("task_id", task.id).detail("actual_status", "in_progress").detail("reason", "prompt_delivery_uncertain").detail("delivery_possible", true)
            .human(format!("Prompt may have been delivered. Task {} remains assigned to {name}, linked to {location}; inspect agent before retrying. Recovery: qqq edit {} --set-status new --session {}", task.id, task.id, quote(&name))))?;
    Ok(Some(db.task(task.id)?))
}