huskies: merge 479_story_build_agent_mode_with_crdt_based_work_claiming

This commit is contained in:
dave
2026-04-10 18:46:44 +00:00
parent 91be0ac47f
commit 6f7a0c7708
10 changed files with 714 additions and 9 deletions
+65 -9
View File
@@ -3,6 +3,7 @@
#![recursion_limit = "256"]
mod agent_log;
mod agent_mode;
mod agents;
mod chat;
mod config;
@@ -46,6 +47,10 @@ struct CliArgs {
path: Option<String>,
/// Whether the `init` subcommand was given.
init: bool,
/// Whether the `agent` subcommand was given.
agent: bool,
/// Rendezvous WebSocket URL for agent mode (e.g. `ws://host:3001/crdt-sync`).
rendezvous: Option<String>,
}
/// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`.
@@ -53,6 +58,8 @@ fn parse_cli_args(args: &[String]) -> Result<CliArgs, String> {
let mut port: Option<u16> = None;
let mut path: Option<String> = None;
let mut init = false;
let mut agent = false;
let mut rendezvous: Option<String> = None;
let mut i = 0;
while i < args.len() {
@@ -82,9 +89,23 @@ fn parse_cli_args(args: &[String]) -> Result<CliArgs, String> {
Err(_) => return Err(format!("invalid port value: '{val}'")),
}
}
"--rendezvous" => {
i += 1;
if i >= args.len() {
return Err("--rendezvous requires a value".to_string());
}
rendezvous = Some(args[i].clone());
}
a if a.starts_with("--rendezvous=") => {
let val = &a["--rendezvous=".len()..];
rendezvous = Some(val.to_string());
}
"init" => {
init = true;
}
"agent" => {
agent = true;
}
a if a.starts_with('-') => {
return Err(format!("unknown option: {a}"));
}
@@ -98,17 +119,23 @@ fn parse_cli_args(args: &[String]) -> Result<CliArgs, String> {
i += 1;
}
Ok(CliArgs { port, path, init })
if agent && rendezvous.is_none() {
return Err("agent mode requires --rendezvous <URL>".to_string());
}
Ok(CliArgs { port, path, init, agent, rendezvous })
}
fn print_help() {
println!("huskies [OPTIONS] [PATH]");
println!("huskies init [OPTIONS] [PATH]");
println!("huskies agent --rendezvous <URL> [OPTIONS] [PATH]");
println!();
println!("Serve a huskies project.");
println!();
println!("COMMANDS:");
println!(" init Scaffold a new .huskies/ project and start the interactive setup wizard.");
println!(" init Scaffold a new .huskies/ project and start the interactive setup wizard.");
println!(" agent Run as a headless build agent — syncs CRDT state, claims and runs work.");
println!();
println!("ARGS:");
println!(
@@ -117,9 +144,11 @@ fn print_help() {
);
println!();
println!("OPTIONS:");
println!(" -h, --help Print this help and exit");
println!(" -V, --version Print the version and exit");
println!(" --port <PORT> Port to listen on (default: 3001). Persisted to project.toml.");
println!(" -h, --help Print this help and exit");
println!(" -V, --version Print the version and exit");
println!(" --port <PORT> Port to listen on (default: 3001). Persisted to project.toml.");
println!(" --rendezvous <URL> WebSocket URL of the rendezvous peer (agent mode only).");
println!(" Example: ws://server:3001/crdt-sync");
}
/// Resolve the optional positional path argument into an absolute `PathBuf`.
@@ -169,6 +198,8 @@ async fn main() -> Result<(), std::io::Error> {
};
let is_init = cli.init;
let is_agent = cli.agent;
let agent_rendezvous = cli.rendezvous.clone();
let explicit_path = resolve_path_arg(cli.path.as_deref(), &cwd);
// Port resolution: CLI flag > project.toml (loaded later) > default.
@@ -309,13 +340,36 @@ async fn main() -> Result<(), std::io::Error> {
// (CRDT state layer is initialised above alongside the legacy pipeline.db.)
// Start the CRDT sync rendezvous client if configured in project.toml.
if let Some(ref root) = *app_state.project_root.lock().unwrap()
&& let Ok(cfg) = config::ProjectConfig::load(root)
&& let Some(rendezvous_url) = cfg.rendezvous
{
// In agent mode, the --rendezvous flag overrides project.toml.
let rendezvous_url_for_sync = if is_agent {
agent_rendezvous.clone()
} else {
app_state
.project_root
.lock()
.unwrap()
.as_ref()
.and_then(|root| config::ProjectConfig::load(root).ok())
.and_then(|cfg| cfg.rendezvous)
};
if let Some(rendezvous_url) = rendezvous_url_for_sync {
crdt_sync::spawn_rendezvous_client(rendezvous_url);
}
// ── Agent mode: headless build agent ────────────────────────────────
//
// When `huskies agent --rendezvous <URL>` is invoked, skip the web UI,
// chat bots, and HTTP server entirely. Instead, run a headless loop that:
// 1. Syncs CRDT state with the rendezvous peer.
// 2. Scans for unclaimed work and claims it via CRDT.
// 3. Runs Claude Code locally for claimed stories.
// 4. Pushes feature branches and reports completion via CRDT.
if is_agent {
let agent_root = app_state.project_root.lock().unwrap().clone();
let rendezvous = agent_rendezvous.expect("agent mode requires --rendezvous");
return agent_mode::run(agent_root, rendezvous, port).await;
}
let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default()));
// Event bus: broadcast channel for pipeline lifecycle events.
@@ -886,6 +940,8 @@ name = "coder"
assert_eq!(result.port, None);
assert_eq!(result.path, None);
assert!(!result.init);
assert!(!result.agent);
assert_eq!(result.rendezvous, None);
}
#[test]