feat(933): add item_type + epic CRDT registers + migrate epic mechanism

Replaces the YAML-only `type: epic` / `epic: <id>` 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<String>` and
  `epic: LwwRegisterCrdt<String>` 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) <noreply@anthropic.com>
This commit is contained in:
Timmy
2026-05-12 19:58:43 +01:00
parent aadbb1b2af
commit 7d7ab85994
16 changed files with 200 additions and 109 deletions
+2 -2
View File
@@ -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)]
+12
View File
@@ -353,6 +353,16 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
_ => 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<PipelineItemV
qa_mode,
mergemaster_attempted,
review_hold,
item_type,
epic,
})
}
+27
View File
@@ -86,6 +86,14 @@ pub struct PipelineItemCrdt {
/// the 929 CRDT-only migration; replaces the legacy `review_hold: true`
/// YAML front-matter field.
pub review_hold: LwwRegisterCrdt<bool>,
/// 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<String>,
/// 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<String>,
}
/// CRDT node that holds a single peer's presence entry.
@@ -207,6 +215,10 @@ pub struct WorkItem {
pub(super) mergemaster_attempted: Option<bool>,
/// Whether the item is held for human review (sub-story 932).
pub(super) review_hold: Option<bool>,
/// Item type (sub-story 933): `"story"`, `"bug"`, `"spike"`, `"refactor"`, `"epic"`.
pub(super) item_type: Option<String>,
/// Epic ID this item belongs to, or `None` (sub-story 933).
pub(super) epic: Option<String>,
}
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<String>,
mergemaster_attempted: Option<bool>,
review_hold: Option<bool>,
item_type: Option<String>,
epic: Option<String>,
) -> Self {
Self {
story_id: story_id.into(),
@@ -315,6 +340,8 @@ impl WorkItem {
qa_mode,
mergemaster_attempted,
review_hold,
item_type,
epic,
}
}
}
+47
View File
@@ -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.
+2 -2
View File
@@ -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};