Files
huskies/server/src/agents/pool/auto_assign/story_checks.rs
T

398 lines
13 KiB
Rust
Raw Normal View History

//! Front-matter checks for story files: review holds, blocked state, and merge failures.
use std::path::Path;
/// Read the optional `agent:` pin for a story.
///
/// After story 871 the agent assignment lives in the CRDT typed register
/// (`PipelineItemView.agent`), not the YAML front matter. We check the CRDT
/// first; falling back to legacy YAML parsing keeps behaviour intact for any
/// stories whose CRDT entry doesn't yet have the field set.
pub(super) fn read_story_front_matter_agent(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> Option<String> {
// Story 929: agent name comes from the CRDT register. The previous
// YAML fallback is gone — post-891 every story has its CRDT entry,
// and any story without one is treated as having no pinned agent.
crate::crdt_state::read_item(story_id)
.and_then(|w| w.agent().map(str::to_string))
.filter(|s| !s.is_empty())
}
2026-05-13 06:05:01 +00:00
/// Return `true` if the story is in `Stage::ReviewHold`.
2026-05-12 14:43:27 +00:00
///
2026-05-13 06:05:01 +00:00
/// 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`).
2026-05-12 14:43:27 +00:00
pub(super) fn has_review_hold(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
crate::crdt_state::read_item(story_id)
2026-05-13 06:05:01 +00:00
.map(|w| w.stage().is_review_hold())
.unwrap_or(false)
}
2026-05-12 14:43:27 +00:00
/// Return `true` if the story is blocked via the typed `Stage::Blocked` or
/// `Stage::MergeFailure` variant (or the legacy `Archived(Blocked)` state).
///
/// The typed pipeline stage register is the only source consulted — the legacy
/// `blocked: true` YAML front-matter field is no longer checked.
pub(super) fn is_story_blocked(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
crate::pipeline_state::read_typed(story_id)
.ok()
2026-05-12 14:43:27 +00:00
.flatten()
.map(|item| item.stage.is_blocked())
.unwrap_or(false)
}
/// Return `true` if the story's merge failure contains a git content-conflict
/// marker (`"Merge conflict"` or `"CONFLICT (content):"`).
///
/// Used by the auto-assigner to decide whether to spawn mergemaster automatically.
/// The typed stage register is consulted first; the CRDT content store is then
/// scanned for conflict markers (the projection layer does not carry the reason
/// string). No YAML front-matter parsing is performed.
pub(super) fn has_content_conflict_failure(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> bool {
let is_merge_failure = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|item| {
matches!(
item.stage,
crate::pipeline_state::Stage::MergeFailure { .. }
)
})
.unwrap_or(false);
if !is_merge_failure {
return false;
}
// The projection does not carry the reason string; read the gate output
// (where the merge runner persists the failure message) and scan for
2026-05-13 11:22:57 +00:00
// conflict markers.
crate::db::read_content(crate::db::ContentKey::GateOutput(story_id))
2026-05-12 14:43:27 +00:00
.map(|content| {
content.contains("Merge conflict") || content.contains("CONFLICT (content):")
})
.unwrap_or(false)
}
2026-05-13 06:05:01 +00:00
/// Return `true` if the story is in `Stage::MergeFailureFinal`.
2026-05-12 14:43:27 +00:00
///
2026-05-13 06:05:01 +00:00
/// Story 945: `Stage::MergeFailureFinal` is the single source of truth —
/// the legacy `mergemaster_attempted: bool` CRDT register has been deleted.
2026-05-12 14:43:27 +00:00
/// Used to prevent the auto-assigner from repeatedly spawning mergemaster for
2026-05-13 06:05:01 +00:00
/// the same story after a failed mergemaster session.
2026-05-12 14:43:27 +00:00
pub(super) fn has_mergemaster_attempted(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> bool {
crate::crdt_state::read_item(story_id)
2026-05-13 06:05:01 +00:00
.map(|view| view.stage().is_mergemaster_attempted())
.unwrap_or(false)
}
/// Return `true` if the story has any `depends_on` entries that are not yet in
/// `5_done` or `6_archived`. Reads dependency state from the CRDT (story 929).
pub(super) fn has_unmet_dependencies(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> bool {
!crate::crdt_state::check_unmet_deps_crdt(story_id).is_empty()
}
/// Return the list of dependency story numbers that are in `6_archived` (satisfied
/// via archive rather than via a clean `5_done` completion). Reads from the CRDT
/// (story 929).
pub(super) fn check_archived_dependencies(
_project_root: &Path,
_stage_dir: &str,
story_id: &str,
) -> Vec<u32> {
crate::crdt_state::check_archived_deps_crdt(story_id)
}
2026-05-13 06:05:01 +00:00
/// Return `true` if the story is in `Stage::Frozen`.
2026-04-29 22:12:23 +00:00
///
2026-05-13 06:05:01 +00:00
/// 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`.
2026-04-29 22:12:23 +00:00
pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
crate::crdt_state::read_item(story_id)
2026-05-13 06:05:01 +00:00
.map(|view| view.stage().is_frozen())
.unwrap_or(false)
}
// ── Tests ──────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
2026-05-12 14:43:27 +00:00
// ── has_review_hold ───────────────────────────────────────────────────────
#[test]
fn has_review_hold_returns_true_when_flag_set() {
2026-05-12 14:43:27 +00:00
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
2026-05-13 06:05:01 +00:00
// 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",
2026-05-13 06:05:01 +00:00
"review_hold",
Some("Held Spike"),
None,
None,
None,
None,
None,
None,
2026-05-12 14:43:27 +00:00
);
assert!(has_review_hold(tmp.path(), "3_qa", "890_spike_held"));
2026-05-12 14:43:27 +00:00
}
#[test]
fn has_review_hold_returns_false_when_flag_unset() {
2026-05-12 14:43:27 +00:00
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
2026-05-12 14:43:27 +00:00
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
2026-05-12 14:43:27 +00:00
"890_spike_active_qa",
"3_qa",
Some("Active QA Spike"),
None,
None,
None,
None,
None,
None,
);
2026-05-12 14:43:27 +00:00
assert!(!has_review_hold(tmp.path(), "3_qa", "890_spike_active_qa"));
}
#[test]
2026-05-12 14:43:27 +00:00
fn has_review_hold_returns_false_when_story_unknown() {
let tmp = tempfile::tempdir().unwrap();
2026-05-12 14:43:27 +00:00
assert!(!has_review_hold(tmp.path(), "3_qa", "99_spike_missing"));
}
2026-05-12 14:43:27 +00:00
// ── is_story_blocked — regression: typed stage is sole authority ──────────
#[test]
2026-05-12 14:43:27 +00:00
fn is_story_blocked_set_via_typed_stage_returns_true() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
2026-05-12 14:43:27 +00:00
crate::db::write_item_with_content(
"890_story_blocked_set",
"2_blocked",
"---\nname: Blocked Story\n---\n",
crate::db::ItemMeta::named("Blocked Story"),
);
assert!(is_story_blocked(
tmp.path(),
"2_blocked",
"890_story_blocked_set"
));
}
#[test]
fn is_story_blocked_cleared_via_typed_stage_returns_false() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
// First set to blocked.
crate::db::write_item_with_content(
"890_story_blocked_clear",
"2_blocked",
"---\nname: Clearable Story\n---\n",
crate::db::ItemMeta::named("Clearable Story"),
);
// Then clear by transitioning to an active stage.
crate::db::write_item_with_content(
"890_story_blocked_clear",
"2_current",
"---\nname: Clearable Story\n---\n",
crate::db::ItemMeta::named("Clearable Story"),
);
assert!(!is_story_blocked(
tmp.path(),
"2_current",
"890_story_blocked_clear"
));
}
2026-05-12 14:43:27 +00:00
#[test]
fn is_story_blocked_stale_yaml_is_ignored() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let tmp = tempfile::tempdir().unwrap();
// YAML front matter says `blocked: true`, but the typed CRDT stage is backlog.
// After removing the YAML fallback, the function must return false.
crate::db::write_item_with_content(
"890_story_stale_yaml",
"1_backlog",
"---\nname: Stale\nblocked: true\n---\n",
crate::db::ItemMeta::named("Stale"),
);
assert!(
!is_story_blocked(tmp.path(), "1_backlog", "890_story_stale_yaml"),
"stale YAML `blocked: true` must not be reported as blocked when typed stage is Backlog"
);
}
// ── has_unmet_dependencies ────────────────────────────────────────────────
#[test]
fn has_unmet_dependencies_returns_true_when_dep_not_done() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
"10_story_blocked",
"2_current",
Some("Blocked"),
None,
None,
Some("[999]"),
None,
None,
None,
);
assert!(has_unmet_dependencies(
tmp.path(),
"2_current",
"10_story_blocked"
));
}
#[test]
fn has_unmet_dependencies_returns_false_when_dep_done() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
"999_story_dep",
"5_done",
Some("Dep"),
None,
None,
None,
None,
None,
None,
);
crate::crdt_state::write_item_str(
"10_story_ok",
"2_current",
Some("Ok"),
None,
None,
Some("[999]"),
None,
None,
None,
);
assert!(!has_unmet_dependencies(
tmp.path(),
"2_current",
"10_story_ok"
));
}
#[test]
fn has_unmet_dependencies_returns_false_when_no_deps() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
"5_story_free",
"2_current",
Some("Free"),
None,
None,
None,
None,
None,
None,
);
assert!(!has_unmet_dependencies(
tmp.path(),
"2_current",
"5_story_free"
));
}
// ── Bug 503: archived-dep visibility ─────────────────────────────────────
/// check_archived_dependencies returns dep IDs that are in 6_archived.
#[test]
fn check_archived_dependencies_returns_archived_ids() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
"500_spike_crdt",
"6_archived",
Some("CRDT Spike"),
None,
None,
None,
None,
None,
None,
);
crate::crdt_state::write_item_str(
"503_story_dependent",
"1_backlog",
Some("Dependent"),
None,
None,
Some("[500]"),
None,
None,
None,
);
let archived_deps =
check_archived_dependencies(tmp.path(), "1_backlog", "503_story_dependent");
assert_eq!(archived_deps, vec![500]);
}
/// check_archived_dependencies returns empty when dep is in 5_done (not archived).
#[test]
fn check_archived_dependencies_empty_when_dep_in_done() {
crate::crdt_state::init_for_test();
let tmp = tempfile::tempdir().unwrap();
crate::crdt_state::write_item_str(
"490_story_done",
"5_done",
Some("Done"),
None,
None,
None,
None,
None,
None,
);
crate::crdt_state::write_item_str(
"503_story_waiting",
"1_backlog",
Some("Waiting"),
None,
None,
Some("[490]"),
None,
None,
None,
);
let archived_deps =
check_archived_dependencies(tmp.path(), "1_backlog", "503_story_waiting");
assert!(archived_deps.is_empty());
}
}