2026-04-29 09:49:45 +00:00
|
|
|
//! 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::io::story_metadata::write_mergemaster_attempted_in_content(
|
|
|
|
|
&contents,
|
|
|
|
|
);
|
|
|
|
|
crate::db::write_content(story_id, &updated);
|
|
|
|
|
crate::db::write_item_with_content(story_id, "4_merge", &updated);
|
|
|
|
|
}
|
2026-04-29 16:05:54 +00:00
|
|
|
crate::crdt_state::set_mergemaster_attempted(story_id, true);
|
2026-04-29 09:49:45 +00:00
|
|
|
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.
|
2026-04-29 22:42:59 +00:00
|
|
|
// If the worktree has no commits on the feature branch, block the
|
|
|
|
|
// story immediately via the state machine — no merge job needed.
|
2026-04-29 09:49:45 +00:00
|
|
|
if let Some(wt_path) = worktree::find_worktree_path(project_root, story_id)
|
|
|
|
|
&& !crate::agents::gates::worktree_has_committed_work(&wt_path)
|
|
|
|
|
{
|
2026-04-29 22:42:59 +00:00
|
|
|
let empty_diff_reason = "Feature branch has no code changes — the coder agent \
|
|
|
|
|
did not produce any commits.";
|
2026-04-29 09:49:45 +00:00
|
|
|
slog_warn!(
|
|
|
|
|
"[auto-assign] Story '{story_id}' in 4_merge/ has no commits \
|
2026-04-29 22:42:59 +00:00
|
|
|
on feature branch. Blocking via state machine."
|
2026-04-29 09:49:45 +00:00
|
|
|
);
|
2026-04-29 22:42:59 +00:00
|
|
|
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}");
|
2026-04-29 09:49:45 +00:00
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|