use crate::parallel::dedup::DiagnosticDeduplicationKey;
use crate::tui::events::LogEntry;
#[cfg(test)]
use crate::tui::events::TuiCommand;
use crate::tui::state::AppState;
impl AppState {
pub(crate) fn handle_processing_error(&mut self, id: String, error: String) {
self.reset_analysis_log_dedupe();
if let Some(change) = self.changes.iter_mut().find(|c| c.id == id) {
change.set_error_message_cache(error.clone());
}
self.add_log(LogEntry::error(format!("Error in {}: {}", id, error)).with_change_id(&id));
self.error_change_id = Some(id.clone());
self.current_change = None;
}
pub(crate) fn handle_apply_failed(&mut self, change_id: String, error: String) {
self.reset_analysis_log_dedupe();
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
change.set_error_message_cache(error.clone());
}
self.add_log(
LogEntry::error(format!("Apply failed for {}: {}", change_id, error))
.with_change_id(&change_id),
);
}
pub(crate) fn handle_archive_failed(&mut self, change_id: String, error: String) {
self.reset_analysis_log_dedupe();
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
change.set_error_message_cache(error.clone());
}
self.add_log(
LogEntry::error(format!("Archive failed for {}: {}", change_id, error))
.with_change_id(&change_id),
);
}
pub(crate) fn handle_resolve_failed(&mut self, change_id: String, error: String) {
self.reset_analysis_log_dedupe();
self.clear_resolving();
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
if change.display_status_cache == "merged" {
self.add_log(
LogEntry::info(format!(
"Ignoring ResolveFailed for '{}': already Merged",
change_id
))
.with_change_id(&change_id),
);
return;
}
change.set_display_status_cache("merge wait");
}
let message = format!("Failed to resolve merge for '{}': {}", change_id, error);
self.add_log(LogEntry::error(message).with_change_id(&change_id));
self.try_transition_to_select();
}
pub(crate) fn handle_change_stop_failed(&mut self, change_id: String, error: String) {
self.add_log(
LogEntry::error(format!("Failed to stop {}: {}", change_id, error))
.with_change_id(&change_id),
);
}
pub(crate) fn handle_hook_failed(
&mut self,
change_id: String,
hook_type: String,
error: String,
) {
self.reset_analysis_log_dedupe();
if hook_type == "on_merged" {
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
if change.display_status_cache != "merged" {
change.set_display_status_cache("merge wait");
}
}
let message = format!(
"on_merged hook failed for '{}'; merged transition blocked: {}",
change_id, error
);
self.show_warning_popup("on_merged hook failed", message.clone());
self.add_log(LogEntry::error(message).with_change_id(&change_id));
self.try_transition_to_select();
} else {
self.add_log(
LogEntry::error(format!(
"Hook '{}' failed for {}: {}",
hook_type, change_id, error
))
.with_change_id(&change_id),
);
}
}
fn add_merge_deferred_warning_log(
&mut self,
change_id: &str,
reason: &str,
auto_resumable: bool,
message: String,
) {
let key = DiagnosticDeduplicationKey::TuiMergeDeferred {
change_id: change_id.to_string(),
reason: reason.to_string(),
auto_resumable,
};
if !self.diagnostic_dedup.should_emit(key) {
tracing::debug!(
change_id = %change_id,
auto_resumable = auto_resumable,
reason = %reason,
"Suppressing repeated merge-deferred TUI diagnostic"
);
return;
}
self.add_log(LogEntry::warn(message).with_change_id(change_id));
}
fn other_change_is_resolving(&self, change_id: &str) -> bool {
self.changes
.iter()
.any(|c| c.id != change_id && c.display_status_cache == "resolving")
}
fn apply_manual_merge_deferral(&mut self, change_id: &str, reason: &str) {
self.remove_from_resolve_queue(change_id);
if self.is_resolving() && !self.other_change_is_resolving(change_id) {
self.clear_resolving();
}
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
change.set_display_status_cache("merge wait");
}
self.add_merge_deferred_warning_log(
change_id,
reason,
false,
format!("Merge deferred for {}: {}", change_id, reason),
);
}
pub(crate) fn handle_merge_deferred(
&mut self,
change_id: String,
reason: String,
auto_resumable: bool,
) {
if !auto_resumable {
self.apply_manual_merge_deferral(&change_id, &reason);
return;
}
if self.is_resolving() {
let is_current_resolving = self
.changes
.iter()
.any(|c| c.id == change_id && c.display_status_cache == "resolving");
if is_current_resolving {
self.add_merge_deferred_warning_log(
&change_id,
&reason,
auto_resumable,
format!(
"Merge deferred for '{}' (currently resolving, not queued): {}",
change_id, reason
),
);
} else {
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
change.set_display_status_cache("resolve pending");
}
if self.add_to_resolve_queue(&change_id) {
self.add_merge_deferred_warning_log(
&change_id,
&reason,
auto_resumable,
format!(
"Merge deferred for '{}' (queued for resolve): {}",
change_id, reason
),
);
} else {
self.add_merge_deferred_warning_log(
&change_id,
&reason,
auto_resumable,
format!(
"Merge deferred for '{}' (already queued): {}",
change_id, reason
),
);
}
}
} else {
if let Some(change) = self.changes.iter_mut().find(|c| c.id == change_id) {
change.set_display_status_cache("resolve pending");
}
self.add_merge_deferred_warning_log(
&change_id,
&reason,
auto_resumable,
format!(
"Merge deferred for '{}' (auto-resumable, awaiting scheduler retry): {}",
change_id, reason
),
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openspec::{Change, ProposalMetadata};
use crate::tui::events::OrchestratorEvent;
use crate::tui::types::{AppExecutionMode, StopMode};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::HashMap;
fn create_test_change(id: &str, completed: u32, total: u32) -> Change {
Change {
id: id.to_string(),
completed_tasks: completed,
total_tasks: total,
last_modified: "now".to_string(),
dependencies: Vec::new(),
metadata: ProposalMetadata::default(),
}
}
fn accept_resolve(
app: &mut AppState,
shared: &std::sync::Arc<
tokio::sync::RwLock<crate::orchestration::state::OrchestratorState>,
>,
change_id: &str,
) {
shared.blocking_write().apply_command(
crate::orchestration::state::ReducerCommand::ResolveMerge(change_id.to_string()),
);
app.set_resolving(change_id);
if let Some(change) = app.changes.iter_mut().find(|c| c.id == change_id) {
change.set_display_status_cache("resolve pending");
}
}
fn change_ids_for_message(app: &AppState, needle: &str) -> Vec<Option<String>> {
app.logs
.iter()
.filter(|entry| entry.message.contains(needle))
.map(|entry| entry.change_id.clone())
.collect()
}
#[test]
fn proposal_error_logs_carry_structured_change_id() {
let mut app = AppState::new(vec![create_test_change("change-a", 0, 1)]);
app.handle_processing_error("change-a".to_string(), "boom".to_string());
app.handle_apply_failed("change-a".to_string(), "boom".to_string());
app.handle_archive_failed("change-a".to_string(), "boom".to_string());
app.handle_resolve_failed("change-a".to_string(), "boom".to_string());
app.handle_change_stop_failed("change-a".to_string(), "boom".to_string());
app.handle_hook_failed(
"change-a".to_string(),
"on_applied".to_string(),
"boom".to_string(),
);
for needle in [
"Error in change-a: boom",
"Apply failed for change-a: boom",
"Archive failed for change-a: boom",
"Failed to resolve merge for 'change-a': boom",
"Failed to stop change-a: boom",
"Hook 'on_applied' failed for change-a: boom",
] {
assert_eq!(
change_ids_for_message(&app, needle),
vec![Some("change-a".to_string())],
"expected structured change_id on {needle}"
);
}
}
#[test]
fn on_merged_hook_failure_and_merge_deferred_logs_carry_structured_change_id() {
let mut app = AppState::new(vec![create_test_change("change-a", 0, 1)]);
app.handle_hook_failed(
"change-a".to_string(),
"on_merged".to_string(),
"boom".to_string(),
);
app.handle_merge_deferred("change-a".to_string(), "conflict".to_string(), false);
assert_eq!(
change_ids_for_message(&app, "on_merged hook failed for 'change-a'"),
vec![Some("change-a".to_string())]
);
assert_eq!(
change_ids_for_message(&app, "Merge deferred for change-a: conflict"),
vec![Some("change-a".to_string())]
);
}
#[test]
fn processing_error_keeps_app_mode() {
let changes = vec![create_test_change("test-change", 0, 1)];
let mut app = AppState::new(changes);
app.execution_mode = AppExecutionMode::Running;
app.current_change = Some("test-change".to_string());
app.changes[0].selected = true;
app.publish_execution_marks();
app.execution_marks().set("test-change", false);
app.handle_orchestrator_event(OrchestratorEvent::ProcessingError {
id: "test-change".to_string(),
error: "Test error message".to_string(),
});
assert_eq!(app.execution_mode, AppExecutionMode::Running);
let change = app.changes.iter().find(|c| c.id == "test-change").unwrap();
assert_eq!(change.display_status_cache, "error");
assert!(!change.selected);
assert_eq!(app.error_change_id, Some("test-change".to_string()));
assert_eq!(app.current_change, None);
}
#[test]
fn processing_error_from_select_mode() {
let changes = vec![create_test_change("test-change", 0, 1)];
let mut app = AppState::new(changes);
app.execution_mode = AppExecutionMode::Select;
app.changes[0].selected = true;
app.publish_execution_marks();
app.execution_marks().set("test-change", false);
app.handle_orchestrator_event(OrchestratorEvent::ProcessingError {
id: "test-change".to_string(),
error: "Test error message".to_string(),
});
assert_eq!(app.execution_mode, AppExecutionMode::Select);
let change = app.changes.iter().find(|c| c.id == "test-change").unwrap();
assert_eq!(change.display_status_cache, "error");
assert!(!change.selected);
}
#[test]
fn handle_on_merged_hook_failed_surfaces_merge_wait_not_merged() {
let changes = vec![create_test_change("change-a", 1, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "archived".to_string();
let hook_error =
"git index lock held\nstderr: fatal: unable to lock index\nstdout: retry later";
app.handle_orchestrator_event(OrchestratorEvent::HookFailed {
change_id: "change-a".to_string(),
hook_type: "on_merged".to_string(),
error: hook_error.to_string(),
});
assert_eq!(app.changes[0].display_status_cache, "merge wait");
let popup = app.warning_popup.as_ref().expect("warning popup");
assert!(popup.message.contains(hook_error));
assert!(popup.message.contains("git index lock held\nstderr:"));
let logged_message = app
.logs
.iter()
.find(|log| log.message.contains("merged transition blocked"))
.map(|log| log.message.as_str())
.expect("on_merged log entry");
assert!(logged_message.contains("git index lock held"));
assert!(logged_message.contains("stderr: fatal: unable to lock index"));
assert!(logged_message.contains("stdout: retry later"));
}
#[test]
fn handle_resolve_failed_does_not_demote_merged() {
let changes = vec![create_test_change("change-a", 1, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merged".to_string();
app.handle_orchestrator_event(OrchestratorEvent::ResolveFailed {
change_id: "change-a".to_string(),
error: "archive check failed".to_string(),
});
assert_eq!(app.changes[0].display_status_cache, "merged");
assert!(app
.logs
.iter()
.any(|log| log.message.contains("already Merged")));
}
#[test]
fn merge_deferred_transitions_to_resolve_wait_when_resolving() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "resolving".to_string();
app.set_resolving("__active__");
app.changes[1].display_status_cache = "archived".to_string();
app.handle_merge_deferred(
"change-b".to_string(),
"Base branch has uncommitted changes".to_string(),
true,
);
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
assert!(app.resolve_reservations().is_reserved("change-b"));
}
#[test]
fn merge_deferred_does_not_queue_current_resolving_change() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "resolving".to_string();
app.set_resolving("__active__");
app.handle_merge_deferred(
"change-a".to_string(),
"Base branch has uncommitted changes".to_string(),
true,
);
assert_eq!(app.changes[0].display_status_cache, "resolving");
assert!(!app.resolve_reservations().is_reserved("change-a"));
}
#[test]
fn merge_deferred_queues_other_change_while_resolving() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "resolving".to_string();
app.set_resolving("__active__");
app.changes[1].display_status_cache = "archived".to_string();
app.handle_merge_deferred(
"change-b".to_string(),
"Base branch has uncommitted changes".to_string(),
true,
);
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
assert!(app.resolve_reservations().is_reserved("change-b"));
}
#[test]
fn merge_deferred_maintains_merge_wait_when_not_resolving() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.clear_resolving();
app.changes[0].display_status_cache = "archived".to_string();
app.handle_merge_deferred(
"change-a".to_string(),
"Base branch has uncommitted changes".to_string(),
false,
);
assert_eq!(app.changes[0].display_status_cache, "merge wait");
assert!(!app.resolve_reservations().is_reserved("change-a"));
}
#[test]
fn auto_resumable_merge_deferred_shows_resolve_wait_not_merge_wait() {
let changes = vec![create_test_change("change-b", 0, 1)];
let mut app = AppState::new(changes);
app.clear_resolving();
app.changes[0].display_status_cache = "archived".to_string();
app.handle_merge_deferred(
"change-b".to_string(),
"Merge in progress (MERGE_HEAD exists)".to_string(),
true,
);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
assert!(
app.warning_message.is_none(),
"an auto-resumable deferral is not an operator-facing refusal"
);
assert!(
!app.resolve_reservations().is_reserved("change-b"),
"an idle deferral is painted only; the scheduler consumes the \
reducer-owned ResolveWait the same event already recorded"
);
}
#[test]
fn auto_resumable_merge_deferred_paints_resolve_pending_when_idle() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.clear_resolving();
app.changes[0].display_status_cache = "merged".to_string();
app.changes[1].display_status_cache = "archived".to_string();
app.handle_merge_deferred(
"change-b".to_string(),
"Base is dirty: Merge in progress (MERGE_HEAD exists)".to_string(),
true,
);
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
assert!(app.warning_message.is_none());
assert!(
!app.resolve_reservations().is_reserved("change-b"),
"the scheduler, not this handler, drives the idle resolve"
);
}
#[test]
fn auto_resumable_deferral_through_the_reducer_neither_commands_nor_warns() {
use crate::orchestration::state::{OrchestratorState, WorkspaceObservation};
use std::sync::Arc;
let mut app = AppState::new(vec![create_test_change("change-a", 0, 1)]);
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string()],
0,
)));
{
let mut guard = shared.blocking_write();
guard.apply_observation("change-a", WorkspaceObservation::WorkspaceArchived);
}
app.set_shared_state(shared.clone());
app.execution_mode = AppExecutionMode::Running;
app.clear_resolving();
let deferred = crate::events::ExecutionEvent::MergeDeferred {
change_id: "change-a".to_string(),
reason: "Base is dirty: Merge in progress (MERGE_HEAD exists)".to_string(),
auto_resumable: true,
};
let display_map = {
let mut guard = shared.blocking_write();
guard.apply_execution_event(&deferred);
guard.all_display_statuses()
};
app.apply_display_statuses_from_reducer(&display_map);
assert_eq!(
app.changes[0].display_status_cache, "resolve pending",
"the reducer, not the TUI, has already demoted the row"
);
assert!(!app.is_resolving(), "the resolver ledger is idle");
app.handle_orchestrator_event(deferred);
assert_eq!(
app.warning_message, None,
"an auto-resumable deferral must never reach the operator as a refusal"
);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
assert_eq!(
shared.blocking_read().resolve_wait_change_ids(),
vec!["change-a".to_string()],
"the reducer keeps the intent the scheduler dispatches from"
);
assert!(!app.resolve_reservations().is_reserved("change-a"));
}
#[test]
fn auto_resumable_merge_deferred_queues_when_resolving() {
let changes = vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
];
let mut app = AppState::new(changes);
app.set_resolving("__active__");
app.changes[0].display_status_cache = "resolving".to_string();
app.changes[1].display_status_cache = "archived".to_string();
app.handle_merge_deferred(
"change-b".to_string(),
"Merge in progress (MERGE_HEAD exists)".to_string(),
true,
);
assert_eq!(app.changes[1].display_status_cache, "resolve pending");
assert!(app.resolve_reservations().is_reserved("change-b"));
}
#[test]
fn duplicate_merge_deferred_warning_is_suppressed() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.handle_merge_deferred(
"change-a".to_string(),
"stale workspace path".to_string(),
false,
);
app.handle_merge_deferred(
"change-a".to_string(),
"stale workspace path".to_string(),
false,
);
let matching_logs = app
.logs
.iter()
.filter(|log| log.message.contains("stale workspace path"))
.count();
assert_eq!(matching_logs, 1);
}
#[test]
fn distinct_merge_deferred_reason_is_logged_after_suppressed_duplicate() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.handle_merge_deferred(
"change-a".to_string(),
"stale workspace path".to_string(),
false,
);
app.handle_merge_deferred(
"change-a".to_string(),
"stale workspace path".to_string(),
false,
);
app.handle_merge_deferred(
"change-a".to_string(),
"Working tree has uncommitted changes".to_string(),
false,
);
assert!(app
.logs
.iter()
.any(|log| log.message.contains("stale workspace path")));
assert!(app
.logs
.iter()
.any(|log| log.message.contains("Working tree has uncommitted changes")));
assert_eq!(
app.logs
.iter()
.filter(|log| log.message.contains("Merge deferred for change-a"))
.count(),
2
);
}
#[test]
fn manual_resolve_merge_in_progress_tui_shows_resolve_wait() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merge wait".to_string();
app.clear_resolving();
app.handle_merge_deferred(
"change-a".to_string(),
"Base is dirty: Merge in progress (MERGE_HEAD exists)".to_string(),
true,
);
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
}
#[test]
fn manual_resolve_uncommitted_changes_tui_shows_merge_wait() {
let changes = vec![create_test_change("change-a", 0, 1)];
let mut app = AppState::new(changes);
app.changes[0].display_status_cache = "merge wait".to_string();
app.clear_resolving();
app.handle_resolve_failed(
"change-a".to_string(),
"Base is dirty: Working tree has uncommitted changes".to_string(),
);
assert_eq!(app.changes[0].display_status_cache, "merge wait");
}
#[test]
fn manual_deferral_before_resolve_started_clears_optimistic_reservation() {
let mut app = AppState::new(vec![create_test_change("change-a", 0, 1)]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "merge wait".to_string();
app.cursor_index = 0;
app.clear_resolving();
let first_cmd = app.resolve_merge();
assert!(matches!(first_cmd, Some(TuiCommand::ResolveMerge(ref id)) if id == "change-a"));
app.set_resolving("change-a");
app.changes[0].set_display_status_cache("resolve pending");
assert!(app.is_resolving());
app.apply_display_statuses_from_reducer(&HashMap::from([(
"change-a".to_string(),
"merge wait",
)]));
app.handle_merge_deferred(
"change-a".to_string(),
"Base is dirty: Working tree has uncommitted changes".to_string(),
false,
);
assert_eq!(
app.changes[0].display_status_cache, "merge wait",
"manual deferral must preserve the reducer-derived merge wait row"
);
assert!(
!app.is_resolving(),
"stale optimistic reservation must be cleared"
);
assert!(!app.resolve_reservations().is_reserved("change-a"));
assert!(app.queued_resolves().is_empty());
}
#[test]
fn manual_deferral_removes_only_its_own_local_queue_entry() {
let mut app = AppState::new(vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
create_test_change("change-c", 0, 1),
]);
app.set_resolving("__active__");
app.changes[0].display_status_cache = "resolving".to_string();
app.changes[1].display_status_cache = "resolve pending".to_string();
app.changes[2].display_status_cache = "resolve pending".to_string();
app.add_to_resolve_queue("change-b");
app.add_to_resolve_queue("change-c");
app.handle_merge_deferred(
"change-b".to_string(),
"Base is dirty: Working tree has uncommitted changes".to_string(),
false,
);
assert_eq!(app.changes[1].display_status_cache, "merge wait");
assert!(!app.resolve_reservations().is_reserved("change-b"));
assert_eq!(
app.queued_resolves(),
vec!["change-c".to_string()],
"unrelated queued resolves must keep FIFO membership"
);
}
#[test]
fn manual_deferral_preserves_serialization_for_other_resolving_change() {
let mut app = AppState::new(vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
]);
app.set_resolving("__active__");
app.changes[0].display_status_cache = "resolving".to_string();
app.changes[1].display_status_cache = "merge wait".to_string();
app.handle_merge_deferred(
"change-b".to_string(),
"Base is dirty: Working tree has uncommitted changes".to_string(),
false,
);
assert_eq!(app.changes[1].display_status_cache, "merge wait");
assert!(!app.resolve_reservations().is_reserved("change-b"));
assert!(
app.is_resolving(),
"serialization must remain owned by the actually resolving change"
);
assert_eq!(app.changes[0].display_status_cache, "resolving");
}
#[test]
fn auto_resumable_merge_deferred_still_queues_behind_optimistic_reservation() {
let mut app = AppState::new(vec![
create_test_change("change-a", 0, 1),
create_test_change("change-b", 0, 1),
]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "merge wait".to_string();
app.changes[1].display_status_cache = "merge wait".to_string();
app.cursor_index = 0;
app.clear_resolving();
assert!(app.resolve_merge().is_some());
app.set_resolving("change-a");
app.handle_merge_deferred(
"change-b".to_string(),
"Merge in progress (MERGE_HEAD exists)".to_string(),
true,
);
assert_eq!(
app.changes[1].display_status_cache, "resolve pending",
"queued retry must wait for the active slot"
);
assert!(app.resolve_reservations().is_reserved("change-b"));
assert!(app.is_resolving());
}
#[test]
fn manual_deferral_keeps_second_m_actionable_without_restart() {
use crate::orchestration::state::{OrchestratorState, WorkspaceObservation};
use std::sync::Arc;
let mut app = AppState::new(vec![create_test_change("change-a", 0, 1)]);
let shared = Arc::new(tokio::sync::RwLock::new(OrchestratorState::new(
vec!["change-a".to_string()],
0,
)));
{
let mut guard = shared.blocking_write();
guard.apply_observation("change-a", WorkspaceObservation::WorkspaceArchived);
}
app.set_shared_state(shared.clone());
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "merge wait".to_string();
app.cursor_index = 0;
app.clear_resolving();
assert!(app.resolve_merge().is_some());
accept_resolve(&mut app, &shared, "change-a");
assert_eq!(
shared.blocking_read().resolve_wait_change_ids(),
vec!["change-a".to_string()]
);
let display_map = {
let mut guard = shared.blocking_write();
guard.apply_execution_event(&crate::events::ExecutionEvent::MergeDeferred {
change_id: "change-a".to_string(),
reason: "Base is dirty: Working tree has uncommitted changes".to_string(),
auto_resumable: false,
});
guard.all_display_statuses()
};
app.apply_display_statuses_from_reducer(&display_map);
app.handle_merge_deferred(
"change-a".to_string(),
"Base is dirty: Working tree has uncommitted changes".to_string(),
false,
);
assert_eq!(app.changes[0].display_status_cache, "merge wait");
assert!(!app.is_resolving());
assert!(app.queued_resolves().is_empty());
assert!(shared.blocking_read().resolve_wait_change_ids().is_empty());
let second_cmd = app.resolve_merge();
assert!(
matches!(second_cmd, Some(TuiCommand::ResolveMerge(ref id)) if id == "change-a"),
"clean second M must dispatch a fresh retry without a TUI restart"
);
accept_resolve(&mut app, &shared, "change-a");
assert_eq!(app.changes[0].display_status_cache, "resolve pending");
assert_eq!(
shared.blocking_read().resolve_wait_change_ids(),
vec!["change-a".to_string()],
"second retry must be scheduler-consumable"
);
assert!(app.queued_resolves().is_empty());
}
#[test]
fn resolve_failed_transitions_to_select_when_no_active() {
let changes = vec![create_test_change("change-a", 3, 3)];
let mut app = AppState::new(changes);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].display_status_cache = "resolving".to_string();
app.set_resolving("__active__");
app.handle_resolve_failed("change-a".to_string(), "conflict".to_string());
assert_eq!(app.execution_mode, AppExecutionMode::Select);
}
fn exhaustion_detail() -> String {
crate::parallel::resolve_failure_detail(
3,
crate::parallel::ResolveFailureClassification::UnresolvedConflict,
"conflicts still present after merge resolution attempt: a.rs",
)
}
#[test]
fn exhausted_post_archive_resolve_stays_change_scoped_and_keeps_running() {
let changes = vec![
create_test_change("alpha", 3, 3),
create_test_change("beta", 0, 1),
];
let mut app = AppState::new(changes);
app.execution_mode = AppExecutionMode::Running;
app.current_change = Some("beta".to_string());
if let Some(change) = app.changes.iter_mut().find(|c| c.id == "beta") {
change.set_display_status_cache("applying");
}
let detail = exhaustion_detail();
app.handle_orchestrator_event(OrchestratorEvent::ConflictResolutionFailed {
error: detail.clone(),
});
assert_eq!(
app.execution_mode,
AppExecutionMode::Running,
"presentation telemetry must not change execution mode"
);
app.handle_orchestrator_event(OrchestratorEvent::ResolveFailed {
change_id: "alpha".to_string(),
error: detail,
});
let alpha = app
.changes
.iter()
.find(|c| c.id == "alpha")
.expect("alpha row");
assert_eq!(alpha.display_status_cache, "merge wait");
assert_ne!(
app.execution_mode,
AppExecutionMode::Error,
"a change-local merge failure must never become a global TUI error"
);
assert_eq!(
app.execution_mode,
AppExecutionMode::Running,
"the run stays Running while other work is active"
);
assert_eq!(
change_ids_for_message(&app, "Failed to resolve merge for 'alpha'"),
vec![Some("alpha".to_string())],
"the failure diagnostic must retain structured change identity"
);
}
#[test]
fn exhausted_post_archive_resolve_is_non_modal_and_leaves_other_work_operable() {
let mut app = AppState::new(vec![
create_test_change("alpha", 3, 3),
create_test_change("beta", 0, 1),
]);
app.execution_mode = AppExecutionMode::Running;
app.current_change = Some("beta".to_string());
app.changes[0].set_display_status_cache("resolving");
app.changes[1].set_display_status_cache("applying");
app.set_resolving("alpha");
app.handle_orchestrator_event(OrchestratorEvent::ResolveFailed {
change_id: "alpha".to_string(),
error: exhaustion_detail(),
});
assert!(
app.warning_popup.is_none(),
"a change-scoped resolve failure must not open a blocking overlay"
);
assert!(
!crate::tui::key_handlers::handle_warning_popup_key(
&mut app,
KeyEvent::new(KeyCode::Down, KeyModifiers::NONE),
),
"no popup may claim operator input ahead of the underlying screen"
);
assert_eq!(
app.stop_mode,
StopMode::None,
"a change-local failure must not request graceful or immediate global stop"
);
assert_eq!(
app.execution_mode,
AppExecutionMode::Running,
"unrelated active work keeps the run alive"
);
assert_eq!(
app.changes[0].display_status_cache, "merge wait",
"the affected row stays retryable rather than terminal"
);
assert_eq!(app.changes[1].display_status_cache, "applying");
assert_eq!(
change_ids_for_message(&app, "Failed to resolve merge for 'alpha'"),
vec![Some("alpha".to_string())],
"the retained diagnostic must identify the affected change"
);
}
#[test]
fn idle_change_scoped_resolve_failure_stays_retryable_without_a_popup() {
let mut app = AppState::new(vec![create_test_change("alpha", 3, 3)]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].set_display_status_cache("resolving");
app.cursor_index = 0;
app.set_resolving("alpha");
app.handle_orchestrator_event(OrchestratorEvent::ResolveFailed {
change_id: "alpha".to_string(),
error: exhaustion_detail(),
});
assert!(app.warning_popup.is_none());
assert_eq!(
app.execution_mode,
AppExecutionMode::Select,
"the existing no-active-work transition remains valid"
);
assert_ne!(app.execution_mode, AppExecutionMode::Error);
assert_eq!(app.changes[0].display_status_cache, "merge wait");
assert!(
matches!(app.resolve_merge(), Some(TuiCommand::ResolveMerge(ref id)) if id == "alpha"),
"the explicit merge retry action must remain available"
);
}
#[test]
fn operator_initiated_resolve_failure_is_also_non_modal_and_retryable() {
let mut app = AppState::new(vec![create_test_change("alpha", 3, 3)]);
app.execution_mode = AppExecutionMode::Running;
app.changes[0].set_display_status_cache("merge wait");
app.cursor_index = 0;
app.clear_resolving();
assert!(
matches!(app.resolve_merge(), Some(TuiCommand::ResolveMerge(ref id)) if id == "alpha")
);
app.set_resolving("alpha");
app.changes[0].set_display_status_cache("resolve pending");
app.handle_orchestrator_event(OrchestratorEvent::ResolveFailed {
change_id: "alpha".to_string(),
error: exhaustion_detail(),
});
assert!(
app.warning_popup.is_none(),
"a manual resolve failure is still change-scoped, not a run stop"
);
assert_eq!(app.stop_mode, StopMode::None);
assert_eq!(app.changes[0].display_status_cache, "merge wait");
assert_eq!(
change_ids_for_message(&app, "Failed to resolve merge for 'alpha'"),
vec![Some("alpha".to_string())],
"the bounded TUI log keeps the structured diagnostic"
);
assert!(
matches!(app.resolve_merge(), Some(TuiCommand::ResolveMerge(ref id)) if id == "alpha"),
"a second explicit retry must remain possible without a restart"
);
}
#[test]
fn unrelated_warning_class_keeps_its_popup_for_the_same_diagnostic_text() {
let detail = exhaustion_detail();
let mut app = AppState::new(vec![create_test_change("alpha", 3, 3)]);
app.changes[0].set_display_status_cache("resolving");
app.handle_orchestrator_event(OrchestratorEvent::ResolveFailed {
change_id: "alpha".to_string(),
error: detail.clone(),
});
assert!(app.warning_popup.is_none());
let mut app = AppState::new(vec![create_test_change("alpha", 3, 3)]);
app.changes[0].set_display_status_cache("archived");
app.handle_orchestrator_event(OrchestratorEvent::HookFailed {
change_id: "alpha".to_string(),
hook_type: "on_merged".to_string(),
error: detail,
});
let popup = app
.warning_popup
.as_ref()
.expect("on_merged hook failure keeps its popup");
assert!(popup.message.contains("merged transition blocked"));
}
#[test]
fn run_fatal_global_error_still_enters_error_mode() {
let mut app = AppState::new(vec![create_test_change("alpha", 3, 3)]);
app.execution_mode = AppExecutionMode::Running;
app.handle_orchestrator_event(OrchestratorEvent::ResolveFailed {
change_id: "alpha".to_string(),
error: exhaustion_detail(),
});
assert_ne!(app.execution_mode, AppExecutionMode::Error);
assert!(
app.warning_popup.is_none(),
"the non-modal change-scoped path must not leave an overlay behind"
);
app.handle_orchestrator_event(OrchestratorEvent::Error {
message: "Background merge failed for 'alpha' (workspace 'ws-alpha'): base branch could not be identified".to_string(),
});
assert_eq!(
app.execution_mode,
AppExecutionMode::Error,
"a run-fatal outcome must still stop the frontend"
);
}
#[test]
fn finite_completion_with_errors_is_not_fatal_and_keeps_retry_available() {
let mut app = AppState::new(vec![create_test_change("alpha", 3, 3)]);
app.execution_mode = AppExecutionMode::Running;
app.handle_orchestrator_event(OrchestratorEvent::ResolveFailed {
change_id: "alpha".to_string(),
error: exhaustion_detail(),
});
app.handle_orchestrator_event(OrchestratorEvent::Log(LogEntry::warn(
"Processing completed with errors".to_string(),
)));
app.handle_orchestrator_event(OrchestratorEvent::AllCompleted);
assert_ne!(app.execution_mode, AppExecutionMode::Error);
assert!(
!app.logs
.iter()
.any(|entry| entry.message.contains("All parallel changes completed")),
"no success completion message may accompany an unresolved change failure"
);
let alpha = app
.changes
.iter()
.find(|c| c.id == "alpha")
.expect("alpha row");
assert_eq!(
alpha.display_status_cache, "merge wait",
"explicit merge retry must remain available after the run ends"
);
}
}