diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index bcaa5cf..3fadffb 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -25,6 +25,7 @@ export interface PipelineStageItem { story_id: string; name: string | null; error: string | null; + merge_failure: string | null; agent: AgentAssignment | null; } diff --git a/frontend/src/components/LozengeFlyContext.test.tsx b/frontend/src/components/LozengeFlyContext.test.tsx index c0db6d2..6f6a06e 100644 --- a/frontend/src/components/LozengeFlyContext.test.tsx +++ b/frontend/src/components/LozengeFlyContext.test.tsx @@ -57,6 +57,7 @@ describe("AgentLozenge fixed intrinsic width", () => { story_id: "74_width_test", name: "Width Test", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, }, ]; @@ -108,6 +109,7 @@ describe("LozengeFlyProvider fly-in visibility", () => { story_id: "74_hidden_test", name: "Hidden Test", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], @@ -143,6 +145,7 @@ describe("LozengeFlyProvider fly-in visibility", () => { story_id: "74_no_roster", name: "No Roster", error: null, + merge_failure: null, agent: { agent_name: "unknown-agent", model: null, @@ -208,6 +211,7 @@ describe("LozengeFlyProvider fly-in clone", () => { story_id: "74_portal_test", name: "Portal Test", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, }, ], @@ -248,6 +252,7 @@ describe("LozengeFlyProvider fly-in clone", () => { story_id: "74_clone_remove", name: "Clone Remove", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], @@ -294,6 +299,7 @@ describe("LozengeFlyProvider fly-in clone", () => { story_id: "74_reveal_test", name: "Reveal Test", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], @@ -362,6 +368,7 @@ describe("LozengeFlyProvider fly-out", () => { story_id: "74_fly_out_test", name: "Fly Out Test", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: "haiku", status: "completed" }, }, ], @@ -386,6 +393,7 @@ describe("LozengeFlyProvider fly-out", () => { story_id: "74_fly_out_test", name: "Fly Out Test", error: null, + merge_failure: null, agent: null, }, ], @@ -417,6 +425,7 @@ describe("AgentLozenge idle vs active appearance", () => { story_id: "74_running_color", name: "Running", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ]; @@ -440,6 +449,7 @@ describe("AgentLozenge idle vs active appearance", () => { story_id: "74_pending_color", name: "Pending", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "pending" }, }, ]; @@ -463,6 +473,7 @@ describe("AgentLozenge idle vs active appearance", () => { story_id: "74_pulse_dot", name: "Pulse", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ]; @@ -513,6 +524,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => { story_id: "85_assign_test", name: "Assign Test", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], @@ -533,6 +545,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => { story_id: "85_no_agent", name: "No Agent", error: null, + merge_failure: null, agent: null, }, ], @@ -554,6 +567,7 @@ describe("hiddenRosterAgents: assigned agents are absent from roster", () => { story_id: "85_transition_test", name: "Transition", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], @@ -613,6 +627,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () story_id: "85_flyout_hidden", name: "Fly-out Hidden", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "completed" }, }, ], @@ -623,6 +638,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () story_id: "85_flyout_hidden", name: "Fly-out Hidden", error: null, + merge_failure: null, agent: null, }, ], @@ -664,6 +680,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () story_id: "85_flyout_reveal", name: "Fly-out Reveal", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "completed" }, }, ], @@ -674,6 +691,7 @@ describe("hiddenRosterAgents: fly-out keeps agent hidden until clone lands", () story_id: "85_flyout_reveal", name: "Fly-out Reveal", error: null, + merge_failure: null, agent: null, }, ], @@ -746,6 +764,7 @@ describe("LozengeFlyProvider agent swap (name change)", () => { story_id: "109_swap_test", name: "Swap Test", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, }, ], @@ -756,6 +775,7 @@ describe("LozengeFlyProvider agent swap (name change)", () => { story_id: "109_swap_test", name: "Swap Test", error: null, + merge_failure: null, agent: { agent_name: "coder-2", model: "haiku", status: "running" }, }, ], @@ -835,6 +855,7 @@ describe("LozengeFlyProvider fly-out without roster element", () => { story_id: "109_no_roster_flyout", name: "No Roster Flyout", error: null, + merge_failure: null, agent: { agent_name: "orphan-agent", model: null, @@ -849,6 +870,7 @@ describe("LozengeFlyProvider fly-out without roster element", () => { story_id: "109_no_roster_flyout", name: "No Roster Flyout", error: null, + merge_failure: null, agent: null, }, ], @@ -919,6 +941,7 @@ describe("FlyingLozengeClone initial non-flying render", () => { story_id: "109_nontransition_test", name: "Non-transition Test", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], @@ -993,6 +1016,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () story_id: "137_rapid_swap", name: "Rapid Swap", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", status: "running" }, }, ], @@ -1003,6 +1027,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () story_id: "137_rapid_swap", name: "Rapid Swap", error: null, + merge_failure: null, agent: { agent_name: "coder-2", model: "haiku", status: "running" }, }, ], @@ -1068,6 +1093,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () story_id: "137_reveal_last", name: "Reveal Last", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: null, status: "running" }, }, ], @@ -1078,6 +1104,7 @@ describe("Bug 137: no animation actions lost during rapid pipeline updates", () story_id: "137_reveal_last", name: "Reveal Last", error: null, + merge_failure: null, agent: { agent_name: "coder-2", model: null, status: "running" }, }, ], @@ -1162,6 +1189,7 @@ describe("Bug 137: animations remain functional through sustained agent activity story_id: "137_sustained", name: "Sustained", error: null, + merge_failure: null, agent: { agent_name: agentName, model: null, status: "running" }, }, ], diff --git a/frontend/src/components/StagePanel.test.tsx b/frontend/src/components/StagePanel.test.tsx index 807fa82..3e637ae 100644 --- a/frontend/src/components/StagePanel.test.tsx +++ b/frontend/src/components/StagePanel.test.tsx @@ -15,6 +15,7 @@ describe("StagePanel", () => { story_id: "42_story_no_agent", name: "No Agent Story", error: null, + merge_failure: null, agent: null, }, ]; @@ -30,6 +31,7 @@ describe("StagePanel", () => { story_id: "43_story_with_agent", name: "Active Story", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: "sonnet", @@ -48,6 +50,7 @@ describe("StagePanel", () => { story_id: "44_story_no_model", name: "No Model Story", error: null, + merge_failure: null, agent: { agent_name: "coder-2", model: null, @@ -65,6 +68,7 @@ describe("StagePanel", () => { story_id: "45_story_pending", name: "Pending Story", error: null, + merge_failure: null, agent: { agent_name: "coder-1", model: "haiku", @@ -82,6 +86,7 @@ describe("StagePanel", () => { story_id: "59_story_current_work_panel", name: "Current Work Panel", error: null, + merge_failure: null, agent: null, }, ]; @@ -95,6 +100,7 @@ describe("StagePanel", () => { story_id: "1_story_bad", name: null, error: "Missing front matter", + merge_failure: null, agent: null, }, ]; @@ -108,6 +114,7 @@ describe("StagePanel", () => { story_id: "10_story_some_feature", name: "Some Feature", error: null, + merge_failure: null, agent: null, }, ]; @@ -123,6 +130,7 @@ describe("StagePanel", () => { story_id: "11_bug_broken_thing", name: "Broken Thing", error: null, + merge_failure: null, agent: null, }, ]; @@ -138,6 +146,7 @@ describe("StagePanel", () => { story_id: "12_spike_investigate_perf", name: "Investigate Perf", error: null, + merge_failure: null, agent: null, }, ]; @@ -153,6 +162,7 @@ describe("StagePanel", () => { story_id: "13_task_do_something", name: "Do Something", error: null, + merge_failure: null, agent: null, }, ]; @@ -168,6 +178,7 @@ describe("StagePanel", () => { story_id: "20_story_uniform_border", name: "Uniform Border", error: null, + merge_failure: null, agent: null, }, ]; @@ -186,6 +197,7 @@ describe("StagePanel", () => { story_id: "21_bug_uniform_border", name: "Uniform Border Bug", error: null, + merge_failure: null, agent: null, }, ]; @@ -201,6 +213,7 @@ describe("StagePanel", () => { story_id: "22_spike_uniform_border", name: "Uniform Border Spike", error: null, + merge_failure: null, agent: null, }, ]; @@ -216,6 +229,7 @@ describe("StagePanel", () => { story_id: "23_task_uniform_border", name: "Uniform Border Task", error: null, + merge_failure: null, agent: null, }, ]; @@ -224,4 +238,42 @@ describe("StagePanel", () => { expect(card.style.borderLeft).not.toContain("3px"); expect(card.style.borderLeft).toBe(card.style.borderTop); }); + + it("shows merge failure icon and reason when merge_failure is set", () => { + const items: PipelineStageItem[] = [ + { + story_id: "30_story_merge_failed", + name: "Failed Merge Story", + error: null, + merge_failure: "Squash merge failed: conflicts in Cargo.lock", + agent: null, + }, + ]; + render(); + expect( + screen.getByTestId("merge-failure-icon-30_story_merge_failed"), + ).toBeInTheDocument(); + expect( + screen.getByTestId("merge-failure-reason-30_story_merge_failed"), + ).toHaveTextContent("Squash merge failed: conflicts in Cargo.lock"); + }); + + it("does not show merge failure elements when merge_failure is null", () => { + const items: PipelineStageItem[] = [ + { + story_id: "31_story_no_failure", + name: "Clean Story", + error: null, + merge_failure: null, + agent: null, + }, + ]; + render(); + expect( + screen.queryByTestId("merge-failure-icon-31_story_no_failure"), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId("merge-failure-reason-31_story_no_failure"), + ).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index 592052b..7ae4042 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -166,17 +166,24 @@ export function StagePanel({ const itemType = getWorkItemType(item.story_id); const borderColor = TYPE_COLORS[itemType]; const typeLabel = TYPE_LABELS[itemType]; + const hasMergeFailure = Boolean(item.merge_failure); return (
+ {hasMergeFailure && ( + + ✕ + + )} {itemNumber && ( )} + {item.merge_failure && ( +
+ {item.merge_failure} +
+ )}
{item.agent && ( diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 1566340..148caf0 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -11,7 +11,7 @@ use crate::http::workflow::{ validate_story_dirs, }; use crate::worktree; -use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos}; +use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos, write_merge_failure}; use crate::workflow::{evaluate_acceptance_with_coverage, TestCaseResult, TestStatus}; use poem::handler; use poem::http::StatusCode; @@ -1668,6 +1668,28 @@ fn tool_report_merge_failure(args: &Value, ctx: &AppContext) -> Result, pub error: Option, + /// Merge failure reason persisted to front matter by the mergemaster agent. + pub merge_failure: Option, /// Active agent working on this item, if any. pub agent: Option, } @@ -115,12 +117,12 @@ fn load_stage_items( .to_string(); let contents = fs::read_to_string(&path) .map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?; - let (name, error) = match parse_front_matter(&contents) { - Ok(meta) => (meta.name, None), - Err(e) => (None, Some(e.to_string())), + let (name, error, merge_failure) = match parse_front_matter(&contents) { + Ok(meta) => (meta.name, None, meta.merge_failure), + Err(e) => (None, Some(e.to_string()), None), }; let agent = agent_map.get(&story_id).cloned(); - stories.push(UpcomingStory { story_id, name, error, agent }); + stories.push(UpcomingStory { story_id, name, error, merge_failure, agent }); } stories.sort_by(|a, b| a.story_id.cmp(&b.story_id)); diff --git a/server/src/http/ws.rs b/server/src/http/ws.rs index 07f212d..9c942cb 100644 --- a/server/src/http/ws.rs +++ b/server/src/http/ws.rs @@ -612,6 +612,7 @@ mod tests { story_id: "10_story_test".to_string(), name: Some("Test".to_string()), error: None, + merge_failure: None, agent: None, }; let resp = WsResponse::PipelineState { @@ -748,12 +749,14 @@ mod tests { story_id: "1_story_a".to_string(), name: Some("Story A".to_string()), error: None, + merge_failure: None, agent: None, }], current: vec![UpcomingStory { story_id: "2_story_b".to_string(), name: Some("Story B".to_string()), error: None, + merge_failure: None, agent: None, }], qa: vec![], @@ -762,6 +765,7 @@ mod tests { story_id: "50_story_done".to_string(), name: Some("Done Story".to_string()), error: None, + merge_failure: None, agent: None, }], }; @@ -913,6 +917,7 @@ mod tests { story_id: "10_story_x".to_string(), name: Some("Story X".to_string()), error: None, + merge_failure: None, agent: Some(crate::http::workflow::AgentAssignment { agent_name: "coder-1".to_string(), model: Some("claude-3-5-sonnet".to_string()), diff --git a/server/src/io/story_metadata.rs b/server/src/io/story_metadata.rs index 808ad9e..c360c0d 100644 --- a/server/src/io/story_metadata.rs +++ b/server/src/io/story_metadata.rs @@ -6,6 +6,7 @@ use std::path::Path; pub struct StoryMetadata { pub name: Option, pub coverage_baseline: Option, + pub merge_failure: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -27,6 +28,7 @@ impl std::fmt::Display for StoryMetaError { struct FrontMatter { name: Option, coverage_baseline: Option, + merge_failure: Option, } pub fn parse_front_matter(contents: &str) -> Result { @@ -58,6 +60,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata { StoryMetadata { name: front.name, coverage_baseline: front.coverage_baseline, + merge_failure: front.merge_failure, } } @@ -74,6 +77,24 @@ pub fn write_coverage_baseline(path: &Path, coverage_pct: f64) -> Result<(), Str Ok(()) } +/// Write or update a `merge_failure:` field in the YAML front matter of a story file. +/// +/// The reason is stored as a quoted YAML string so that colons, hashes, and newlines +/// in the failure message do not break front-matter parsing. +/// If no front matter is present, this is a no-op (returns Ok). +pub fn write_merge_failure(path: &Path, reason: &str) -> Result<(), String> { + let contents = + fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; + + // Produce a YAML-safe inline quoted string: collapse newlines, escape inner quotes. + let escaped = reason.replace('"', "\\\"").replace('\n', " ").replace('\r', ""); + let yaml_value = format!("\"{escaped}\""); + + let updated = set_front_matter_field(&contents, "merge_failure", &yaml_value); + fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; + Ok(()) +} + /// Insert or update a key: value pair in the YAML front matter of a markdown string. /// /// If no front matter (opening `---`) is found, returns the content unchanged.