huskies: merge 867

This commit is contained in:
dave
2026-04-29 22:12:23 +00:00
parent e56bd2d834
commit a49f668b5a
17 changed files with 286 additions and 61 deletions
+1
View File
@@ -349,6 +349,7 @@ fn stage_to_name(s: &Stage) -> &'static str {
Stage::Merge { .. } => "merge",
Stage::Done { .. } => "done",
Stage::Archived { .. } => "archived",
Stage::Frozen { .. } => "frozen",
}
}
@@ -93,16 +93,14 @@ pub(super) fn check_archived_dependencies(
crate::io::story_metadata::check_archived_deps(project_root, stage_dir, story_id)
}
/// Return `true` if the story file has `frozen: true` in its front matter.
pub(super) fn is_story_frozen(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
use crate::io::story_metadata::parse_front_matter;
let contents = match read_story_contents(project_root, story_id) {
Some(c) => c,
None => return false,
};
parse_front_matter(&contents)
/// Return `true` if the story is in the `Frozen` pipeline stage.
///
/// Checks the typed CRDT stage via `read_typed`.
pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id: &str) -> bool {
crate::pipeline_state::read_typed(story_id)
.ok()
.and_then(|m| m.frozen)
.flatten()
.map(|item| item.stage.is_frozen())
.unwrap_or(false)
}
+62 -43
View File
@@ -1,12 +1,10 @@
//! Handler for the `freeze` and `unfreeze` commands.
//!
//! `freeze <number>` sets `frozen: true` on the story, halting pipeline
//! advancement and auto-assign until `unfreeze <number>` clears the flag.
//! `freeze <number>` transitions the story to `Stage::Frozen`, halting pipeline
//! advancement and auto-assign until `unfreeze <number>` restores the prior stage.
use super::CommandContext;
use crate::io::story_metadata::{
clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field,
};
use crate::io::story_metadata::parse_front_matter;
use std::path::Path;
/// Handle the `freeze` command.
@@ -52,23 +50,22 @@ fn freeze_by_story_id(story_id: &str) -> String {
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
if meta.frozen == Some(true) {
// Check if already frozen via the typed stage.
if crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|i| i.stage.is_frozen())
.unwrap_or(false)
{
return format!("**{story_name}** ({story_id}) is already frozen.");
}
let updated = set_front_matter_field(&contents, "frozen", "true");
crate::db::write_content(story_id, &updated);
let stage = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| "2_current".to_string());
crate::db::write_item_with_content(story_id, &stage, &updated);
format!(
"Frozen **{story_name}** ({story_id}). Pipeline advancement and auto-assign suppressed until unfrozen."
)
match crate::pipeline_state::transition_to_frozen(story_id) {
Ok(_) => format!(
"Frozen **{story_name}** ({story_id}). Pipeline advancement and auto-assign suppressed until unfrozen."
),
Err(e) => format!("Failed to freeze **{story_name}** ({story_id}): {e}"),
}
}
/// Handle the `unfreeze` command.
@@ -112,21 +109,23 @@ fn unfreeze_by_story_id(story_id: &str) -> String {
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
if meta.frozen != Some(true) {
// Check frozen via typed stage.
let is_frozen = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|i| i.stage.is_frozen())
.unwrap_or(false);
if !is_frozen {
return format!("**{story_name}** ({story_id}) is not frozen. Nothing to unfreeze.");
}
let updated = clear_front_matter_field_in_content(&contents, "frozen");
crate::db::write_content(story_id, &updated);
let stage = crate::pipeline_state::read_typed(story_id)
.ok()
.flatten()
.map(|i| i.stage.dir_name().to_string())
.unwrap_or_else(|| "2_current".to_string());
crate::db::write_item_with_content(story_id, &stage, &updated);
format!("Unfrozen **{story_name}** ({story_id}). Normal pipeline behaviour resumed.")
match crate::pipeline_state::transition_to_unfrozen(story_id) {
Ok(_) => {
format!("Unfrozen **{story_name}** ({story_id}). Normal pipeline behaviour resumed.")
}
Err(e) => format!("Failed to unfreeze **{story_name}** ({story_id}): {e}"),
}
}
// ---------------------------------------------------------------------------
@@ -212,8 +211,9 @@ mod tests {
}
#[test]
fn freeze_command_sets_frozen_flag() {
fn freeze_command_sets_stage_to_frozen() {
let tmp = tempfile::TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
write_story_file(
tmp.path(),
@@ -226,40 +226,54 @@ mod tests {
output.contains("Frozen") && output.contains("Freeze Me"),
"should confirm freeze with story name: {output}"
);
let contents = crate::db::read_content("9940_story_freezeme")
.expect("story content should be readable after freeze");
let item = crate::pipeline_state::read_typed("9940_story_freezeme")
.expect("read_typed should succeed")
.expect("item should be present");
assert!(
contents.contains("frozen: true"),
"frozen flag should be set: {contents}"
item.stage.is_frozen(),
"stage should be Frozen after freeze: {:?}",
item.stage
);
}
#[test]
fn unfreeze_command_clears_frozen_flag() {
fn unfreeze_command_restores_prior_stage() {
let tmp = tempfile::TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
write_story_file(
tmp.path(),
"2_current",
"9941_story_frozen.md",
"---\nname: Frozen Story\nfrozen: true\n---\n# Story\n",
"---\nname: Frozen Story\n---\n# Story\n",
);
// Freeze first.
let freeze_out = freeze_cmd_with_root(tmp.path(), "9941").unwrap();
assert!(
freeze_out.contains("Frozen"),
"should confirm freeze: {freeze_out}"
);
// Now unfreeze.
let output = unfreeze_cmd_with_root(tmp.path(), "9941").unwrap();
assert!(
output.contains("Unfrozen") && output.contains("Frozen Story"),
"should confirm unfreeze with story name: {output}"
);
let contents = crate::db::read_content("9941_story_frozen")
.expect("story content should be readable after unfreeze");
let item = crate::pipeline_state::read_typed("9941_story_frozen")
.expect("read_typed should succeed")
.expect("item should be present");
assert!(
!contents.contains("frozen:"),
"frozen flag should be removed: {contents}"
matches!(item.stage, crate::pipeline_state::Stage::Coding),
"stage should be restored to Coding: {:?}",
item.stage
);
}
#[test]
fn unfreeze_command_not_frozen_returns_error() {
let tmp = tempfile::TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
write_story_file(
tmp.path(),
"2_current",
@@ -276,12 +290,17 @@ mod tests {
#[test]
fn freeze_command_already_frozen_returns_message() {
let tmp = tempfile::TempDir::new().unwrap();
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
write_story_file(
tmp.path(),
"2_current",
"9943_story_alreadyfrozen.md",
"---\nname: Already Frozen\nfrozen: true\n---\n# Story\n",
"---\nname: Already Frozen\n---\n# Story\n",
);
// Freeze it first.
freeze_cmd_with_root(tmp.path(), "9943").unwrap();
// Try to freeze again.
let output = freeze_cmd_with_root(tmp.path(), "9943").unwrap();
assert!(
output.contains("already frozen"),
@@ -113,6 +113,7 @@ fn stage_display_name(stage: &str) -> &str {
Some(Stage::Merge { .. }) => "merge",
Some(Stage::Done { .. }) => "done",
Some(Stage::Archived { .. }) => "archived",
Some(Stage::Frozen { .. }) => "frozen",
None => stage,
}
}
+1
View File
@@ -144,6 +144,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
Stage::Merge { .. } => "merge",
Stage::Done { .. } => "done",
Stage::Archived { .. } => "archived",
Stage::Frozen { .. } => "frozen",
};
member_items.push(json!({
"story_id": sid,
+1
View File
@@ -150,6 +150,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
Stage::Merge { .. } => state.merge.push(story),
Stage::Done { .. } => state.done.push(story),
Stage::Archived { .. } => {} // skip archived
Stage::Frozen { .. } => state.backlog.push(story), // show frozen with backlog
}
}
+9 -8
View File
@@ -22,6 +22,8 @@ pub(super) struct FrontMatter {
pub depends_on: Option<Vec<u32>>,
/// When `true`, the story is frozen.
pub frozen: Option<bool>,
/// Stage directory to restore on unfreeze (e.g. `"2_current"`).
pub resume_to_stage: Option<String>,
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
/// Used by the bug-645 salvage path to distinguish a genuine test-passing
/// session from one that merely compiled.
@@ -76,6 +78,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata {
blocked: front.blocked,
depends_on: front.depends_on,
frozen: front.frozen,
resume_to_stage: front.resume_to_stage,
run_tests_passed: front.run_tests_passed,
item_type: front.item_type,
mergemaster_attempted: front.mergemaster_attempted,
@@ -131,17 +134,15 @@ pub fn resolve_qa_mode_from_content(story_id: &str, contents: &str, default: QaM
}
}
/// Return `true` if the story has `frozen: true` in the content store.
/// Return `true` if the story is in the `Frozen` pipeline stage.
///
/// Used by the pipeline advance code to suppress stage transitions for frozen stories.
/// Checks the typed CRDT stage via `read_typed`. Used by the pipeline advance
/// code to suppress stage transitions for frozen stories.
pub fn is_story_frozen_in_store(story_id: &str) -> bool {
let contents = match crate::db::read_content(story_id) {
Some(c) => c,
None => return false,
};
parse_front_matter(&contents)
crate::pipeline_state::read_typed(story_id)
.ok()
.and_then(|m| m.frozen)
.flatten()
.map(|item| item.stage.is_frozen())
.unwrap_or(false)
}
+3
View File
@@ -59,6 +59,9 @@ pub struct StoryMetadata {
/// When `true`, the story is frozen: auto-assign skips it, the pipeline
/// does not advance it, and no mergemaster is spawned.
pub frozen: Option<bool>,
/// Pipeline stage to restore when unfreezing (e.g. `"2_current"`).
/// Written by `transition_to_frozen`; cleared by `transition_to_unfrozen`.
pub resume_to_stage: Option<String>,
/// Set to `true` when an agent's `run_tests` call returns `passed=true`.
/// Used by the bug-645 salvage path to require real test evidence, not just
/// compilation success.
+1
View File
@@ -56,6 +56,7 @@ pub fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, Strin
Stage::Merge { .. } => ("merge", format!("huskies: queue {item_id} for merge")),
Stage::Done { .. } => ("done", format!("huskies: done {item_id}")),
Stage::Archived { .. } => ("accept", format!("huskies: accept {item_id}")),
Stage::Frozen { .. } => ("freeze", format!("huskies: freeze {item_id}")),
};
Some((action, msg))
}
+25
View File
@@ -97,3 +97,28 @@ pub fn apply_transition_str(
) -> Result<TransitionFired, String> {
apply_transition(story_id, event, content_transform).map_err(|e| e.to_string())
}
/// Freeze a story at its current stage.
///
/// Transitions the story to `Stage::Frozen { resume_to: current_stage }` and
/// writes `resume_to_stage` into the front matter so the projection layer can
/// reconstruct the full typed stage on subsequent reads.
pub fn transition_to_frozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
let item = read_typed(story_id)?.ok_or_else(|| ApplyError::NotFound(story_id.to_string()))?;
let resume_dir = item.stage.dir_name().to_string();
let transform = move |content: &str| -> String {
crate::io::story_metadata::set_front_matter_field(content, "resume_to_stage", &resume_dir)
};
apply_transition(story_id, PipelineEvent::Freeze, Some(&transform))
}
/// Unfreeze a story, restoring it to the stage it was in before freezing.
///
/// Transitions `Stage::Frozen { resume_to }` back to `resume_to` and removes
/// the `resume_to_stage` field from the front matter.
pub fn transition_to_unfrozen(story_id: &str) -> Result<TransitionFired, ApplyError> {
let transform = |content: &str| -> String {
crate::io::story_metadata::clear_front_matter_field_in_content(content, "resume_to_stage")
};
apply_transition(story_id, PipelineEvent::Unfreeze, Some(&transform))
}
+4 -1
View File
@@ -54,7 +54,10 @@ pub use projection::{ProjectionError, project_stage};
pub use projection::{read_all_typed, read_typed};
#[allow(unused_imports)]
pub use apply::{ApplyError, apply_transition, apply_transition_str};
pub use apply::{
ApplyError, apply_transition, apply_transition_str, transition_to_frozen,
transition_to_unfrozen,
};
#[allow(unused_imports)]
pub use subscribers::{
+16
View File
@@ -119,6 +119,21 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
reason,
})
}
"7_frozen" => {
// The stage to resume to is stored in front matter as `resume_to_stage`.
// Fall back to Coding if the field is absent (e.g. legacy frozen items).
let resume_to = crate::db::read_content(&view.story_id)
.and_then(|content| {
crate::io::story_metadata::parse_front_matter(&content)
.ok()
.and_then(|m| m.resume_to_stage)
.and_then(|dir| Stage::from_dir(&dir))
})
.unwrap_or(Stage::Coding);
Ok(Stage::Frozen {
resume_to: Box::new(resume_to),
})
}
other => Err(ProjectionError::UnknownStage(other.to_string())),
}
}
@@ -137,6 +152,7 @@ impl PipelineItem {
..
}
);
// Frozen stories map to "7_frozen"; they are not "blocked" in the CRDT sense.
(dir, blocked)
}
}
+120
View File
@@ -538,4 +538,124 @@ fn cannot_reject_from_archived() {
));
}
// ── Freeze / Unfreeze ───────────────────────────────────────────────
#[test]
fn freeze_from_active_stages() {
for s in [Stage::Upcoming, Stage::Backlog, Stage::Coding, Stage::Qa] {
let result = transition(s.clone(), PipelineEvent::Freeze).unwrap();
assert!(
matches!(result, Stage::Frozen { .. }),
"expected Frozen from {s:?}"
);
if let Stage::Frozen { resume_to } = result {
assert_eq!(*resume_to, s);
}
}
}
#[test]
fn freeze_from_merge() {
let m = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
};
let result = transition(m.clone(), PipelineEvent::Freeze).unwrap();
assert!(matches!(result, Stage::Frozen { .. }));
if let Stage::Frozen { resume_to } = result {
assert_eq!(*resume_to, m);
}
}
#[test]
fn unfreeze_restores_prior_stage() {
let prior = Stage::Coding;
let frozen = Stage::Frozen {
resume_to: Box::new(prior.clone()),
};
let result = transition(frozen, PipelineEvent::Unfreeze).unwrap();
assert_eq!(result, prior);
}
#[test]
fn cannot_freeze_done() {
let s = Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("abc"),
};
let result = transition(s, PipelineEvent::Freeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_freeze_archived() {
let s = Stage::Archived {
archived_at: chrono::Utc::now(),
reason: ArchiveReason::Completed,
};
let result = transition(s, PipelineEvent::Freeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_unfreeze_coding() {
let result = transition(Stage::Coding, PipelineEvent::Unfreeze);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
/// Regression test: freeze → unfreeze round-trip via `apply_transition`.
/// Verifies that the CRDT shows the correct prior stage restored.
#[test]
fn regression_freeze_unfreeze_restores_crdt_stage() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "9950_story_freeze_regression";
let content = "---\nname: Freeze Regression\n---\n# Story\n";
crate::db::write_item_with_content(story_id, "2_current", content);
// Confirm starting stage.
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Coding),
"should start at Coding"
);
// Freeze.
super::apply::transition_to_frozen(story_id).expect("freeze should succeed");
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Frozen { .. }),
"should be Frozen after freeze: {:?}",
item.stage
);
if let Stage::Frozen { ref resume_to } = item.stage {
assert!(
matches!(**resume_to, Stage::Coding),
"resume_to should be Coding: {:?}",
resume_to
);
}
// Unfreeze.
super::apply::transition_to_unfrozen(story_id).expect("unfreeze should succeed");
let item = read_typed(story_id).unwrap().unwrap();
assert!(
matches!(item.stage, Stage::Coding),
"should be restored to Coding after unfreeze: {:?}",
item.stage
);
}
// ── ProjectionError Display ─────────────────────────────────────────
+17
View File
@@ -56,6 +56,10 @@ pub enum PipelineEvent {
Close,
/// Manual demotion back to backlog from an active stage.
Demote,
/// Freeze the story at its current stage (suspends pipeline and auto-assign).
Freeze,
/// Unfreeze the story, restoring it to the stage it was at when frozen.
Unfreeze,
}
// ── Per-node execution events ───────────────────────────────────────────────
@@ -94,6 +98,8 @@ pub fn event_label(e: &PipelineEvent) -> &'static str {
PipelineEvent::Triage => "Triage",
PipelineEvent::Close => "Close",
PipelineEvent::Demote => "Demote",
PipelineEvent::Freeze => "Freeze",
PipelineEvent::Unfreeze => "Unfreeze",
}
}
@@ -231,6 +237,17 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
Unblock,
) => Ok(Backlog),
// ── Freeze: any active stage → Frozen(resume_to=current) ────────
(stage @ (Upcoming | Backlog | Coding | Qa), Freeze) => Ok(Frozen {
resume_to: Box::new(stage),
}),
(stage @ Merge { .. }, Freeze) => Ok(Frozen {
resume_to: Box::new(stage),
}),
// ── Unfreeze: Frozen → resume_to ─────────────────────────────────
(Frozen { resume_to }, Unfreeze) => Ok(*resume_to),
// ── Everything else is invalid ──────────────────────────────────
_ => Err(invalid()),
}
+16
View File
@@ -100,6 +100,10 @@ pub enum Stage {
archived_at: DateTime<Utc>,
reason: ArchiveReason,
},
/// Pipeline advancement and auto-assign are suspended. Resumes to
/// `resume_to` when unfrozen.
Frozen { resume_to: Box<Stage> },
}
/// Why a story was archived. Subsumes the old `blocked`, `merge_failure`,
@@ -130,6 +134,11 @@ impl Stage {
matches!(self, Stage::Coding | Stage::Qa | Stage::Merge { .. })
}
/// Returns true if this stage is `Frozen`.
pub fn is_frozen(&self) -> bool {
matches!(self, Stage::Frozen { .. })
}
/// Returns true if this is the Upcoming variant.
pub fn is_upcoming(&self) -> bool {
matches!(self, Stage::Upcoming)
@@ -177,6 +186,11 @@ impl Stage {
archived_at: DateTime::<Utc>::UNIX_EPOCH,
reason: ArchiveReason::Completed,
}),
// Frozen: stub with Coding as resume_to — rich resume_to is loaded
// from front matter by the projection layer.
"7_frozen" => Some(Stage::Frozen {
resume_to: Box::new(Stage::Coding),
}),
_ => None,
}
}
@@ -256,6 +270,7 @@ pub fn stage_label(s: &Stage) -> &'static str {
Stage::Merge { .. } => "Merge",
Stage::Done { .. } => "Done",
Stage::Archived { .. } => "Archived",
Stage::Frozen { .. } => "Frozen",
}
}
@@ -269,5 +284,6 @@ pub fn stage_dir_name(s: &Stage) -> &'static str {
Stage::Merge { .. } => "4_merge",
Stage::Done { .. } => "5_done",
Stage::Archived { .. } => "6_archived",
Stage::Frozen { .. } => "7_frozen",
}
}
+1
View File
@@ -209,6 +209,7 @@ pub fn get_work_item_content(
crate::pipeline_state::Stage::Merge { .. } => "merge",
crate::pipeline_state::Stage::Done { .. } => "done",
crate::pipeline_state::Stage::Archived { .. } => "archived",
crate::pipeline_state::Stage::Frozen { .. } => "frozen",
})
.unwrap_or("unknown")
.to_string();
@@ -17,6 +17,7 @@ pub fn stage_display_name(stage: &str) -> &'static str {
Some(Stage::Merge { .. }) => "Merge",
Some(Stage::Done { .. }) => "Done",
Some(Stage::Archived { .. }) => "Archived",
Some(Stage::Frozen { .. }) => "Frozen",
None => "Unknown",
}
}