Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 52 additions & 78 deletions cli/src/services/hooks/claude_mutation_scope/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,48 +149,6 @@ mod tests {
remove_test_git_dir(&git_dir);
}

#[test]
fn recovery_pending_false_is_healthy_even_with_live_attempts() {
let git_dir = unique_test_git_dir("recovery-false");
std::fs::create_dir_all(&git_dir).expect("git dir should be created");
state::allocate_attempt(&git_dir, &key("toolu_1"), "Write")
.expect("allocation should succeed");

let health = classify_health(&git_dir);

assert_eq!(health.status, MutationScopeHealthStatus::Healthy);

remove_test_git_dir(&git_dir);
}

#[test]
fn recovery_pending_true_with_empty_attempts_is_recovering() {
let git_dir = unique_test_git_dir("recovering");
std::fs::create_dir_all(&git_dir).expect("git dir should be created");
state::mark_recovery_pending(&git_dir).expect("marking recovery pending should succeed");

let health = classify_health(&git_dir);

assert_eq!(health.status, MutationScopeHealthStatus::Recovering);

remove_test_git_dir(&git_dir);
}

#[test]
fn recovery_pending_true_with_non_empty_attempts_is_blocked() {
let git_dir = unique_test_git_dir("blocked");
std::fs::create_dir_all(&git_dir).expect("git dir should be created");
state::allocate_attempt(&git_dir, &key("toolu_1"), "Write")
.expect("allocation should succeed");
state::mark_recovery_pending(&git_dir).expect("marking recovery pending should succeed");

let health = classify_health(&git_dir);

assert_eq!(health.status, MutationScopeHealthStatus::Blocked);

remove_test_git_dir(&git_dir);
}

