huskies: merge 982
This commit is contained in:
@@ -11,8 +11,8 @@ use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use crate::pipeline_state::{
|
||||
ApplyError, ArchiveReason, BranchName, GitSha, PipelineEvent, Stage, TransitionFired,
|
||||
apply_transition, stage_label,
|
||||
ApplyError, ArchiveReason, BranchName, GitSha, MergeFailureKind, PipelineEvent, Stage,
|
||||
TransitionFired, apply_transition, stage_label,
|
||||
};
|
||||
use crate::slog;
|
||||
|
||||
@@ -246,10 +246,12 @@ pub fn transition_to_blocked(story_id: &str, reason: &str) -> Result<(), String>
|
||||
/// Transition a story from `Stage::Merge` (or `Stage::MergeFailure`) to
|
||||
/// `Stage::MergeFailure` via the state machine.
|
||||
///
|
||||
/// Builds a `PipelineEvent::MergeFailed { reason }`, validates the transition,
|
||||
/// writes the resulting `Stage::MergeFailure` to the CRDT, and persists the
|
||||
/// reason to the typed `MergeJob.error` CRDT register so it survives server
|
||||
/// restarts (story 929: the legacy YAML write of `merge_failure: "..."` is gone).
|
||||
/// Builds a `PipelineEvent::MergeFailed { kind }`, validates the transition,
|
||||
/// writes the resulting `Stage::MergeFailure` to the CRDT, and persists two
|
||||
/// display-only copies for status tools:
|
||||
/// - `ContentKey::GateOutput`: the kind's gate-output string so the CRDT
|
||||
/// projection layer can reconstruct the kind after a server restart.
|
||||
/// - `MergeJob.error`: human-readable description for status renderers.
|
||||
///
|
||||
/// When the story is already in `MergeFailure`, this is a silent self-loop: the
|
||||
/// returned `TransitionFired::before` will be `Stage::MergeFailure`. Callers
|
||||
@@ -258,26 +260,27 @@ pub fn transition_to_blocked(story_id: &str, reason: &str) -> Result<(), String>
|
||||
/// Returns `Err` on `TransitionError` — callers must NOT fall back to direct register writes.
|
||||
pub fn transition_to_merge_failure(
|
||||
story_id: &str,
|
||||
reason: &str,
|
||||
kind: MergeFailureKind,
|
||||
) -> Result<TransitionFired, String> {
|
||||
let fired = apply_transition(
|
||||
story_id,
|
||||
PipelineEvent::MergeFailed {
|
||||
reason: reason.to_string(),
|
||||
},
|
||||
None,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let display = kind.display_reason();
|
||||
let gate_output = kind.to_gate_output();
|
||||
|
||||
// Persist the failure reason on the MergeJob CRDT entry so display tools
|
||||
// (status_tools, chat status renderer, pipeline.rs::load_pipeline_state)
|
||||
// can surface it without re-parsing YAML.
|
||||
let fired = apply_transition(story_id, PipelineEvent::MergeFailed { kind }, None)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Persist gate-output string so the CRDT projection can reconstruct the
|
||||
// MergeFailureKind on server restart (display-only; scheduling uses the
|
||||
// typed kind from the Stage variant).
|
||||
crate::db::write_content(crate::db::ContentKey::GateOutput(story_id), &gate_output);
|
||||
|
||||
// Persist human-readable description on the MergeJob CRDT entry so display
|
||||
// tools (status renderer, pipeline state view) can surface it.
|
||||
crate::crdt_state::write_merge_job(
|
||||
story_id,
|
||||
"failed",
|
||||
chrono::Utc::now().timestamp() as f64,
|
||||
None,
|
||||
Some(reason),
|
||||
Some(&display),
|
||||
);
|
||||
|
||||
Ok(fired)
|
||||
|
||||
@@ -901,4 +901,72 @@ mod tests {
|
||||
through the watcher bridge (story 958 regression)"
|
||||
);
|
||||
}
|
||||
|
||||
/// AC5 (story 982): a merge failure with content conflicts — seeded via the
|
||||
/// typed `transition_to_merge_failure(ConflictDetected)` path without any
|
||||
/// direct content-store or MergeJob writes in the test — produces
|
||||
/// `Stage::MergeFailure { kind: ConflictDetected(_), .. }` and
|
||||
/// auto-spawn-mergemaster fires within one `auto_assign_available_work` call.
|
||||
#[tokio::test]
|
||||
async fn auto_spawn_mergemaster_for_conflict_detected_kind_without_content_store_writes() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sk = tmp.path().join(".huskies");
|
||||
std::fs::create_dir_all(&sk).unwrap();
|
||||
std::fs::write(
|
||||
sk.join("project.toml"),
|
||||
"[[agent]]\nname = \"mergemaster\"\nstage = \"mergemaster\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
|
||||
let story_id = "982_ac5_conflict_auto_spawn";
|
||||
// Seed at Merge stage so the transition is valid.
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"4_merge",
|
||||
"---\nname: AC5 auto-spawn test\n---\n",
|
||||
crate::db::ItemMeta::named("AC5 auto-spawn test"),
|
||||
);
|
||||
// Transition to MergeFailure(ConflictDetected) via lifecycle — no direct
|
||||
// content-store writes in this test body.
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
crate::pipeline_state::MergeFailureKind::ConflictDetected(Some(
|
||||
"CONFLICT (content): server/src/lib.rs".to_string(),
|
||||
)),
|
||||
)
|
||||
.expect("transition to MergeFailure(ConflictDetected) should succeed");
|
||||
|
||||
// Verify the stage kind before triggering auto-assign.
|
||||
let item = crate::pipeline_state::read_typed(story_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
item.stage,
|
||||
crate::pipeline_state::Stage::MergeFailure {
|
||||
kind: crate::pipeline_state::MergeFailureKind::ConflictDetected(_),
|
||||
..
|
||||
}
|
||||
),
|
||||
"stage must be MergeFailure(ConflictDetected) before auto-assign: {:?}",
|
||||
item.stage
|
||||
);
|
||||
|
||||
// One auto-assign cycle should spawn mergemaster.
|
||||
let pool = AgentPool::new_test(3001);
|
||||
pool.auto_assign_available_work(tmp.path()).await;
|
||||
|
||||
let agents = pool.agents.lock().unwrap();
|
||||
let mergemaster_spawned = agents.iter().any(|(key, a)| {
|
||||
key.contains(story_id)
|
||||
&& a.agent_name == "mergemaster"
|
||||
&& matches!(a.status, AgentStatus::Pending | AgentStatus::Running)
|
||||
});
|
||||
assert!(
|
||||
mergemaster_spawned,
|
||||
"mergemaster must be auto-spawned for ConflictDetected kind in one auto-assign cycle"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ impl AgentPool {
|
||||
// Content conflicts get one automatic mergemaster attempt; other failures
|
||||
// require human intervention.
|
||||
let merge_failure_stage = Stage::MergeFailure {
|
||||
reason: String::new(),
|
||||
kind: crate::pipeline_state::MergeFailureKind::Other(String::new()),
|
||||
feature_branch: BranchName(String::new()),
|
||||
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
|
||||
};
|
||||
|
||||
@@ -39,34 +39,26 @@ pub(super) fn is_story_blocked(story_id: &str) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Return `true` if the story's merge failure contains a git content-conflict
|
||||
/// marker (`"Merge conflict"` or `"CONFLICT (content):"`).
|
||||
/// Return `true` if the story's merge failure is a git content-conflict
|
||||
/// (`Stage::MergeFailure { kind: ConflictDetected(_), .. }`).
|
||||
///
|
||||
/// Used by the auto-assigner to decide whether to spawn mergemaster automatically.
|
||||
/// The typed stage register is consulted first; the CRDT content store is then
|
||||
/// scanned for conflict markers (the projection layer does not carry the reason
|
||||
/// string). No YAML front-matter parsing is performed.
|
||||
/// The typed kind is carried by the CRDT projection layer (which reads
|
||||
/// `ContentKey::GateOutput` on projection to reconstruct the kind on restart),
|
||||
/// so no direct content-store access is needed here (story 982).
|
||||
pub(super) fn has_content_conflict_failure(story_id: &str) -> bool {
|
||||
let is_merge_failure = crate::pipeline_state::read_typed(story_id)
|
||||
crate::pipeline_state::read_typed(story_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|item| {
|
||||
matches!(
|
||||
item.stage,
|
||||
crate::pipeline_state::Stage::MergeFailure { .. }
|
||||
crate::pipeline_state::Stage::MergeFailure {
|
||||
kind: crate::pipeline_state::MergeFailureKind::ConflictDetected(_),
|
||||
..
|
||||
}
|
||||
)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if !is_merge_failure {
|
||||
return false;
|
||||
}
|
||||
// The projection does not carry the reason string; read the gate output
|
||||
// (where the merge runner persists the failure message) and scan for
|
||||
// conflict markers.
|
||||
crate::db::read_content(crate::db::ContentKey::GateOutput(story_id))
|
||||
.map(|content| {
|
||||
content.contains("Merge conflict") || content.contains("CONFLICT (content):")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
@@ -337,4 +329,81 @@ mod tests {
|
||||
let archived_deps = check_archived_dependencies("503_story_waiting");
|
||||
assert!(archived_deps.is_empty());
|
||||
}
|
||||
|
||||
// ── Story 982: typed MergeFailureKind — has_content_conflict_failure ──────
|
||||
|
||||
/// AC2 (story 982): `has_content_conflict_failure` returns `true` when the
|
||||
/// story is in `Stage::MergeFailure { kind: ConflictDetected(_), .. }`.
|
||||
/// The test seeds the stage via `transition_to_merge_failure` (no direct
|
||||
/// content-store or MergeJob writes in the test body).
|
||||
#[test]
|
||||
fn has_content_conflict_failure_true_for_conflict_detected_kind() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
let story_id = "982_ac2_conflict_detected";
|
||||
// Seed at Merge stage so the transition is valid.
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"4_merge",
|
||||
"---\nname: AC2 conflict test\n---\n",
|
||||
crate::db::ItemMeta::named("AC2 conflict test"),
|
||||
);
|
||||
// Transition via the lifecycle helper — internally writes ContentKey::GateOutput
|
||||
// so the CRDT projection can reconstruct the kind; no content-store writes here.
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
crate::pipeline_state::MergeFailureKind::ConflictDetected(Some(
|
||||
"CONFLICT (content): server/src/lib.rs".to_string(),
|
||||
)),
|
||||
)
|
||||
.expect("transition should succeed");
|
||||
|
||||
// The typed match now drives the predicate — no substring scan.
|
||||
assert!(
|
||||
has_content_conflict_failure(story_id),
|
||||
"has_content_conflict_failure must be true for ConflictDetected kind"
|
||||
);
|
||||
// Verify the projected stage carries the typed kind.
|
||||
let item = crate::pipeline_state::read_typed(story_id)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(
|
||||
item.stage,
|
||||
crate::pipeline_state::Stage::MergeFailure {
|
||||
kind: crate::pipeline_state::MergeFailureKind::ConflictDetected(_),
|
||||
..
|
||||
}
|
||||
),
|
||||
"stage must be MergeFailure(ConflictDetected): {:?}",
|
||||
item.stage
|
||||
);
|
||||
}
|
||||
|
||||
/// AC2 (story 982): `has_content_conflict_failure` returns `false` when the
|
||||
/// kind is `GatesFailed` — no mergemaster spawn for gate-only failures.
|
||||
#[test]
|
||||
fn has_content_conflict_failure_false_for_gates_failed_kind() {
|
||||
crate::crdt_state::init_for_test();
|
||||
crate::db::ensure_content_store();
|
||||
let story_id = "982_ac2_gates_failed";
|
||||
crate::db::write_item_with_content(
|
||||
story_id,
|
||||
"4_merge",
|
||||
"---\nname: AC2 gates test\n---\n",
|
||||
crate::db::ItemMeta::named("AC2 gates test"),
|
||||
);
|
||||
crate::agents::lifecycle::transition_to_merge_failure(
|
||||
story_id,
|
||||
crate::pipeline_state::MergeFailureKind::GatesFailed(
|
||||
"error[clippy::unused_variable]".to_string(),
|
||||
),
|
||||
)
|
||||
.expect("transition should succeed");
|
||||
|
||||
assert!(
|
||||
!has_content_conflict_failure(story_id),
|
||||
"has_content_conflict_failure must be false for GatesFailed kind"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,12 +186,13 @@ impl AgentPool {
|
||||
fixup failure: {e}"
|
||||
);
|
||||
}
|
||||
let reason = format!(
|
||||
let kind = crate::pipeline_state::MergeFailureKind::GatesFailed(format!(
|
||||
"Merge fixup coder could not resolve gate failures: {}",
|
||||
truncate_gate_output(&completion.gate_output)
|
||||
);
|
||||
));
|
||||
let display = kind.display_reason();
|
||||
if let Err(e) =
|
||||
crate::agents::lifecycle::transition_to_merge_failure(story_id, &reason)
|
||||
crate::agents::lifecycle::transition_to_merge_failure(story_id, kind)
|
||||
{
|
||||
slog_error!(
|
||||
"[pipeline] Failed to transition '{story_id}' to MergeFailure \
|
||||
@@ -200,7 +201,7 @@ impl AgentPool {
|
||||
}
|
||||
let _ = self.watcher_tx.send(WatcherEvent::MergeFailure {
|
||||
story_id: story_id.to_string(),
|
||||
reason,
|
||||
reason: display,
|
||||
});
|
||||
}
|
||||
} else if completion.gates_passed {
|
||||
|
||||
@@ -129,32 +129,35 @@ impl AgentPool {
|
||||
|
||||
// On any failure: record merge_failure in CRDT and emit notification.
|
||||
if !success {
|
||||
let reason = match &report {
|
||||
let kind = match &report {
|
||||
Ok(r) => {
|
||||
if r.had_conflicts {
|
||||
format!(
|
||||
"Merge conflict: {}",
|
||||
r.conflict_details
|
||||
.as_deref()
|
||||
.unwrap_or("conflicts detected")
|
||||
crate::pipeline_state::MergeFailureKind::ConflictDetected(
|
||||
r.conflict_details.clone(),
|
||||
)
|
||||
} else {
|
||||
format!("Quality gates failed: {}", r.gate_output)
|
||||
crate::pipeline_state::MergeFailureKind::GatesFailed(
|
||||
r.gate_output.clone(),
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(e) => e.clone(),
|
||||
Err(e) => crate::pipeline_state::MergeFailureKind::Other(e.clone()),
|
||||
};
|
||||
let is_no_commits = reason.contains("no commits to merge");
|
||||
// Self-evident fix: gate-only failure (no conflicts) whose output matches
|
||||
// a pattern a fixup coder can resolve in one short session (story 981).
|
||||
let gate_output = match &report {
|
||||
Ok(r) if !r.had_conflicts => r.gate_output.clone(),
|
||||
_ => String::new(),
|
||||
let is_no_commits = matches!(
|
||||
&kind,
|
||||
crate::pipeline_state::MergeFailureKind::Other(r) if r.contains("no commits to merge")
|
||||
);
|
||||
// Self-evident fix: gate-only failure whose output matches a pattern
|
||||
// a fixup coder can resolve in one short session (story 981).
|
||||
let fixup_output = match &kind {
|
||||
crate::pipeline_state::MergeFailureKind::GatesFailed(o) => o.as_str(),
|
||||
_ => "",
|
||||
};
|
||||
let is_fixup =
|
||||
!is_no_commits && !gate_output.is_empty() && is_self_evident_fix(&gate_output);
|
||||
!is_no_commits && !fixup_output.is_empty() && is_self_evident_fix(fixup_output);
|
||||
|
||||
if is_no_commits {
|
||||
let reason = kind.display_reason();
|
||||
if let Err(e) = crate::agents::lifecycle::transition_to_blocked(&sid, &reason) {
|
||||
slog_error!("[merge] Failed to transition '{sid}' to Blocked: {e}");
|
||||
}
|
||||
@@ -165,15 +168,16 @@ impl AgentPool {
|
||||
reason,
|
||||
});
|
||||
} else if is_fixup {
|
||||
// Save gate output and mark fixup pending before any state transition
|
||||
// so that a concurrent auto-assign that fires after the state change
|
||||
// sees the keys already set.
|
||||
crate::db::write_content(crate::db::ContentKey::GateOutput(&sid), &gate_output);
|
||||
// Mark fixup pending before any state transition so a concurrent
|
||||
// auto-assign that fires after the state change sees the key set.
|
||||
crate::db::write_content(crate::db::ContentKey::MergeFixupPending(&sid), "1");
|
||||
// Merge → MergeFailure → Coding. FixupRequested also sets
|
||||
// retry_count=1 so maybe_inject_gate_failure injects the gate
|
||||
// output into --append-system-prompt on the fixup spawn.
|
||||
let _ = crate::agents::lifecycle::transition_to_merge_failure(&sid, &reason);
|
||||
// retry_count=1 so maybe_inject_gate_failure injects gate output
|
||||
// into --append-system-prompt on the fixup spawn.
|
||||
// transition_to_merge_failure also writes ContentKey::GateOutput.
|
||||
let display = kind.display_reason();
|
||||
let _ =
|
||||
crate::agents::lifecycle::transition_to_merge_failure(sid.as_str(), kind);
|
||||
match crate::agents::lifecycle::move_story_to_stage(&sid, "current") {
|
||||
Ok(_) => {
|
||||
slog!(
|
||||
@@ -203,7 +207,7 @@ impl AgentPool {
|
||||
let _ = pool.watcher_tx.send(
|
||||
crate::io::watcher::WatcherEvent::MergeFailure {
|
||||
story_id: sid.clone(),
|
||||
reason,
|
||||
reason: display,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -212,8 +216,10 @@ impl AgentPool {
|
||||
// Transition through the state machine (Merge → MergeFailure).
|
||||
// Only send the notification when the stage actually changed; if the
|
||||
// story was already in MergeFailure (self-loop), suppress the duplicate.
|
||||
let display = kind.display_reason();
|
||||
let should_notify = match crate::agents::lifecycle::transition_to_merge_failure(
|
||||
&sid, &reason,
|
||||
sid.as_str(),
|
||||
kind,
|
||||
) {
|
||||
Ok(fired) => !matches!(
|
||||
fired.before,
|
||||
@@ -231,7 +237,7 @@ impl AgentPool {
|
||||
pool.watcher_tx
|
||||
.send(crate::io::watcher::WatcherEvent::MergeFailure {
|
||||
story_id: sid.clone(),
|
||||
reason,
|
||||
reason: display,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user