diff --git a/server/src/chat/commands/depends.rs b/server/src/chat/commands/depends.rs index ad1e8f02..9cde9f91 100644 --- a/server/src/chat/commands/depends.rs +++ b/server/src/chat/commands/depends.rs @@ -7,7 +7,6 @@ //! Passing no dependency numbers clears the field entirely. use super::CommandContext; -use crate::db::yaml_legacy::parse_front_matter; /// Handle the `depends` command. /// @@ -51,7 +50,7 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option { } // 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) { Some(found) => found, None => { @@ -61,10 +60,10 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option { } }; - let story_name = content - .as_deref() - .and_then(|c| parse_front_matter(c).ok()) - .and_then(|m| m.name) + // Story name comes from the CRDT register, not the on-disk YAML + // (story 929 — CRDT is the sole source of story metadata). + let story_name = crate::crdt_state::read_item(&story_id) + .and_then(|w| w.name().map(str::to_string)) .unwrap_or_else(|| story_id.clone()); // Write depends_on to the typed CRDT register — single source of truth. diff --git a/server/src/chat/commands/freeze.rs b/server/src/chat/commands/freeze.rs index 98ff5232..fb286504 100644 --- a/server/src/chat/commands/freeze.rs +++ b/server/src/chat/commands/freeze.rs @@ -4,7 +4,6 @@ //! advancement and auto-assign until `unfreeze ` restores the prior stage. use super::CommandContext; -use crate::db::yaml_legacy::parse_front_matter; use std::path::Path; /// 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 -/// cannot be parsed. +/// Falls back to `story_id` if no CRDT entry exists. fn resolve_story_name(story_id: &str) -> String { - crate::db::read_content(story_id) - .as_deref() - .and_then(|c| parse_front_matter(c).ok()) - .and_then(|m| m.name) + crate::crdt_state::read_item(story_id) + .and_then(|w| w.name().map(str::to_string)) .unwrap_or_else(|| story_id.to_string()) } diff --git a/server/src/chat/commands/move_story.rs b/server/src/chat/commands/move_story.rs index cb3b9b0a..bfdfb260 100644 --- a/server/src/chat/commands/move_story.rs +++ b/server/src/chat/commands/move_story.rs @@ -46,7 +46,7 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option { } // 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) { Some(found) => found, None => { @@ -56,9 +56,9 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option { } }; - let found_name = content - .and_then(|c| crate::db::yaml_legacy::parse_front_matter(&c).ok()) - .and_then(|m| m.name); + // Display name comes from the CRDT name register (story 929). + let found_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); diff --git a/server/src/chat/commands/overview.rs b/server/src/chat/commands/overview.rs index 5ca676c8..b673eaee 100644 --- a/server/src/chat/commands/overview.rs +++ b/server/src/chat/commands/overview.rs @@ -105,13 +105,11 @@ fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option Option { - let (_, _, _, content) = crate::chat::lookup::find_story_by_number(root, num_str)?; - let content = content?; - crate::db::yaml_legacy::parse_front_matter(&content) - .ok() - .and_then(|m| m.name) + let (story_id, _, _, _) = crate::chat::lookup::find_story_by_number(root, num_str)?; + crate::crdt_state::read_item(&story_id).and_then(|w| w.name().map(str::to_string)) } /// Return the `git show --stat` output for a commit. diff --git a/server/src/chat/commands/status/render.rs b/server/src/chat/commands/status/render.rs index 173ecf9e..46391294 100644 --- a/server/src/chat/commands/status/render.rs +++ b/server/src/chat/commands/status/render.rs @@ -81,14 +81,15 @@ pub(crate) fn build_status_from_items( .unwrap_or_default() .into_iter() .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 = items .iter() .filter(|i| matches!(i.stage, Stage::Merge { .. })) .filter_map(|i| { - let content = crate::db::read_content(&i.story_id.0)?; - let meta = crate::db::yaml_legacy::parse_front_matter(&content).ok()?; - let mf = meta.merge_failure?; - Some((i.story_id.0.clone(), mf)) + let job = crate::crdt_state::read_merge_job(&i.story_id.0)?; + let err = job.error?; + Some((i.story_id.0.clone(), err)) }) .collect(); diff --git a/server/src/chat/commands/status/tests.rs b/server/src/chat/commands/status/tests.rs index 0cbe034c..ca7b28e3 100644 --- a/server/src/chat/commands/status/tests.rs +++ b/server/src/chat/commands/status/tests.rs @@ -522,14 +522,15 @@ fn merge_item_with_active_agent_shows_robot() { fn merge_item_with_failure_shows_stop_sign_and_snippet() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); + crate::crdt_state::init_for_test(); 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", - "4_merge", - "---\nname: Failed Story\nmerge_failure: \"conflicts in src/lib.rs\"\n---\n", - crate::db::ItemMeta::from_yaml( - "---\nname: Failed Story\nmerge_failure: \"conflicts in src/lib.rs\"\n---\n", - ), + "failed", + 0.0, + None, + Some("conflicts in src/lib.rs"), ); let items = vec![make_item( "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() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); + crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let long_reason = "x".repeat(200); - let content = format!("---\nname: Long Fail\nmerge_failure: \"{long_reason}\"\n---\n"); - 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( "904_story_long_fail", - "4_merge", - &content, - crate::db::ItemMeta::from_yaml(&content), + "failed", + 0.0, + None, + Some(&long_reason), ); let items = vec![make_item("904_story_long_fail", "Long Fail", merge_stage())]; 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() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); + crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); // Multi-line failure reason — first non-empty line should be used. - let reason = "\nfirst line of error\nsecond line"; - let content = format!( - "---\nname: Multi Line\nmerge_failure: \"{}\" \n---\n", - reason.replace('\n', "\\n") - ); - crate::db::write_item_with_content( + // Post-929: merge_failure detail lives on the MergeJob CRDT entry. + crate::crdt_state::write_merge_job( "905_story_multiline", - "4_merge", - &content, - crate::db::ItemMeta::from_yaml(&content), - ); - // 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), + "failed", + 0.0, + None, + Some("\nfirst line of error\nsecond line"), ); let items = vec![make_item( "905_story_multiline", diff --git a/server/src/chat/commands/triage.rs b/server/src/chat/commands/triage.rs index 5f57fd6b..98d2aa1f 100644 --- a/server/src/chat/commands/triage.rs +++ b/server/src/chat/commands/triage.rs @@ -69,10 +69,12 @@ fn build_triage_dump( None => return format!("Story {num_str}: content not found in content store."), }; - let meta = crate::db::yaml_legacy::parse_front_matter(&contents).ok(); - let name = meta + // Story metadata now comes from the CRDT registers and adjacent CRDT entries + // (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() - .and_then(|m| m.name.as_deref()) + .and_then(|w| w.name()) .unwrap_or("(unnamed)"); let mut out = String::new(); @@ -83,40 +85,36 @@ fn build_triage_dump( let dir_name = crate::pipeline_state::stage_dir_name(&item.stage); out.push_str(&format!("**Stage:** {stage_name} (`{dir_name}`)\n\n")); - // ---- Front matter fields ---- - if let Some(ref m) = meta { + // ---- CRDT metadata ---- + if let Some(ref w) = crdt_item { let mut fields: Vec = Vec::new(); - if let Some(true) = m.blocked { + if w.blocked() { fields.push("**blocked:** true".to_string()); } - if let Some(ref agent) = m.agent { + if let Some(agent) = w.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}")); } - if let Some(true) = m.review_hold { - fields.push("**review_hold:** true".to_string()); - } - if let Some(rc) = m.retry_count - && rc > 0 - { + let rc = w.retry_count(); + if rc > 0 { fields.push(format!("**retry_count:** {rc}")); } - if let Some(ref cb) = m.coverage_baseline { - fields.push(format!("**coverage_baseline:** {cb}")); - } - if let Some(ref mf) = m.merge_failure { - fields.push(format!("**merge_failure:** {mf}")); - } - if let Some(ref deps) = m.depends_on - && !deps.is_empty() + // merge_failure detail lives on the MergeJob CRDT entry, not on the + // pipeline item itself. + if let Some(job) = crate::crdt_state::read_merge_job(story_id) + && let Some(err) = job.error { + fields.push(format!("**merge_failure:** {err}")); + } + let deps = w.depends_on(); + if !deps.is_empty() { let nums: Vec = deps.iter().map(|n| format!("#{n}")).collect(); fields.push(format!("**depends_on:** {}", nums.join(", "))); } if !fields.is_empty() { - out.push_str("**Front matter:**\n"); + out.push_str("**Metadata:**\n"); for f in &fields { out.push_str(&format!(" • {f}\n")); } diff --git a/server/src/chat/commands/unblock.rs b/server/src/chat/commands/unblock.rs index c219a82c..00cf0f97 100644 --- a/server/src/chat/commands/unblock.rs +++ b/server/src/chat/commands/unblock.rs @@ -5,7 +5,6 @@ //! and returns a confirmation. use super::CommandContext; -use crate::db::yaml_legacy::clear_front_matter_field_in_content; use std::path::Path; /// Handle the `unblock` command. @@ -80,37 +79,14 @@ fn unblock_by_story_id(story_id: &str) -> String { // the CRDT. 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 - // valid `Unblock` transition out of its current stage), fall back to - // a direct CRDT/content cleanup. The legacy front-matter cleanup is - // gated on content actually still containing YAML, so post-865 - // CRDT-only stories don't hit a parse error. + // valid `Unblock` transition out of its current stage), at least + // reset retry_count directly in the CRDT so the agent doesn't stay + // tagged with a stale fail counter. Post-929 there's no FS shadow + // to clean up alongside. crate::slog_warn!( "[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); } @@ -221,14 +197,15 @@ mod tests { // global content store. write_story_file( tmp.path(), - "2_current", + "2_blocked", "9903_story_stuck.md", "---\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( "9903_story_stuck", - "2_current", + "2_blocked", Some("Stuck Story"), None, Some(5), @@ -249,25 +226,9 @@ mod tests { "should include story_id in response: {output}" ); - // The unblock command writes back via the content store; blocked field should be gone. - let contents = crate::db::read_content("9903_story_stuck") - .or_else(|| { - 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}" - ); + // Post-929: the CRDT is the sole source of truth; we no longer clean + // YAML front matter from on-disk content. Verify the CRDT registers + // were updated correctly. let item = crate::crdt_state::read_item("9903_story_stuck") .expect("story should be in CRDT after unblock"); assert_eq!( @@ -275,6 +236,10 @@ mod tests { 0, "retry_count should be reset to 0 in CRDT after unblock" ); + assert!( + !item.blocked(), + "blocked flag should be cleared in CRDT after unblock" + ); } #[test] diff --git a/server/src/chat/commands/unreleased.rs b/server/src/chat/commands/unreleased.rs index 4a24ced7..7c62d7eb 100644 --- a/server/src/chat/commands/unreleased.rs +++ b/server/src/chat/commands/unreleased.rs @@ -140,55 +140,13 @@ fn slug_to_name(slug: &str) -> String { words.join(" ") } -/// Find the human-readable name of a story by searching content store then filesystem. -fn find_story_name(root: &std::path::Path, num_str: &str) -> Option { - // Try content store first. - for id in crate::db::all_content_ids() { - let file_num = id.split('_').next().unwrap_or(""); - if file_num == num_str - && let Some(c) = crate::db::read_content(&id) - { - 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) - }); - } - } - } +/// Find the human-readable name of a story from the CRDT (story 929 — +/// CRDT is the sole source of story metadata). +fn find_story_name(_root: &std::path::Path, num_str: &str) -> Option { + let items = crate::crdt_state::read_all_items()?; + for item in items { + if item.story_id().split('_').next().unwrap_or("") == num_str { + return item.name().map(str::to_string); } } None