qqq-cli 0.13.0

Local-first task queue for coding agent sessions
#![cfg(unix)]
#[path = "common/next_output.rs"]
mod next_output;

use serde_json::Value;
use std::{
    fs,
    os::unix::fs::PermissionsExt,
    process::{Command, Stdio},
    time::{Duration, Instant},
};
use tempfile::TempDir;

struct Project(TempDir);

impl Project {
    fn new(config: &str) -> Self {
        let p = Self(TempDir::new().unwrap());
        let root = p.0.path();
        fs::create_dir_all(root.join(".config/qqq")).unwrap();
        fs::write(root.join(".config/qqq/config.toml"), config).unwrap();
        fs::write(
            root.join("herdr"),
            "#!/bin/sh\nprintf '%s\\n' \"$*\" >> calls\nexit 1\n",
        )
        .unwrap();
        fs::set_permissions(root.join("herdr"), fs::Permissions::from_mode(0o755)).unwrap();
        p.ok(&["init"]);
        p
    }

    fn command(&self) -> Command {
        let mut command = Command::new(env!("CARGO_BIN_EXE_qqq"));
        command
            .current_dir(self.0.path())
            .arg("--json")
            .env("HOME", self.0.path())
            .env("PATH", self.0.path())
            .env("HERDR_ENV", "1")
            .env("HERDR_PANE_ID", "caller")
            .env("HERDR_WORKSPACE_ID", "workspace")
            .env_remove("HERDR_SOCKET_PATH")
            .env_remove("QQQ_SESSION")
            .env_remove("CODEX_THREAD_ID")
            .env_remove("CODEX_SESSION_ID")
            .env_remove("CLAUDE_CODE_SESSION_ID");
        command
    }

    fn ok(&self, args: &[&str]) -> Value {
        let output = self.command().args(args).output().unwrap();
        if args.first() == Some(&"next") {
            return next_output::json(output);
        }
        assert!(output.status.success(), "{args:?}: {output:?}");
        serde_json::from_slice(&output.stdout).unwrap()
    }

    fn assert_no_herdr(&self) {
        assert!(!self.0.path().join("calls").exists());
    }
}

#[test]
fn legacy_dispatch_configuration_never_spawns_or_blocks_local_claims() {
    for config in [
        "[herdr]\nnext-to-new-agent = true\n",
        "[herdr]\nnext-to-new-agent = 'wrong'\n",
        "invalid = [",
    ] {
        let p = Project::new(config);
        p.ok(&["add", "Task"]);
        let task = p.ok(&["next", "--session", "worker"]);
        assert_eq!(task["id"], 1);
        assert_eq!(task["harness_session"], "worker");
        assert!(task["orchestrator_session"].is_null());
        assert_eq!(p.ok(&["next", "--session", "worker"]), task);
        p.assert_no_herdr();
    }
}

#[test]
fn local_flag_remains_compatible_with_normal_claims() {
    let p = Project::new("[herdr]\nnext-to-new-agent = true\n");
    p.ok(&["add", "First"]);
    p.ok(&["add", "Second"]);
    assert_eq!(p.ok(&["next", "--local", "--session", "worker"])["id"], 1);
    p.ok(&["edit", "1", "--complete", "--session", "worker"]);
    assert_eq!(p.ok(&["next", "--session", "worker"])["id"], 2);
    p.assert_no_herdr();
}

#[test]
fn wait_claims_arriving_work_for_the_calling_worker() {
    let p = Project::new("[herdr]\nnext-to-new-agent = true\n");
    let mut child = p
        .command()
        .args(["next", "--wait", "--session", "worker"])
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .unwrap();
    std::thread::sleep(Duration::from_millis(100));
    assert!(child.try_wait().unwrap().is_none());
    p.assert_no_herdr();
    p.ok(&["add", "Arrived"]);
    let started = Instant::now();
    while child.try_wait().unwrap().is_none() {
        if started.elapsed() > Duration::from_secs(5) {
            child.kill().unwrap();
            child.wait().unwrap();
            panic!("waiting worker did not claim arriving work");
        }
        std::thread::sleep(Duration::from_millis(10));
    }
    let task = next_output::json(child.wait_with_output().unwrap());
    assert_eq!(task["harness_session"], "worker");
    assert_eq!(task["id"], 1);
    p.assert_no_herdr();
}