diff --git a/server/src/crdt_state/mod.rs b/server/src/crdt_state/mod.rs index f0e8000e..8628260d 100644 --- a/server/src/crdt_state/mod.rs +++ b/server/src/crdt_state/mod.rs @@ -55,8 +55,8 @@ pub use types::{ pub use write::{ bump_retry_count, migrate_legacy_stage_strings, migrate_merge_job, migrate_names_from_slugs, migrate_node_claims_to_agent_claims, migrate_story_ids_to_numeric, name_from_story_id, - set_agent, set_depends_on, set_epic, set_item_type, set_name, set_plan_state, set_qa_mode, - set_resume_to, set_resume_to_raw, set_retry_count, write_item, + purge_done_stage_merge_jobs, set_agent, set_depends_on, set_epic, set_item_type, set_name, + set_plan_state, set_qa_mode, set_resume_to, set_resume_to_raw, set_retry_count, write_item, }; #[cfg(test)] diff --git a/server/src/crdt_state/write/migrations.rs b/server/src/crdt_state/write/migrations.rs index a96a6b38..f3f2f2ba 100644 --- a/server/src/crdt_state/write/migrations.rs +++ b/server/src/crdt_state/write/migrations.rs @@ -650,6 +650,61 @@ pub fn migrate_merge_job(db_path: &std::path::Path) { slog!("[crdt] Migrated {count} MergeJob entries to typed MergeResult format"); } +/// Delete MergeJob CRDT entries for stories that have reached a terminal stage +/// (Done, Archived, Abandoned, Superseded, Rejected). +/// +/// Pre-1036 code left stale MergeJob entries after a story recovered from a +/// merge failure and transitioned to Done. Those entries caused the gateway +/// UI to mislabel recovered stories as "FAILED" because `load_pipeline_state` +/// unconditionally read the MergeJob error field for every story. This +/// migration removes the orphaned entries so the state is clean on the next +/// server start. +/// +/// Running this migration repeatedly is safe — tombstoned entries are filtered +/// out by the read path, so subsequent calls are no-ops. +pub fn purge_done_stage_merge_jobs() { + use crate::pipeline_state::Stage; + + let Some(jobs) = crate::crdt_state::read_all_merge_jobs() else { + return; + }; + if jobs.is_empty() { + return; + } + + // Collect IDs of every story currently in a terminal stage. + let terminal_ids: std::collections::HashSet = crate::pipeline_state::read_all_typed() + .into_iter() + .filter(|item| { + matches!( + item.stage, + Stage::Done { .. } + | Stage::Archived { .. } + | Stage::Abandoned { .. } + | Stage::Superseded { .. } + | Stage::Rejected { .. } + ) + }) + .map(|item| item.story_id.0.clone()) + .collect(); + + let to_delete: Vec = jobs + .into_iter() + .filter(|j| terminal_ids.contains(&j.story_id)) + .map(|j| j.story_id) + .collect(); + + if to_delete.is_empty() { + return; + } + + let count = to_delete.len(); + for story_id in &to_delete { + crate::crdt_state::delete_merge_job(story_id); + } + slog!("[crdt] Purged {count} stale MergeJob entries for terminal-stage stories"); +} + #[cfg(test)] mod merge_job_migration_tests { use super::super::super::state::init_for_test; diff --git a/server/src/crdt_state/write/mod.rs b/server/src/crdt_state/write/mod.rs index 361a1e59..ce693b5f 100644 --- a/server/src/crdt_state/write/mod.rs +++ b/server/src/crdt_state/write/mod.rs @@ -19,4 +19,5 @@ pub use item::write_item_str; pub use migrations::{ migrate_legacy_stage_strings, migrate_merge_job, migrate_names_from_slugs, migrate_node_claims_to_agent_claims, migrate_story_ids_to_numeric, name_from_story_id, + purge_done_stage_merge_jobs, }; diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs index 74889622..eba5c2b2 100644 --- a/server/src/http/workflow/pipeline.rs +++ b/server/src/http/workflow/pipeline.rs @@ -116,7 +116,9 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { // Story 945: review_hold is `Stage::ReviewHold`; qa_mode and epic_id // come from typed CRDT registers. merge_failure detail lives on the - // MergeJob CRDT entry (same as status_tools.rs). + // MergeJob CRDT entry, but only surfaces for items currently in a + // failure stage — Done/terminal items must never inherit stale errors + // from a previously-failed merge attempt (story 1052). let view = crate::crdt_state::read_item(sid); let review_hold = if matches!(item.stage, Stage::ReviewHold { .. }) { Some(true) @@ -125,7 +127,14 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { }; let qa = view.as_ref().and_then(|v| v.qa_mode()); let epic_id = view.as_ref().and_then(|v| v.epic()); - let merge_failure = crate::crdt_state::read_merge_job(sid).and_then(|j| j.error); + let merge_failure = if matches!( + item.stage, + Stage::MergeFailure { .. } | Stage::MergeFailureFinal { .. } + ) { + crate::crdt_state::read_merge_job(sid).and_then(|j| j.error) + } else { + None + }; let story = UpcomingStory { story_id: sid.clone(), diff --git a/server/src/service/pipeline/mod.rs b/server/src/service/pipeline/mod.rs index 76c3e185..558de5ed 100644 --- a/server/src/service/pipeline/mod.rs +++ b/server/src/service/pipeline/mod.rs @@ -47,7 +47,7 @@ pub fn aggregate_pipeline_counts(pipeline: &Value) -> Value { .map(|f| !f.is_null() && f != "") .unwrap_or(false); - if is_blocked || has_merge_failure { + if (is_blocked || has_merge_failure) && stage != "done" { let story_id = item .get("story_id") .and_then(|s| s.as_str()) diff --git a/server/src/startup/project.rs b/server/src/startup/project.rs index 1f0e46f7..7151c445 100644 --- a/server/src/startup/project.rs +++ b/server/src/startup/project.rs @@ -167,6 +167,9 @@ pub(crate) async fn init_subsystems(app_state: &Arc, cwd: &Path) { crdt_state::migrate_merge_job(db_path); // Story 1009: drop legacy node-hex claims that can't be converted to AgentName. crdt_state::migrate_node_claims_to_agent_claims(); + // Story 1052: remove stale MergeJob entries for terminal-stage + // stories so they can never cause "FAILED" labels in the UI. + crdt_state::purge_done_stage_merge_jobs(); } } }