diff --git a/server/src/agents/pool/auto_assign/auto_assign.rs b/server/src/agents/pool/auto_assign/auto_assign.rs index 8eb96a8d..5c8e2e2d 100644 --- a/server/src/agents/pool/auto_assign/auto_assign.rs +++ b/server/src/agents/pool/auto_assign/auto_assign.rs @@ -15,7 +15,7 @@ use super::scan::{ }; use super::story_checks::{ check_archived_dependencies, has_merge_failure, has_review_hold, has_unmet_dependencies, - is_story_blocked, read_story_front_matter_agent, + is_story_blocked, is_story_frozen, read_story_front_matter_agent, }; impl AgentPool { @@ -103,6 +103,12 @@ impl AgentPool { continue; } + // Skip frozen stories — pipeline advancement is suspended. + if is_story_frozen(project_root, stage_dir, story_id) { + slog!("[auto-assign] Story '{story_id}' is frozen; skipping until unfrozen."); + continue; + } + // Skip blocked stories (retry limit exceeded). if is_story_blocked(project_root, stage_dir, story_id) { continue; diff --git a/server/src/agents/pool/auto_assign/story_checks.rs b/server/src/agents/pool/auto_assign/story_checks.rs index f5e0177a..b7739c75 100644 --- a/server/src/agents/pool/auto_assign/story_checks.rs +++ b/server/src/agents/pool/auto_assign/story_checks.rs @@ -93,6 +93,19 @@ 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) + .ok() + .and_then(|m| m.frozen) + .unwrap_or(false) +} + /// Return `true` if the story file has a `merge_failure` field in its front matter. pub(super) fn has_merge_failure(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool { use crate::io::story_metadata::parse_front_matter; diff --git a/server/src/agents/pool/pipeline/advance.rs b/server/src/agents/pool/pipeline/advance.rs index 01ce02e8..1103229b 100644 --- a/server/src/agents/pool/pipeline/advance.rs +++ b/server/src/agents/pool/pipeline/advance.rs @@ -40,6 +40,13 @@ impl AgentPool { .map(agent_config_stage) .unwrap_or_else(|| pipeline_stage(agent_name)); + // If the story is frozen, do not advance the pipeline. The agent's work + // is done but the story stays at its current stage. + if crate::io::story_metadata::is_story_frozen_in_store(story_id) { + slog!("[pipeline] Story '{story_id}' is frozen; pipeline advancement suppressed."); + return; + } + match stage { PipelineStage::Other => { // Supervisors and unknown agents do not advance the pipeline. diff --git a/server/src/chat/commands/freeze.rs b/server/src/chat/commands/freeze.rs new file mode 100644 index 00000000..67b8473c --- /dev/null +++ b/server/src/chat/commands/freeze.rs @@ -0,0 +1,300 @@ +//! Handler for the `freeze` and `unfreeze` commands. +//! +//! `freeze ` sets `frozen: true` on the story, halting pipeline +//! advancement and auto-assign until `unfreeze ` clears the flag. + +use super::CommandContext; +use crate::io::story_metadata::{ + clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field, +}; +use std::path::Path; + +/// Handle the `freeze` command. +/// +/// Parses `` from `ctx.args`, locates the work item, and sets +/// `frozen: true` in its front matter. +pub(super) fn handle_freeze(ctx: &CommandContext) -> Option { + let num_str = ctx.args.trim(); + if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) { + return Some(format!( + "Usage: `{} freeze ` (e.g. `freeze 42`)", + ctx.bot_name + )); + } + Some(freeze_by_number(ctx.project_root, num_str)) +} + +/// Core freeze logic: find story by numeric prefix and set `frozen: true`. +/// +/// Returns a Markdown-formatted response string suitable for all transports. +pub(crate) fn freeze_by_number(project_root: &Path, story_number: &str) -> String { + let (story_id, _, _, _) = + match crate::chat::lookup::find_story_by_number(project_root, story_number) { + Some(found) => found, + None => { + return format!("No story, bug, or spike with number **{story_number}** found."); + } + }; + + freeze_by_story_id(&story_id) +} + +fn freeze_by_story_id(story_id: &str) -> String { + let contents = match crate::db::read_content(story_id) { + Some(c) => c, + None => return format!("Failed to read story content for **{story_id}**"), + }; + + let meta = match parse_front_matter(&contents) { + Ok(m) => m, + Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"), + }; + + let story_name = meta.name.as_deref().unwrap_or(story_id).to_string(); + + if meta.frozen == Some(true) { + 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." + ) +} + +/// Handle the `unfreeze` command. +/// +/// Parses `` from `ctx.args`, locates the work item, and clears the +/// `frozen` flag to resume normal pipeline behaviour. +pub(super) fn handle_unfreeze(ctx: &CommandContext) -> Option { + let num_str = ctx.args.trim(); + if num_str.is_empty() || !num_str.chars().all(|c| c.is_ascii_digit()) { + return Some(format!( + "Usage: `{} unfreeze ` (e.g. `unfreeze 42`)", + ctx.bot_name + )); + } + Some(unfreeze_by_number(ctx.project_root, num_str)) +} + +/// Core unfreeze logic: find story by numeric prefix and clear `frozen` flag. +pub(crate) fn unfreeze_by_number(project_root: &Path, story_number: &str) -> String { + let (story_id, _, _, _) = + match crate::chat::lookup::find_story_by_number(project_root, story_number) { + Some(found) => found, + None => { + return format!("No story, bug, or spike with number **{story_number}** found."); + } + }; + + unfreeze_by_story_id(&story_id) +} + +fn unfreeze_by_story_id(story_id: &str) -> String { + let contents = match crate::db::read_content(story_id) { + Some(c) => c, + None => return format!("Failed to read story content for **{story_id}**"), + }; + + let meta = match parse_front_matter(&contents) { + Ok(m) => m, + Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"), + }; + + let story_name = meta.name.as_deref().unwrap_or(story_id).to_string(); + + if meta.frozen != Some(true) { + 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.") +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use crate::agents::AgentPool; + use crate::chat::test_helpers::write_story_file; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; + + use super::super::{CommandDispatch, try_handle_command}; + + fn freeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option { + let agents = Arc::new(AgentPool::new_test(3000)); + let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); + let room_id = "!test:example.com".to_string(); + let dispatch = CommandDispatch { + bot_name: "Timmy", + bot_user_id: "@timmy:homeserver.local", + project_root: root, + agents: &agents, + ambient_rooms: &ambient_rooms, + room_id: &room_id, + }; + try_handle_command(&dispatch, &format!("@timmy freeze {args}")) + } + + fn unfreeze_cmd_with_root(root: &std::path::Path, args: &str) -> Option { + let agents = Arc::new(AgentPool::new_test(3000)); + let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); + let room_id = "!test:example.com".to_string(); + let dispatch = CommandDispatch { + bot_name: "Timmy", + bot_user_id: "@timmy:homeserver.local", + project_root: root, + agents: &agents, + ambient_rooms: &ambient_rooms, + room_id: &room_id, + }; + try_handle_command(&dispatch, &format!("@timmy unfreeze {args}")) + } + + #[test] + fn freeze_command_is_registered() { + use super::super::commands; + assert!( + commands().iter().any(|c| c.name == "freeze"), + "freeze command must be in the registry" + ); + } + + #[test] + fn unfreeze_command_is_registered() { + use super::super::commands; + assert!( + commands().iter().any(|c| c.name == "unfreeze"), + "unfreeze command must be in the registry" + ); + } + + #[test] + fn freeze_command_no_args_returns_usage() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = freeze_cmd_with_root(tmp.path(), "").unwrap(); + assert!( + output.contains("Usage"), + "no args should show usage: {output}" + ); + } + + #[test] + fn unfreeze_command_no_args_returns_usage() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = unfreeze_cmd_with_root(tmp.path(), "").unwrap(); + assert!( + output.contains("Usage"), + "no args should show usage: {output}" + ); + } + + #[test] + fn freeze_command_not_found_returns_error() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = freeze_cmd_with_root(tmp.path(), "9988").unwrap(); + assert!( + output.contains("9988") && output.contains("found"), + "not-found message should include number and 'found': {output}" + ); + } + + #[test] + fn freeze_command_sets_frozen_flag() { + let tmp = tempfile::TempDir::new().unwrap(); + crate::db::ensure_content_store(); + write_story_file( + tmp.path(), + "2_current", + "9940_story_freezeme.md", + "---\nname: Freeze Me\n---\n# Story\n", + ); + let output = freeze_cmd_with_root(tmp.path(), "9940").unwrap(); + assert!( + 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"); + assert!( + contents.contains("frozen: true"), + "frozen flag should be set: {contents}" + ); + } + + #[test] + fn unfreeze_command_clears_frozen_flag() { + let tmp = tempfile::TempDir::new().unwrap(); + 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", + ); + 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"); + assert!( + !contents.contains("frozen:"), + "frozen flag should be removed: {contents}" + ); + } + + #[test] + fn unfreeze_command_not_frozen_returns_error() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "9942_story_notfrozen.md", + "---\nname: Not Frozen\n---\n# Story\n", + ); + let output = unfreeze_cmd_with_root(tmp.path(), "9942").unwrap(); + assert!( + output.contains("not frozen"), + "should return not-frozen error: {output}" + ); + } + + #[test] + fn freeze_command_already_frozen_returns_message() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "9943_story_alreadyfrozen.md", + "---\nname: Already Frozen\nfrozen: true\n---\n# Story\n", + ); + let output = freeze_cmd_with_root(tmp.path(), "9943").unwrap(); + assert!( + output.contains("already frozen"), + "should say already frozen: {output}" + ); + } +} diff --git a/server/src/chat/commands/mod.rs b/server/src/chat/commands/mod.rs index 79c1c1c5..ddf23766 100644 --- a/server/src/chat/commands/mod.rs +++ b/server/src/chat/commands/mod.rs @@ -11,6 +11,7 @@ mod backlog; mod cost; mod coverage; mod depends; +mod freeze; mod git; mod help; pub(crate) mod loc; @@ -203,6 +204,16 @@ pub fn commands() -> &'static [BotCommand] { description: "Reset a blocked story: `unblock ` (clears blocked flag and resets retry count)", handler: unblock::handle_unblock, }, + BotCommand { + name: "freeze", + description: "Freeze a story at its current stage: `freeze ` (suppresses pipeline advancement and auto-assign)", + handler: freeze::handle_freeze, + }, + BotCommand { + name: "unfreeze", + description: "Unfreeze a story: `unfreeze ` (resumes normal pipeline behaviour)", + handler: freeze::handle_unfreeze, + }, BotCommand { name: "unreleased", description: "Show stories merged to master since the last release tag", diff --git a/server/src/chat/commands/status.rs b/server/src/chat/commands/status.rs index f3d00e48..e4cf429e 100644 --- a/server/src/chat/commands/status.rs +++ b/server/src/chat/commands/status.rs @@ -228,7 +228,13 @@ fn render_item_line( } else { Some(item.name.as_str()) }; - let display = story_short_label(story_id, name_opt); + let frozen = crate::io::story_metadata::is_story_frozen_in_store(story_id); + let base_label = story_short_label(story_id, name_opt); + let display = if frozen { + format!("\u{2744}\u{FE0F} {base_label}") // ❄️ prefix + } else { + base_label + }; let cost_suffix = cost_by_story .get(story_id) .filter(|&&c| c > 0.0) diff --git a/server/src/io/story_metadata.rs b/server/src/io/story_metadata.rs index 434bf9b1..bb0ee288 100644 --- a/server/src/io/story_metadata.rs +++ b/server/src/io/story_metadata.rs @@ -57,6 +57,9 @@ pub struct StoryMetadata { /// Story numbers this story depends on. Auto-assign will skip this story /// until all dependencies have reached `5_done` or `6_archived`. pub depends_on: Option>, + /// When `true`, the story is frozen: auto-assign skips it, the pipeline + /// does not advance it, and no mergemaster is spawned. + pub frozen: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -89,6 +92,8 @@ struct FrontMatter { blocked: Option, /// Story numbers this story depends on. depends_on: Option>, + /// When `true`, the story is frozen. + frozen: Option, } pub fn parse_front_matter(contents: &str) -> Result { @@ -129,6 +134,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata { retry_count: front.retry_count, blocked: front.blocked, depends_on: front.depends_on, + frozen: front.frozen, } } @@ -439,6 +445,20 @@ pub fn increment_retry_count_in_content(contents: &str) -> (String, u32) { (updated, new_count) } +/// Return `true` if the story has `frozen: true` in the content store. +/// +/// 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) + .ok() + .and_then(|m| m.frozen) + .unwrap_or(false) +} + /// Write `blocked: true` to story content (pure function). pub fn write_blocked_in_content(contents: &str) -> String { set_front_matter_field(contents, "blocked", "true")