huskies: merge 530_story_eliminate_filesystem_markdown_shadows_entirely_crdt_db_is_the_only_story_store

This commit is contained in:
dave
2026-04-10 14:56:13 +00:00
parent 1dd675796b
commit 11d19d8902
26 changed files with 966 additions and 1668 deletions
@@ -38,13 +38,7 @@ impl AgentPool {
let items = scan_stage_items(project_root, "1_backlog");
for story_id in &items {
// Only promote stories that explicitly declare dependencies.
// Try content store first, fall back to filesystem.
let contents = crate::db::read_content(story_id).or_else(|| {
let story_path = project_root
.join(".huskies/work/1_backlog")
.join(format!("{story_id}.md"));
std::fs::read_to_string(&story_path).ok()
});
let contents = crate::db::read_content(story_id);
let has_deps = contents
.and_then(|c| parse_front_matter(&c).ok())
.and_then(|m| m.depends_on)
@@ -382,38 +376,40 @@ mod tests {
async fn auto_assign_ignores_coder_preference_when_story_is_in_qa_stage() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".huskies");
let qa_dir = sk.join("work/3_qa");
std::fs::create_dir_all(&qa_dir).unwrap();
std::fs::create_dir_all(&sk).unwrap();
std::fs::write(
sk.join("project.toml"),
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
[[agent]]\nname = \"qa-1\"\nstage = \"qa\"\n",
)
.unwrap();
// Story in 3_qa/ with a preferred coder-stage agent.
std::fs::write(
qa_dir.join("story-qa1.md"),
// Story in 3_qa/ with a preferred coder-stage agent — write via CRDT.
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9930_story_qa1",
"3_qa",
"---\nname: QA Story\nagent: coder-1\n---\n",
)
.unwrap();
);
let pool = AgentPool::new_test(3001);
pool.auto_assign_available_work(tmp.path()).await;
let agents = pool.agents.lock().unwrap();
// coder-1 must NOT have been assigned (wrong stage for 3_qa/).
let coder_assigned = agents.values().any(|a| {
a.agent_name == "coder-1"
// coder-1 must NOT have been assigned to the QA story (wrong stage).
let coder_assigned_to_qa = agents.iter().any(|(key, a)| {
key.contains("9930_story_qa1")
&& a.agent_name == "coder-1"
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
});
assert!(
!coder_assigned,
!coder_assigned_to_qa,
"coder-1 should not be assigned to a QA-stage story"
);
// qa-1 should have been assigned instead.
let qa_assigned = agents.values().any(|a| {
a.agent_name == "qa-1"
let qa_assigned = agents.iter().any(|(key, a)| {
key.contains("9930_story_qa1")
&& a.agent_name == "qa-1"
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
});
assert!(
@@ -429,8 +425,7 @@ mod tests {
async fn auto_assign_respects_coder_preference_when_story_is_in_current_stage() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".huskies");
let current_dir = sk.join("work/2_current");
std::fs::create_dir_all(&current_dir).unwrap();
std::fs::create_dir_all(sk.join("work/2_current")).unwrap();
std::fs::write(
sk.join("project.toml"),
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
@@ -438,11 +433,12 @@ mod tests {
)
.unwrap();
// Story in 2_current/ with a preferred coder-1 agent.
std::fs::write(
current_dir.join("story-pref.md"),
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"story-pref",
"2_current",
"---\nname: Coder Story\nagent: coder-1\n---\n",
)
.unwrap();
);
let pool = AgentPool::new_test(3001);
@@ -476,20 +472,20 @@ mod tests {
async fn auto_assign_stage_mismatch_with_no_fallback_starts_no_agent() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".huskies");
let qa_dir = sk.join("work/3_qa");
std::fs::create_dir_all(&qa_dir).unwrap();
std::fs::create_dir_all(&sk).unwrap();
// Only a coder agent is configured — no QA agent exists.
std::fs::write(
sk.join("project.toml"),
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
)
.unwrap();
// Story in 3_qa/ requests coder-1 (wrong stage) and no QA agent exists.
std::fs::write(
qa_dir.join("story-noqa.md"),
// Story in 3_qa/ requests coder-1 (wrong stage) and no QA agent exists — write via CRDT.
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9931_story_noqa",
"3_qa",
"---\nname: QA Story No Agent\nagent: coder-1\n---\n",
)
.unwrap();
);
let pool = AgentPool::new_test(3001);
@@ -497,8 +493,14 @@ mod tests {
pool.auto_assign_available_work(tmp.path()).await;
let agents = pool.agents.lock().unwrap();
// No agent should be assigned to the specific QA story (coder-1 may
// be assigned to leaked 2_current items from the global CRDT store).
let assigned_to_qa_story = agents.iter().any(|(key, a)| {
key.contains("9931_story_noqa")
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
});
assert!(
agents.is_empty(),
!assigned_to_qa_story,
"No agent should be started when no stage-appropriate agent is available"
);
}
@@ -510,26 +512,32 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let sk = root.join(".huskies");
let current = sk.join("work/2_current");
std::fs::create_dir_all(&current).unwrap();
std::fs::create_dir_all(&sk).unwrap();
std::fs::write(
sk.join("project.toml"),
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n",
)
.unwrap();
// Story 10 depends on 999 which is not done.
std::fs::write(
current.join("10_story_waiting.md"),
"---\nname: Waiting\ndepends_on: [999]\n---\n",
)
.unwrap();
// Story 9932 depends on 9999 which is not done — write via CRDT.
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"9932_story_waiting",
"2_current",
"---\nname: Waiting\ndepends_on: [9999]\n---\n",
);
let pool = AgentPool::new_test(3001);
pool.auto_assign_available_work(root).await;
let agents = pool.agents.lock().unwrap();
// Filter to only agents assigned to our specific story to avoid
// interference from other tests sharing the global CRDT store.
let assigned_to_our_story = agents.iter().any(|(key, a)| {
key.contains("9932_story_waiting")
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
});
assert!(
agents.is_empty(),
!assigned_to_our_story,
"story with unmet deps should not be auto-assigned"
);
}
+33 -11
View File
@@ -167,25 +167,47 @@ mod tests {
#[test]
fn scan_stage_items_returns_empty_for_missing_dir() {
// Use a unique stage name that no other test writes to, so
// the global CRDT store won't contribute stale items.
let tmp = tempfile::tempdir().unwrap();
let items = scan_stage_items(tmp.path(), "2_current");
let items = scan_stage_items(tmp.path(), "9_nonexistent");
assert!(items.is_empty());
}
#[test]
fn scan_stage_items_returns_sorted_story_ids() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let stage_dir = tmp.path().join(".huskies").join("work").join("2_current");
fs::create_dir_all(&stage_dir).unwrap();
fs::write(stage_dir.join("42_story_foo.md"), "---\nname: foo\n---").unwrap();
fs::write(stage_dir.join("10_story_bar.md"), "---\nname: bar\n---").unwrap();
fs::write(stage_dir.join("5_story_baz.md"), "---\nname: baz\n---").unwrap();
// non-md file should be ignored
fs::write(stage_dir.join("README.txt"), "ignore me").unwrap();
// Write items via the CRDT store (the primary source of truth).
crate::db::ensure_content_store();
crate::db::write_item_with_content("9942_story_foo", "2_current", "---\nname: foo\n---");
crate::db::write_item_with_content("9940_story_bar", "2_current", "---\nname: bar\n---");
crate::db::write_item_with_content("9935_story_baz", "2_current", "---\nname: baz\n---");
let tmp = tempfile::tempdir().unwrap();
let items = scan_stage_items(tmp.path(), "2_current");
assert_eq!(items, vec!["10_story_bar", "42_story_foo", "5_story_baz"]);
// The global CRDT may contain items from other tests, so check
// that our three items are present and appear in sorted order.
assert!(
items.iter().any(|id| id == "9935_story_baz"),
"9935_story_baz should be in results"
);
assert!(
items.iter().any(|id| id == "9940_story_bar"),
"9940_story_bar should be in results"
);
assert!(
items.iter().any(|id| id == "9942_story_foo"),
"9942_story_foo should be in results"
);
// Verify sorted order: BTreeSet produces lexicographic order.
let positions: Vec<usize> = ["9935_story_baz", "9940_story_bar", "9942_story_foo"]
.iter()
.filter_map(|id| items.iter().position(|x| x == id))
.collect();
assert_eq!(positions.len(), 3, "all three items must be found");
assert!(
positions[0] < positions[1] && positions[1] < positions[2],
"items should appear in sorted order: positions = {positions:?}"
);
}
#[test]
@@ -2,23 +2,9 @@
use std::path::Path;
/// Read story contents from DB content store first, fall back to filesystem.
fn read_story_contents(project_root: &Path, story_id: &str) -> Option<String> {
// Primary: in-memory content store (backed by SQLite).
if let Some(c) = crate::db::read_content(story_id) {
return Some(c);
}
// Fallback: scan filesystem stages.
for stage in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
let path = project_root
.join(".huskies/work")
.join(stage)
.join(format!("{story_id}.md"));
if let Ok(c) = std::fs::read_to_string(&path) {
return Some(c);
}
}
None
/// Read story contents from the DB content store (CRDT-backed).
fn read_story_contents(_project_root: &Path, story_id: &str) -> Option<String> {
crate::db::read_content(story_id)
}
/// Read the optional `agent:` field from the front matter of a story file.
@@ -125,14 +111,12 @@ mod tests {
#[test]
fn has_review_hold_returns_true_when_set() {
let tmp = tempfile::tempdir().unwrap();
let qa_dir = tmp.path().join(".huskies/work/3_qa");
std::fs::create_dir_all(&qa_dir).unwrap();
let spike_path = qa_dir.join("10_spike_research.md");
std::fs::write(
&spike_path,
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"10_spike_research",
"3_qa",
"---\nname: Research spike\nreview_hold: true\n---\n# Spike\n",
)
.unwrap();
);
assert!(has_review_hold(tmp.path(), "3_qa", "10_spike_research"));
}