wip(929): stage 1 — migrate chat/commands/* off yaml_legacy

Each chat command that previously read parse_front_matter for story
metadata (name, agent, depends_on, blocked, retry_count, merge_failure,
qa_mode) now reads from the typed CRDT API:

- WorkItem (via crdt_state::read_item) for pipeline-item registers.
- MergeJobView (via crdt_state::read_merge_job) for the merge failure
  detail text, which has its own LWW-map CRDT entry.

Files migrated: depends.rs, freeze.rs, move_story.rs, overview.rs,
status/render.rs, triage.rs, unblock.rs, unreleased.rs.

unblock.rs: also removes the legacy front-matter cleanup branch that
fired when the typed Blocked→Coding transition failed. Post-929 there
is no YAML on disk to clean; the fallback now just resets retry_count
in the CRDT.

triage.rs: drops the YAML-only `review_hold` and `coverage_baseline`
fields from the dump. These have no CRDT register and were never
load-bearing on the triage output; if needed later, add a CRDT register
and surface it back.

Tests:
- The three status/render merge-failure rendering tests now seed a
  MergeJob CRDT entry via write_merge_job instead of writing YAML.
- The unblock test that asserted YAML cleanup on disk is now an assertion
  on the CRDT registers (blocked=false, retry_count=0). Also re-seeded
  in `2_blocked` stage so the typed Blocked → Coding transition actually
  fires (not the fallback path).

All 2855 tests pass; fmt clean; clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timmy
2026-05-12 18:41:43 +01:00
parent b940b95ec3
commit a49a1cf7cb
9 changed files with 88 additions and 180 deletions
+5 -6
View File
@@ -7,7 +7,6 @@
//! Passing no dependency numbers clears the field entirely. //! Passing no dependency numbers clears the field entirely.
use super::CommandContext; use super::CommandContext;
use crate::db::yaml_legacy::parse_front_matter;
/// Handle the `depends` command. /// Handle the `depends` command.
/// ///
@@ -51,7 +50,7 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
} }
// Find the story by numeric prefix: CRDT → content store → filesystem. // Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, _stage_dir, _path, content) = let (story_id, _stage_dir, _path, _content) =
match crate::chat::lookup::find_story_by_number(ctx.effective_root(), num_str) { match crate::chat::lookup::find_story_by_number(ctx.effective_root(), num_str) {
Some(found) => found, Some(found) => found,
None => { None => {
@@ -61,10 +60,10 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
} }
}; };
let story_name = content // Story name comes from the CRDT register, not the on-disk YAML
.as_deref() // (story 929 — CRDT is the sole source of story metadata).
.and_then(|c| parse_front_matter(c).ok()) let story_name = crate::crdt_state::read_item(&story_id)
.and_then(|m| m.name) .and_then(|w| w.name().map(str::to_string))
.unwrap_or_else(|| story_id.clone()); .unwrap_or_else(|| story_id.clone());
// Write depends_on to the typed CRDT register — single source of truth. // Write depends_on to the typed CRDT register — single source of truth.
+5 -8
View File
@@ -4,7 +4,6 @@
//! advancement and auto-assign until `unfreeze <number>` restores the prior stage. //! advancement and auto-assign until `unfreeze <number>` restores the prior stage.
use super::CommandContext; use super::CommandContext;
use crate::db::yaml_legacy::parse_front_matter;
use std::path::Path; use std::path::Path;
/// Handle the `freeze` command. /// Handle the `freeze` command.
@@ -93,15 +92,13 @@ fn unfreeze_by_story_id(story_id: &str) -> String {
} }
} }
/// Look up the display name for a story by reading its content store entry. /// Look up the display name for a story from the CRDT name register
/// (story 929 — CRDT is the sole source of story metadata).
/// ///
/// Falls back to `story_id` if the content is missing or the front matter /// Falls back to `story_id` if no CRDT entry exists.
/// cannot be parsed.
fn resolve_story_name(story_id: &str) -> String { fn resolve_story_name(story_id: &str) -> String {
crate::db::read_content(story_id) crate::crdt_state::read_item(story_id)
.as_deref() .and_then(|w| w.name().map(str::to_string))
.and_then(|c| parse_front_matter(c).ok())
.and_then(|m| m.name)
.unwrap_or_else(|| story_id.to_string()) .unwrap_or_else(|| story_id.to_string())
} }
+4 -4
View File
@@ -46,7 +46,7 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
} }
// Find the story by numeric prefix: CRDT → content store → filesystem. // Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, _stage_dir, _path, content) = let (story_id, _stage_dir, _path, _content) =
match crate::chat::lookup::find_story_by_number(ctx.effective_root(), num_str) { match crate::chat::lookup::find_story_by_number(ctx.effective_root(), num_str) {
Some(found) => found, Some(found) => found,
None => { None => {
@@ -56,9 +56,9 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
} }
}; };
let found_name = content // Display name comes from the CRDT name register (story 929).
.and_then(|c| crate::db::yaml_legacy::parse_front_matter(&c).ok()) let found_name =
.and_then(|m| m.name); crate::crdt_state::read_item(&story_id).and_then(|w| w.name().map(str::to_string));
let display_name = found_name.as_deref().unwrap_or(&story_id); let display_name = found_name.as_deref().unwrap_or(&story_id);
+4 -6
View File
@@ -105,13 +105,11 @@ fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option<Stri
if hash.is_empty() { None } else { Some(hash) } if hash.is_empty() { None } else { Some(hash) }
} }
/// Find the human-readable name of a story by searching CRDT then content store. /// Find the human-readable name of a story from the CRDT name register
/// (story 929 — CRDT is the sole source of story metadata).
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> { fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
let (_, _, _, content) = crate::chat::lookup::find_story_by_number(root, num_str)?; let (story_id, _, _, _) = crate::chat::lookup::find_story_by_number(root, num_str)?;
let content = content?; crate::crdt_state::read_item(&story_id).and_then(|w| w.name().map(str::to_string))
crate::db::yaml_legacy::parse_front_matter(&content)
.ok()
.and_then(|m| m.name)
} }
/// Return the `git show --stat` output for a commit. /// Return the `git show --stat` output for a commit.
+5 -4
View File
@@ -81,14 +81,15 @@ pub(crate) fn build_status_from_items(
.unwrap_or_default() .unwrap_or_default()
.into_iter() .into_iter()
.collect(); .collect();
// Merge-failure detail now lives on the typed MergeJob CRDT entry
// (story 929 — CRDT is the sole source of metadata).
let merge_failures: HashMap<String, String> = items let merge_failures: HashMap<String, String> = items
.iter() .iter()
.filter(|i| matches!(i.stage, Stage::Merge { .. })) .filter(|i| matches!(i.stage, Stage::Merge { .. }))
.filter_map(|i| { .filter_map(|i| {
let content = crate::db::read_content(&i.story_id.0)?; let job = crate::crdt_state::read_merge_job(&i.story_id.0)?;
let meta = crate::db::yaml_legacy::parse_front_matter(&content).ok()?; let err = job.error?;
let mf = meta.merge_failure?; Some((i.story_id.0.clone(), err))
Some((i.story_id.0.clone(), mf))
}) })
.collect(); .collect();
+21 -29
View File
@@ -522,14 +522,15 @@ fn merge_item_with_active_agent_shows_robot() {
fn merge_item_with_failure_shows_stop_sign_and_snippet() { fn merge_item_with_failure_shows_stop_sign_and_snippet() {
use tempfile::TempDir; use tempfile::TempDir;
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store(); crate::db::ensure_content_store();
crate::db::write_item_with_content( // Post-929: merge_failure detail lives on the MergeJob CRDT entry, not in YAML.
crate::crdt_state::write_merge_job(
"903_story_merge_fail", "903_story_merge_fail",
"4_merge", "failed",
"---\nname: Failed Story\nmerge_failure: \"conflicts in src/lib.rs\"\n---\n", 0.0,
crate::db::ItemMeta::from_yaml( None,
"---\nname: Failed Story\nmerge_failure: \"conflicts in src/lib.rs\"\n---\n", Some("conflicts in src/lib.rs"),
),
); );
let items = vec![make_item( let items = vec![make_item(
"903_story_merge_fail", "903_story_merge_fail",
@@ -552,14 +553,16 @@ fn merge_item_with_failure_shows_stop_sign_and_snippet() {
fn merge_item_failure_snippet_truncated_at_120_chars() { fn merge_item_failure_snippet_truncated_at_120_chars() {
use tempfile::TempDir; use tempfile::TempDir;
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store(); crate::db::ensure_content_store();
let long_reason = "x".repeat(200); let long_reason = "x".repeat(200);
let content = format!("---\nname: Long Fail\nmerge_failure: \"{long_reason}\"\n---\n"); // Post-929: merge_failure detail lives on the MergeJob CRDT entry, not in YAML.
crate::db::write_item_with_content( crate::crdt_state::write_merge_job(
"904_story_long_fail", "904_story_long_fail",
"4_merge", "failed",
&content, 0.0,
crate::db::ItemMeta::from_yaml(&content), None,
Some(&long_reason),
); );
let items = vec![make_item("904_story_long_fail", "Long Fail", merge_stage())]; let items = vec![make_item("904_story_long_fail", "Long Fail", merge_stage())];
let agents = AgentPool::new_test(3000); let agents = AgentPool::new_test(3000);
@@ -585,27 +588,16 @@ fn merge_item_failure_snippet_truncated_at_120_chars() {
fn merge_item_failure_snippet_is_first_non_empty_line() { fn merge_item_failure_snippet_is_first_non_empty_line() {
use tempfile::TempDir; use tempfile::TempDir;
let tmp = TempDir::new().unwrap(); let tmp = TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store(); crate::db::ensure_content_store();
// Multi-line failure reason — first non-empty line should be used. // Multi-line failure reason — first non-empty line should be used.
let reason = "\nfirst line of error\nsecond line"; // Post-929: merge_failure detail lives on the MergeJob CRDT entry.
let content = format!( crate::crdt_state::write_merge_job(
"---\nname: Multi Line\nmerge_failure: \"{}\" \n---\n",
reason.replace('\n', "\\n")
);
crate::db::write_item_with_content(
"905_story_multiline", "905_story_multiline",
"4_merge", "failed",
&content, 0.0,
crate::db::ItemMeta::from_yaml(&content), None,
); Some("\nfirst line of error\nsecond line"),
// Write with literal \n as the content (simulating stored text with newlines).
let content2 =
"---\nname: Multi Line\nmerge_failure: |\n \n first line of error\n second line\n---\n";
crate::db::write_item_with_content(
"905_story_multiline",
"4_merge",
content2,
crate::db::ItemMeta::from_yaml(content2),
); );
let items = vec![make_item( let items = vec![make_item(
"905_story_multiline", "905_story_multiline",
+21 -23
View File
@@ -69,10 +69,12 @@ fn build_triage_dump(
None => return format!("Story {num_str}: content not found in content store."), None => return format!("Story {num_str}: content not found in content store."),
}; };
let meta = crate::db::yaml_legacy::parse_front_matter(&contents).ok(); // Story metadata now comes from the CRDT registers and adjacent CRDT entries
let name = meta // (MergeJob.error), not from YAML front matter (story 929).
let crdt_item = crate::crdt_state::read_item(story_id);
let name = crdt_item
.as_ref() .as_ref()
.and_then(|m| m.name.as_deref()) .and_then(|w| w.name())
.unwrap_or("(unnamed)"); .unwrap_or("(unnamed)");
let mut out = String::new(); let mut out = String::new();
@@ -83,40 +85,36 @@ fn build_triage_dump(
let dir_name = crate::pipeline_state::stage_dir_name(&item.stage); let dir_name = crate::pipeline_state::stage_dir_name(&item.stage);
out.push_str(&format!("**Stage:** {stage_name} (`{dir_name}`)\n\n")); out.push_str(&format!("**Stage:** {stage_name} (`{dir_name}`)\n\n"));
// ---- Front matter fields ---- // ---- CRDT metadata ----
if let Some(ref m) = meta { if let Some(ref w) = crdt_item {
let mut fields: Vec<String> = Vec::new(); let mut fields: Vec<String> = Vec::new();
if let Some(true) = m.blocked { if w.blocked() {
fields.push("**blocked:** true".to_string()); fields.push("**blocked:** true".to_string());
} }
if let Some(ref agent) = m.agent { if let Some(agent) = w.agent() {
fields.push(format!("**agent:** {agent}")); fields.push(format!("**agent:** {agent}"));
} }
if let Some(ref qa) = m.qa { if let Some(qa) = w.qa_mode() {
fields.push(format!("**qa:** {qa}")); fields.push(format!("**qa:** {qa}"));
} }
if let Some(true) = m.review_hold { let rc = w.retry_count();
fields.push("**review_hold:** true".to_string()); if rc > 0 {
}
if let Some(rc) = m.retry_count
&& rc > 0
{
fields.push(format!("**retry_count:** {rc}")); fields.push(format!("**retry_count:** {rc}"));
} }
if let Some(ref cb) = m.coverage_baseline { // merge_failure detail lives on the MergeJob CRDT entry, not on the
fields.push(format!("**coverage_baseline:** {cb}")); // pipeline item itself.
} if let Some(job) = crate::crdt_state::read_merge_job(story_id)
if let Some(ref mf) = m.merge_failure { && let Some(err) = job.error
fields.push(format!("**merge_failure:** {mf}"));
}
if let Some(ref deps) = m.depends_on
&& !deps.is_empty()
{ {
fields.push(format!("**merge_failure:** {err}"));
}
let deps = w.depends_on();
if !deps.is_empty() {
let nums: Vec<String> = deps.iter().map(|n| format!("#{n}")).collect(); let nums: Vec<String> = deps.iter().map(|n| format!("#{n}")).collect();
fields.push(format!("**depends_on:** {}", nums.join(", "))); fields.push(format!("**depends_on:** {}", nums.join(", ")));
} }
if !fields.is_empty() { if !fields.is_empty() {
out.push_str("**Front matter:**\n"); out.push_str("**Metadata:**\n");
for f in &fields { for f in &fields {
out.push_str(&format!("{f}\n")); out.push_str(&format!("{f}\n"));
} }
+16 -51
View File
@@ -5,7 +5,6 @@
//! and returns a confirmation. //! and returns a confirmation.
use super::CommandContext; use super::CommandContext;
use crate::db::yaml_legacy::clear_front_matter_field_in_content;
use std::path::Path; use std::path::Path;
/// Handle the `unblock` command. /// Handle the `unblock` command.
@@ -80,37 +79,14 @@ fn unblock_by_story_id(story_id: &str) -> String {
// the CRDT. // the CRDT.
if let Err(e) = crate::agents::lifecycle::transition_to_unblocked(story_id) { if let Err(e) = crate::agents::lifecycle::transition_to_unblocked(story_id) {
// If the typed transition fails (e.g. a legacy Archived item with no // If the typed transition fails (e.g. a legacy Archived item with no
// valid `Unblock` transition out of its current stage), fall back to // valid `Unblock` transition out of its current stage), at least
// a direct CRDT/content cleanup. The legacy front-matter cleanup is // reset retry_count directly in the CRDT so the agent doesn't stay
// gated on content actually still containing YAML, so post-865 // tagged with a stale fail counter. Post-929 there's no FS shadow
// CRDT-only stories don't hit a parse error. // to clean up alongside.
crate::slog_warn!( crate::slog_warn!(
"[unblock] State-machine transition failed for '{story_id}': {e}. \ "[unblock] State-machine transition failed for '{story_id}': {e}. \
Falling back to direct CRDT cleanup." Falling back to direct CRDT retry_count reset."
); );
if let Some(content) = crate::db::read_content(story_id) {
// Only run legacy front-matter cleanup if the stored content still
// begins with a `---` YAML block. Post-865 content has been
// stripped and would no-op here anyway.
if content.trim_start().starts_with("---") {
let mut updated = content;
updated = clear_front_matter_field_in_content(&updated, "blocked");
updated = clear_front_matter_field_in_content(&updated, "merge_failure");
updated = clear_front_matter_field_in_content(&updated, "retry_count");
crate::db::write_content(story_id, &updated);
let stage = typed_item
.as_ref()
.map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| "2_current".to_string());
crate::db::write_item_with_content(
story_id,
&stage,
&updated,
crate::db::ItemMeta::from_yaml(&updated),
);
}
}
crate::crdt_state::set_retry_count(story_id, 0); crate::crdt_state::set_retry_count(story_id, 0);
} }
@@ -221,14 +197,15 @@ mod tests {
// global content store. // global content store.
write_story_file( write_story_file(
tmp.path(), tmp.path(),
"2_current", "2_blocked",
"9903_story_stuck.md", "9903_story_stuck.md",
"---\nname: Stuck Story\nblocked: true\nretry_count: 5\n---\n# Story\n", "---\nname: Stuck Story\nblocked: true\nretry_count: 5\n---\n# Story\n",
); );
// Seed the story in the CRDT with retry_count=5 so set_retry_count can reset it. // Seed the story in the CRDT in 2_blocked stage so the typed
// Blocked → Coding transition fires and clears `blocked` properly.
crate::crdt_state::write_item( crate::crdt_state::write_item(
"9903_story_stuck", "9903_story_stuck",
"2_current", "2_blocked",
Some("Stuck Story"), Some("Stuck Story"),
None, None,
Some(5), Some(5),
@@ -249,25 +226,9 @@ mod tests {
"should include story_id in response: {output}" "should include story_id in response: {output}"
); );
// The unblock command writes back via the content store; blocked field should be gone. // Post-929: the CRDT is the sole source of truth; we no longer clean
let contents = crate::db::read_content("9903_story_stuck") // YAML front matter from on-disk content. Verify the CRDT registers
.or_else(|| { // were updated correctly.
std::fs::read_to_string(
tmp.path()
.join(".huskies/work/2_current/9903_story_stuck.md"),
)
.ok()
})
.expect("story content should be readable after unblock");
assert!(
!contents.contains("blocked:"),
"blocked field should be removed: {contents}"
);
// retry_count is now in the CRDT, not in front-matter.
assert!(
!contents.contains("retry_count:"),
"retry_count should be cleared from front-matter after unblock: {contents}"
);
let item = crate::crdt_state::read_item("9903_story_stuck") let item = crate::crdt_state::read_item("9903_story_stuck")
.expect("story should be in CRDT after unblock"); .expect("story should be in CRDT after unblock");
assert_eq!( assert_eq!(
@@ -275,6 +236,10 @@ mod tests {
0, 0,
"retry_count should be reset to 0 in CRDT after unblock" "retry_count should be reset to 0 in CRDT after unblock"
); );
assert!(
!item.blocked(),
"blocked flag should be cleared in CRDT after unblock"
);
} }
#[test] #[test]
+7 -49
View File
@@ -140,55 +140,13 @@ fn slug_to_name(slug: &str) -> String {
words.join(" ") words.join(" ")
} }
/// Find the human-readable name of a story by searching content store then filesystem. /// Find the human-readable name of a story from the CRDT (story 929 —
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> { /// CRDT is the sole source of story metadata).
// Try content store first. fn find_story_name(_root: &std::path::Path, num_str: &str) -> Option<String> {
for id in crate::db::all_content_ids() { let items = crate::crdt_state::read_all_items()?;
let file_num = id.split('_').next().unwrap_or(""); for item in items {
if file_num == num_str if item.story_id().split('_').next().unwrap_or("") == num_str {
&& let Some(c) = crate::db::read_content(&id) return item.name().map(str::to_string);
{
return crate::db::yaml_legacy::parse_front_matter(&c)
.ok()
.and_then(|m| m.name);
}
}
// Fallback: filesystem scan.
const STAGES: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
for stage in STAGES {
let dir = root.join(".huskies").join("work").join(stage);
if !dir.exists() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == num_str {
return std::fs::read_to_string(&path).ok().and_then(|c| {
crate::db::yaml_legacy::parse_front_matter(&c)
.ok()
.and_then(|m| m.name)
});
}
}
}
} }
} }
None None