huskies: merge 945

This commit is contained in:
dave
2026-05-13 06:05:01 +00:00
parent 3a8894ea8f
commit 9ce5a8df0c
53 changed files with 497 additions and 654 deletions
@@ -594,13 +594,15 @@ mod tests {
)
.unwrap();
crate::db::ensure_content_store();
// Story 945: "blocked AND in 4_merge" is no longer representable as
// separate states. A blocked story lives in `Stage::Blocked` (which
// maps to wire-form "blocked"), so auto-assign won't see it in 4_merge.
crate::db::write_item_with_content(
"9863_story_blocked_conflict",
"4_merge",
"blocked",
"CONFLICT (content): foo.rs",
crate::db::ItemMeta {
name: Some("Blocked conflict".to_string()),
blocked: Some(true),
..Default::default()
},
);
@@ -633,13 +635,13 @@ mod tests {
)
.unwrap();
crate::db::ensure_content_store();
// Story 945: "mergemaster attempted" is now `Stage::MergeFailureFinal`.
crate::db::write_item_with_content(
"9862_story_attempted",
"4_merge",
"merge_failure_final",
"CONFLICT (content): foo.rs",
crate::db::ItemMeta::named("Already tried"),
);
crate::crdt_state::set_mergemaster_attempted("9862_story_attempted", true);
let pool = AgentPool::new_test(3001);
pool.auto_assign_available_work(tmp.path()).await;
@@ -712,16 +714,13 @@ mod tests {
.unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
// mergemaster_attempted is set by the exit path when genuine give-up occurs.
// Story 945: the genuine give-up state is now `Stage::MergeFailureFinal`.
crate::db::write_item_with_content(
"920_story_genuine",
"4_merge_failure",
"merge_failure_final",
"CONFLICT (content): bar.rs",
crate::db::ItemMeta::named("Genuine"),
);
// The CRDT register is the sole authority; set it explicitly as the
// spawn exit path would after report_merge_failure.
crate::crdt_state::set_mergemaster_attempted("920_story_genuine", true);
let pool = AgentPool::new_test(3001);
pool.auto_assign_available_work(tmp.path()).await;
@@ -215,10 +215,16 @@ impl AgentPool {
message: format!("Failed to advance to QA: {e}"),
});
} else {
// Story 932: review_hold is a typed CRDT register.
crate::crdt_state::set_review_hold(story_id, true);
// Story 945: ReviewHold is a typed Stage variant.
let _ = crate::pipeline_state::apply_transition(
story_id,
crate::pipeline_state::PipelineEvent::ReviewHold {
reason: "qa: human — gates passed, awaiting review".to_string(),
},
None,
);
eprintln!(
"[startup:reconcile] Moved '{story_id}' → 3_qa/ (qa: human — holding for review)."
"[startup:reconcile] Moved '{story_id}' → review_hold (qa: human — holding for review)."
);
let _ = progress_tx.send(ReconciliationEvent {
story_id: story_id.clone(),
@@ -278,8 +284,14 @@ impl AgentPool {
};
if needs_human_review {
// Story 932: review_hold is a typed CRDT register.
crate::crdt_state::set_review_hold(story_id, true);
// Story 945: ReviewHold is a typed Stage variant.
let _ = crate::pipeline_state::apply_transition(
story_id,
crate::pipeline_state::PipelineEvent::ReviewHold {
reason: "Passed QA — waiting for human review.".to_string(),
},
None,
);
eprintln!(
"[startup:reconcile] '{story_id}' passed QA — holding for human review."
);
@@ -21,15 +21,16 @@ pub(super) fn read_story_front_matter_agent(
.filter(|s| !s.is_empty())
}
/// Return `true` if the story has its `review_hold` CRDT register set.
/// Return `true` if the story is in `Stage::ReviewHold`.
///
/// 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`).
/// Story 945: `Stage::ReviewHold { resume_to, reason }` is the single source
/// of truth — the legacy `review_hold: bool` CRDT register has been deleted.
/// 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::crdt_state::read_item(story_id)
.map(|w| w.review_hold())
.map(|w| w.stage().is_review_hold())
.unwrap_or(false)
}
@@ -80,18 +81,19 @@ pub(super) fn has_content_conflict_failure(
.unwrap_or(false)
}
/// Return `true` if the CRDT `mergemaster_attempted` register is set for this story.
/// Return `true` if the story is in `Stage::MergeFailureFinal`.
///
/// Story 945: `Stage::MergeFailureFinal` is the single source of truth —
/// the legacy `mergemaster_attempted: bool` CRDT register has been deleted.
/// Used to prevent the auto-assigner from repeatedly spawning mergemaster for
/// the same story after a failed mergemaster session. The CRDT register is the
/// only source consulted — the legacy YAML field is no longer checked.
/// the same story after a failed mergemaster session.
pub(super) fn has_mergemaster_attempted(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> bool {
crate::crdt_state::read_item(story_id)
.map(|view| view.mergemaster_attempted())
.map(|view| view.stage().is_mergemaster_attempted())
.unwrap_or(false)
}
@@ -116,13 +118,15 @@ pub(super) fn check_archived_dependencies(
crate::crdt_state::check_archived_deps_crdt(story_id)
}
/// Return `true` if the story's `frozen` CRDT flag is set (story 934, stage 4).
/// Return `true` if the story is in `Stage::Frozen`.
///
/// `frozen` is orthogonal to [`Stage`]: a frozen story keeps its current stage
/// register but is skipped by the auto-assigner.
/// Story 945: `Stage::Frozen { resume_to }` is the single source of truth —
/// the legacy `frozen: bool` CRDT register has been deleted. Frozen stories
/// are skipped by the auto-assigner until `Unfreeze` returns them to
/// `resume_to`.
pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
crate::crdt_state::read_item(story_id)
.map(|view| view.frozen())
.map(|view| view.stage().is_frozen())
.unwrap_or(false)
}
@@ -139,9 +143,11 @@ mod tests {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
// Story 945: review_hold is now a typed Stage variant, seeded via
// the wire-form stage register directly.
crate::crdt_state::write_item_str(
"890_spike_held",
"3_qa",
"review_hold",
Some("Held Spike"),
None,
None,
@@ -149,9 +155,7 @@ mod tests {
None,
None,
None,
None,
);
crate::crdt_state::set_review_hold("890_spike_held", true);
assert!(has_review_hold(tmp.path(), "3_qa", "890_spike_held"));
}
@@ -170,7 +174,6 @@ mod tests {
None,
None,
None,
None,
);
assert!(!has_review_hold(tmp.path(), "3_qa", "890_spike_active_qa"));
}
@@ -258,7 +261,6 @@ mod tests {
Some("Blocked"),
None,
None,
None,
Some("[999]"),
None,
None,
@@ -285,7 +287,6 @@ mod tests {
None,
None,
None,
None,
);
crate::crdt_state::write_item_str(
"10_story_ok",
@@ -293,7 +294,6 @@ mod tests {
Some("Ok"),
None,
None,
None,
Some("[999]"),
None,
None,
@@ -320,7 +320,6 @@ mod tests {
None,
None,
None,
None,
);
assert!(!has_unmet_dependencies(
tmp.path(),
@@ -346,7 +345,6 @@ mod tests {
None,
None,
None,
None,
);
crate::crdt_state::write_item_str(
"503_story_dependent",
@@ -354,7 +352,6 @@ mod tests {
Some("Dependent"),
None,
None,
None,
Some("[500]"),
None,
None,
@@ -380,7 +377,6 @@ mod tests {
None,
None,
None,
None,
);
crate::crdt_state::write_item_str(
"503_story_waiting",
@@ -388,7 +384,6 @@ mod tests {
Some("Waiting"),
None,
None,
None,
Some("[490]"),
None,
None,
@@ -253,7 +253,6 @@ max_turns = 10
None,
None,
None,
None,
);
// 12 turns in a single session exceeds the configured max of 10.
@@ -381,7 +380,6 @@ max_turns = 10
None,
None,
None,
None,
);
// Prior session with 5 turns (under limit alone).
@@ -461,7 +459,6 @@ max_turns = 10
None,
None,
None,
None,
);
// Session 1: exceeds limit → retry_count=1 in CRDT, NOT blocked.
@@ -66,10 +66,21 @@ pub(super) fn resolve_qa_mode_from_store(
.unwrap_or(default)
}
/// Mark a story as held for human review (story 932: CRDT register).
/// Mark a story as held for human review (story 945: `Stage::ReviewHold`).
///
/// The caller has just moved the story to QA via `move_story_to_qa`, so the
/// story is in `Stage::Qa`. We transition to `Stage::ReviewHold { resume_to:
/// Qa, reason }` so the auto-assigner skips it while preserving the resume
/// target.
pub(super) fn write_review_hold_to_store(story_id: &str) {
if !crate::crdt_state::set_review_hold(story_id, true) {
slog_error!("[pipeline] Cannot set review_hold for '{story_id}': no CRDT entry");
if let Err(e) = crate::pipeline_state::apply_transition_str(
story_id,
crate::pipeline_state::PipelineEvent::ReviewHold {
reason: "Held for human review".to_string(),
},
None,
) {
slog_error!("[pipeline] Cannot set review_hold for '{story_id}': {e}");
}
}
+5 -1
View File
@@ -574,7 +574,11 @@ pub(super) async fn run_agent_spawn(
}
};
if is_genuine {
crate::crdt_state::set_mergemaster_attempted(&sid, true);
let _ = crate::pipeline_state::apply_transition_str(
&sid,
crate::pipeline_state::PipelineEvent::MergemasterAttempted,
None,
);
}
let _ = tx_done.send(AgentEvent::Done {
story_id: sid.clone(),
@@ -293,7 +293,6 @@ stage = "coder"
None,
None,
None,
None,
);
let pool = AgentPool::new_test(3011);