feat(932): add review_hold CRDT register + migrate callers off yaml_legacy

review_hold is now a typed bool register on PipelineItemCrdt alongside
blocked / mergemaster_attempted. Exposed via the typed setter
`crdt_state::set_review_hold(story_id, value)` and the
`WorkItem::review_hold()` accessor. Replaces the legacy
`review_hold: true` YAML front-matter field.

Migrated callers:
- http/mcp/qa_tools.rs::tool_approve_qa  — clear via set_review_hold(false)
- agents/lifecycle.rs::reject_story_from_qa  — clear via set_review_hold(false)
- agents/pool/pipeline/advance/helpers.rs::write_review_hold_to_store
  — set via set_review_hold(true), no more content rewrite
- agents/pool/auto_assign/reconcile.rs (two callsites) — set via
  set_review_hold(true) instead of FS YAML write
- agents/pool/auto_assign/story_checks.rs::has_review_hold — reads the
  typed register instead of conflating with Stage::Frozen (real bug fix:
  the legacy implementation returned `stage.is_frozen()`, which made
  the auto-assigner treat *every* held-for-review item as frozen even
  when it wasn't actually parked at the freeze stage).

Dead yaml_legacy helpers removed:
- write_review_hold(path), write_review_hold_in_content(content)
- clear_front_matter_field(path) — last caller was the qa_tools wrap

The yaml_residue marker doc now only mentions 933; the 932 line is gone.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timmy
2026-05-12 19:49:36 +01:00
parent f9f16d6a14
commit aadbb1b2af
12 changed files with 122 additions and 130 deletions
@@ -215,19 +215,8 @@ impl AgentPool {
message: format!("Failed to advance to QA: {e}"),
});
} else {
// Story 929 / sub-story 932: the review_hold signal still
// lives in YAML on disk because no CRDT register exists.
// Wrapped in `yaml_residue` so the gap is grep-findable.
let story_path = project_root
.join(".huskies/work/3_qa")
.join(format!("{story_id}.md"));
if let Err(e) = crate::db::yaml_legacy::yaml_residue(
crate::db::yaml_legacy::write_review_hold(&story_path),
) {
eprintln!(
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
);
}
// Story 932: review_hold is a typed CRDT register.
crate::crdt_state::set_review_hold(story_id, true);
eprintln!(
"[startup:reconcile] Moved '{story_id}' → 3_qa/ (qa: human — holding for review)."
);
@@ -289,18 +278,8 @@ impl AgentPool {
};
if needs_human_review {
// Story 929 / sub-story 932: see note above; review_hold
// is YAML-only until the CRDT register exists.
let story_path = project_root
.join(".huskies/work/3_qa")
.join(format!("{story_id}.md"));
if let Err(e) = crate::db::yaml_legacy::yaml_residue(
crate::db::yaml_legacy::write_review_hold(&story_path),
) {
eprintln!(
"[startup:reconcile] Failed to set review_hold on '{story_id}': {e}"
);
}
// Story 932: review_hold is a typed CRDT register.
crate::crdt_state::set_review_hold(story_id, true);
eprintln!(
"[startup:reconcile] '{story_id}' passed QA — holding for human review."
);
@@ -21,17 +21,15 @@ pub(super) fn read_story_front_matter_agent(
.filter(|s| !s.is_empty())
}
/// Return `true` if the story is in the `Frozen` pipeline stage.
/// Return `true` if the story has its `review_hold` CRDT register set.
///
/// In the typed CRDT model, `Frozen` is the authoritative representation of
/// stories that are held for human review (replacing the legacy
/// `review_hold: true` YAML front-matter field). The typed stage register is
/// the only source consulted — stale YAML is ignored.
/// Sub-story 932: `review_hold` is now a dedicated CRDT register on
/// `PipelineItemCrdt`, distinct from `Stage::Frozen`. The auto-assigner uses
/// this to keep human-QA items / spikes parked after gates pass until a
/// reviewer explicitly clears the hold (e.g. via `tool_approve_qa`).
pub(super) fn has_review_hold(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|item| item.stage.is_frozen())
crate::crdt_state::read_item(story_id)
.map(|w| w.review_hold())
.unwrap_or(false)
}
@@ -138,29 +136,42 @@ mod tests {
// ── has_review_hold ───────────────────────────────────────────────────────
#[test]
fn has_review_hold_returns_true_when_frozen() {
fn has_review_hold_returns_true_when_flag_set() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
crate::db::write_item_with_content(
"890_spike_frozen",
"7_frozen",
"---\nname: Frozen Spike\n---\n# Spike\n",
crate::db::ItemMeta::named("Frozen Spike"),
crate::crdt_state::write_item(
"890_spike_held",
"3_qa",
Some("Held Spike"),
None,
None,
None,
None,
None,
None,
None,
);
assert!(has_review_hold(tmp.path(), "3_qa", "890_spike_frozen"));
crate::crdt_state::set_review_hold("890_spike_held", true);
assert!(has_review_hold(tmp.path(), "3_qa", "890_spike_held"));
}
#[test]
fn has_review_hold_returns_false_for_qa_stage() {
fn has_review_hold_returns_false_when_flag_unset() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
crate::db::write_item_with_content(
crate::crdt_state::write_item(
"890_spike_active_qa",
"3_qa",
"---\nname: Active QA Spike\n---\n# Spike\n",
crate::db::ItemMeta::named("Active QA Spike"),
Some("Active QA Spike"),
None,
None,
None,
None,
None,
None,
None,
);
assert!(!has_review_hold(tmp.path(), "3_qa", "890_spike_active_qa"));
}
@@ -66,25 +66,10 @@ pub(super) fn resolve_qa_mode_from_store(
.unwrap_or(default)
}
/// Write review_hold to the content store.
/// Mark a story as held for human review (story 932: CRDT register).
pub(super) fn write_review_hold_to_store(story_id: &str) {
if let Some(contents) = crate::db::read_content(story_id) {
let updated = crate::db::yaml_legacy::write_review_hold_in_content(&contents);
crate::db::write_content(story_id, &updated);
// Also persist to SQLite via shadow write.
let stage = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| "3_qa".to_string());
crate::db::write_item_with_content(
story_id,
&stage,
&updated,
crate::db::ItemMeta::from_yaml(&updated),
);
} else {
slog_error!("[pipeline] Cannot write review_hold for '{story_id}': no content in store");
if !crate::crdt_state::set_review_hold(story_id, true) {
slog_error!("[pipeline] Cannot set review_hold for '{story_id}': no CRDT entry");
}
}