wip(929): stage 3 — migrate http/mcp/* off yaml_legacy + introduce yaml_residue marker

Three MCP files touched:

- status_tools.rs (story-status JSON dump): every field with a CRDT
  equivalent now reads from WorkItem (name, agent, blocked, qa_mode,
  retry_count, depends_on, claimed_by, claimed_at) or MergeJob.error
  (merge_failure detail). One field — review_hold — has no CRDT register
  yet (sub-story 932) and is wrapped in `yaml_residue(parse_front_matter(...))`
  so the gap is visible at every code-search.

- qa_tools.rs:
  • tool_approve_qa wraps the legacy `clear_front_matter_field("review_hold")`
    write in `yaml_residue(...)` pending sub-story 932.
  • tool_reject_qa now reads the agent name from the CRDT WorkItem instead
    of parsing front matter on disk.

- story_tools/epic.rs: the entire epic feature (item_type, epic link)
  has no CRDT analog — sub-story 933. Every parse_front_matter call here
  is wrapped in `yaml_residue(...)`.

Also: new identity wrapper `db::yaml_legacy::yaml_residue<T>(v: T) -> T`
that marks a yaml_legacy callsite blocked on a CRDT-register gap. Pure
identity at runtime; the distinctive name makes the residue grep-findable
(`grep -rn yaml_residue`). Sub-stories 932 and 933 enumerate the gaps.

Filed:
- 932: Add CRDT register for review_hold
- 933: Add CRDT registers for the epic mechanism

All 2854 tests pass; fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timmy
2026-05-12 18:54:32 +01:00
parent 9eb5116f7e
commit b8945654bf
4 changed files with 61 additions and 47 deletions
+17
View File
@@ -12,6 +12,23 @@ use serde::Deserialize;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
/// Identity wrapper that flags a yaml_legacy callsite blocked on adding a
/// CRDT register (story 929 residue). Every wrap is a grep-findable marker —
/// `grep -rn yaml_residue` enumerates every remaining gap — so it stays
/// visible in every code review.
///
/// When the CRDT register lands and the caller is migrated, delete the wrap.
/// Once every wrap is gone, delete this function and `db::yaml_legacy`
/// entirely (929 stage 10).
///
/// Filed sub-stories enumerate each gap:
/// - 932: `review_hold` flag (write-side in qa_tools, read-side in
/// auto_assign).
/// - 933: epic mechanism — `item_type` and `epic` link fields.
pub fn yaml_residue<T>(v: T) -> T {
v
}
/// Front-matter fields used by the legacy `parse_front_matter` API. Mirrors /// Front-matter fields used by the legacy `parse_front_matter` API. Mirrors
/// the original `io::story_metadata::FrontMatter`. /// the original `io::story_metadata::FrontMatter`.
#[derive(Debug, Default, Deserialize)] #[derive(Debug, Default, Deserialize)]
+10 -14
View File
@@ -54,12 +54,16 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
let project_root = ctx.services.agents.get_project_root(&ctx.state)?; let project_root = ctx.services.agents.get_project_root(&ctx.state)?;
// Clear review_hold before moving // Clear review_hold before moving.
// review_hold has no CRDT register yet — story 932. Marked with
// `yaml_residue` so the gap is grep-findable.
let qa_path = project_root let qa_path = project_root
.join(".huskies/work/3_qa") .join(".huskies/work/3_qa")
.join(format!("{story_id}.md")); .join(format!("{story_id}.md"));
if qa_path.exists() { if qa_path.exists() {
let _ = crate::db::yaml_legacy::clear_front_matter_field(&qa_path, "review_hold"); let _ = crate::db::yaml_legacy::yaml_residue(
crate::db::yaml_legacy::clear_front_matter_field(&qa_path, "review_hold"),
);
} }
if is_spike(story_id) { if is_spike(story_id) {
@@ -136,18 +140,10 @@ pub(super) async fn tool_reject_qa(args: &Value, ctx: &AppContext) -> Result<Str
// Move story from work/3_qa/ back to work/2_current/ with rejection notes // Move story from work/3_qa/ back to work/2_current/ with rejection notes
reject_story_from_qa(story_id, notes)?; reject_story_from_qa(story_id, notes)?;
// Restart the coder agent with rejection context // Restart the coder agent with rejection context.
let story_path = project_root // Agent name comes from the CRDT WorkItem register (story 929).
.join(".huskies/work/2_current") let agent_name =
.join(format!("{story_id}.md")); crate::crdt_state::read_item(story_id).and_then(|w| w.agent().map(str::to_string));
let agent_name = if story_path.exists() {
let contents = std::fs::read_to_string(&story_path).unwrap_or_default();
crate::db::yaml_legacy::parse_front_matter(&contents)
.ok()
.and_then(|meta| meta.agent)
} else {
None
};
let agent_name = agent_name.as_deref().unwrap_or("coder-opus"); let agent_name = agent_name.as_deref().unwrap_or("coder-opus");
let context = format!( let context = format!(
+27 -29
View File
@@ -174,47 +174,29 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
let contents = crate::db::read_content(story_id) let contents = crate::db::read_content(story_id)
.ok_or_else(|| format!("Story '{story_id}' has no content in the content store."))?; .ok_or_else(|| format!("Story '{story_id}' has no content in the content store."))?;
// --- Front matter --- // --- Metadata (story 929: CRDT-first, yaml_residue marks gaps) ---
let mut front_matter = serde_json::Map::new(); let mut front_matter = serde_json::Map::new();
if let Ok(meta) = crate::db::yaml_legacy::parse_front_matter(&contents) { if let Some(view) = crate::crdt_state::read_item(story_id) {
if let Some(name) = &meta.name { if let Some(name) = view.name() {
front_matter.insert("name".to_string(), json!(name)); front_matter.insert("name".to_string(), json!(name));
} }
if let Some(agent) = &meta.agent { if let Some(agent) = view.agent() {
front_matter.insert("agent".to_string(), json!(agent)); front_matter.insert("agent".to_string(), json!(agent));
} }
if let Some(true) = meta.blocked { if view.blocked() {
front_matter.insert("blocked".to_string(), json!(true)); front_matter.insert("blocked".to_string(), json!(true));
} }
if let Some(qa) = &meta.qa { if let Some(qa) = view.qa_mode() {
front_matter.insert("qa".to_string(), json!(qa.as_str())); front_matter.insert("qa".to_string(), json!(qa));
} }
if let Some(rc) = meta.retry_count let rc = view.retry_count();
&& rc > 0 if rc > 0 {
{
front_matter.insert("retry_count".to_string(), json!(rc)); front_matter.insert("retry_count".to_string(), json!(rc));
} }
if let Some(mf) = &meta.merge_failure { let deps = view.depends_on();
front_matter.insert("merge_failure".to_string(), json!(mf)); if !deps.is_empty() {
}
if let Some(rh) = meta.review_hold
&& rh
{
front_matter.insert("review_hold".to_string(), json!(rh));
}
if let Some(deps) = &meta.depends_on
&& !deps.is_empty()
{
front_matter.insert("depends_on".to_string(), json!(deps)); front_matter.insert("depends_on".to_string(), json!(deps));
} }
}
// --- CRDT view fields (claimed_by, claimed_at, is_deleted) ---
// read_item uses the visible index, so is_deleted is always false here;
// we include it only when true (which cannot happen for stories that
// pass the read_typed / 2_current check above, but the code is present
// for completeness and future-proofing).
if let Some(view) = crate::crdt_state::read_item(story_id) {
if let Some(cb) = view.claimed_by() if let Some(cb) = view.claimed_by()
&& !cb.is_empty() && !cb.is_empty()
{ {
@@ -227,6 +209,22 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
} }
} }
// Merge-failure detail lives on the MergeJob CRDT entry, not on WorkItem.
if let Some(job) = crate::crdt_state::read_merge_job(story_id)
&& let Some(mf) = job.error
{
front_matter.insert("merge_failure".to_string(), json!(mf));
}
// review_hold has no CRDT register yet — see story 932. Wrap the
// yaml_legacy read in `yaml_residue(...)` so it's grep-findable.
if let Ok(meta) =
crate::db::yaml_legacy::yaml_residue(crate::db::yaml_legacy::parse_front_matter(&contents))
&& let Some(true) = meta.review_hold
{
front_matter.insert("review_hold".to_string(), json!(true));
}
// --- AC checklist --- // --- AC checklist ---
let ac_items: Vec<Value> = parse_ac_items(&contents) let ac_items: Vec<Value> = parse_ac_items(&contents)
.into_iter() .into_iter()
+7 -4
View File
@@ -4,7 +4,10 @@
//! and refactors. They are not pipeline-driven but provide authoritative context //! and refactors. They are not pipeline-driven but provide authoritative context
//! injected into agent prompts for all member work items. //! injected into agent prompts for all member work items.
use crate::db::yaml_legacy::parse_front_matter; // 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::context::AppContext;
use crate::http::workflow::create_epic_file; use crate::http::workflow::create_epic_file;
use serde_json::{Value, json}; use serde_json::{Value, json};
@@ -62,7 +65,7 @@ pub(crate) fn tool_list_epics(_ctx: &AppContext) -> Result<String, String> {
Some(c) => c, Some(c) => c,
None => continue, None => continue,
}; };
let meta = match parse_front_matter(&content) { let meta = match yaml_residue(parse_front_matter(&content)) {
Ok(m) => m, Ok(m) => m,
Err(_) => continue, Err(_) => continue,
}; };
@@ -113,7 +116,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
let content = crate::db::read_content(epic_id) let content = crate::db::read_content(epic_id)
.ok_or_else(|| format!("Epic '{epic_id}' not found in content store"))?; .ok_or_else(|| format!("Epic '{epic_id}' not found in content store"))?;
let meta = parse_front_matter(&content) let meta = yaml_residue(parse_front_matter(&content))
.map_err(|e| format!("Failed to parse epic front matter: {e}"))?; .map_err(|e| format!("Failed to parse epic front matter: {e}"))?;
if meta.item_type.as_deref() != Some("epic") { if meta.item_type.as_deref() != Some("epic") {
@@ -132,7 +135,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
Some(c) => c, Some(c) => c,
None => continue, None => continue,
}; };
let member_meta = match parse_front_matter(&member_content) { let member_meta = match yaml_residue(parse_front_matter(&member_content)) {
Ok(m) => m, Ok(m) => m,
Err(_) => continue, Err(_) => continue,
}; };