From 7d7ab85994090979e22eebcabcf1c75948c88397 Mon Sep 17 00:00:00 2001 From: Timmy Date: Tue, 12 May 2026 19:58:43 +0100 Subject: [PATCH] feat(933): add item_type + epic CRDT registers + migrate epic mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the YAML-only `type: epic` / `epic: ` front-matter fields with typed CRDT registers on PipelineItemCrdt. The epic-mechanism MCP tools (`tool_list_epics`, `tool_show_epic`), the epic-context injection in agent spawn, and the type-classifier helpers (`item_type_from_id`, `is_bug_item`, `is_refactor_item`) now all read from the CRDT. Schema: - PipelineItemCrdt: `item_type: LwwRegisterCrdt` and `epic: LwwRegisterCrdt` registers. - WorkItem: typed `item_type()` and `epic()` accessors returning `Option<&str>`. - crdt_state::set_item_type(story_id, Option<&str>) and crdt_state::set_epic(story_id, Option<&str>) typed setters. Write paths populate the new registers: - create_story_file / create_bug_file / create_spike_file / create_refactor_file / create_epic_file — each calls set_item_type after write_story_content. - tool_update_story intercepts `epic` and `type` fields and routes them to the typed setters (same pattern as qa / depends_on). Read paths migrated off yaml_legacy: - http/mcp/story_tools/epic.rs: tool_list_epics + tool_show_epic. - agents/lifecycle.rs::item_type_from_id (numeric-only IDs). - agents/pool/start/spawn.rs epic-context injection. - http/workflow/bug_ops/bug.rs::is_bug_item, refactor.rs::is_refactor_item. - http/workflow/pipeline.rs::load_pipeline_state — review_hold/qa/epic_id all come from the CRDT now; only merge_failure is still YAML (sweep in 929 stage 10). All `yaml_residue(...)` wraps for item_type / epic are removed; the remaining residue marker doc no longer references 933. cargo fmt --check, clippy --all-targets -- -D warnings, and the 2857-test suite all pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- server/src/agents/lifecycle.rs | 50 ++++++------- server/src/agents/pool/start/spawn.rs | 14 +--- server/src/crdt_state/mod.rs | 4 +- server/src/crdt_state/read.rs | 12 ++++ server/src/crdt_state/types.rs | 27 +++++++ server/src/crdt_state/write/item.rs | 47 ++++++++++++ server/src/crdt_state/write/mod.rs | 4 +- server/src/http/mcp/story_tools/epic.rs | 72 ++++++++----------- .../src/http/mcp/story_tools/story/update.rs | 11 +++ server/src/http/workflow/bug_ops/bug.rs | 11 +-- server/src/http/workflow/bug_ops/epic.rs | 3 + server/src/http/workflow/bug_ops/refactor.rs | 13 ++-- server/src/http/workflow/bug_ops/spike.rs | 3 + server/src/http/workflow/pipeline.rs | 25 +++---- server/src/http/workflow/story_ops/create.rs | 3 + server/src/pipeline_state/projection.rs | 10 +++ 16 files changed, 200 insertions(+), 109 deletions(-) diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index d46505e8..7bb5f006 100644 --- a/server/src/agents/lifecycle.rs +++ b/server/src/agents/lifecycle.rs @@ -20,8 +20,8 @@ use crate::slog; /// Determine the item type ("story", "bug", "spike", or "refactor") from the item ID. /// /// For slug-format IDs (e.g. `"4_bug_login_crash"`), the type is embedded in the ID. -/// For numeric-only IDs (e.g. `"4"`), the type is read from the `type:` field in -/// the content-store front matter. Falls back to `"story"` if not found. +/// For numeric-only IDs (e.g. `"4"`), the type is read from the typed CRDT +/// `item_type` register (story 933). Falls back to `"story"` if not found. pub(crate) fn item_type_from_id(item_id: &str) -> &'static str { let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit()); if after_num.starts_with("_bug_") { @@ -31,15 +31,10 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str { } else if after_num.starts_with("_refactor_") { return "refactor"; } - // Numeric-only ID: check content store front matter for explicit type. - // `item_type` has no CRDT register yet (story 933 — epic mechanism). - // Marked with yaml_residue so the gap is grep-findable. + // Numeric-only ID: consult the typed CRDT register. if after_num.is_empty() - && let Some(content) = crate::db::read_content(item_id) - && let Ok(meta) = crate::db::yaml_legacy::yaml_residue( - crate::db::yaml_legacy::parse_front_matter(&content), - ) - && let Some(t) = meta.item_type.as_deref() + && let Some(view) = crate::crdt_state::read_item(item_id) + && let Some(t) = view.item_type() { return match t { "bug" => "bug", @@ -519,30 +514,31 @@ mod tests { } #[test] - fn item_type_from_id_falls_back_to_content_store_for_numeric_ids() { + fn item_type_from_id_uses_crdt_register_for_numeric_ids() { + crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); - // Write a bug item with numeric-only ID into the content store. - let bug_content = "---\ntype: bug\nname: \"Test Bug\"\n---\n\n# Bug 9999: Test Bug\n"; - crate::db::write_content("9999", bug_content); - - let spike_content = - "---\ntype: spike\nname: \"Test Spike\"\n---\n\n# Spike 9998: Test Spike\n"; - crate::db::write_content("9998", spike_content); - - let refactor_content = - "---\ntype: refactor\nname: \"Test Refactor\"\n---\n\n# Refactor 9997: Test Refactor\n"; - crate::db::write_content("9997", refactor_content); - - let story_content = - "---\ntype: story\nname: \"Test Story\"\n---\n\n# Story 9996: Test Story\n"; - crate::db::write_content("9996", story_content); + // Story 933: numeric-only IDs read item_type from the CRDT register. + for (id, t) in [ + ("9999", "bug"), + ("9998", "spike"), + ("9997", "refactor"), + ("9996", "story"), + ] { + crate::db::write_item_with_content( + id, + "1_backlog", + &format!("# Test {t}\n"), + crate::db::ItemMeta::named(format!("Test {t}")), + ); + crate::crdt_state::set_item_type(id, Some(t)); + } assert_eq!(item_type_from_id("9999"), "bug"); assert_eq!(item_type_from_id("9998"), "spike"); assert_eq!(item_type_from_id("9997"), "refactor"); assert_eq!(item_type_from_id("9996"), "story"); - // No content store entry → defaults to "story". + // No CRDT entry → defaults to "story". assert_eq!(item_type_from_id("99999"), "story"); } diff --git a/server/src/agents/pool/start/spawn.rs b/server/src/agents/pool/start/spawn.rs index 196a6879..c9e877c1 100644 --- a/server/src/agents/pool/start/spawn.rs +++ b/server/src/agents/pool/start/spawn.rs @@ -227,17 +227,9 @@ pub(super) async fn run_agent_spawn( } // Prepend epic context when the story belongs to an epic (AC3, story 880). - // Read the story's front matter to find the epic ID, then load the epic's - // content and prepend it to the system prompt so the agent treats it as - // authoritative context. - // - // Epic linkage has no CRDT register yet (story 933) — wrap the parse in - // `yaml_residue` so the gap is grep-findable. - if let Some(story_content) = crate::db::read_content(&sid) - && let Ok(meta) = crate::db::yaml_legacy::yaml_residue( - crate::db::yaml_legacy::parse_front_matter(&story_content), - ) - && let Some(ref epic_id) = meta.epic + // Story 933: epic linkage is now a typed CRDT register on PipelineItemCrdt. + if let Some(view) = crate::crdt_state::read_item(&sid) + && let Some(epic_id) = view.epic() && let Some(epic_content) = crate::db::read_content(epic_id) { let block = format!( diff --git a/server/src/crdt_state/mod.rs b/server/src/crdt_state/mod.rs index ed3da9dd..0934425d 100644 --- a/server/src/crdt_state/mod.rs +++ b/server/src/crdt_state/mod.rs @@ -53,8 +53,8 @@ pub use types::{ }; pub use write::{ bump_retry_count, migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id, - set_agent, set_blocked, set_depends_on, set_mergemaster_attempted, set_qa_mode, - set_retry_count, set_review_hold, write_item, + set_agent, set_blocked, set_depends_on, set_epic, set_item_type, set_mergemaster_attempted, + set_qa_mode, set_retry_count, set_review_hold, write_item, }; #[cfg(test)] diff --git a/server/src/crdt_state/read.rs b/server/src/crdt_state/read.rs index 63e43c0f..d2ba3c2d 100644 --- a/server/src/crdt_state/read.rs +++ b/server/src/crdt_state/read.rs @@ -353,6 +353,16 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option None, }; + let item_type = match item.item_type.view() { + JsonValue::String(s) if !s.is_empty() => Some(s), + _ => None, + }; + + let epic = match item.epic.view() { + JsonValue::String(s) if !s.is_empty() => Some(s), + _ => None, + }; + Some(PipelineItemView { story_id, stage, @@ -367,6 +377,8 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option, + /// Item type: `"story"`, `"bug"`, `"spike"`, `"refactor"`, or `"epic"`. + /// Empty string means "infer from story_id slug or default to story". + /// Sub-story 933; replaces the legacy `type:` YAML front-matter field. + pub item_type: LwwRegisterCrdt, + /// Epic ID this item belongs to, or empty string when the item is not a + /// member of any epic. Sub-story 933; replaces the legacy `epic:` YAML + /// front-matter field that linked member work items to their epic. + pub epic: LwwRegisterCrdt, } /// CRDT node that holds a single peer's presence entry. @@ -207,6 +215,10 @@ pub struct WorkItem { pub(super) mergemaster_attempted: Option, /// Whether the item is held for human review (sub-story 932). pub(super) review_hold: Option, + /// Item type (sub-story 933): `"story"`, `"bug"`, `"spike"`, `"refactor"`, `"epic"`. + pub(super) item_type: Option, + /// Epic ID this item belongs to, or `None` (sub-story 933). + pub(super) epic: Option, } impl WorkItem { @@ -280,6 +292,17 @@ impl WorkItem { self.review_hold.unwrap_or(false) } + /// Item type (`"story"`, `"bug"`, `"spike"`, `"refactor"`, `"epic"`), or + /// `None` when the register is unset. + pub fn item_type(&self) -> Option<&str> { + self.item_type.as_deref() + } + + /// Epic ID this item is a member of, or `None` when unset. + pub fn epic(&self) -> Option<&str> { + self.epic.as_deref() + } + /// Construct a `WorkItem` for use in tests outside `crdt_state::*`. /// /// Within `crdt_state` use a struct literal directly (fields are `pub(super)`). @@ -300,6 +323,8 @@ impl WorkItem { qa_mode: Option, mergemaster_attempted: Option, review_hold: Option, + item_type: Option, + epic: Option, ) -> Self { Self { story_id: story_id.into(), @@ -315,6 +340,8 @@ impl WorkItem { qa_mode, mergemaster_attempted, review_hold, + item_type, + epic, } } } diff --git a/server/src/crdt_state/write/item.rs b/server/src/crdt_state/write/item.rs index 196d6fc4..3d998664 100644 --- a/server/src/crdt_state/write/item.rs +++ b/server/src/crdt_state/write/item.rs @@ -38,6 +38,49 @@ pub fn set_depends_on(story_id: &str, deps: &[u32]) -> bool { true } +/// Set the `item_type` CRDT register for a pipeline item (sub-story 933). +/// +/// `Some(t)` writes the type string (e.g. `"story"`, `"epic"`, `"bug"`). +/// `None` clears the register to an empty string, which means "use the +/// id-prefix heuristic" (see `item_type_from_id`). +/// +/// Returns `true` if the item was found and the op was applied, `false` otherwise. +pub fn set_item_type(story_id: &str, item_type: Option<&str>) -> bool { + let Some(state_mutex) = get_crdt() else { + return false; + }; + let Ok(mut state) = state_mutex.lock() else { + return false; + }; + let Some(&idx) = state.index.get(story_id) else { + return false; + }; + let value = item_type.unwrap_or("").to_string(); + apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].item_type.set(value)); + true +} + +/// Set the `epic` CRDT register for a pipeline item (sub-story 933). +/// +/// `Some(epic_id)` links the item to its parent epic. +/// `None` clears the register to an empty string (no epic membership). +/// +/// Returns `true` if the item was found and the op was applied, `false` otherwise. +pub fn set_epic(story_id: &str, epic_id: Option<&str>) -> bool { + let Some(state_mutex) = get_crdt() else { + return false; + }; + let Ok(mut state) = state_mutex.lock() else { + return false; + }; + let Some(&idx) = state.index.get(story_id) else { + return false; + }; + let value = epic_id.unwrap_or("").to_string(); + apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].epic.set(value)); + true +} + /// Set the `review_hold` CRDT flag for a pipeline item (sub-story 932). /// /// `true` marks the item as held for human review at a pipeline-stage boundary; @@ -250,6 +293,8 @@ pub fn write_item( "qa_mode": "", "mergemaster_attempted": false, "review_hold": false, + "item_type": "", + "epic": "", }) .into(); @@ -278,6 +323,8 @@ pub fn write_item( item.qa_mode.advance_seq(floor); item.mergemaster_attempted.advance_seq(floor); item.review_hold.advance_seq(floor); + item.item_type.advance_seq(floor); + item.epic.advance_seq(floor); } // Broadcast a CrdtEvent for the new item. diff --git a/server/src/crdt_state/write/mod.rs b/server/src/crdt_state/write/mod.rs index e9bd2aae..703b309e 100644 --- a/server/src/crdt_state/write/mod.rs +++ b/server/src/crdt_state/write/mod.rs @@ -10,7 +10,7 @@ mod migrations; mod tests; pub use item::{ - bump_retry_count, set_agent, set_blocked, set_depends_on, set_mergemaster_attempted, - set_qa_mode, set_retry_count, set_review_hold, write_item, + bump_retry_count, set_agent, set_blocked, set_depends_on, set_epic, set_item_type, + set_mergemaster_attempted, set_qa_mode, set_retry_count, set_review_hold, write_item, }; pub use migrations::{migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id}; diff --git a/server/src/http/mcp/story_tools/epic.rs b/server/src/http/mcp/story_tools/epic.rs index a76b68fb..a26af53b 100644 --- a/server/src/http/mcp/story_tools/epic.rs +++ b/server/src/http/mcp/story_tools/epic.rs @@ -4,10 +4,6 @@ //! and refactors. They are not pipeline-driven but provide authoritative context //! injected into agent prompts for all member work items. -// Epic mechanism (item_type, epic link) has no CRDT register yet — story 933. -// parse_front_matter calls here are wrapped in `yaml_residue` so they're -// grep-findable until 933 lands. -use crate::db::yaml_legacy::{parse_front_matter, yaml_residue}; use crate::http::context::AppContext; use crate::http::workflow::create_epic_file; use serde_json::{Value, json}; @@ -53,7 +49,7 @@ pub(crate) fn tool_list_epics(_ctx: &AppContext) -> Result { let all_items = crate::pipeline_state::read_all_typed(); - // Collect epics: items with type == "epic". + // Collect epics: items with item_type == "epic" in the CRDT register. let mut epics: Vec<(String, String)> = Vec::new(); // (id, name) // Collect member items: map from epic_id → list of (story_id, is_done). let mut members: std::collections::HashMap> = @@ -61,23 +57,18 @@ pub(crate) fn tool_list_epics(_ctx: &AppContext) -> Result { for item in &all_items { let sid = &item.story_id.0; - let content = match crate::db::read_content(sid) { - Some(c) => c, - None => continue, - }; - let meta = match yaml_residue(parse_front_matter(&content)) { - Ok(m) => m, - Err(_) => continue, + let Some(view) = crate::crdt_state::read_item(sid) else { + continue; }; - if meta.item_type.as_deref() == Some("epic") { + if view.item_type() == Some("epic") { epics.push((sid.clone(), item.name.clone())); } - if let Some(epic_id) = meta.epic { + if let Some(epic_id) = view.epic() { let is_done = matches!(item.stage, Stage::Done { .. }); members - .entry(epic_id) + .entry(epic_id.to_string()) .or_default() .push((sid.clone(), is_done)); } @@ -116,13 +107,13 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result Result = Vec::new(); for item in &all_items { let sid = &item.story_id.0; - let member_content = match crate::db::read_content(sid) { - Some(c) => c, - None => continue, + let Some(member_view) = crate::crdt_state::read_item(sid) else { + continue; }; - let member_meta = match yaml_residue(parse_front_matter(&member_content)) { - Ok(m) => m, - Err(_) => continue, - }; - if member_meta.epic.as_deref() == Some(epic_id) { + if member_view.epic() == Some(epic_id) { let stage_name = match &item.stage { Stage::Upcoming | Stage::Backlog => "backlog", Stage::Coding => "current", @@ -164,7 +150,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result Result bool { let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); if after_num.starts_with("_bug_") { return true; } - // Numeric-only ID: check content store front matter. if after_num.is_empty() { - return crate::db::read_content(stem) - .and_then(|c| parse_front_matter(&c).ok()) - .and_then(|m| m.item_type) + return crate::crdt_state::read_item(stem) + .and_then(|v| v.item_type().map(str::to_string)) .map(|t| t == "bug") .unwrap_or(false); } diff --git a/server/src/http/workflow/bug_ops/epic.rs b/server/src/http/workflow/bug_ops/epic.rs index 5989e3c7..951481d9 100644 --- a/server/src/http/workflow/bug_ops/epic.rs +++ b/server/src/http/workflow/bug_ops/epic.rs @@ -72,5 +72,8 @@ pub fn create_epic_file( // Epics are stored in backlog (no pipeline advancement). write_story_content(root, &epic_id, "1_backlog", &content); + // Story 933: typed CRDT register for item_type. + crate::crdt_state::set_item_type(&epic_id, Some("epic")); + Ok(epic_id) } diff --git a/server/src/http/workflow/bug_ops/refactor.rs b/server/src/http/workflow/bug_ops/refactor.rs index 6eb6770d..e52b231e 100644 --- a/server/src/http/workflow/bug_ops/refactor.rs +++ b/server/src/http/workflow/bug_ops/refactor.rs @@ -62,23 +62,24 @@ pub fn create_refactor_file( // Sync depends_on to the typed CRDT register. crate::crdt_state::set_depends_on(&refactor_id, depends_on.unwrap_or(&[])); + // Story 933: typed CRDT register for item_type. + crate::crdt_state::set_item_type(&refactor_id, Some("refactor")); + Ok(refactor_id) } /// Returns true if the item stem is a refactor item. /// -/// Checks the slug-based ID format first (e.g. `"5_refactor_split_agents_rs"`), then -/// falls back to reading `type: refactor` from the content store for numeric-only IDs. +/// Checks the slug-based ID format first (e.g. `"5_refactor_split_agents_rs"`), +/// then consults the typed CRDT `item_type` register for numeric-only IDs (933). pub(super) fn is_refactor_item(stem: &str) -> bool { let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); if after_num.starts_with("_refactor_") { return true; } - // Numeric-only ID: check content store front matter. if after_num.is_empty() { - return crate::db::read_content(stem) - .and_then(|c| parse_front_matter(&c).ok()) - .and_then(|m| m.item_type) + return crate::crdt_state::read_item(stem) + .and_then(|v| v.item_type().map(str::to_string)) .map(|t| t == "refactor") .unwrap_or(false); } diff --git a/server/src/http/workflow/bug_ops/spike.rs b/server/src/http/workflow/bug_ops/spike.rs index d4c0851b..1922fc94 100644 --- a/server/src/http/workflow/bug_ops/spike.rs +++ b/server/src/http/workflow/bug_ops/spike.rs @@ -66,5 +66,8 @@ pub fn create_spike_file( // Sync depends_on to the typed CRDT register. crate::crdt_state::set_depends_on(&spike_id, depends_on.unwrap_or(&[])); + // Story 933: typed CRDT register for item_type. + crate::crdt_state::set_item_type(&spike_id, Some("spike")); + Ok(spike_id) } diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs index c4c85b93..f780c490 100644 --- a/server/src/http/workflow/pipeline.rs +++ b/server/src/http/workflow/pipeline.rs @@ -95,18 +95,16 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { let sid = &item.story_id.0; let agent = agent_map.get(sid).cloned(); - // Enrich with content-derived metadata (merge_failure, review_hold, qa, epic_id). - let (merge_failure, review_hold, qa, epic_id) = crate::db::read_content(sid) + // Stories 929/932/933: review_hold, qa_mode, epic_id come from typed + // CRDT registers. merge_failure remains in YAML for now (tracked by + // 929 stage 10's sweep). + let view = crate::crdt_state::read_item(sid); + let review_hold = view.as_ref().map(|v| v.review_hold()).filter(|b| *b); + let qa = view.as_ref().and_then(|v| v.qa_mode().map(str::to_string)); + let epic_id = view.as_ref().and_then(|v| v.epic().map(str::to_string)); + let merge_failure = crate::db::read_content(sid) .and_then(|c| parse_front_matter(&c).ok()) - .map(|meta| { - ( - meta.merge_failure, - meta.review_hold, - meta.qa.map(|m| m.as_str().to_string()), - meta.epic, - ) - }) - .unwrap_or((None, None, None, None)); + .and_then(|meta| meta.merge_failure); let story = UpcomingStory { story_id: sid.clone(), @@ -211,9 +209,8 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result, St .filter(|item| matches!(item.stage, Stage::Backlog)) .map(|item| { let sid = &item.story_id.0; - let epic_id = crate::db::read_content(sid) - .and_then(|c| parse_front_matter(&c).ok()) - .and_then(|meta| meta.epic); + let epic_id = + crate::crdt_state::read_item(sid).and_then(|v| v.epic().map(str::to_string)); UpcomingStory { story_id: item.story_id.0.clone(), name: if item.name.is_empty() { diff --git a/server/src/http/workflow/story_ops/create.rs b/server/src/http/workflow/story_ops/create.rs index a8772687..f2195938 100644 --- a/server/src/http/workflow/story_ops/create.rs +++ b/server/src/http/workflow/story_ops/create.rs @@ -71,6 +71,9 @@ pub fn create_story_file( // Sync depends_on to the typed CRDT register. crate::crdt_state::set_depends_on(&story_id, depends_on.unwrap_or(&[])); + // Story 933: typed CRDT register for item_type. + crate::crdt_state::set_item_type(&story_id, Some("story")); + Ok(story_id) } diff --git a/server/src/pipeline_state/projection.rs b/server/src/pipeline_state/projection.rs index 0c63184a..e69cdf34 100644 --- a/server/src/pipeline_state/projection.rs +++ b/server/src/pipeline_state/projection.rs @@ -230,6 +230,8 @@ mod tests { None, None, None, + None, + None, ) } @@ -256,6 +258,8 @@ mod tests { None, None, None, + None, + None, ); let item = PipelineItem::try_from(&view).unwrap(); assert_eq!(item.story_id, StoryId("42_story_test".to_string())); @@ -281,6 +285,8 @@ mod tests { None, None, None, + None, + None, ); let item = PipelineItem::try_from(&view).unwrap(); assert!(matches!(item.stage, Stage::Coding)); @@ -325,6 +331,8 @@ mod tests { None, None, None, + None, + None, ); let item = PipelineItem::try_from(&view).unwrap(); assert!(matches!( @@ -352,6 +360,8 @@ mod tests { None, None, None, + None, + None, ); let item = PipelineItem::try_from(&view).unwrap(); assert!(matches!(