//! Merge stage dispatch: trigger server-side merges and auto-spawn mergemaster for content conflicts. use std::path::Path; use crate::config::ProjectConfig; use crate::slog; use crate::slog_error; use crate::slog_warn; use crate::worktree; use super::super::super::PipelineStage; use super::super::AgentPool; use super::scan::{find_free_agent_for_stage, is_story_assigned_for_stage, scan_stage_items}; use super::story_checks::{ has_content_conflict_failure, has_merge_failure, has_mergemaster_attempted, has_review_hold, has_unmet_dependencies, is_story_blocked, is_story_frozen, }; impl AgentPool { /// Process stories in `4_merge/`: trigger server-side squash-merges and auto-spawn /// a mergemaster agent when a content-conflict failure is detected. /// /// Stories with a recorded merge failure may be eligible for automatic mergemaster /// dispatch when the failure is a content conflict — otherwise they need human /// intervention. Each eligible story without an active merge job triggers /// `trigger_server_side_merge`. pub(super) async fn assign_merge_stage(&self, project_root: &Path, config: &ProjectConfig) { // ── 4_merge: deterministic server-side merge (no LLM agent) ────────── // // Stories in 4_merge/ are handled directly by the server rather than by // a mergemaster agent. For each eligible story, trigger start_merge_agent_work // which runs the squash-merge pipeline in a background task. On success // the story advances to 5_done automatically. On failure merge_failure is // written to the CRDT and a notification is emitted; the story stays in // 4_merge/ until a human intervenes or an explicit `start_agent mergemaster` // call invokes the LLM-driven recovery path. let merge_items = scan_stage_items(project_root, "4_merge"); for story_id in &merge_items { // Stories with a recorded merge failure may be eligible for // automatic mergemaster dispatch when the failure is a content // conflict — otherwise they need human intervention. if has_merge_failure(project_root, "4_merge", story_id) { // Auto-spawn mergemaster for content conflicts, but only once. if has_content_conflict_failure(project_root, "4_merge", story_id) && !has_mergemaster_attempted(project_root, "4_merge", story_id) && !is_story_blocked(project_root, "4_merge", story_id) { // Find the mergemaster agent. let mergemaster_agent = { let agents = match self.agents.lock() { Ok(a) => a, Err(e) => { slog_error!( "[auto-assign] Failed to lock agents for mergemaster check: {e}" ); continue; } }; if is_story_assigned_for_stage( config, &agents, story_id, &PipelineStage::Mergemaster, ) { // Already running — don't spawn again. None } else { find_free_agent_for_stage(config, &agents, &PipelineStage::Mergemaster) .map(str::to_string) } }; if let Some(agent_name) = mergemaster_agent { slog!( "[auto-assign] Content conflict on '{story_id}'; \ auto-spawning mergemaster '{agent_name}'." ); // Record mergemaster_attempted before spawning so a // crash/restart doesn't re-trigger an infinite loop. if let Some(contents) = crate::db::read_content(story_id) { let updated = crate::db::yaml_legacy::write_mergemaster_attempted_in_content( &contents, ); crate::db::write_content(story_id, &updated); crate::db::write_item_with_content( story_id, "4_merge", &updated, crate::db::ItemMeta::from_yaml(&updated), ); } crate::crdt_state::set_mergemaster_attempted(story_id, true); if let Err(e) = self .start_agent(project_root, story_id, Some(&agent_name), None, None) .await { slog!( "[auto-assign] Failed to start mergemaster '{agent_name}' \ for '{story_id}': {e}" ); } } } continue; } if has_review_hold(project_root, "4_merge", story_id) { continue; } if is_story_frozen(project_root, "4_merge", story_id) { slog!("[auto-assign] Story '{story_id}' in 4_merge/ is frozen; skipping."); continue; } if is_story_blocked(project_root, "4_merge", story_id) { continue; } if has_unmet_dependencies(project_root, "4_merge", story_id) { slog!("[auto-assign] Story '{story_id}' in 4_merge/ has unmet deps; skipping."); continue; } // AC6: Detect empty-diff stories before starting the merge pipeline. // If the worktree has no commits on the feature branch, block the // story immediately via the state machine — no merge job needed. if let Some(wt_path) = worktree::find_worktree_path(project_root, story_id) && !crate::agents::gates::worktree_has_committed_work(&wt_path) { let empty_diff_reason = "Feature branch has no code changes — the coder agent \ did not produce any commits."; slog_warn!( "[auto-assign] Story '{story_id}' in 4_merge/ has no commits \ on feature branch. Blocking via state machine." ); if let Err(e) = crate::agents::lifecycle::transition_to_blocked(story_id, empty_diff_reason) { slog_error!("[auto-assign] Failed to transition '{story_id}' to Blocked: {e}"); } let _ = self .watcher_tx .send(crate::io::watcher::WatcherEvent::StoryBlocked { story_id: story_id.to_string(), reason: empty_diff_reason.to_string(), }); continue; } // Skip if a merge job is already running for this story (e.g. triggered // by a previous auto-assign pass or by pipeline advancement). let already_running = crate::crdt_state::read_merge_job(story_id.as_str()) .is_some_and(|job| job.status == "running"); if already_running { continue; } // Skip if an explicit mergemaster LLM agent is already running // (operator-driven failure recovery path). let has_mergemaster = { let agents = match self.agents.lock() { Ok(a) => a, Err(e) => { slog_error!("[auto-assign] Failed to lock agents: {e}"); break; } }; is_story_assigned_for_stage(config, &agents, story_id, &PipelineStage::Mergemaster) }; if has_mergemaster { continue; } slog!("[auto-assign] Triggering server-side merge for '{story_id}' in 4_merge/"); self.trigger_server_side_merge(project_root, story_id); } } }