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"));
}
+2 -6
View File
@@ -398,19 +398,15 @@ pub(super) fn spawn_pipeline_advance(
});
}
/// Resolve QA mode from the content store (or filesystem fallback).
/// Resolve QA mode from the content store.
fn resolve_qa_mode_from_store(
project_root: &Path,
_project_root: &Path,
story_id: &str,
default: crate::io::story_metadata::QaMode,
) -> crate::io::story_metadata::QaMode {
if let Some(contents) = crate::db::read_content(story_id) {
return crate::io::story_metadata::resolve_qa_mode_from_content(&contents, default);
}
// Fallback: try filesystem.
if let Ok(path) = crate::http::workflow::find_story_file_on_disk(project_root, story_id) {
return crate::io::story_metadata::resolve_qa_mode(&path, default);
}
default
}
+20 -23
View File
@@ -92,13 +92,7 @@ impl AgentPool {
// honour `agent: coder-opus` written by the `assign` command — mirroring
// the auto_assign path (bug 379).
let front_matter_agent: Option<String> = if agent_name.is_none() {
find_active_story_stage(project_root, story_id).and_then(|stage_dir| {
let path = project_root
.join(".huskies")
.join("work")
.join(stage_dir)
.join(format!("{story_id}.md"));
let contents = std::fs::read_to_string(path).ok()?;
crate::db::read_content(story_id).and_then(|contents| {
crate::io::story_metadata::parse_front_matter(&contents).ok()?.agent
})
} else {
@@ -1218,11 +1212,12 @@ stage = "coder"
[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
)
.unwrap();
fs::write(
sk_dir.join("work/2_current/310_story_foo.md"),
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"310_story_foo",
"2_current",
"---\nname: Foo\n---\n",
)
.unwrap();
);
let pool = AgentPool::new_test(3099);
let result = pool
@@ -1248,22 +1243,23 @@ stage = "coder"
let root = tmp.path();
let sk_dir = root.join(".huskies");
fs::create_dir_all(sk_dir.join("work/3_qa")).unwrap();
fs::create_dir_all(&sk_dir).unwrap();
fs::write(
sk_dir.join("project.toml"),
"[[agent]]\nname = \"coder-1\"\nstage = \"coder\"\n\n\
[[agent]]\nname = \"qa\"\nstage = \"qa\"\n",
)
.unwrap();
fs::write(
sk_dir.join("work/3_qa/42_story_bar.md"),
"---\nname: Bar\n---\n",
)
.unwrap();
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"8842_story_qa_guard",
"3_qa",
"---\nname: QA Guard\n---\n",
);
let pool = AgentPool::new_test(3099);
let result = pool
.start_agent(root, "42_story_bar", Some("coder-1"), None)
.start_agent(root, "8842_story_qa_guard", Some("coder-1"), None)
.await;
assert!(
@@ -1285,18 +1281,19 @@ stage = "coder"
let root = tmp.path();
let sk_dir = root.join(".huskies");
fs::create_dir_all(sk_dir.join("work/4_merge")).unwrap();
fs::create_dir_all(&sk_dir).unwrap();
fs::write(
sk_dir.join("project.toml"),
"[[agent]]\nname = \"qa\"\nstage = \"qa\"\n\n\
[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
)
.unwrap();
fs::write(
sk_dir.join("work/4_merge/55_story_baz.md"),
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"55_story_baz",
"4_merge",
"---\nname: Baz\n---\n",
)
.unwrap();
);
let pool = AgentPool::new_test(3099);
let result = pool
+22 -36
View File
@@ -22,26 +22,12 @@ impl AgentPool {
/// Return the active pipeline stage directory name for `story_id`, or `None` if the
/// story is not in any active stage (`2_current/`, `3_qa/`, `4_merge/`).
pub(super) fn find_active_story_stage(project_root: &Path, story_id: &str) -> Option<&'static str> {
// Try typed CRDT projection first — primary source of truth.
pub(super) fn find_active_story_stage(_project_root: &Path, story_id: &str) -> Option<&'static str> {
if let Ok(Some(item)) = crate::pipeline_state::read_typed(story_id)
&& item.stage.is_active()
{
return Some(item.stage.dir_name());
}
// Also check filesystem (backwards compat / tests).
const STAGES: [&str; 3] = ["2_current", "3_qa", "4_merge"];
for stage in &STAGES {
let path = project_root
.join(".huskies")
.join("work")
.join(stage)
.join(format!("{story_id}.md"));
if path.exists() {
return Some(stage);
}
}
None
}
@@ -51,42 +37,42 @@ mod tests {
#[test]
fn find_active_story_stage_detects_current() {
use std::fs;
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"10_story_test",
"2_current",
"---\nname: Test\n---\n",
);
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".huskies/work/2_current");
fs::create_dir_all(&current).unwrap();
fs::write(current.join("10_story_test.md"), "test").unwrap();
assert_eq!(
find_active_story_stage(root, "10_story_test"),
find_active_story_stage(tmp.path(), "10_story_test"),
Some("2_current")
);
}
#[test]
fn find_active_story_stage_detects_qa() {
use std::fs;
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"11_story_test",
"3_qa",
"---\nname: Test\n---\n",
);
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let qa = root.join(".huskies/work/3_qa");
fs::create_dir_all(&qa).unwrap();
fs::write(qa.join("11_story_test.md"), "test").unwrap();
assert_eq!(find_active_story_stage(root, "11_story_test"), Some("3_qa"));
assert_eq!(find_active_story_stage(tmp.path(), "11_story_test"), Some("3_qa"));
}
#[test]
fn find_active_story_stage_detects_merge() {
use std::fs;
crate::db::ensure_content_store();
crate::db::write_item_with_content(
"12_story_test",
"4_merge",
"---\nname: Test\n---\n",
);
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let merge = root.join(".huskies/work/4_merge");
fs::create_dir_all(&merge).unwrap();
fs::write(merge.join("12_story_test.md"), "test").unwrap();
assert_eq!(
find_active_story_stage(root, "12_story_test"),
find_active_story_stage(tmp.path(), "12_story_test"),
Some("4_merge")
);
}