#[test]
fn malformed_state_file_is_invalid_with_the_read_error_surfaced() {
let git_dir = unique_test_git_dir("malformed");
Expand Down Expand Up @@ -768,8 +726,58 @@ mod tests {
}

#[test]
fn clear_recovery_with_pending_abandon_is_invalid() {
let git_dir = unique_test_git_dir("invalid-clear-pending-abandon");
fn health_classification_table_covers_every_recovery_pending_and_attempt_phase_shape() {
use state::AttemptPhase::{Active, PendingAbandon, PendingStart};
use MutationScopeHealthStatus::{Blocked, Healthy, Invalid, Recovering};

let rows: [(bool, &[state::AttemptPhase], MutationScopeHealthStatus); 12] = [
(false, &[], Healthy),
(false, &[PendingStart], Healthy),
(false, &[Active], Healthy),
(false, &[PendingAbandon], Invalid),
(false, &[PendingAbandon, PendingStart], Invalid),
(false, &[PendingAbandon, Active], Invalid),
(true, &[], Recovering),
(true, &[PendingStart], Blocked),
(true, &[Active], Blocked),
(true, &[PendingAbandon], Blocked),
(true, &[PendingAbandon, PendingStart], Blocked),
(true, &[PendingAbandon, Active], Blocked),
];

for (index, (recovery_pending, phases, expected)) in rows.into_iter().enumerate() {
let git_dir = unique_test_git_dir(&format!("table-{index}"));
let attempts: Vec<state::AdapterAttempt> = (1u64..)
.zip(phases)
.map(|(attempt_seq, phase)| {
hand_constructed_attempt(attempt_seq, &format!("toolu_{attempt_seq}"), *phase)
})
.collect();
let next_attempt_seq = attempts.last().map_or(1, |attempt| attempt.attempt_seq + 1);
write_hand_constructed_state(
&git_dir,
&state::AdapterState {
version: 1,
next_attempt_seq,
recovery_pending,
attempts,
},
);

assert_eq!(
classify_health(&git_dir).status,
expected,
"row {index}: recovery_pending={recovery_pending} phases={phases:?}",
);

remove_test_git_dir(&git_dir);
}
}

#[test]
fn assess_repairability_is_manual_only_for_an_impossible_clear_recovery_with_a_pending_abandon_attempt(
) {
let git_dir = unique_test_git_dir("assess-invalid-clear-pending-abandon");
write_hand_constructed_state(
&git_dir,
&state::AdapterState {
Expand All @@ -784,13 +792,6 @@ mod tests {
},
);

let health = classify_health(&git_dir);
assert_eq!(
health.status,
MutationScopeHealthStatus::Invalid,
"recovery_pending == false with an unresolved PendingAbandon attempt is a \
structurally impossible state and must never classify Healthy"
);
assert_eq!(
assess_repairability(&git_dir),
Repairability::ManualOnly,
Expand All @@ -799,31 +800,4 @@ mod tests {

remove_test_git_dir(&git_dir);
}

#[test]
fn invalid_takes_priority_over_blocked_in_mixed_impossible_state() {
let git_dir = unique_test_git_dir("invalid-priority-mixed");
write_hand_constructed_state(
&git_dir,
&state::AdapterState {
version: 1,
next_attempt_seq: 3,
recovery_pending: false,
attempts: vec![
hand_constructed_attempt(1, "toolu_1", state::AttemptPhase::PendingAbandon),
hand_constructed_attempt(2, "toolu_2", state::AttemptPhase::PendingStart),
],
},
);

let health = classify_health(&git_dir);
assert_eq!(
health.status,
MutationScopeHealthStatus::Invalid,
"Clear + unresolved PendingAbandon must take priority over an otherwise-Blocked \
shape from a coexisting PendingStart attempt"
);

remove_test_git_dir(&git_dir);
}
}
60 changes: 0 additions & 60 deletions cli/src/services/hooks/mutation_scope_health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,63 +45,3 @@ impl MutationScopeAdapterHealth {
self
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn health_status_variants_are_distinct() {
assert_ne!(
MutationScopeHealthStatus::Healthy,
MutationScopeHealthStatus::Recovering
);
assert_ne!(
MutationScopeHealthStatus::Recovering,
MutationScopeHealthStatus::Blocked
);
assert_ne!(
MutationScopeHealthStatus::Blocked,
MutationScopeHealthStatus::Invalid
);
assert_eq!(
MutationScopeHealthStatus::Healthy,
MutationScopeHealthStatus::Healthy
);
}

#[test]
fn repairability_variants_are_distinct() {
assert_ne!(Repairability::AutoFixable, Repairability::ManualOnly);
assert_eq!(Repairability::AutoFixable, Repairability::AutoFixable);
}

#[test]
fn new_adapter_health_has_no_detail_by_default() {
let health = MutationScopeAdapterHealth::new(
ActorKind::ClaudeCode,
MutationScopeHealthStatus::Healthy,
"no persisted recovery problem",
);

assert_eq!(health.adapter, ActorKind::ClaudeCode);
assert_eq!(health.status, MutationScopeHealthStatus::Healthy);
assert_eq!(health.reason, "no persisted recovery problem");
assert_eq!(health.detail, None);
}

#[test]
fn with_detail_attaches_machine_detail_without_changing_status_or_reason() {
let health = MutationScopeAdapterHealth::new(
ActorKind::Codex,
MutationScopeHealthStatus::Invalid,
"state file failed to parse",
)
.with_detail("unexpected EOF at byte 12");

assert_eq!(health.adapter, ActorKind::Codex);
assert_eq!(health.status, MutationScopeHealthStatus::Invalid);
assert_eq!(health.reason, "state file failed to parse");
assert_eq!(health.detail.as_deref(), Some("unexpected EOF at byte 12"));
}
}
82 changes: 25 additions & 57 deletions cli/src/services/hooks/opencode_mutation_scope/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,19 +232,6 @@ mod tests {
remove_test_git_dir(&git_dir);
}

#[test]
fn clear_recovery_is_healthy_even_with_active_attempts() {
let git_dir = unique_test_git_dir("clear-with-active");
std::fs::create_dir_all(&git_dir).expect("git dir should be created");
state::seed_attempt_for_tests(&git_dir, &key("call-1"), "write", AttemptPhase::Active);

let health = classify_health(&git_dir);

assert_eq!(health.status, MutationScopeHealthStatus::Healthy);

remove_test_git_dir(&git_dir);
}

#[test]
fn pending_start_with_clear_recovery_is_blocked_and_denies_repeated_unrelated_admissions_ac4() {
let git_dir = unique_test_git_dir("pending-start-blocked");
Expand Down Expand Up @@ -608,38 +595,6 @@ mod tests {
remove_test_git_dir(&git_dir);
}

#[test]
fn clear_recovery_with_a_pending_abandon_attempt_is_a_structurally_impossible_state_classified_invalid(
) {
let git_dir = unique_test_git_dir("clear-with-pending-abandon-invalid");
let dir = state::adapter_state_dir(&git_dir);
std::fs::create_dir_all(&dir).expect("state dir should be created");
std::fs::write(
state::state_path(&git_dir),
serde_json::json!({
"version": 1,
"next_recovery_generation": 1,
"recovery": { "phase": "clear" },
"attempts": [{
"scope_id": "oc-tool-v1|s=8:ses-main|c=6:call-1",
"session_id": "ses-main",
"call_id": "call-1",
"tool_name": "write",
"phase": "pending_abandon",
}],
})
.to_string(),
)
.expect("hand-seeded state file should be writable");

assert_eq!(
classify_health(&git_dir).status,
MutationScopeHealthStatus::Invalid
);

remove_test_git_dir(&git_dir);
}

#[test]
fn malformed_state_file_is_invalid_with_the_read_error_surfaced() {
let git_dir = unique_test_git_dir("malformed");
Expand Down Expand Up @@ -683,13 +638,17 @@ mod tests {
fn write_matrix_state(
git_dir: &Path,
recovery: RecoveryState,
has_active: bool,
has_pending_abandon: bool,
has_pending_start: bool,
) {
std::fs::create_dir_all(state::adapter_state_dir(git_dir))
.expect("adapter state dir should be created");

let mut attempts = vec![matrix_attempt("call-active", AttemptPhase::Active)];
let mut attempts = Vec::new();
if has_active {
attempts.push(matrix_attempt("call-active", AttemptPhase::Active));
}
if has_pending_abandon {
attempts.push(matrix_attempt("call-abandon", AttemptPhase::PendingAbandon));
}
Expand Down Expand Up @@ -736,17 +695,26 @@ mod tests {
for (index, (recovery, has_pending_abandon, has_pending_start, expected)) in
rows.into_iter().enumerate()
{
let git_dir = unique_test_git_dir(&format!("matrix-{index}"));
write_matrix_state(&git_dir, recovery, has_pending_abandon, has_pending_start);

assert_eq!(
classify_health(&git_dir).status,
expected,
"row {index}: recovery={recovery:?} has_pending_abandon={has_pending_abandon} \
has_pending_start={has_pending_start}",
);

remove_test_git_dir(&git_dir);
for has_active in [false, true] {
let git_dir = unique_test_git_dir(&format!("matrix-{index}-{has_active}"));
write_matrix_state(
&git_dir,
recovery,
has_active,
has_pending_abandon,
has_pending_start,
);

assert_eq!(
classify_health(&git_dir).status,
expected,
"row {index}: recovery={recovery:?} has_active={has_active} \
has_pending_abandon={has_pending_abandon} \
has_pending_start={has_pending_start}",
);

remove_test_git_dir(&git_dir);
}
}
}

Expand Down
Loading
Loading