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
+449
View File
@@ -0,0 +1,449 @@
/// Headless build agent mode.
///
/// When invoked via `huskies agent --rendezvous ws://host:3001/crdt-sync`, this
/// module runs a headless loop that:
///
/// 1. Syncs CRDT state with the rendezvous peer.
/// 2. Writes a heartbeat to the CRDT `nodes` list.
/// 3. Scans for unclaimed stories in `2_current` and claims them via CRDT.
/// 4. Spawns Claude Code locally for each claimed story.
/// 5. Pushes the feature branch to the git remote when done.
/// 6. Reports completion by advancing the story stage via CRDT.
/// 7. Handles offline/reconnect: CRDT merges on reconnect, interrupted work
/// is reclaimed after a timeout.
///
/// No web UI, HTTP server, or chat interface is started.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::broadcast;
use crate::agents::AgentPool;
use crate::config::ProjectConfig;
use crate::crdt_state;
use crate::io::watcher;
use crate::slog;
/// Default claim timeout in seconds. If a node has not updated its heartbeat
/// within this window, other nodes may reclaim the story.
const CLAIM_TIMEOUT_SECS: f64 = 600.0; // 10 minutes
/// Interval between heartbeat writes and work scans.
const SCAN_INTERVAL_SECS: u64 = 15;
/// Run the headless build agent loop.
///
/// This function never returns under normal operation — it runs until the
/// process is terminated (SIGINT/SIGTERM).
pub async fn run(
project_root: Option<PathBuf>,
rendezvous_url: String,
port: u16,
) -> Result<(), std::io::Error> {
let project_root = match project_root {
Some(r) => r,
None => {
eprintln!("error: agent mode requires a project root (no .huskies/ found)");
std::process::exit(1);
}
};
println!("\x1b[96;1m[agent-mode]\x1b[0m Starting headless build agent");
println!("\x1b[96;1m[agent-mode]\x1b[0m Rendezvous: {rendezvous_url}");
println!("\x1b[96;1m[agent-mode]\x1b[0m Project: {}", project_root.display());
// Validate project config.
let config = ProjectConfig::load(&project_root).unwrap_or_else(|e| {
eprintln!("error: invalid project config: {e}");
std::process::exit(1);
});
slog!("[agent-mode] Loaded config with {} agents", config.agent.len());
// Event bus for pipeline lifecycle events.
let (watcher_tx, _) = broadcast::channel::<watcher::WatcherEvent>(1024);
let agents = Arc::new(AgentPool::new(port, watcher_tx.clone()));
// Start filesystem watcher for config hot-reload.
watcher::start_watcher(project_root.clone(), watcher_tx.clone());
// Bridge CRDT events to watcher channel (same as main server).
{
let crdt_watcher_tx = watcher_tx.clone();
let crdt_prune_root = Some(project_root.clone());
if let Some(mut crdt_rx) = crdt_state::subscribe() {
tokio::spawn(async move {
while let Ok(evt) = crdt_rx.recv().await {
if evt.to_stage == "6_archived"
&& let Some(root) = crdt_prune_root.as_ref().cloned()
{
let story_id = evt.story_id.clone();
tokio::task::spawn_blocking(move || {
if let Err(e) =
crate::worktree::prune_worktree_sync(&root, &story_id)
{
slog!("[agent-mode] worktree prune failed for {story_id}: {e}");
}
});
}
let (action, commit_msg) =
watcher::stage_metadata(&evt.to_stage, &evt.story_id)
.unwrap_or(("update", format!("huskies: update {}", evt.story_id)));
let watcher_evt = watcher::WatcherEvent::WorkItem {
stage: evt.to_stage,
item_id: evt.story_id,
action: action.to_string(),
commit_msg,
from_stage: evt.from_stage,
};
let _ = crdt_watcher_tx.send(watcher_evt);
}
});
}
}
// Subscribe to watcher events to trigger auto-assign on stage transitions.
{
let auto_rx = watcher_tx.subscribe();
let auto_agents = Arc::clone(&agents);
let auto_root = project_root.clone();
tokio::spawn(async move {
let mut rx = auto_rx;
while let Ok(event) = rx.recv().await {
if let watcher::WatcherEvent::WorkItem { ref stage, .. } = event
&& matches!(stage.as_str(), "2_current" | "3_qa" | "4_merge")
{
slog!(
"[agent-mode] CRDT transition in {stage}/; triggering auto-assign."
);
auto_agents.auto_assign_available_work(&auto_root).await;
}
}
});
}
// Write initial heartbeat.
write_heartbeat(&rendezvous_url, port);
// Reconcile any committed work from a previous session.
{
let recon_agents = Arc::clone(&agents);
let recon_root = project_root.clone();
let (recon_tx, _) = broadcast::channel(64);
slog!("[agent-mode] Reconciling completed worktrees from previous session.");
recon_agents
.reconcile_on_startup(&recon_root, &recon_tx)
.await;
}
// Run initial auto-assign.
slog!("[agent-mode] Initial auto-assign scan.");
agents.auto_assign_available_work(&project_root).await;
// Track which stories we've claimed so we can detect conflicts.
let mut our_claims: HashMap<String, f64> = HashMap::new();
// Main loop: heartbeat, scan, claim, detect conflicts.
let mut interval = tokio::time::interval(std::time::Duration::from_secs(SCAN_INTERVAL_SECS));
loop {
interval.tick().await;
// Write heartbeat.
write_heartbeat(&rendezvous_url, port);
// Scan CRDT for claimable work.
scan_and_claim(&agents, &project_root, &mut our_claims).await;
// Detect claim conflicts: if another node overwrote our claim, stop our agent.
detect_conflicts(&agents, &project_root, &mut our_claims).await;
// Reclaim timed-out work from dead nodes.
reclaim_timed_out_work(&project_root);
// Check for completed agents and push their branches.
check_completions_and_push(&agents, &project_root).await;
}
}
/// Write this node's heartbeat to the CRDT `nodes` list.
fn write_heartbeat(rendezvous_url: &str, port: u16) {
let Some(node_id) = crdt_state::our_node_id() else {
return;
};
let now = chrono::Utc::now().timestamp() as f64;
// Advertise our crdt-sync endpoint.
let address = format!("ws://0.0.0.0:{port}/crdt-sync");
crdt_state::write_node_presence(&node_id, &address, now, true);
slog!(
"[agent-mode] Heartbeat written: node={:.12}… rendezvous={rendezvous_url}",
&node_id
);
}
/// Scan CRDT pipeline for unclaimed stories and claim them.
async fn scan_and_claim(
agents: &AgentPool,
project_root: &Path,
our_claims: &mut HashMap<String, f64>,
) {
let Some(items) = crdt_state::read_all_items() else {
return;
};
let Some(our_node) = crdt_state::our_node_id() else {
return;
};
for item in &items {
// Only claim stories in active stages.
if !matches!(item.stage.as_str(), "2_current" | "3_qa" | "4_merge") {
continue;
}
// Skip blocked stories.
if item.blocked == Some(true) {
continue;
}
// If already claimed by us, skip.
if item.claimed_by.as_deref() == Some(&our_node) {
continue;
}
// If claimed by another alive node and claim is fresh, skip.
if let Some(ref claimer) = item.claimed_by
&& !claimer.is_empty()
&& claimer != &our_node
&& let Some(claimed_at) = item.claimed_at
{
let now = chrono::Utc::now().timestamp() as f64;
if now - claimed_at < CLAIM_TIMEOUT_SECS && is_node_alive(claimer) {
continue;
}
}
// Try to claim this story.
slog!(
"[agent-mode] Claiming story '{}' for this node",
item.story_id
);
if crdt_state::write_claim(&item.story_id) {
let now = chrono::Utc::now().timestamp() as f64;
our_claims.insert(item.story_id.clone(), now);
}
}
// Trigger auto-assign to start agents for newly claimed work.
agents.auto_assign_available_work(project_root).await;
}
/// Detect if another node overwrote our claims (CRDT conflict resolution).
/// If so, stop our local agent for that story.
async fn detect_conflicts(
agents: &AgentPool,
project_root: &Path,
our_claims: &mut HashMap<String, f64>,
) {
let lost: Vec<String> = our_claims
.keys()
.filter(|story_id| !crdt_state::is_claimed_by_us(story_id))
.cloned()
.collect();
for story_id in lost {
slog!(
"[agent-mode] Lost claim on '{}' to another node; stopping local agent.",
story_id
);
our_claims.remove(&story_id);
// Stop any local agent for this story by looking up its name.
if let Ok(agent_list) = agents.list_agents() {
for info in agent_list {
if info.story_id == story_id {
let _ = agents
.stop_agent(project_root, &story_id, &info.agent_name)
.await;
break;
}
}
}
// Release our claim (in case it wasn't fully overwritten).
crdt_state::release_claim(&story_id);
}
}
/// Reclaim work from nodes that have timed out (stale heartbeat).
fn reclaim_timed_out_work(_project_root: &Path) {
let Some(items) = crdt_state::read_all_items() else {
return;
};
let now = chrono::Utc::now().timestamp() as f64;
for item in &items {
if !matches!(item.stage.as_str(), "2_current" | "3_qa" | "4_merge") {
continue;
}
// Check if the claim has timed out.
if let Some(ref claimer) = item.claimed_by {
if claimer.is_empty() {
continue;
}
if let Some(claimed_at) = item.claimed_at
&& now - claimed_at >= CLAIM_TIMEOUT_SECS
&& !is_node_alive(claimer)
{
slog!(
"[agent-mode] Reclaiming timed-out story '{}' from dead node {:.12}…",
item.story_id,
claimer
);
crdt_state::release_claim(&item.story_id);
}
}
}
}
/// Check if a node is alive according to the CRDT nodes list.
fn is_node_alive(node_id: &str) -> bool {
let Some(nodes) = crdt_state::read_all_node_presence() else {
return false;
};
let now = chrono::Utc::now().timestamp() as f64;
for node in &nodes {
if node.node_id == node_id {
// Node is considered alive if it's marked alive AND its heartbeat
// is within the timeout window.
return node.alive && (now - node.last_seen) < CLAIM_TIMEOUT_SECS;
}
}
false
}
/// Check for completed agents, push their feature branches to the remote,
/// and report completion via CRDT.
async fn check_completions_and_push(agents: &AgentPool, _project_root: &Path) {
let Ok(agent_list) = agents.list_agents() else {
return;
};
for info in agent_list {
if !matches!(
info.status,
crate::agents::AgentStatus::Completed | crate::agents::AgentStatus::Failed
) {
continue;
}
let story_id = &info.story_id;
// Only push if this node still owns the claim.
if !crdt_state::is_claimed_by_us(story_id) {
continue;
}
slog!(
"[agent-mode] Agent {} for '{}'; pushing feature branch.",
if matches!(info.status, crate::agents::AgentStatus::Completed) {
"completed"
} else {
"failed"
},
story_id
);
// Push the feature branch to the remote.
if let Some(ref wt) = info.worktree_path {
let push_result = push_feature_branch(wt, story_id);
match push_result {
Ok(()) => {
slog!("[agent-mode] Pushed feature branch for '{story_id}' to remote.");
}
Err(e) => {
slog!("[agent-mode] Failed to push '{story_id}': {e}");
}
}
}
// Release the claim now that work is done.
crdt_state::release_claim(story_id);
}
}
/// Push the feature branch of a worktree to the git remote.
fn push_feature_branch(worktree_path: &str, story_id: &str) -> Result<(), String> {
let branch = format!("feature/story-{story_id}");
// Try to push to 'origin'. If origin doesn't exist, try the first remote.
let output = std::process::Command::new("git")
.args(["push", "origin", &branch])
.current_dir(worktree_path)
.output()
.map_err(|e| format!("Failed to run git push: {e}"))?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
// If 'origin' doesn't exist, try to find any remote.
if stderr.contains("does not appear to be a git repository")
|| stderr.contains("No such remote")
{
let remotes = std::process::Command::new("git")
.args(["remote"])
.current_dir(worktree_path)
.output()
.map_err(|e| format!("Failed to list remotes: {e}"))?;
let remote_list = String::from_utf8_lossy(&remotes.stdout);
let first_remote = remote_list.lines().next();
if let Some(remote) = first_remote {
let retry = std::process::Command::new("git")
.args(["push", remote.trim(), &branch])
.current_dir(worktree_path)
.output()
.map_err(|e| format!("Failed to push to {remote}: {e}"))?;
if retry.status.success() {
return Ok(());
}
return Err(format!(
"git push to '{remote}' failed: {}",
String::from_utf8_lossy(&retry.stderr)
));
}
// No remotes configured — not an error in agent mode, just skip.
slog!("[agent-mode] No git remote configured; skipping push for '{story_id}'.");
Ok(())
} else {
Err(format!("git push failed: {stderr}"))
}
}
}
// ── Tests ────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_node_alive_returns_false_for_unknown_node() {
// Without CRDT init, should return false.
assert!(!is_node_alive("nonexistent_node_id"));
}
#[test]
fn push_feature_branch_handles_missing_worktree() {
let result = push_feature_branch("/nonexistent/path", "test_story");
assert!(result.is_err());
}
#[test]
fn claim_timeout_is_ten_minutes() {
assert_eq!(CLAIM_TIMEOUT_SECS, 600.0);
}
}
+144
View File
@@ -91,6 +91,14 @@ pub struct PipelineItemCrdt {
pub retry_count: LwwRegisterCrdt<f64>,
pub blocked: LwwRegisterCrdt<bool>,
pub depends_on: LwwRegisterCrdt<String>,
/// Node ID (hex-encoded Ed25519 pubkey) of the node that claimed this item.
/// Used for distributed work claiming — the LWW register resolves conflicts
/// deterministically so all nodes converge on the same claimer.
pub claimed_by: LwwRegisterCrdt<String>,
/// Unix timestamp (seconds) when the claim was written.
/// Used for timeout-based reclaim: if a node crashes, other nodes can
/// reclaim the item after the timeout expires.
pub claimed_at: LwwRegisterCrdt<f64>,
}
/// CRDT node that holds a single peer's presence entry.
@@ -119,6 +127,10 @@ pub struct PipelineItemView {
pub retry_count: Option<i64>,
pub blocked: Option<bool>,
pub depends_on: Option<Vec<u32>>,
/// Node ID of the node that claimed this item (hex-encoded Ed25519 pubkey).
pub claimed_by: Option<String>,
/// Unix timestamp when the item was claimed.
pub claimed_at: Option<f64>,
}
/// A snapshot of a single node presence entry derived from the CRDT document.
@@ -366,6 +378,7 @@ where
///
/// When the stage changes (or a new item is created), a [`CrdtEvent`] is
/// broadcast so subscribers can react to the transition.
#[allow(clippy::too_many_arguments)]
pub fn write_item(
story_id: &str,
stage: &str,
@@ -374,6 +387,8 @@ pub fn write_item(
retry_count: Option<i64>,
blocked: Option<bool>,
depends_on: Option<&str>,
claimed_by: Option<&str>,
claimed_at: Option<f64>,
) {
let Some(state_mutex) = CRDT_STATE.get() else {
return;
@@ -419,6 +434,16 @@ pub fn write_item(
s.crdt.doc.items[idx].depends_on.set(d.to_string())
});
}
if let Some(cb) = claimed_by {
apply_and_persist(&mut state, |s| {
s.crdt.doc.items[idx].claimed_by.set(cb.to_string())
});
}
if let Some(ca) = claimed_at {
apply_and_persist(&mut state, |s| {
s.crdt.doc.items[idx].claimed_at.set(ca)
});
}
// Broadcast a CrdtEvent if the stage actually changed.
let stage_changed = old_stage.as_deref() != Some(stage);
@@ -445,6 +470,8 @@ pub fn write_item(
"retry_count": retry_count.unwrap_or(0) as f64,
"blocked": blocked.unwrap_or(false),
"depends_on": depends_on.unwrap_or(""),
"claimed_by": claimed_by.unwrap_or(""),
"claimed_at": claimed_at.unwrap_or(0.0),
})
.into();
@@ -573,6 +600,73 @@ pub fn our_node_id() -> Option<String> {
Some(hex::encode(&state.crdt.id))
}
/// Write a claim on a pipeline item via CRDT.
///
/// Sets `claimed_by` to this node's ID and `claimed_at` to the current time.
/// The LWW register ensures deterministic conflict resolution — if two nodes
/// claim the same item simultaneously, both will converge to the same winner
/// after CRDT sync.
///
/// Returns `true` if the claim was written, `false` if the item doesn't exist
/// or CRDT is not initialised.
pub fn write_claim(story_id: &str) -> bool {
let Some(node_id) = our_node_id() else {
return false;
};
let now = chrono::Utc::now().timestamp() as f64;
let Some(state_mutex) = CRDT_STATE.get() else {
return false;
};
let Ok(mut state) = state_mutex.lock() else {
return false;
};
let Some(&idx) = state.index.get(story_id) else {
return false;
};
apply_and_persist(&mut state, |s| {
s.crdt.doc.items[idx].claimed_by.set(node_id.clone())
});
apply_and_persist(&mut state, |s| {
s.crdt.doc.items[idx].claimed_at.set(now)
});
true
}
/// Release a claim on a pipeline item (clear claimed_by and claimed_at).
pub fn release_claim(story_id: &str) {
let Some(state_mutex) = CRDT_STATE.get() else {
return;
};
let Ok(mut state) = state_mutex.lock() else {
return;
};
let Some(&idx) = state.index.get(story_id) else {
return;
};
apply_and_persist(&mut state, |s| {
s.crdt.doc.items[idx].claimed_by.set(String::new())
});
apply_and_persist(&mut state, |s| {
s.crdt.doc.items[idx].claimed_at.set(0.0)
});
}
/// Check if this node currently holds the claim on a pipeline item.
pub fn is_claimed_by_us(story_id: &str) -> bool {
let Some(node_id) = our_node_id() else {
return false;
};
let Some(item) = read_item(story_id) else {
return false;
};
item.claimed_by.as_deref() == Some(&node_id)
}
/// Write or update a node presence entry in the CRDT.
///
/// If a node with the given `node_id` already exists, only `last_seen`,
@@ -673,6 +767,8 @@ pub struct CrdtItemDump {
pub retry_count: Option<i64>,
pub blocked: Option<bool>,
pub depends_on: Option<Vec<u32>>,
pub claimed_by: Option<String>,
pub claimed_at: Option<f64>,
/// Hex-encoded OpId of the list insert op — cross-reference with `crdt_ops`.
pub content_index: String,
pub is_deleted: bool,
@@ -793,6 +889,15 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
_ => None,
};
let claimed_by = match item_crdt.claimed_by.view() {
JsonValue::String(s) if !s.is_empty() => Some(s),
_ => None,
};
let claimed_at = match item_crdt.claimed_at.view() {
JsonValue::Number(n) if n > 0.0 => Some(n),
_ => None,
};
let content_index = op.id.iter().map(|b| format!("{b:02x}")).collect::<String>();
items.push(CrdtItemDump {
@@ -803,6 +908,8 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
retry_count,
blocked,
depends_on,
claimed_by,
claimed_at,
content_index,
is_deleted: op.is_deleted,
});
@@ -941,6 +1048,15 @@ fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemView> {
_ => None,
};
let claimed_by = match item.claimed_by.view() {
JsonValue::String(s) if !s.is_empty() => Some(s),
_ => None,
};
let claimed_at = match item.claimed_at.view() {
JsonValue::Number(n) if n > 0.0 => Some(n),
_ => None,
};
Some(PipelineItemView {
story_id,
stage,
@@ -949,6 +1065,8 @@ fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemView> {
retry_count,
blocked,
depends_on,
claimed_by,
claimed_at,
})
}
@@ -1049,6 +1167,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
@@ -1076,6 +1196,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
@@ -1106,6 +1228,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
@@ -1147,6 +1271,8 @@ mod tests {
"retry_count": 2.0,
"blocked": true,
"depends_on": "[10,20]",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
@@ -1177,6 +1303,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
@@ -1213,6 +1341,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
@@ -1266,6 +1396,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
@@ -1385,6 +1517,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op = crdt.doc.items.insert(bft_json_crdt::op::ROOT_ID, item).sign(&kp);
@@ -1406,6 +1540,8 @@ mod tests {
"retry_count": 1.0,
"blocked": false,
"depends_on": "[10]",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op = crdt.doc.items.insert(bft_json_crdt::op::ROOT_ID, item).sign(&kp);
@@ -1433,6 +1569,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op = crdt.doc.items.insert(bft_json_crdt::op::ROOT_ID, item).sign(&kp);
@@ -1479,6 +1617,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
@@ -1495,6 +1635,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let insert_op = crdt.doc.items.insert(ROOT_ID, target_item).sign(&kp);
@@ -1608,6 +1750,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
+32
View File
@@ -398,6 +398,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a);
@@ -500,6 +502,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
@@ -662,6 +666,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
@@ -717,6 +723,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
@@ -785,6 +793,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
@@ -839,6 +849,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let phantom_op = source
@@ -913,6 +925,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op_a1 = crdt_a.doc.items.insert(ROOT_ID, item_a).sign(&kp_a);
@@ -934,6 +948,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b);
@@ -1030,6 +1046,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
@@ -1080,6 +1098,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
@@ -1125,6 +1145,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
@@ -1158,6 +1180,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op1 = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
@@ -1237,6 +1261,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a);
@@ -1302,6 +1328,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b);
@@ -1368,6 +1396,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op_a1 = crdt_a.doc.items.insert(ROOT_ID, item_a).sign(&kp_a);
@@ -1392,6 +1422,8 @@ name = "test"
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b);
+2
View File
@@ -131,6 +131,8 @@ mod tests {
"retry_count": 0.0,
"blocked": false,
"depends_on": "",
"claimed_by": "",
"claimed_at": 0.0,
})
.into();
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
+4
View File
@@ -201,6 +201,8 @@ pub fn write_item_with_content(
retry_count,
blocked,
depends_on.as_deref(),
None,
None,
);
// Shadow: pipeline_items table (only when DB is initialised).
@@ -267,6 +269,8 @@ pub fn move_item_stage(
retry_count,
blocked,
depends_on.as_deref(),
None,
None,
);
// Shadow table.
+2
View File
@@ -284,6 +284,8 @@ pub(super) fn tool_dump_crdt(args: &Value) -> Result<String, String> {
"retry_count": item.retry_count,
"blocked": item.blocked,
"depends_on": item.depends_on,
"claimed_by": item.claimed_by,
"claimed_at": item.claimed_at,
"content_index": item.content_index,
"is_deleted": item.is_deleted,
})
+2
View File
@@ -161,6 +161,8 @@ pub fn debug_crdt_handler(req: &poem::Request) -> poem::Response {
"retry_count": item.retry_count,
"blocked": item.blocked,
"depends_on": item.depends_on,
"claimed_by": item.claimed_by,
"claimed_at": item.claimed_at,
"content_index": item.content_index,
"is_deleted": item.is_deleted,
})
+2
View File
@@ -347,6 +347,8 @@ pub(crate) fn sweep_done_to_archived(done_retention: Duration) {
None,
Some(false),
None,
None,
None,
);
slog!("[watcher] sweep: promoted {story_id} → 6_archived/");
}
+61 -5
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!(" agent Run as a headless build agent — syncs CRDT state, claims and runs work.");
println!();
println!("ARGS:");
println!(
@@ -120,6 +147,8 @@ fn print_help() {
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]
+12
View File
@@ -1145,6 +1145,8 @@ mod tests {
retry_count: None,
blocked: None,
depends_on: Some(vec![10, 20]),
claimed_by: None,
claimed_at: None,
};
let item = PipelineItem::try_from(&view).unwrap();
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
@@ -1164,6 +1166,8 @@ mod tests {
retry_count: Some(2),
blocked: None,
depends_on: None,
claimed_by: None,
claimed_at: None,
};
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Coding));
@@ -1180,6 +1184,8 @@ mod tests {
retry_count: None,
blocked: None,
depends_on: None,
claimed_by: None,
claimed_at: None,
};
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Merge { .. }));
@@ -1203,6 +1209,8 @@ mod tests {
retry_count: None,
blocked: Some(true),
depends_on: None,
claimed_by: None,
claimed_at: None,
};
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(
@@ -1224,6 +1232,8 @@ mod tests {
retry_count: None,
blocked: Some(false),
depends_on: None,
claimed_by: None,
claimed_at: None,
};
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(
@@ -1245,6 +1255,8 @@ mod tests {
retry_count: None,
blocked: None,
depends_on: None,
claimed_by: None,
claimed_at: None,
};
let result = PipelineItem::try_from(&view);
assert!(matches!(