huskies: merge 490_story_crdt_state_layer_backed_by_sqlite

CRDT state layer backed by SQLite for pipeline state. Integrates the
BFT JSON CRDT crate with SQLite persistence via sqlx. Ops are persisted
and replayed on startup. Node identity via Ed25519 keypair.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dave
2026-04-07 16:12:19 +00:00
parent c621bca7b1
commit c73153dd4e
8 changed files with 1990 additions and 69 deletions
+86
View File
@@ -72,8 +72,62 @@ pub struct PipelineState {
}
/// Load the full pipeline state (all 5 active stages).
///
/// Reads from the CRDT document when available, falling back to the
/// filesystem for any items not yet in the CRDT (e.g. first run before
/// migration). Agent assignments are always overlaid from the in-memory
/// agent pool.
pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
let agent_map = build_active_agent_map(ctx);
// Try CRDT-first read.
if let Some(crdt_items) = crate::crdt_state::read_all_items() {
let mut state = PipelineState {
backlog: Vec::new(),
current: Vec::new(),
qa: Vec::new(),
merge: Vec::new(),
done: Vec::new(),
};
for item in crdt_items {
let agent = agent_map.get(&item.story_id).cloned();
let story = UpcomingStory {
story_id: item.story_id,
name: item.name,
error: None,
merge_failure: None,
agent,
review_hold: None,
qa: None,
retry_count: item.retry_count.map(|r| r as u32),
blocked: item.blocked,
depends_on: item.depends_on,
};
match item.stage.as_str() {
"1_backlog" => state.backlog.push(story),
"2_current" => state.current.push(story),
"3_qa" => state.qa.push(story),
"4_merge" => state.merge.push(story),
"5_done" => state.done.push(story),
_ => {} // ignore archived or unknown stages
}
}
// Sort each stage for deterministic output.
state.backlog.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.current.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.qa.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.merge.sort_by(|a, b| a.story_id.cmp(&b.story_id));
state.done.sort_by(|a, b| a.story_id.cmp(&b.story_id));
// Merge in any filesystem-only items not yet in the CRDT.
merge_filesystem_items(ctx, &mut state, &agent_map)?;
return Ok(state);
}
// Fallback: filesystem-only read (CRDT not initialised).
Ok(PipelineState {
backlog: load_stage_items(ctx, "1_backlog", &HashMap::new())?,
current: load_stage_items(ctx, "2_current", &agent_map)?,
@@ -83,6 +137,38 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
})
}
/// Merge filesystem items that are not already present in the CRDT state.
fn merge_filesystem_items(
ctx: &AppContext,
state: &mut PipelineState,
agent_map: &HashMap<String, AgentAssignment>,
) -> Result<(), String> {
let stages = [
("1_backlog", &mut state.backlog),
("2_current", &mut state.current),
("3_qa", &mut state.qa),
("4_merge", &mut state.merge),
("5_done", &mut state.done),
];
for (stage_dir, stage_vec) in stages {
let empty_map = HashMap::new();
let map = if stage_dir == "2_current" || stage_dir == "3_qa" || stage_dir == "4_merge" {
agent_map
} else {
&empty_map
};
let fs_items = load_stage_items(ctx, stage_dir, map)?;
for fs_item in fs_items {
if !stage_vec.iter().any(|s| s.story_id == fs_item.story_id) {
stage_vec.push(fs_item);
}
}
stage_vec.sort_by(|a, b| a.story_id.cmp(&b.story_id));
}
Ok(())
}
/// Build a map from story_id → AgentAssignment for all pending/running agents.
fn build_active_agent_map(ctx: &AppContext) -> HashMap<String, AgentAssignment> {
let agents = match ctx.agents.list_agents() {