huskies: merge 479_story_build_agent_mode_with_crdt_based_work_claiming
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,6 +91,14 @@ pub struct PipelineItemCrdt {
|
|||||||
pub retry_count: LwwRegisterCrdt<f64>,
|
pub retry_count: LwwRegisterCrdt<f64>,
|
||||||
pub blocked: LwwRegisterCrdt<bool>,
|
pub blocked: LwwRegisterCrdt<bool>,
|
||||||
pub depends_on: LwwRegisterCrdt<String>,
|
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.
|
/// CRDT node that holds a single peer's presence entry.
|
||||||
@@ -119,6 +127,10 @@ pub struct PipelineItemView {
|
|||||||
pub retry_count: Option<i64>,
|
pub retry_count: Option<i64>,
|
||||||
pub blocked: Option<bool>,
|
pub blocked: Option<bool>,
|
||||||
pub depends_on: Option<Vec<u32>>,
|
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.
|
/// 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
|
/// When the stage changes (or a new item is created), a [`CrdtEvent`] is
|
||||||
/// broadcast so subscribers can react to the transition.
|
/// broadcast so subscribers can react to the transition.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn write_item(
|
pub fn write_item(
|
||||||
story_id: &str,
|
story_id: &str,
|
||||||
stage: &str,
|
stage: &str,
|
||||||
@@ -374,6 +387,8 @@ pub fn write_item(
|
|||||||
retry_count: Option<i64>,
|
retry_count: Option<i64>,
|
||||||
blocked: Option<bool>,
|
blocked: Option<bool>,
|
||||||
depends_on: Option<&str>,
|
depends_on: Option<&str>,
|
||||||
|
claimed_by: Option<&str>,
|
||||||
|
claimed_at: Option<f64>,
|
||||||
) {
|
) {
|
||||||
let Some(state_mutex) = CRDT_STATE.get() else {
|
let Some(state_mutex) = CRDT_STATE.get() else {
|
||||||
return;
|
return;
|
||||||
@@ -419,6 +434,16 @@ pub fn write_item(
|
|||||||
s.crdt.doc.items[idx].depends_on.set(d.to_string())
|
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.
|
// Broadcast a CrdtEvent if the stage actually changed.
|
||||||
let stage_changed = old_stage.as_deref() != Some(stage);
|
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,
|
"retry_count": retry_count.unwrap_or(0) as f64,
|
||||||
"blocked": blocked.unwrap_or(false),
|
"blocked": blocked.unwrap_or(false),
|
||||||
"depends_on": depends_on.unwrap_or(""),
|
"depends_on": depends_on.unwrap_or(""),
|
||||||
|
"claimed_by": claimed_by.unwrap_or(""),
|
||||||
|
"claimed_at": claimed_at.unwrap_or(0.0),
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -573,6 +600,73 @@ pub fn our_node_id() -> Option<String> {
|
|||||||
Some(hex::encode(&state.crdt.id))
|
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.
|
/// Write or update a node presence entry in the CRDT.
|
||||||
///
|
///
|
||||||
/// If a node with the given `node_id` already exists, only `last_seen`,
|
/// 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 retry_count: Option<i64>,
|
||||||
pub blocked: Option<bool>,
|
pub blocked: Option<bool>,
|
||||||
pub depends_on: Option<Vec<u32>>,
|
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`.
|
/// Hex-encoded OpId of the list insert op — cross-reference with `crdt_ops`.
|
||||||
pub content_index: String,
|
pub content_index: String,
|
||||||
pub is_deleted: bool,
|
pub is_deleted: bool,
|
||||||
@@ -793,6 +889,15 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
|
|||||||
_ => None,
|
_ => 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>();
|
let content_index = op.id.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||||
|
|
||||||
items.push(CrdtItemDump {
|
items.push(CrdtItemDump {
|
||||||
@@ -803,6 +908,8 @@ pub fn dump_crdt_state(story_id_filter: Option<&str>) -> CrdtStateDump {
|
|||||||
retry_count,
|
retry_count,
|
||||||
blocked,
|
blocked,
|
||||||
depends_on,
|
depends_on,
|
||||||
|
claimed_by,
|
||||||
|
claimed_at,
|
||||||
content_index,
|
content_index,
|
||||||
is_deleted: op.is_deleted,
|
is_deleted: op.is_deleted,
|
||||||
});
|
});
|
||||||
@@ -941,6 +1048,15 @@ fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemView> {
|
|||||||
_ => None,
|
_ => 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 {
|
Some(PipelineItemView {
|
||||||
story_id,
|
story_id,
|
||||||
stage,
|
stage,
|
||||||
@@ -949,6 +1065,8 @@ fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemView> {
|
|||||||
retry_count,
|
retry_count,
|
||||||
blocked,
|
blocked,
|
||||||
depends_on,
|
depends_on,
|
||||||
|
claimed_by,
|
||||||
|
claimed_at,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1049,6 +1167,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -1076,6 +1196,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -1106,6 +1228,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -1147,6 +1271,8 @@ mod tests {
|
|||||||
"retry_count": 2.0,
|
"retry_count": 2.0,
|
||||||
"blocked": true,
|
"blocked": true,
|
||||||
"depends_on": "[10,20]",
|
"depends_on": "[10,20]",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -1177,6 +1303,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
||||||
@@ -1213,6 +1341,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -1266,6 +1396,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -1385,6 +1517,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(bft_json_crdt::op::ROOT_ID, item).sign(&kp);
|
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,
|
"retry_count": 1.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "[10]",
|
"depends_on": "[10]",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(bft_json_crdt::op::ROOT_ID, item).sign(&kp);
|
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,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(bft_json_crdt::op::ROOT_ID, item).sign(&kp);
|
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,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
||||||
@@ -1495,6 +1635,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let insert_op = crdt.doc.items.insert(ROOT_ID, target_item).sign(&kp);
|
let insert_op = crdt.doc.items.insert(ROOT_ID, target_item).sign(&kp);
|
||||||
@@ -1608,6 +1750,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
|
|||||||
@@ -398,6 +398,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a);
|
let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a);
|
||||||
@@ -500,6 +502,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -662,6 +666,8 @@ name = "test"
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -717,6 +723,8 @@ name = "test"
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -785,6 +793,8 @@ name = "test"
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -839,6 +849,8 @@ name = "test"
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let phantom_op = source
|
let phantom_op = source
|
||||||
@@ -913,6 +925,8 @@ name = "test"
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op_a1 = crdt_a.doc.items.insert(ROOT_ID, item_a).sign(&kp_a);
|
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,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b);
|
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,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
||||||
@@ -1080,6 +1098,8 @@ name = "test"
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
||||||
@@ -1125,6 +1145,8 @@ name = "test"
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
||||||
@@ -1158,6 +1180,8 @@ name = "test"
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op1 = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
let op1 = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
||||||
@@ -1237,6 +1261,8 @@ name = "test"
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a);
|
let op1 = crdt_a.doc.items.insert(ROOT_ID, item).sign(&kp_a);
|
||||||
@@ -1302,6 +1328,8 @@ name = "test"
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b);
|
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,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op_a1 = crdt_a.doc.items.insert(ROOT_ID, item_a).sign(&kp_a);
|
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,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b);
|
let op_b1 = crdt_b.doc.items.insert(ROOT_ID, item_b).sign(&kp_b);
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ mod tests {
|
|||||||
"retry_count": 0.0,
|
"retry_count": 0.0,
|
||||||
"blocked": false,
|
"blocked": false,
|
||||||
"depends_on": "",
|
"depends_on": "",
|
||||||
|
"claimed_by": "",
|
||||||
|
"claimed_at": 0.0,
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
let op = crdt.doc.items.insert(ROOT_ID, item).sign(&kp);
|
||||||
|
|||||||
@@ -201,6 +201,8 @@ pub fn write_item_with_content(
|
|||||||
retry_count,
|
retry_count,
|
||||||
blocked,
|
blocked,
|
||||||
depends_on.as_deref(),
|
depends_on.as_deref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Shadow: pipeline_items table (only when DB is initialised).
|
// Shadow: pipeline_items table (only when DB is initialised).
|
||||||
@@ -267,6 +269,8 @@ pub fn move_item_stage(
|
|||||||
retry_count,
|
retry_count,
|
||||||
blocked,
|
blocked,
|
||||||
depends_on.as_deref(),
|
depends_on.as_deref(),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Shadow table.
|
// Shadow table.
|
||||||
|
|||||||
@@ -284,6 +284,8 @@ pub(super) fn tool_dump_crdt(args: &Value) -> Result<String, String> {
|
|||||||
"retry_count": item.retry_count,
|
"retry_count": item.retry_count,
|
||||||
"blocked": item.blocked,
|
"blocked": item.blocked,
|
||||||
"depends_on": item.depends_on,
|
"depends_on": item.depends_on,
|
||||||
|
"claimed_by": item.claimed_by,
|
||||||
|
"claimed_at": item.claimed_at,
|
||||||
"content_index": item.content_index,
|
"content_index": item.content_index,
|
||||||
"is_deleted": item.is_deleted,
|
"is_deleted": item.is_deleted,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ pub fn debug_crdt_handler(req: &poem::Request) -> poem::Response {
|
|||||||
"retry_count": item.retry_count,
|
"retry_count": item.retry_count,
|
||||||
"blocked": item.blocked,
|
"blocked": item.blocked,
|
||||||
"depends_on": item.depends_on,
|
"depends_on": item.depends_on,
|
||||||
|
"claimed_by": item.claimed_by,
|
||||||
|
"claimed_at": item.claimed_at,
|
||||||
"content_index": item.content_index,
|
"content_index": item.content_index,
|
||||||
"is_deleted": item.is_deleted,
|
"is_deleted": item.is_deleted,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -347,6 +347,8 @@ pub(crate) fn sweep_done_to_archived(done_retention: Duration) {
|
|||||||
None,
|
None,
|
||||||
Some(false),
|
Some(false),
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
slog!("[watcher] sweep: promoted {story_id} → 6_archived/");
|
slog!("[watcher] sweep: promoted {story_id} → 6_archived/");
|
||||||
}
|
}
|
||||||
|
|||||||
+61
-5
@@ -3,6 +3,7 @@
|
|||||||
#![recursion_limit = "256"]
|
#![recursion_limit = "256"]
|
||||||
|
|
||||||
mod agent_log;
|
mod agent_log;
|
||||||
|
mod agent_mode;
|
||||||
mod agents;
|
mod agents;
|
||||||
mod chat;
|
mod chat;
|
||||||
mod config;
|
mod config;
|
||||||
@@ -46,6 +47,10 @@ struct CliArgs {
|
|||||||
path: Option<String>,
|
path: Option<String>,
|
||||||
/// Whether the `init` subcommand was given.
|
/// Whether the `init` subcommand was given.
|
||||||
init: bool,
|
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`.
|
/// 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 port: Option<u16> = None;
|
||||||
let mut path: Option<String> = None;
|
let mut path: Option<String> = None;
|
||||||
let mut init = false;
|
let mut init = false;
|
||||||
|
let mut agent = false;
|
||||||
|
let mut rendezvous: Option<String> = None;
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
|
|
||||||
while i < args.len() {
|
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}'")),
|
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" => {
|
||||||
init = true;
|
init = true;
|
||||||
}
|
}
|
||||||
|
"agent" => {
|
||||||
|
agent = true;
|
||||||
|
}
|
||||||
a if a.starts_with('-') => {
|
a if a.starts_with('-') => {
|
||||||
return Err(format!("unknown option: {a}"));
|
return Err(format!("unknown option: {a}"));
|
||||||
}
|
}
|
||||||
@@ -98,17 +119,23 @@ fn parse_cli_args(args: &[String]) -> Result<CliArgs, String> {
|
|||||||
i += 1;
|
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() {
|
fn print_help() {
|
||||||
println!("huskies [OPTIONS] [PATH]");
|
println!("huskies [OPTIONS] [PATH]");
|
||||||
println!("huskies init [OPTIONS] [PATH]");
|
println!("huskies init [OPTIONS] [PATH]");
|
||||||
|
println!("huskies agent --rendezvous <URL> [OPTIONS] [PATH]");
|
||||||
println!();
|
println!();
|
||||||
println!("Serve a huskies project.");
|
println!("Serve a huskies project.");
|
||||||
println!();
|
println!();
|
||||||
println!("COMMANDS:");
|
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!();
|
||||||
println!("ARGS:");
|
println!("ARGS:");
|
||||||
println!(
|
println!(
|
||||||
@@ -120,6 +147,8 @@ fn print_help() {
|
|||||||
println!(" -h, --help Print this help and exit");
|
println!(" -h, --help Print this help and exit");
|
||||||
println!(" -V, --version Print the version and exit");
|
println!(" -V, --version Print the version and exit");
|
||||||
println!(" --port <PORT> Port to listen on (default: 3001). Persisted to project.toml.");
|
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`.
|
/// 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_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);
|
let explicit_path = resolve_path_arg(cli.path.as_deref(), &cwd);
|
||||||
|
|
||||||
// Port resolution: CLI flag > project.toml (loaded later) > default.
|
// 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.)
|
// (CRDT state layer is initialised above alongside the legacy pipeline.db.)
|
||||||
|
|
||||||
// Start the CRDT sync rendezvous client if configured in project.toml.
|
// Start the CRDT sync rendezvous client if configured in project.toml.
|
||||||
if let Some(ref root) = *app_state.project_root.lock().unwrap()
|
// In agent mode, the --rendezvous flag overrides project.toml.
|
||||||
&& let Ok(cfg) = config::ProjectConfig::load(root)
|
let rendezvous_url_for_sync = if is_agent {
|
||||||
&& let Some(rendezvous_url) = cfg.rendezvous
|
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);
|
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()));
|
let workflow = Arc::new(std::sync::Mutex::new(WorkflowState::default()));
|
||||||
|
|
||||||
// Event bus: broadcast channel for pipeline lifecycle events.
|
// Event bus: broadcast channel for pipeline lifecycle events.
|
||||||
@@ -886,6 +940,8 @@ name = "coder"
|
|||||||
assert_eq!(result.port, None);
|
assert_eq!(result.port, None);
|
||||||
assert_eq!(result.path, None);
|
assert_eq!(result.path, None);
|
||||||
assert!(!result.init);
|
assert!(!result.init);
|
||||||
|
assert!(!result.agent);
|
||||||
|
assert_eq!(result.rendezvous, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1145,6 +1145,8 @@ mod tests {
|
|||||||
retry_count: None,
|
retry_count: None,
|
||||||
blocked: None,
|
blocked: None,
|
||||||
depends_on: Some(vec![10, 20]),
|
depends_on: Some(vec![10, 20]),
|
||||||
|
claimed_by: None,
|
||||||
|
claimed_at: None,
|
||||||
};
|
};
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
|
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
|
||||||
@@ -1164,6 +1166,8 @@ mod tests {
|
|||||||
retry_count: Some(2),
|
retry_count: Some(2),
|
||||||
blocked: None,
|
blocked: None,
|
||||||
depends_on: None,
|
depends_on: None,
|
||||||
|
claimed_by: None,
|
||||||
|
claimed_at: None,
|
||||||
};
|
};
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(item.stage, Stage::Coding));
|
assert!(matches!(item.stage, Stage::Coding));
|
||||||
@@ -1180,6 +1184,8 @@ mod tests {
|
|||||||
retry_count: None,
|
retry_count: None,
|
||||||
blocked: None,
|
blocked: None,
|
||||||
depends_on: None,
|
depends_on: None,
|
||||||
|
claimed_by: None,
|
||||||
|
claimed_at: None,
|
||||||
};
|
};
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(item.stage, Stage::Merge { .. }));
|
assert!(matches!(item.stage, Stage::Merge { .. }));
|
||||||
@@ -1203,6 +1209,8 @@ mod tests {
|
|||||||
retry_count: None,
|
retry_count: None,
|
||||||
blocked: Some(true),
|
blocked: Some(true),
|
||||||
depends_on: None,
|
depends_on: None,
|
||||||
|
claimed_by: None,
|
||||||
|
claimed_at: None,
|
||||||
};
|
};
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -1224,6 +1232,8 @@ mod tests {
|
|||||||
retry_count: None,
|
retry_count: None,
|
||||||
blocked: Some(false),
|
blocked: Some(false),
|
||||||
depends_on: None,
|
depends_on: None,
|
||||||
|
claimed_by: None,
|
||||||
|
claimed_at: None,
|
||||||
};
|
};
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -1245,6 +1255,8 @@ mod tests {
|
|||||||
retry_count: None,
|
retry_count: None,
|
||||||
blocked: None,
|
blocked: None,
|
||||||
depends_on: None,
|
depends_on: None,
|
||||||
|
claimed_by: None,
|
||||||
|
claimed_at: None,
|
||||||
};
|
};
|
||||||
let result = PipelineItem::try_from(&view);
|
let result = PipelineItem::try_from(&view);
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
Reference in New Issue
Block a user