mod transition;
mod validation;
use crate::reducer::event::CommitEvent;
use crate::reducer::state::{CommitState, ContinuationState, PipelineState};
use transition::compute_post_commit_transition;
use validation::reduce_commit_validation_failed;
fn compute_post_commit_phase_data(
state: &PipelineState,
) -> (
crate::reducer::event::PipelinePhase,
u32,
u32,
crate::reducer::state::AgentChainState,
ContinuationState,
) {
let (next_phase, next_iter, next_reviewer_pass) = compute_post_commit_transition(state);
let agent_chain = if next_phase == crate::reducer::event::PipelinePhase::Review {
crate::reducer::state::AgentChainState::initial()
.with_max_cycles(state.agent_chain.max_cycles)
.with_backoff_policy(
state.agent_chain.retry_delay_ms,
state.agent_chain.backoff_multiplier,
state.agent_chain.max_backoff_ms,
)
.reset_for_drain(crate::agents::AgentDrain::Review)
} else {
state.agent_chain.clone()
};
let continuation = if next_phase == crate::reducer::event::PipelinePhase::Planning {
crate::reducer::state::ContinuationState {
invalid_output_attempts: 0,
..state.continuation.clone()
}
} else {
state.continuation.clone()
};
(
next_phase,
next_iter,
next_reviewer_pass,
agent_chain,
continuation,
)
}
pub(super) fn reduce_commit_event(state: PipelineState, event: CommitEvent) -> PipelineState {
const MAX_CONSECUTIVE_PUSH_FAILURES: u32 = 3;
match event {
CommitEvent::GenerationStarted => PipelineState {
commit: CommitState::Generating {
attempt: 1,
max_attempts: crate::reducer::state::MAX_VALIDATION_RETRY_ATTEMPTS,
},
commit_prompt_prepared: false,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_selected_files: vec![],
commit_excluded_files: vec![],
..state
},
CommitEvent::DiffPrepared {
empty,
content_id_sha256,
} => PipelineState {
commit_diff_prepared: true,
commit_diff_empty: empty,
commit_diff_content_id_sha256: Some(content_id_sha256),
prompt_inputs: state.prompt_inputs.with_commit_cleared(),
..state
},
CommitEvent::DiffFailed { .. } | CommitEvent::PullRequestFailed { .. } => state,
CommitEvent::DiffInvalidated { .. } => PipelineState {
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
commit_prompt_prepared: false,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_selected_files: vec![],
commit_excluded_files: vec![],
prompt_inputs: state.prompt_inputs.with_commit_cleared(),
..state
},
CommitEvent::PromptPrepared { .. } => PipelineState {
commit: match state.commit {
CommitState::NotStarted => CommitState::Generating {
attempt: 1,
max_attempts: crate::reducer::state::MAX_VALIDATION_RETRY_ATTEMPTS,
},
other => other,
},
commit_prompt_prepared: true,
continuation: crate::reducer::state::ContinuationState {
xsd_retry_pending: false,
xsd_retry_session_reuse_pending: state.continuation.xsd_retry_session_reuse_pending,
same_agent_retry_pending: false,
same_agent_retry_reason: None,
..state.continuation
},
..state
},
CommitEvent::AgentInvoked { .. } => PipelineState {
commit_agent_invoked: true,
continuation: crate::reducer::state::ContinuationState {
xsd_retry_pending: false,
xsd_retry_session_reuse_pending: false,
same_agent_retry_pending: false,
same_agent_retry_reason: None,
last_xsd_error: None,
..state.continuation
},
..state
},
CommitEvent::CommitXmlCleaned { .. } => PipelineState {
commit_required_files_cleaned: true,
..state
},
CommitEvent::CommitXmlExtracted { .. } => PipelineState {
commit_xml_extracted: true,
..state
},
CommitEvent::CommitXmlMissing { attempt } => PipelineState {
commit_xml_extracted: true,
commit_validated_outcome: Some(crate::reducer::state::CommitValidatedOutcome {
attempt,
message: None,
reason: Some("Commit XML missing".to_string()),
}),
..state
},
CommitEvent::CommitXmlValidated {
message,
attempt,
files,
excluded_files,
} => PipelineState {
commit_validated_outcome: Some(crate::reducer::state::CommitValidatedOutcome {
attempt,
message: Some(message),
reason: None,
}),
commit_selected_files: files,
commit_excluded_files: excluded_files,
..state
},
CommitEvent::CommitXmlValidationFailed { reason, attempt } => PipelineState {
commit_validated_outcome: Some(crate::reducer::state::CommitValidatedOutcome {
attempt,
message: None,
reason: Some(reason),
}),
..state
},
CommitEvent::CommitXmlArchived { .. } => PipelineState {
commit_xml_archived: true,
..state
},
CommitEvent::MessageGenerated { message, .. } => PipelineState {
commit: CommitState::Generated { message },
..state
},
CommitEvent::Created { hash, .. } => {
let needs_residual_handling =
!state.commit_selected_files.is_empty() || state.commit_residual_retry_pass > 0;
let pending_push = if state.cloud.enabled {
Some(hash.clone())
} else {
None
};
if let Some(resume_phase) = state.termination_resume_phase {
if needs_residual_handling {
return PipelineState {
commit: CommitState::Committed { hash },
phase: crate::reducer::event::PipelinePhase::CommitMessage,
termination_resume_phase: Some(resume_phase),
pre_termination_commit_checked: false,
context_cleaned: false,
commit_prompt_prepared: false,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
commit_residual_files: vec![],
commit_excluded_files: vec![],
prompt_inputs: state.prompt_inputs.clone().with_commit_cleared(),
metrics: state.metrics.increment_commits_created_total(),
pending_push_commit: pending_push,
push_retry_count: 0,
last_push_error: None,
..state
};
}
return PipelineState {
commit: CommitState::Committed { hash },
phase: resume_phase,
termination_resume_phase: None,
pre_termination_commit_checked: false,
previous_phase: None,
context_cleaned: false,
commit_prompt_prepared: false,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_selected_files: vec![],
commit_residual_files: vec![],
commit_residual_retry_pass: 0,
commit_excluded_files: vec![],
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
prompt_inputs: state.prompt_inputs.clone().with_commit_cleared(),
metrics: state.metrics.increment_commits_created_total(),
pending_push_commit: pending_push,
push_retry_count: 0,
last_push_error: None,
..state
};
}
if needs_residual_handling {
return PipelineState {
commit: CommitState::Committed { hash },
phase: crate::reducer::event::PipelinePhase::CommitMessage,
context_cleaned: false,
commit_prompt_prepared: false,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
commit_residual_files: vec![],
commit_excluded_files: vec![],
prompt_inputs: state.prompt_inputs.clone().with_commit_cleared(),
metrics: state.metrics.increment_commits_created_total(),
pending_push_commit: pending_push,
push_retry_count: 0,
last_push_error: None,
..state
};
}
let (next_phase, next_iter, next_reviewer_pass, agent_chain, continuation) =
compute_post_commit_phase_data(&state);
PipelineState {
commit: CommitState::Committed { hash },
phase: next_phase,
previous_phase: None,
iteration: next_iter,
reviewer_pass: next_reviewer_pass,
context_cleaned: false,
commit_required_files_cleaned: false,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
commit_residual_files: vec![],
commit_excluded_files: vec![],
prompt_inputs: state.prompt_inputs.clone().with_commit_cleared(),
agent_chain,
continuation,
metrics: state.metrics.increment_commits_created_total(),
pending_push_commit: pending_push,
push_retry_count: 0,
last_push_error: None,
..state
}
}
CommitEvent::GitAuthConfigured => PipelineState {
git_auth_configured: true,
..state
},
CommitEvent::PushExecuted {
commit_sha, result, ..
} => {
if result.exit_code == 0 {
PipelineState {
pending_push_commit: None,
push_count: state.push_count + 1,
push_retry_count: 0,
last_push_error: None,
last_pushed_commit: Some(commit_sha),
..state
}
} else {
let error = crate::cloud::redaction::redact_secrets(&result.stderr);
let new_retry_count = state.push_retry_count.saturating_add(1);
let at_failure_limit = new_retry_count >= MAX_CONSECUTIVE_PUSH_FAILURES;
let (pending_push_commit, unpushed_commits, final_retry_count) = if at_failure_limit
{
let commits: Vec<_> = state
.unpushed_commits
.iter()
.chain(state.pending_push_commit.iter())
.cloned()
.collect();
(None, commits, 0)
} else {
(
state.pending_push_commit.clone(),
state.unpushed_commits.clone(),
new_retry_count,
)
};
PipelineState {
push_retry_count: final_retry_count,
last_push_error: Some(error),
pending_push_commit,
unpushed_commits,
..state
}
}
}
CommitEvent::PushCompleted { .. } => state,
CommitEvent::PushFailed { .. } => state,
CommitEvent::PullRequestCreated { url, number } => PipelineState {
pr_created: true,
pr_url: Some(url),
pr_number: Some(number),
..state
},
CommitEvent::GenerationFailed { .. } => PipelineState {
commit: CommitState::NotStarted,
commit_prompt_prepared: false,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_selected_files: vec![],
commit_excluded_files: vec![],
commit_residual_retry_pass: 0,
..state
},
CommitEvent::Skipped { .. } => {
if let Some(resume_phase) = state.termination_resume_phase {
let checked = state.commit_diff_empty;
return PipelineState {
commit: CommitState::Skipped,
phase: resume_phase,
termination_resume_phase: None,
pre_termination_commit_checked: checked,
previous_phase: None,
commit_prompt_prepared: false,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_selected_files: vec![],
commit_excluded_files: vec![],
commit_residual_retry_pass: 0,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
prompt_inputs: state.prompt_inputs.clone().with_commit_cleared(),
..state
};
}
let (next_phase, next_iter, next_reviewer_pass) =
compute_post_commit_transition(&state);
let agent_chain = if next_phase == crate::reducer::event::PipelinePhase::Review {
crate::reducer::state::AgentChainState::initial()
.with_max_cycles(state.agent_chain.max_cycles)
.with_backoff_policy(
state.agent_chain.retry_delay_ms,
state.agent_chain.backoff_multiplier,
state.agent_chain.max_backoff_ms,
)
.reset_for_drain(crate::agents::AgentDrain::Review)
} else {
state.agent_chain.clone()
};
let continuation = if next_phase == crate::reducer::event::PipelinePhase::Planning {
ContinuationState {
invalid_output_attempts: 0,
..state.continuation
}
} else {
state.continuation.clone()
};
PipelineState {
commit: CommitState::Skipped,
phase: next_phase,
previous_phase: None,
iteration: next_iter,
reviewer_pass: next_reviewer_pass,
commit_prompt_prepared: false,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_selected_files: vec![],
commit_excluded_files: vec![],
commit_residual_retry_pass: 0,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
context_cleaned: false,
prompt_inputs: state.prompt_inputs.clone().with_commit_cleared(),
agent_chain,
continuation,
..state
}
}
CommitEvent::MessageValidationFailed { attempt, reason } => {
reduce_commit_validation_failed(state, attempt, reason)
}
CommitEvent::PreTerminationSafetyCheckPassed => PipelineState {
pre_termination_commit_checked: true,
..state
},
CommitEvent::PreTerminationUncommittedChangesDetected { .. } => {
let resume_phase = state.phase;
PipelineState {
phase: crate::reducer::event::PipelinePhase::CommitMessage,
termination_resume_phase: Some(resume_phase),
commit: CommitState::NotStarted,
commit_prompt_prepared: false,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_selected_files: vec![],
commit_excluded_files: vec![],
commit_residual_retry_pass: 0,
prompt_inputs: state.prompt_inputs.with_commit_cleared(),
pre_termination_commit_checked: false,
..state
}
}
CommitEvent::ResidualFilesFound { files, pass } => {
reduce_residual_files_found(state, files, pass)
}
CommitEvent::ResidualFilesNone => reduce_residual_files_none(state),
}
}
fn reduce_residual_files_found(
state: PipelineState,
files: Vec<String>,
pass: u8,
) -> PipelineState {
let final_pass = 1u8.saturating_add(state.max_commit_residual_retries);
if !(1..=final_pass).contains(&pass) {
let in_recovery_loop = state.phase == crate::reducer::event::PipelinePhase::AwaitingDevFix
|| state.previous_phase == Some(crate::reducer::event::PipelinePhase::AwaitingDevFix);
let (dev_fix_attempt_count, recovery_escalation_level) = if !in_recovery_loop {
(0, 0)
} else {
(state.dev_fix_attempt_count, state.recovery_escalation_level)
};
return PipelineState {
previous_phase: Some(state.phase),
phase: crate::reducer::event::PipelinePhase::AwaitingDevFix,
dev_fix_triggered: false,
failed_phase_for_recovery: Some(crate::reducer::event::PipelinePhase::CommitMessage),
dev_fix_attempt_count,
recovery_escalation_level,
..state
};
}
if pass < final_pass {
return PipelineState {
phase: crate::reducer::event::PipelinePhase::CommitMessage,
commit_residual_retry_pass: pass + 1,
commit: CommitState::NotStarted,
commit_prompt_prepared: false,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_selected_files: vec![],
commit_excluded_files: vec![],
prompt_inputs: state.prompt_inputs.with_commit_cleared(),
..state
};
}
let base = PipelineState {
commit_residual_retry_pass: 0,
commit_selected_files: vec![],
commit_excluded_files: vec![],
commit_residual_files: files,
..state
};
if let Some(resume_phase) = base.termination_resume_phase {
PipelineState {
phase: resume_phase,
termination_resume_phase: None,
pre_termination_commit_checked: false,
previous_phase: None,
commit_prompt_prepared: false,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
prompt_inputs: base.prompt_inputs.clone().with_commit_cleared(),
context_cleaned: false,
..base
}
} else {
let (next_phase, next_iter, next_reviewer_pass, agent_chain, continuation) =
compute_post_commit_phase_data(&base);
PipelineState {
phase: next_phase,
previous_phase: None,
iteration: next_iter,
reviewer_pass: next_reviewer_pass,
context_cleaned: false,
commit_required_files_cleaned: false,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
prompt_inputs: base.prompt_inputs.clone().with_commit_cleared(),
agent_chain,
continuation,
..base
}
}
}
fn reduce_residual_files_none(state: PipelineState) -> PipelineState {
let base = PipelineState {
commit_residual_retry_pass: 0,
commit_selected_files: vec![],
commit_excluded_files: vec![],
commit_residual_files: vec![],
..state
};
if let Some(resume_phase) = base.termination_resume_phase {
PipelineState {
phase: resume_phase,
termination_resume_phase: None,
pre_termination_commit_checked: true,
previous_phase: None,
commit_prompt_prepared: false,
commit_agent_invoked: false,
commit_required_files_cleaned: false,
commit_xml_extracted: false,
commit_validated_outcome: None,
commit_xml_archived: false,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
prompt_inputs: base.prompt_inputs.clone().with_commit_cleared(),
context_cleaned: false,
..base
}
} else {
let (next_phase, next_iter, next_reviewer_pass, agent_chain, continuation) =
compute_post_commit_phase_data(&base);
PipelineState {
phase: next_phase,
previous_phase: None,
iteration: next_iter,
reviewer_pass: next_reviewer_pass,
context_cleaned: false,
commit_required_files_cleaned: false,
commit_diff_prepared: false,
commit_diff_empty: false,
commit_diff_content_id_sha256: None,
prompt_inputs: base.prompt_inputs.clone().with_commit_cleared(),
agent_chain,
continuation,
..base
}
}
}
#[cfg(test)]
mod tests {
use super::reduce_commit_event;
use crate::reducer::event::CommitEvent;
use crate::reducer::state::pipeline::{ExcludedFile, ExcludedFileReason};
use crate::reducer::state::PipelineState;
fn excluded(path: &str) -> ExcludedFile {
ExcludedFile {
path: path.to_string(),
reason: ExcludedFileReason::Deferred,
}
}
#[test]
fn test_generation_started_clears_commit_excluded_files() {
let state = PipelineState {
commit_excluded_files: vec![excluded("src/leftover.txt")],
..PipelineState::initial(1, 0)
};
let next = reduce_commit_event(state, CommitEvent::GenerationStarted);
assert!(
next.commit_excluded_files.is_empty(),
"commit_excluded_files must be cleared on commit phase reset"
);
}
#[test]
fn test_diff_invalidated_clears_commit_excluded_files() {
let state = PipelineState {
commit_excluded_files: vec![excluded("src/leftover.txt")],
..PipelineState::initial(1, 0)
};
let next = reduce_commit_event(
state,
CommitEvent::DiffInvalidated {
reason: "missing diff".to_string(),
},
);
assert!(next.commit_excluded_files.is_empty());
}
#[test]
fn test_generation_failed_clears_retry_pass_and_excluded_files() {
let state = PipelineState {
commit_residual_retry_pass: 2,
commit_excluded_files: vec![excluded("src/leftover.txt")],
..PipelineState::initial(1, 0)
};
let next = reduce_commit_event(
state,
CommitEvent::GenerationFailed {
reason: "nope".to_string(),
},
);
assert_eq!(next.commit_residual_retry_pass, 0);
assert!(next.commit_excluded_files.is_empty());
}
#[test]
fn test_skipped_clears_retry_pass_and_excluded_files() {
let state = PipelineState {
commit_residual_retry_pass: 2,
commit_excluded_files: vec![excluded("src/leftover.txt")],
..PipelineState::initial(1, 0)
};
let next = reduce_commit_event(
state,
CommitEvent::Skipped {
reason: "skip".to_string(),
},
);
assert_eq!(next.commit_residual_retry_pass, 0);
assert!(next.commit_excluded_files.is_empty());
}
#[test]
fn test_residual_files_none_clears_excluded_files() {
let state = PipelineState {
commit_excluded_files: vec![excluded("src/leftover.txt")],
..PipelineState::initial(1, 0)
};
let next = reduce_commit_event(state, CommitEvent::ResidualFilesNone);
assert!(next.commit_excluded_files.is_empty());
}
#[test]
fn test_push_executed_does_not_double_count() {
let initial = PipelineState {
pending_push_commit: Some("abc123".to_string()),
push_count: 5,
..PipelineState::initial(1, 0)
};
let after_executed = reduce_commit_event(
initial.clone(),
CommitEvent::PushExecuted {
remote: "origin".to_string(),
branch: "main".to_string(),
commit_sha: "abc123".to_string(),
result: crate::reducer::event::ProcessExecutionResult {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
},
},
);
assert_eq!(
after_executed.push_count, 6,
"PushExecuted increments count"
);
assert_eq!(
after_executed.pending_push_commit, None,
"PushExecuted clears pending"
);
assert_eq!(
after_executed.last_pushed_commit,
Some("abc123".to_string()),
"PushExecuted records SHA"
);
let after_completed = reduce_commit_event(
after_executed.clone(),
CommitEvent::PushCompleted {
remote: "origin".to_string(),
branch: "main".to_string(),
commit_sha: "abc123".to_string(),
},
);
assert_eq!(
after_completed.push_count, 6,
"PushCompleted must NOT increment again (no-op for compat)"
);
assert_eq!(
after_completed.pending_push_commit, None,
"state unchanged by PushCompleted"
);
}
#[test]
fn test_push_executed_failure_does_not_double_count() {
let initial = PipelineState {
pending_push_commit: Some("abc123".to_string()),
push_retry_count: 1,
..PipelineState::initial(1, 0)
};
let after_executed = reduce_commit_event(
initial.clone(),
CommitEvent::PushExecuted {
remote: "origin".to_string(),
branch: "main".to_string(),
commit_sha: "abc123".to_string(),
result: crate::reducer::event::ProcessExecutionResult {
exit_code: 1,
stdout: String::new(),
stderr: "rejected".to_string(),
},
},
);
assert_eq!(
after_executed.push_retry_count, 2,
"PushExecuted increments retry on failure"
);
assert_eq!(
after_executed.last_push_error,
Some("rejected".to_string()),
"PushExecuted records error"
);
let after_failed = reduce_commit_event(
after_executed.clone(),
CommitEvent::PushFailed {
remote: "origin".to_string(),
branch: "main".to_string(),
error: "rejected".to_string(),
},
);
assert_eq!(
after_failed.push_retry_count, 2,
"PushFailed must NOT increment again (no-op for compat)"
);
}
#[test]
fn test_reducer_interprets_exit_code_policy() {
let base = PipelineState {
pending_push_commit: Some("abc123".to_string()),
push_count: 0,
push_retry_count: 0,
..PipelineState::initial(1, 0)
};
let success = reduce_commit_event(
base.clone(),
CommitEvent::PushExecuted {
remote: "origin".to_string(),
branch: "main".to_string(),
commit_sha: "abc123".to_string(),
result: crate::reducer::event::ProcessExecutionResult {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
},
},
);
assert_eq!(success.push_count, 1, "exit 0 = success, count increments");
assert_eq!(success.pending_push_commit, None, "pending cleared");
assert_eq!(success.push_retry_count, 0, "retry count reset");
assert_eq!(success.last_push_error, None, "error cleared");
let failure = reduce_commit_event(
base.clone(),
CommitEvent::PushExecuted {
remote: "origin".to_string(),
branch: "main".to_string(),
commit_sha: "abc123".to_string(),
result: crate::reducer::event::ProcessExecutionResult {
exit_code: 1,
stdout: String::new(),
stderr: "auth failed".to_string(),
},
},
);
assert_eq!(
failure.push_count, 0,
"exit non-zero = failure, count unchanged"
);
assert_eq!(
failure.pending_push_commit,
Some("abc123".to_string()),
"pending retained for retry"
);
assert_eq!(failure.push_retry_count, 1, "retry count increments");
assert_eq!(
failure.last_push_error,
Some("auth failed".to_string()),
"error recorded"
);
}
}